diff --git a/.gitignore b/.gitignore index e4149fc..7c79e54 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ .env.local .env.*.local !.env.example - +scripts/ # IDE and editor .cursor/ .vscode/ diff --git a/api/app/alembic/env.py b/api/app/alembic/env.py index 95047e3..114f11d 100644 --- a/api/app/alembic/env.py +++ b/api/app/alembic/env.py @@ -10,7 +10,11 @@ from app.modules.boards.model import Board, BoardStar, BoardView # noqa: F401 - metadata from app.modules.elements.model import Element # noqa: F401 - metadata from app.modules.users.model import OAuthAccount, User # noqa: F401 - metadata -from app.modules.workspaces.model import Workspace, WorkspaceMember # noqa: F401 - metadata +from app.modules.workspaces.model import ( # noqa: F401 - metadata + Workspace, + WorkspaceInvitation, + WorkspaceMember, +) config = context.config diff --git a/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py b/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py new file mode 100644 index 0000000..b08b45d --- /dev/null +++ b/api/app/alembic/versions/7d6ba2fe0af9_feat_add_workspace_invitations_table.py @@ -0,0 +1,67 @@ +"""[FEAT] Add workspace invitations table + +Revision ID: 7d6ba2fe0af9 +Revises: 24b35055e774 +Create Date: 2026-03-24 18:51:12.834194 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "7d6ba2fe0af9" +down_revision: Union[str, Sequence[str], None] = "24b35055e774" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "workspace_invitations", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("workspace_id", sa.UUID(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("role", sa.String(length=50), nullable=False), + sa.Column("token", sa.String(length=128), nullable=False), + sa.Column("invited_by", sa.UUID(), nullable=False), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + server_default=sa.text("now()"), + nullable=False, + ), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("accepted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["invited_by"], ["users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["workspace_id"], ["workspaces.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_workspace_invitations_email"), "workspace_invitations", ["email"], unique=False + ) + op.create_index( + op.f("ix_workspace_invitations_token"), "workspace_invitations", ["token"], unique=True + ) + op.create_index( + op.f("ix_workspace_invitations_workspace_id"), + "workspace_invitations", + ["workspace_id"], + unique=False, + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_workspace_invitations_workspace_id"), table_name="workspace_invitations") + op.drop_index(op.f("ix_workspace_invitations_token"), table_name="workspace_invitations") + op.drop_index(op.f("ix_workspace_invitations_email"), table_name="workspace_invitations") + op.drop_table("workspace_invitations") + # ### end Alembic commands ### diff --git a/api/app/app/core/redis.py b/api/app/app/core/redis.py index e634003..b73b513 100644 --- a/api/app/app/core/redis.py +++ b/api/app/app/core/redis.py @@ -31,24 +31,34 @@ def publish_board_event(board_id: str, event: str, data: Dict[str, Any]) -> int: OAUTH_STATE_TTL = 600 # 10 minutes -def set_oauth_state(state: str) -> bool: +def set_oauth_state(state: str, invite_token: str | None = None) -> bool: """Store OAuth state for CSRF validation.""" try: r = get_redis() - r.setex(f"{OAUTH_STATE_PREFIX}{state}", OAUTH_STATE_TTL, "1") + payload = {"invite_token": invite_token} + r.setex(f"{OAUTH_STATE_PREFIX}{state}", OAUTH_STATE_TTL, json.dumps(payload)) return True except Exception: return False -def validate_oauth_state(state: str) -> bool: - """Validate and consume OAuth state.""" +def validate_oauth_state(state: str) -> Dict[str, Any] | None: + """Validate and consume OAuth state, returning stored payload.""" try: r = get_redis() key = f"{OAUTH_STATE_PREFIX}{state}" - if r.get(key): + raw = r.get(key) + if raw: r.delete(key) - return True - return False + if not isinstance(raw, (str, bytes, bytearray)): + return {} + try: + data = json.loads(raw) + if isinstance(data, dict): + return data + except Exception: + return {} + return {} + return None except Exception: - return False + return None diff --git a/api/app/app/modules/auth/router.py b/api/app/app/modules/auth/router.py index bb2801c..5a88a7b 100644 --- a/api/app/app/modules/auth/router.py +++ b/api/app/app/modules/auth/router.py @@ -46,14 +46,14 @@ def login_endpoint( # --- GitHub OAuth --- @router.get("/github") -def github_login() -> RedirectResponse: +def github_login(invite_token: str | None = None) -> RedirectResponse: if not settings.github_client_id: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="GitHub OAuth is not configured", ) state = secrets.token_urlsafe(32) - set_oauth_state(state) + set_oauth_state(state, invite_token=invite_token) return RedirectResponse(url=get_github_authorize_url(state)) @@ -65,7 +65,10 @@ async def github_callback( ) -> RedirectResponse: if not code: raise HTTPException(status_code=400, detail="Missing authorization code") - if not state or not validate_oauth_state(state): + if not state: + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") + state_data = validate_oauth_state(state) + if state_data is None: raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") info = await get_github_user_info(code) if not info: @@ -76,19 +79,22 @@ async def github_callback( user, _ = get_or_create_oauth_user(db, info) token = create_access_token(subject=str(user.id)) redirect_url = f"{settings.frontend_url.rstrip('/')}/auth/callback?token={token}" + invite_token = state_data.get("invite_token") + if isinstance(invite_token, str) and invite_token: + redirect_url = f"{redirect_url}&invite_token={invite_token}" return RedirectResponse(url=redirect_url) # --- Google OAuth --- @router.get("/google") -def google_login() -> RedirectResponse: +def google_login(invite_token: str | None = None) -> RedirectResponse: if not settings.google_client_id: raise HTTPException( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Google OAuth is not configured", ) state = secrets.token_urlsafe(32) - set_oauth_state(state) + set_oauth_state(state, invite_token=invite_token) return RedirectResponse(url=get_google_authorize_url(state)) @@ -100,7 +106,10 @@ async def google_callback( ) -> RedirectResponse: if not code: raise HTTPException(status_code=400, detail="Missing authorization code") - if not state or not validate_oauth_state(state): + if not state: + raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") + state_data = validate_oauth_state(state) + if state_data is None: raise HTTPException(status_code=400, detail="Invalid or expired OAuth state") info = await get_google_user_info(code) if not info: @@ -111,4 +120,7 @@ async def google_callback( user, _ = get_or_create_oauth_user(db, info) token = create_access_token(subject=str(user.id)) redirect_url = f"{settings.frontend_url.rstrip('/')}/auth/callback?token={token}" + invite_token = state_data.get("invite_token") + if isinstance(invite_token, str) and invite_token: + redirect_url = f"{redirect_url}&invite_token={invite_token}" return RedirectResponse(url=redirect_url) diff --git a/api/app/app/modules/workspaces/invitations.py b/api/app/app/modules/workspaces/invitations.py new file mode 100644 index 0000000..76e2f5a --- /dev/null +++ b/api/app/app/modules/workspaces/invitations.py @@ -0,0 +1,122 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from uuid import UUID + +from app.api.deps import get_current_user +from app.config import settings +from app.db.session import get_db +from app.modules.users.model import User +from app.modules.workspaces.model import WorkspaceInvitation +from app.modules.workspaces.schemas import ( + WorkspaceInvitationAcceptResponse, + WorkspaceInvitationCreate, + WorkspaceInvitationListResponse, + WorkspaceInvitationResponse, +) +from app.modules.workspaces.service import ( + accept_workspace_invitation_for_user, + create_workspace_invitation_for_user, + get_workspace_invitation_by_token, + list_workspace_invitations_for_user, +) + +router = APIRouter(tags=["workspace-invitations"]) + + +def _to_response(token: str, invitation: WorkspaceInvitation) -> WorkspaceInvitationResponse: + base_url = ( + settings.frontend_url.rstrip("/") if settings.frontend_url else "http://localhost:5173" + ) + return WorkspaceInvitationResponse( + id=invitation.id, + workspace_id=invitation.workspace_id, + workspace_name=invitation.workspace.name if invitation.workspace else "", + email=invitation.email, + role=invitation.role, + token=token, + invite_url=f"{base_url}/invite/{token}", + created_at=invitation.created_at, + expires_at=invitation.expires_at, + accepted_at=invitation.accepted_at, + ) + + +@router.post( + "/{workspace_id}/invitations", + response_model=WorkspaceInvitationResponse, + status_code=status.HTTP_201_CREATED, +) +def create_workspace_invitation_endpoint( + workspace_id: UUID, + data: WorkspaceInvitationCreate, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationResponse: + invitation = create_workspace_invitation_for_user( + db, + workspace_id, + current_user, + email=data.email, + role=data.role, + ) + if invitation is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the workspace owner can create invitations", + ) + loaded = get_workspace_invitation_by_token(db, invitation.token) + if loaded is None: + raise HTTPException(status_code=404, detail="Invitation not found") + return _to_response(invitation.token, loaded) + + +@router.get( + "/{workspace_id}/invitations", + response_model=WorkspaceInvitationListResponse, +) +def list_workspace_invitations_endpoint( + workspace_id: UUID, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationListResponse: + invitations = list_workspace_invitations_for_user(db, workspace_id, current_user) + if invitations is None: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the workspace owner can view pending invitations", + ) + return WorkspaceInvitationListResponse( + items=[_to_response(invitation.token, invitation) for invitation in invitations] + ) + + +@router.get("/invitations/{token}", response_model=WorkspaceInvitationResponse) +def get_workspace_invitation_endpoint( + token: str, + db: Session = Depends(get_db), +) -> WorkspaceInvitationResponse: + invitation = get_workspace_invitation_by_token(db, token) + if invitation is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Invitation not found") + return _to_response(token, invitation) + + +@router.post( + "/invitations/{token}/accept", + response_model=WorkspaceInvitationAcceptResponse, +) +def accept_workspace_invitation_endpoint( + token: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +) -> WorkspaceInvitationAcceptResponse: + invitation = accept_workspace_invitation_for_user(db, token, current_user) + if invitation is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invitation is invalid, expired, already used, or email does not match", + ) + return WorkspaceInvitationAcceptResponse( + workspace_id=invitation.workspace_id, + workspace_name=invitation.workspace.name if invitation.workspace else "", + ) diff --git a/api/app/app/modules/workspaces/model.py b/api/app/app/modules/workspaces/model.py index e56963e..f338bff 100644 --- a/api/app/app/modules/workspaces/model.py +++ b/api/app/app/modules/workspaces/model.py @@ -46,6 +46,9 @@ class Workspace(Base): members: Mapped[list["WorkspaceMember"]] = relationship( "WorkspaceMember", back_populates="workspace", cascade="all, delete-orphan" ) + invitations: Mapped[list["WorkspaceInvitation"]] = relationship( + "WorkspaceInvitation", back_populates="workspace", cascade="all, delete-orphan" + ) class WorkspaceMember(Base): @@ -75,3 +78,34 @@ class WorkspaceMember(Base): workspace: Mapped["Workspace"] = relationship("Workspace", back_populates="members") user: Mapped["User"] = relationship("User", back_populates="workspace_memberships") + + +class WorkspaceInvitation(Base): + __tablename__ = "workspace_invitations" + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + workspace_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("workspaces.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) + email: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + role: Mapped[str] = mapped_column(String(50), nullable=False, default="member") + token: Mapped[str] = mapped_column(String(128), nullable=False, unique=True, index=True) + invited_by: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + accepted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + workspace: Mapped["Workspace"] = relationship("Workspace", back_populates="invitations") diff --git a/api/app/app/modules/workspaces/repository.py b/api/app/app/modules/workspaces/repository.py index 3f7c06c..96a80c8 100644 --- a/api/app/app/modules/workspaces/repository.py +++ b/api/app/app/modules/workspaces/repository.py @@ -1,12 +1,13 @@ import re import secrets +from datetime import datetime, timedelta, timezone from typing import Tuple, List from uuid import UUID from sqlalchemy import func from sqlalchemy.orm import Session, joinedload -from app.modules.workspaces.model import Workspace, WorkspaceMember +from app.modules.workspaces.model import Workspace, WorkspaceInvitation, WorkspaceMember def _slugify(text: str) -> str: @@ -145,3 +146,59 @@ def remove_member(db: Session, workspace_id: UUID, user_id: UUID) -> bool: db.delete(member) db.commit() return True + + +def create_invitation( + db: Session, + *, + workspace_id: UUID, + email: str, + invited_by: UUID, + role: str = "member", + expires_in_days: int = 7, +) -> WorkspaceInvitation: + token = secrets.token_urlsafe(32) + invitation = WorkspaceInvitation( + workspace_id=workspace_id, + email=email.strip().lower(), + role=role, + token=token, + invited_by=invited_by, + expires_at=datetime.now(timezone.utc) + timedelta(days=expires_in_days), + ) + db.add(invitation) + db.commit() + db.refresh(invitation) + return invitation + + +def get_invitation_by_token(db: Session, token: str) -> WorkspaceInvitation | None: + return ( + db.query(WorkspaceInvitation) + .options(joinedload(WorkspaceInvitation.workspace)) + .filter(WorkspaceInvitation.token == token) + .first() + ) + + +def mark_invitation_accepted(db: Session, invitation: WorkspaceInvitation) -> WorkspaceInvitation: + invitation.accepted_at = datetime.now(timezone.utc) + db.commit() + db.refresh(invitation) + return invitation + + +def list_workspace_invitations( + db: Session, + *, + workspace_id: UUID, + pending_only: bool = True, +) -> List[WorkspaceInvitation]: + query = ( + db.query(WorkspaceInvitation) + .options(joinedload(WorkspaceInvitation.workspace)) + .filter(WorkspaceInvitation.workspace_id == workspace_id) + ) + if pending_only: + query = query.filter(WorkspaceInvitation.accepted_at.is_(None)) + return query.order_by(WorkspaceInvitation.created_at.desc()).all() diff --git a/api/app/app/modules/workspaces/router.py b/api/app/app/modules/workspaces/router.py index 10d63ba..1fcf4ed 100644 --- a/api/app/app/modules/workspaces/router.py +++ b/api/app/app/modules/workspaces/router.py @@ -21,9 +21,11 @@ update_workspace_for_user, ) from app.modules.workspaces.members import router as members_router +from app.modules.workspaces.invitations import router as invitations_router router = APIRouter(prefix="/workspaces", tags=["workspaces"]) router.include_router(members_router, prefix="/{workspace_id}") +router.include_router(invitations_router) def _workspace_to_response(workspace: Workspace) -> WorkspaceResponse: diff --git a/api/app/app/modules/workspaces/schemas.py b/api/app/app/modules/workspaces/schemas.py index 6600e3d..fbea701 100644 --- a/api/app/app/modules/workspaces/schemas.py +++ b/api/app/app/modules/workspaces/schemas.py @@ -29,3 +29,30 @@ class WorkspaceListResponse(BaseModel): total: int page: int limit: int + + +class WorkspaceInvitationCreate(BaseModel): + email: str + role: str = "member" + + +class WorkspaceInvitationResponse(BaseModel): + id: UUID + workspace_id: UUID + workspace_name: str + email: str + role: str + token: str + invite_url: str + created_at: datetime + expires_at: datetime | None = None + accepted_at: datetime | None = None + + +class WorkspaceInvitationAcceptResponse(BaseModel): + workspace_id: UUID + workspace_name: str + + +class WorkspaceInvitationListResponse(BaseModel): + items: list[WorkspaceInvitationResponse] diff --git a/api/app/app/modules/workspaces/service.py b/api/app/app/modules/workspaces/service.py index ef6e368..69b93c8 100644 --- a/api/app/app/modules/workspaces/service.py +++ b/api/app/app/modules/workspaces/service.py @@ -1,16 +1,22 @@ from typing import Tuple +from datetime import datetime, timezone from uuid import UUID from sqlalchemy.orm import Session from app.modules.users.model import User -from app.modules.workspaces.model import Workspace +from app.modules.workspaces.model import Workspace, WorkspaceInvitation from app.modules.workspaces.repository import ( + create_invitation, create as create_workspace, delete as delete_workspace, + add_member, + get_invitation_by_token, + list_workspace_invitations, get_by_id, get_user_workspaces, is_member, + mark_invitation_accepted, make_unique_slug, update as update_workspace, ) @@ -66,3 +72,58 @@ def delete_workspace_for_user(db: Session, workspace_id: UUID, user: User) -> bo return False delete_workspace(db, workspace) return True + + +def create_workspace_invitation_for_user( + db: Session, + workspace_id: UUID, + user: User, + *, + email: str, + role: str = "member", +) -> WorkspaceInvitation | None: + workspace = get_workspace(db, workspace_id, user) + if not workspace or workspace.owner_id != user.id: + return None + return create_invitation( + db, + workspace_id=workspace_id, + email=email, + role=role, + invited_by=user.id, + ) + + +def get_workspace_invitation_by_token(db: Session, token: str) -> WorkspaceInvitation | None: + return get_invitation_by_token(db, token) + + +def list_workspace_invitations_for_user( + db: Session, + workspace_id: UUID, + user: User, +) -> list[WorkspaceInvitation] | None: + workspace = get_workspace(db, workspace_id, user) + if workspace is None: + return None + if workspace.owner_id != user.id: + return None + return list_workspace_invitations(db, workspace_id=workspace_id, pending_only=True) + + +def accept_workspace_invitation_for_user( + db: Session, token: str, user: User +) -> WorkspaceInvitation | None: + invitation = get_invitation_by_token(db, token) + if not invitation: + return None + if invitation.accepted_at is not None: + return None + if invitation.expires_at is not None and invitation.expires_at <= datetime.now(timezone.utc): + return None + if invitation.email.strip().lower() != user.email.strip().lower(): + return None + + if not is_member(db, invitation.workspace_id, user.id): + add_member(db, invitation.workspace_id, user.id, role=invitation.role) + return mark_invitation_accepted(db, invitation) diff --git a/api/app/pyproject.toml b/api/app/pyproject.toml index 976c5c3..50a171b 100644 --- a/api/app/pyproject.toml +++ b/api/app/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "loomy-api" -version = "0.0.1" +version = "0.1.0" description = "Loomy API" readme = "README.md" requires-python = ">=3.12" diff --git a/api/app/uv.lock b/api/app/uv.lock index 8decdbb..09a9ca4 100644 --- a/api/app/uv.lock +++ b/api/app/uv.lock @@ -488,7 +488,7 @@ wheels = [ [[package]] name = "loomy-api" -version = "0.0.1" +version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "alembic" }, diff --git a/apps/frontend/package-lock.json b/apps/frontend/package-lock.json index 67cf0a8..e1cd407 100644 --- a/apps/frontend/package-lock.json +++ b/apps/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "loomy", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loomy", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "@excalidraw/excalidraw": "^0.18.0", "@tailwindcss/vite": "^4.2.1", @@ -14,6 +14,7 @@ "react-dom": "^19.2.0", "react-helmet-async": "^3.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.8.0", "tailwindcss": "^4.2.1", "zustand": "^5.0.11" }, @@ -2162,6 +2163,42 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.2", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", @@ -2494,6 +2531,18 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/core": { "version": "1.15.18", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.18.tgz", @@ -2977,6 +3026,39 @@ "vite": "^5.2.0 || ^6 || ^7" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", @@ -2992,12 +3074,27 @@ "integrity": "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, "node_modules/@types/d3-time": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", "license": "MIT" }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -3071,6 +3168,12 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", @@ -4276,6 +4379,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", @@ -4370,6 +4479,16 @@ "node": ">=10.13.0" } }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/es6-promise-pool": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/es6-promise-pool/-/es6-promise-pool-2.5.0.tgz", @@ -4627,6 +4746,12 @@ "node": ">=0.10.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4880,6 +5005,16 @@ "pica": "^7.1.0" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -6387,6 +6522,36 @@ "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/react-is": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz", + "integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", @@ -6509,6 +6674,66 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/recharts": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.0.tgz", + "integrity": "sha512-Z/m38DX3L73ExO4Tpc9/iZWHmHnlzWG4njQbxsF5aSjwqmHNDDIm0rdEBArkwsBvR8U6EirlEHiQNYWCVh9sGQ==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -6748,6 +6973,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -7036,6 +7267,28 @@ "node": ">=8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 721420d..e5f5b6a 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -1,7 +1,7 @@ { "name": "loomy", "private": true, - "version": "0.1.0", + "version": "0.2.0", "type": "module", "scripts": { "dev": "vite", @@ -20,6 +20,7 @@ "react-dom": "^19.2.0", "react-helmet-async": "^3.0.0", "react-router-dom": "^7.13.1", + "recharts": "^3.8.0", "tailwindcss": "^4.2.1", "zustand": "^5.0.11" }, diff --git a/apps/frontend/src/components/layout/DashboardLayout.tsx b/apps/frontend/src/components/layout/DashboardLayout.tsx index f6816ce..0112ad6 100644 --- a/apps/frontend/src/components/layout/DashboardLayout.tsx +++ b/apps/frontend/src/components/layout/DashboardLayout.tsx @@ -11,6 +11,21 @@ import { useDashboardStore } from "@/stores/dashboardStore"; const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; +export interface DashboardOutletContext { + workspaces: { id: string; name: string; owner_id: string }[]; + selectedWorkspaceId: string | null; + setSelectedWorkspaceId: (id: string | null) => void; + fetchWorkspaces: () => Promise; + updateWorkspace: ( + workspaceId: string, + name: string, + ) => Promise<{ + id: string; + name: string; + } | null>; + deleteWorkspace: (workspaceId: string) => Promise; +} + export function DashboardLayout() { const { t } = useI18n(); const navigate = useNavigate(); @@ -31,6 +46,8 @@ export function DashboardLayout() { loading: workspacesLoading, createWorkspace, fetchWorkspaces, + updateWorkspace, + deleteWorkspace, } = useWorkspaces(); const [showInviteModal, setShowInviteModal] = useState(false); const [inviteEmail, setInviteEmail] = useState(""); @@ -165,8 +182,24 @@ export function DashboardLayout() { selectedWorkspaceId={selectedWorkspaceId} onSelectWorkspace={setSelectedWorkspaceId} onInviteClick={() => setShowInviteModal(true)} + onWorkspaceSettingsClick={() => + navigate("/dashboard/workspace-settings") + } > - + ({ + id: w.id, + name: w.name, + owner_id: w.owner_id, + })), + selectedWorkspaceId, + setSelectedWorkspaceId, + fetchWorkspaces, + updateWorkspace, + deleteWorkspace, + }} + /> {showInviteModal && selectedWorkspaceId && ( diff --git a/apps/frontend/src/components/layout/DashboardShell.tsx b/apps/frontend/src/components/layout/DashboardShell.tsx index fec74b9..37c0d30 100644 --- a/apps/frontend/src/components/layout/DashboardShell.tsx +++ b/apps/frontend/src/components/layout/DashboardShell.tsx @@ -13,6 +13,7 @@ interface DashboardShellProps { selectedWorkspaceId?: string | null; onSelectWorkspace?: (id: string) => void; onInviteClick?: () => void; + onWorkspaceSettingsClick?: () => void; } export function DashboardShell({ @@ -23,6 +24,7 @@ export function DashboardShell({ selectedWorkspaceId, onSelectWorkspace, onInviteClick, + onWorkspaceSettingsClick, }: DashboardShellProps) { const { t } = useI18n(); const navigate = useNavigate(); @@ -118,6 +120,7 @@ export function DashboardShell({ diff --git a/apps/frontend/src/i18n/locales/az.json b/apps/frontend/src/i18n/locales/az.json index 410fd3a..3cfa609 100644 --- a/apps/frontend/src/i18n/locales/az.json +++ b/apps/frontend/src/i18n/locales/az.json @@ -109,6 +109,44 @@ "deleteWorkspace": "İş sahəsini sil", "inviteByEmail": "E-poçtla dəvət et", "workspaceSettings": "İş sahəsi parametrləri", + "membersTab": "İstifadəçilər", + "analyticsTab": "Analitika", + "generalTab": "Ümumi", + "members": "Üzvlər", + "manageWorkspace": "İş sahəsini idarə et", + "searchMembers": "Üzvlərdə axtar", + "searchByNameOrEmail": "Ada və ya e-poçta görə axtar", + "role": "Rol", + "actions": "Əməliyyatlar", + "removeMember": "Sil", + "noMembersFound": "Üzv tapılmadı", + "workspaceName": "İş sahəsinin adı", + "dangerZone": "Riskli bölmə", + "deleteWorkspaceWarning": "Bu əməliyyat iş sahəsini və ona bağlı bütün lövhələri tamamilə silir.", + "deleteWorkspaceConfirm": "Bu iş sahəsi həmişəlik silinsin?", + "copyInviteLink": "Dəvət linkini kopyala", + "copyLatestInviteLink": "Son dəvəti kopyala", + "pendingInvitations": "Gözləyən dəvətlər", + "noPendingInvitations": "Gözləyən dəvət yoxdur", + "shareableInviteLink": "Paylaşıla bilən dəvət linki", + "inviteCreated": "Dəvət linki yaradıldı", + "createInviteFirst": "Əvvəlcə dəvət yaradın", + "noInviteGenerated": "Hələ dəvət linki yaradılmayıb", + "inviteLinkCopied": "Dəvət linki kopyalandı. E-poçt çatmazsa komanda ilə paylaşın.", + "inviteLinkCopiedShort": "Kopyalandı", + "inviteLinkCopyFailed": "Dəvət linki kopyalana bilmədi", + "inviteFallbackHint": "Bu e-poçt üçün hesab tapılmadı. Qeydiyyat üçün dəvət linkini paylaşın.", + "inviteSuccess": "Dəvət uğurla göndərildi", + "totalBoards": "Ümumi lövhələr", + "totalMembers": "Ümumi üzvlər", + "boardsPerMember": "Hər üzvə düşən lövhə", + "loadingAnalytics": "Analitika yüklənir...", + "boardCreationTrend": "Lövhə yaradılma trendləri", + "activityDistribution": "Lövhə aktivliyi bölgüsü", + "activityLast7Days": "Son 7 gündə yenilənən", + "activityLast30Days": "Son 30 gündə yenilənən", + "activityOlder": "Daha əvvəl yenilənən", + "recentMembers": "Son üzvlər", "lastWeek": "Keçən həftə", "older": "Daha əvvəl", "noRecentBoards": "Son açılan lövhə yoxdur. Lövhə açanda burada görünəcək.", diff --git a/apps/frontend/src/i18n/locales/en.json b/apps/frontend/src/i18n/locales/en.json index e081fb7..ffdf4ec 100644 --- a/apps/frontend/src/i18n/locales/en.json +++ b/apps/frontend/src/i18n/locales/en.json @@ -109,6 +109,44 @@ "deleteWorkspace": "Delete workspace", "inviteByEmail": "Invite by email", "workspaceSettings": "Workspace settings", + "membersTab": "Users", + "analyticsTab": "Analytics", + "generalTab": "General", + "members": "Members", + "manageWorkspace": "Manage workspace", + "searchMembers": "Search members", + "searchByNameOrEmail": "Search by name or email", + "role": "Role", + "actions": "Actions", + "removeMember": "Remove", + "noMembersFound": "No members found", + "workspaceName": "Workspace name", + "dangerZone": "Danger zone", + "deleteWorkspaceWarning": "This action permanently deletes the workspace and all related boards.", + "deleteWorkspaceConfirm": "Delete this workspace permanently?", + "copyInviteLink": "Copy invite link", + "copyLatestInviteLink": "Copy latest invite", + "pendingInvitations": "Pending invitations", + "noPendingInvitations": "No pending invitations", + "shareableInviteLink": "Shareable invite link", + "inviteCreated": "Invitation link created", + "createInviteFirst": "Create an invitation first", + "noInviteGenerated": "No invitation generated yet", + "inviteLinkCopied": "Invite link copied. Share it with teammates if email delivery fails.", + "inviteLinkCopiedShort": "Copied", + "inviteLinkCopyFailed": "Could not copy invite link", + "inviteFallbackHint": "No account found for this email. Share the invite link so they can sign up first.", + "inviteSuccess": "Invitation sent successfully", + "totalBoards": "Total boards", + "totalMembers": "Total members", + "boardsPerMember": "Boards per member", + "loadingAnalytics": "Loading analytics...", + "boardCreationTrend": "Board creation trend", + "activityDistribution": "Board activity distribution", + "activityLast7Days": "Updated in last 7 days", + "activityLast30Days": "Updated in last 30 days", + "activityOlder": "Updated earlier", + "recentMembers": "Recent members", "lastWeek": "Last week", "older": "Older", "noRecentBoards": "No recently opened boards. Open a board to see it here.", diff --git a/apps/frontend/src/i18n/locales/ru.json b/apps/frontend/src/i18n/locales/ru.json index fbc3999..701ce94 100644 --- a/apps/frontend/src/i18n/locales/ru.json +++ b/apps/frontend/src/i18n/locales/ru.json @@ -109,6 +109,44 @@ "deleteWorkspace": "Удалить пространство", "inviteByEmail": "Пригласить по email", "workspaceSettings": "Настройки пространства", + "membersTab": "Пользователи", + "analyticsTab": "Аналитика", + "generalTab": "Общие", + "members": "Участники", + "manageWorkspace": "Управление пространством", + "searchMembers": "Поиск участников", + "searchByNameOrEmail": "Поиск по имени или email", + "role": "Роль", + "actions": "Действия", + "removeMember": "Удалить", + "noMembersFound": "Участники не найдены", + "workspaceName": "Название пространства", + "dangerZone": "Опасная зона", + "deleteWorkspaceWarning": "Это действие навсегда удалит пространство и все связанные доски.", + "deleteWorkspaceConfirm": "Удалить это пространство безвозвратно?", + "copyInviteLink": "Скопировать ссылку-приглашение", + "copyLatestInviteLink": "Скопировать последнее приглашение", + "pendingInvitations": "Ожидающие приглашения", + "noPendingInvitations": "Ожидающих приглашений нет", + "shareableInviteLink": "Ссылка-приглашение для отправки", + "inviteCreated": "Ссылка-приглашение создана", + "createInviteFirst": "Сначала создайте приглашение", + "noInviteGenerated": "Ссылка-приглашение еще не создана", + "inviteLinkCopied": "Ссылка скопирована. Поделитесь ею, если письмо не дошло.", + "inviteLinkCopiedShort": "Скопировано", + "inviteLinkCopyFailed": "Не удалось скопировать ссылку", + "inviteFallbackHint": "Для этого email нет аккаунта. Отправьте ссылку, чтобы человек сначала зарегистрировался.", + "inviteSuccess": "Приглашение успешно отправлено", + "totalBoards": "Всего досок", + "totalMembers": "Всего участников", + "boardsPerMember": "Досок на участника", + "loadingAnalytics": "Загрузка аналитики...", + "boardCreationTrend": "Динамика создания досок", + "activityDistribution": "Распределение активности досок", + "activityLast7Days": "Обновлялись за 7 дней", + "activityLast30Days": "Обновлялись за 30 дней", + "activityOlder": "Обновлялись раньше", + "recentMembers": "Недавние участники", "lastWeek": "На прошлой неделе", "older": "Ранее", "noRecentBoards": "Нет недавно открытых досок. Откройте доску, и она появится здесь.", diff --git a/apps/frontend/src/lib/api.ts b/apps/frontend/src/lib/api.ts index 24aea2c..7b46bce 100644 --- a/apps/frontend/src/lib/api.ts +++ b/apps/frontend/src/lib/api.ts @@ -101,3 +101,20 @@ export interface WorkspaceMember { avatar_url: string | null; role: string; } + +export interface WorkspaceInvitation { + id: string; + workspace_id: string; + workspace_name: string; + email: string; + role: string; + token: string; + invite_url: string; + created_at: string; + expires_at?: string | null; + accepted_at?: string | null; +} + +export interface WorkspaceInvitationListResponse { + items: WorkspaceInvitation[]; +} diff --git a/apps/frontend/src/pages/auth/AuthCallbackPage.tsx b/apps/frontend/src/pages/auth/AuthCallbackPage.tsx index 4ef6b84..f316387 100644 --- a/apps/frontend/src/pages/auth/AuthCallbackPage.tsx +++ b/apps/frontend/src/pages/auth/AuthCallbackPage.tsx @@ -12,15 +12,33 @@ export function AuthCallbackPage() { const navigate = useNavigate(); const setToken = useAuthStore((s) => s.setToken); const token = searchParams.get("token"); + const inviteToken = searchParams.get("invite_token"); + const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; useEffect(() => { - if (token) { - setToken(token); - navigate("/dashboard", { replace: true }); - } else { + if (!token) { navigate("/login", { replace: true }); + return; } - }, [token, navigate, setToken]); + + setToken(token); + const acceptMaybe = async () => { + if (inviteToken) { + await fetch( + `${API_BASE}/api/workspaces/invitations/${inviteToken}/accept`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }, + ); + } + navigate("/dashboard", { replace: true }); + }; + void acceptMaybe(); + }, [token, inviteToken, navigate, setToken, API_BASE]); return (
diff --git a/apps/frontend/src/pages/auth/InvitePage.tsx b/apps/frontend/src/pages/auth/InvitePage.tsx new file mode 100644 index 0000000..982b815 --- /dev/null +++ b/apps/frontend/src/pages/auth/InvitePage.tsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; +import { PageTitle } from "@/components/PageTitle"; +import { Header } from "@/components/layout"; +import { Button } from "@/components/ui"; +import { apiFetch, formatApiError, type WorkspaceInvitation } from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; + +const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; + +export function InvitePage() { + const navigate = useNavigate(); + const { token } = useParams<{ token: string }>(); + const authToken = useAuthStore((s) => s.token); + const [loading, setLoading] = useState(Boolean(token)); + const [accepting, setAccepting] = useState(false); + const [error, setError] = useState(null); + const [invite, setInvite] = useState(null); + const [success, setSuccess] = useState(null); + + useEffect(() => { + if (!token) return; + fetch(`${API_BASE}/api/workspaces/invitations/${token}`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(formatApiError(data.detail, "Invitation not found")); + } + return res.json() as Promise; + }) + .then((data) => { + setInvite(data); + }) + .catch((err: unknown) => { + setError(err instanceof Error ? err.message : "Invitation not found"); + }) + .finally(() => setLoading(false)); + }, [token]); + + const inviteTokenQuery = useMemo( + () => (token ? `?invite_token=${encodeURIComponent(token)}` : ""), + [token], + ); + + async function acceptInvitation() { + if (!token) return; + setAccepting(true); + setError(null); + const res = await apiFetch(`/api/workspaces/invitations/${token}/accept`, { + method: "POST", + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setError(formatApiError(data.detail, "Failed to accept invitation")); + setAccepting(false); + return; + } + setSuccess("Invitation accepted. Redirecting to dashboard..."); + window.setTimeout(() => navigate("/dashboard"), 700); + } + + return ( +
+ +
+
+
+

+ Workspace invitation +

+ + {loading && ( +

Loading...

+ )} + {!loading && !token && ( +

+ Invitation token is missing +

+ )} + {!loading && error && ( +

{error}

+ )} + + {!loading && invite && ( +
+

+ You are invited to join {invite.workspace_name}{" "} + as {invite.role} for{" "} + {invite.email}. +

+ + {success && ( +

+ {success} +

+ )} + + {authToken ? ( + + ) : ( +
+ + + + + + +
+ )} +
+ )} +
+
+
+ ); +} diff --git a/apps/frontend/src/pages/auth/LoginPage.tsx b/apps/frontend/src/pages/auth/LoginPage.tsx index fcd1109..581b54f 100644 --- a/apps/frontend/src/pages/auth/LoginPage.tsx +++ b/apps/frontend/src/pages/auth/LoginPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useI18n } from "@/context/I18nContext"; import { PageTitle } from "@/components/PageTitle"; import { Header } from "@/components/layout"; @@ -12,6 +12,7 @@ const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; export function LoginPage() { const { t } = useI18n(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const setToken = useAuthStore((s) => s.setToken); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -34,6 +35,28 @@ export function LoginPage() { return; } setToken(data.access_token); + const inviteToken = searchParams.get("invite_token"); + if (inviteToken) { + const acceptRes = await fetch( + `${API_BASE}/api/workspaces/invitations/${inviteToken}/accept`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${data.access_token}`, + }, + }, + ); + if (!acceptRes.ok) { + const acceptData = await acceptRes.json().catch(() => ({})); + setError( + formatApiError( + acceptData.detail, + "Login succeeded, invite was not accepted", + ), + ); + } + } navigate("/dashboard"); } catch { setError("Network error"); @@ -43,7 +66,11 @@ export function LoginPage() { } function handleOAuth(provider: "github" | "google") { - window.location.href = `${API_BASE}/api/auth/${provider}`; + const inviteToken = searchParams.get("invite_token"); + const suffix = inviteToken + ? `?invite_token=${encodeURIComponent(inviteToken)}` + : ""; + window.location.href = `${API_BASE}/api/auth/${provider}${suffix}`; } return ( @@ -114,7 +141,7 @@ export function LoginPage() {

{t("auth.login.noAccount")}{" "} {t("common.signUp")} diff --git a/apps/frontend/src/pages/auth/RegisterPage.tsx b/apps/frontend/src/pages/auth/RegisterPage.tsx index 84ed98f..516953a 100644 --- a/apps/frontend/src/pages/auth/RegisterPage.tsx +++ b/apps/frontend/src/pages/auth/RegisterPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useI18n } from "@/context/I18nContext"; import { PageTitle } from "@/components/PageTitle"; import { Header } from "@/components/layout"; @@ -11,6 +11,7 @@ const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8000"; export function RegisterPage() { const { t } = useI18n(); const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const [firstName, setFirstName] = useState(""); const [lastName, setLastName] = useState(""); const [email, setEmail] = useState(""); @@ -40,7 +41,12 @@ export function RegisterPage() { setError(formatApiError(data.detail, "Registration failed")); return; } - navigate("/login"); + const inviteToken = searchParams.get("invite_token"); + if (inviteToken) { + navigate(`/login?invite_token=${encodeURIComponent(inviteToken)}`); + } else { + navigate("/login"); + } } catch { setError("Network error"); } finally { @@ -49,7 +55,11 @@ export function RegisterPage() { } function handleOAuth(provider: "github" | "google") { - window.location.href = `${API_BASE}/api/auth/${provider}`; + const inviteToken = searchParams.get("invite_token"); + const suffix = inviteToken + ? `?invite_token=${encodeURIComponent(inviteToken)}` + : ""; + window.location.href = `${API_BASE}/api/auth/${provider}${suffix}`; } return ( @@ -146,7 +156,10 @@ export function RegisterPage() {

{t("auth.register.hasAccount")}{" "} - + {t("common.login")}

diff --git a/apps/frontend/src/pages/auth/index.ts b/apps/frontend/src/pages/auth/index.ts index ddbb8d1..5da71da 100644 --- a/apps/frontend/src/pages/auth/index.ts +++ b/apps/frontend/src/pages/auth/index.ts @@ -1,3 +1,4 @@ export { AuthCallbackPage } from "./AuthCallbackPage"; +export { InvitePage } from "./InvitePage"; export { LoginPage } from "./LoginPage"; export { RegisterPage } from "./RegisterPage"; diff --git a/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx new file mode 100644 index 0000000..5364b03 --- /dev/null +++ b/apps/frontend/src/pages/dashboard/WorkspaceSettingsPage.tsx @@ -0,0 +1,791 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { useNavigate, useOutletContext } from "react-router-dom"; +import { + Area, + AreaChart, + CartesianGrid, + Cell, + Pie, + PieChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { useI18n } from "@/context/I18nContext"; +import { Button, Input } from "@/components/ui"; +import { + apiFetch, + formatApiError, + type Board, + type BoardListResponse, + type WorkspaceInvitation, + type WorkspaceInvitationListResponse, + type WorkspaceMember, +} from "@/lib/api"; +import { useAuthStore } from "@/stores/authStore"; +import type { DashboardOutletContext } from "@/components/layout/DashboardLayout"; + +type SettingsTab = "general" | "members" | "analytics"; + +const ANALYTICS_COLORS = ["#4f46e5", "#0891b2", "#059669", "#d97706"]; + +export function WorkspaceSettingsPage() { + const { t } = useI18n(); + const navigate = useNavigate(); + const { user } = useAuthStore(); + const { + workspaces, + selectedWorkspaceId, + setSelectedWorkspaceId, + fetchWorkspaces, + updateWorkspace, + deleteWorkspace, + } = useOutletContext(); + + const [activeTab, setActiveTab] = useState("members"); + const [members, setMembers] = useState([]); + const [membersLoading, setMembersLoading] = useState(false); + const [membersError, setMembersError] = useState(null); + const [memberSearch, setMemberSearch] = useState(""); + const [inviteEmail, setInviteEmail] = useState(""); + const [inviteError, setInviteError] = useState(null); + const [inviteInfo, setInviteInfo] = useState(null); + const [savingInvite, setSavingInvite] = useState(false); + const [copiedInviteId, setCopiedInviteId] = useState(null); + const [invitations, setInvitations] = useState([]); + const [invitationsLoading, setInvitationsLoading] = useState(false); + const [invitationsError, setInvitationsError] = useState(null); + + const [workspaceNameDrafts, setWorkspaceNameDrafts] = useState< + Record + >({}); + const [generalError, setGeneralError] = useState(null); + const [savingGeneral, setSavingGeneral] = useState(false); + const [deletingWorkspace, setDeletingWorkspace] = useState(false); + const [boards, setBoards] = useState([]); + const [boardsLoading, setBoardsLoading] = useState(false); + const [boardsError, setBoardsError] = useState(null); + + const selectedWorkspace = useMemo( + () => workspaces.find((ws) => ws.id === selectedWorkspaceId) ?? null, + [workspaces, selectedWorkspaceId], + ); + + const isOwner = + Boolean(user && selectedWorkspace) && + user?.id === selectedWorkspace?.owner_id; + + const effectiveWorkspaceName = + (selectedWorkspaceId && workspaceNameDrafts[selectedWorkspaceId]) || + selectedWorkspace?.name || + ""; + + const loadMembers = async (workspaceId: string) => { + setMembersLoading(true); + setMembersError(null); + await apiFetch(`/api/workspaces/${workspaceId}/members`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + formatApiError(data.detail, "Failed to load workspace members"), + ); + } + return res.json() as Promise<{ items?: WorkspaceMember[] }>; + }) + .then((data) => { + setMembers(data.items ?? []); + }) + .catch((err: unknown) => { + setMembersError( + err instanceof Error + ? err.message + : "Failed to load workspace members", + ); + }) + .finally(() => { + setMembersLoading(false); + }); + }; + + const loadBoards = async (workspaceId: string) => { + setBoardsLoading(true); + setBoardsError(null); + await apiFetch(`/api/boards?workspace_id=${workspaceId}&page=1&limit=100`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(formatApiError(data.detail, "Failed to load boards")); + } + return res.json() as Promise; + }) + .then((data) => { + setBoards(data.items ?? []); + }) + .catch((err: unknown) => { + setBoardsError( + err instanceof Error ? err.message : "Failed to load boards", + ); + }) + .finally(() => { + setBoardsLoading(false); + }); + }; + + const loadInvitations = useCallback( + async (workspaceId: string) => { + if (!isOwner) { + setInvitations([]); + setInvitationsError(null); + return; + } + setInvitationsLoading(true); + setInvitationsError(null); + await apiFetch(`/api/workspaces/${workspaceId}/invitations`) + .then(async (res) => { + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error( + formatApiError(data.detail, "Failed to load pending invitations"), + ); + } + return res.json() as Promise; + }) + .then((data) => setInvitations(data.items ?? [])) + .catch((err: unknown) => { + setInvitationsError( + err instanceof Error + ? err.message + : "Failed to load pending invitations", + ); + }) + .finally(() => setInvitationsLoading(false)); + }, + [isOwner], + ); + + useEffect(() => { + if (!selectedWorkspaceId) return; + const timer = window.setTimeout(() => { + void loadMembers(selectedWorkspaceId); + void loadBoards(selectedWorkspaceId); + void loadInvitations(selectedWorkspaceId); + }, 0); + return () => window.clearTimeout(timer); + }, [selectedWorkspaceId, isOwner, loadInvitations]); + + const filteredMembers = useMemo(() => { + const q = memberSearch.trim().toLowerCase(); + if (!q) return members; + return members.filter((member) => { + const username = (member.username ?? "").toLowerCase(); + const email = (member.email ?? "").toLowerCase(); + return username.includes(q) || email.includes(q); + }); + }, [members, memberSearch]); + + const monthlyBoardActivity = useMemo(() => { + const byMonth = new Map(); + const now = new Date(); + for (let i = 5; i >= 0; i -= 1) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + byMonth.set(key, 0); + } + for (const board of boards) { + const date = new Date(board.created_at); + const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + if (byMonth.has(key)) { + byMonth.set(key, (byMonth.get(key) ?? 0) + 1); + } + } + return Array.from(byMonth.entries()).map(([key, created]) => { + const [year, month] = key.split("-"); + return { + key, + month: `${month}/${year.slice(2)}`, + created, + }; + }); + }, [boards]); + + const boardAgeDistribution = useMemo(() => { + const latestUpdatedMs = boards.reduce((acc, board) => { + const updatedMs = new Date(board.updated_at).getTime(); + return Number.isNaN(updatedMs) ? acc : Math.max(acc, updatedMs); + }, 0); + let lastWeek = 0; + let lastMonth = 0; + let older = 0; + for (const board of boards) { + const updatedMs = new Date(board.updated_at).getTime(); + const days = Math.floor( + (latestUpdatedMs - updatedMs) / (1000 * 60 * 60 * 24), + ); + if (days <= 7) lastWeek += 1; + else if (days <= 30) lastMonth += 1; + else older += 1; + } + return [ + { name: t("dashboard.activityLast7Days"), value: lastWeek }, + { name: t("dashboard.activityLast30Days"), value: lastMonth }, + { name: t("dashboard.activityOlder"), value: older }, + ]; + }, [boards, t]); + + const recentMembers = useMemo(() => { + return [...members].slice(0, 5); + }, [members]); + + if (!selectedWorkspaceId || !selectedWorkspace) { + return ( +
+

+ {t("dashboard.workspaceSettings")} +

+

+ {t("dashboard.selectWorkspace")} +

+ +
+ ); + } + + return ( +
+
+

+ {t("dashboard.workspaceSettings")} +

+

+ {selectedWorkspace.name} +

+
+ +
+ +
+ + {activeTab === "members" && ( +
+
+

+ {t("dashboard.members")} ({members.length}) +

+ +
+ +
+ setMemberSearch(e.target.value)} + placeholder={t("dashboard.searchByNameOrEmail")} + /> + +
{ + e.preventDefault(); + if (!inviteEmail.trim()) return; + setInviteError(null); + setInviteInfo(null); + setSavingInvite(true); + const res = await apiFetch( + `/api/workspaces/${selectedWorkspaceId}/invitations`, + { + method: "POST", + body: JSON.stringify({ + email: inviteEmail.trim(), + role: "member", + }), + }, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setInviteError( + formatApiError(data.detail, "Failed to create invitation"), + ); + setSavingInvite(false); + return; + } + const createdInvitation = + (await res.json()) as WorkspaceInvitation; + setInviteEmail(""); + setInviteInfo(t("dashboard.inviteCreated")); + await fetchWorkspaces(); + await loadMembers(selectedWorkspaceId); + await loadInvitations(selectedWorkspaceId); + setCopiedInviteId(createdInvitation.id); + setSavingInvite(false); + }} + > +
+ setInviteEmail(e.target.value)} + placeholder="user@example.com" + /> +
+ + +
+
+ + {inviteError &&

{inviteError}

} + {inviteInfo && ( +

{inviteInfo}

+ )} + {invitationsError && ( +

{invitationsError}

+ )} + {membersError && ( +

{membersError}

+ )} + +
+
+

+ {t("dashboard.pendingInvitations")} +

+
+ {invitationsLoading ? ( +
+ Loading... +
+ ) : invitations.length === 0 ? ( +
+ {t("dashboard.noPendingInvitations")} +
+ ) : ( +
+ {invitations.map((invitation) => ( +
+
+

+ {invitation.email} +

+

+ {new Date(invitation.created_at).toLocaleString()} +

+
+ +
+ ))} +
+ )} +
+ +
+
+ {t("dashboard.name")} + {t("dashboard.role")} + {t("dashboard.actions")} +
+ + {membersLoading ? ( +
+ Loading... +
+ ) : filteredMembers.length === 0 ? ( +
+ {t("dashboard.noMembersFound")} +
+ ) : ( + filteredMembers.map((member) => ( +
+
+

+ {member.username || member.email || member.user_id} +

+ {member.email && ( +

+ {member.email} +

+ )} +
+ + {member.role} + +
+ {member.user_id !== selectedWorkspace.owner_id && ( + + )} +
+
+ )) + )} +
+
+ )} + + {activeTab === "analytics" && ( +
+
+
+

+ {t("dashboard.totalBoards")} +

+

+ {boards.length} +

+
+
+

+ {t("dashboard.totalMembers")} +

+

+ {members.length} +

+
+
+

+ {t("dashboard.boardsPerMember")} +

+

+ {members.length > 0 + ? (boards.length / members.length).toFixed(1) + : "0.0"} +

+
+
+ + {(boardsLoading || membersLoading) && ( +

+ {t("dashboard.loadingAnalytics")} +

+ )} + {boardsError &&

{boardsError}

} + +
+
+

+ {t("dashboard.boardCreationTrend")} +

+
+ + + + + + + + + + + + + + + +
+
+ +
+

+ {t("dashboard.activityDistribution")} +

+
+ + + + {boardAgeDistribution.map((entry, index) => ( + + ))} + + + + +
+
+ {boardAgeDistribution.map((item, index) => ( +
+ + + {item.name} + + + {item.value} + +
+ ))} +
+
+
+ +
+

+ {t("dashboard.recentMembers")} +

+
+ {recentMembers.map((member) => ( +
+

+ {member.username || member.email || member.user_id} +

+

+ {member.role} +

+
+ ))} + {recentMembers.length === 0 && ( +

+ {t("dashboard.noMembersFound")} +

+ )} +
+
+
+ )} + + {activeTab === "general" && ( +
+
+

+ {t("dashboard.generalTab")} +

+
{ + e.preventDefault(); + setGeneralError(null); + setSavingGeneral(true); + const updated = await updateWorkspace( + selectedWorkspaceId, + effectiveWorkspaceName.trim(), + ); + if (!updated) { + setGeneralError("Failed to update workspace"); + setSavingGeneral(false); + return; + } + setWorkspaceNameDrafts((prev) => { + const next = { ...prev }; + delete next[selectedWorkspaceId]; + return next; + }); + await fetchWorkspaces(); + setSelectedWorkspaceId(updated.id); + setSavingGeneral(false); + }} + > + + setWorkspaceNameDrafts((prev) => ({ + ...prev, + [selectedWorkspaceId]: e.target.value, + })) + } + required + disabled={!isOwner} + /> + + {generalError && ( +

+ {generalError} +

+ )} +
+
+ +
+

+ {t("dashboard.dangerZone")} +

+

+ {t("dashboard.deleteWorkspaceWarning")} +

+ +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/pages/dashboard/index.ts b/apps/frontend/src/pages/dashboard/index.ts index 02662e9..9dd7428 100644 --- a/apps/frontend/src/pages/dashboard/index.ts +++ b/apps/frontend/src/pages/dashboard/index.ts @@ -1,3 +1,4 @@ export { DashboardPage } from "./DashboardPage"; export { RecentPage } from "./RecentPage"; export { StarredPage } from "./StarredPage"; +export { WorkspaceSettingsPage } from "./WorkspaceSettingsPage"; diff --git a/apps/frontend/src/routes/index.tsx b/apps/frontend/src/routes/index.tsx index e7dea71..d5c3b30 100644 --- a/apps/frontend/src/routes/index.tsx +++ b/apps/frontend/src/routes/index.tsx @@ -19,6 +19,9 @@ const RegisterPage = lazy(() => const AuthCallbackPage = lazy(() => import("@/pages/auth").then((m) => ({ default: m.AuthCallbackPage })), ); +const InvitePage = lazy(() => + import("@/pages/auth").then((m) => ({ default: m.InvitePage })), +); const DashboardPage = lazy(() => import("@/pages/dashboard").then((m) => ({ default: m.DashboardPage })), ); @@ -28,6 +31,11 @@ const RecentPage = lazy(() => const StarredPage = lazy(() => import("@/pages/dashboard").then((m) => ({ default: m.StarredPage })), ); +const WorkspaceSettingsPage = lazy(() => + import("@/pages/dashboard").then((m) => ({ + default: m.WorkspaceSettingsPage, + })), +); const BoardPage = lazy(() => import("@/pages/board").then((m) => ({ default: m.BoardPage })), ); @@ -58,6 +66,14 @@ const router = createBrowserRouter([ ), }, + { + path: "/invite/:token", + element: ( + + + + ), + }, { path: "/dashboard", element: ( @@ -90,6 +106,14 @@ const router = createBrowserRouter([ ), }, + { + path: "workspace-settings", + element: ( + + + + ), + }, ], }, {