Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
f4f9a3b
[FEAT] Add workspace settings page with members and general sections
martian56 Mar 24, 2026
adab57c
[FEAT] Provide workspace settings context from dashboard layout
martian56 Mar 24, 2026
4177b36
[FEAT] Connect workspace settings action in dashboard shell
martian56 Mar 24, 2026
6b83d1b
[FEAT] Register dashboard workspace settings route
martian56 Mar 24, 2026
0f3fd36
[CHORE] Export workspace settings page from dashboard pages
martian56 Mar 24, 2026
8a93a0a
[FEAT] Add English labels for workspace settings
martian56 Mar 24, 2026
152d38e
[FEAT] Add Azerbaijani labels for workspace settings
martian56 Mar 24, 2026
092ad2d
[FEAT] Add Russian labels for workspace settings
martian56 Mar 24, 2026
7a7ecfa
[FEAT] Add analytics tab and invite link fallback in workspace settings
martian56 Mar 24, 2026
70d99c1
[FEAT] Add workspace settings analytics and invite link translations
martian56 Mar 24, 2026
da9b667
[CHORE] Add recharts dependency for workspace analytics
martian56 Mar 24, 2026
3fc952c
[FEAT] Add invite link UX and invitation accept page
martian56 Mar 24, 2026
eb9836a
[FIX] Preserve invite token through OAuth callbacks
martian56 Mar 24, 2026
d3410f8
[FEAT] Add invitation token flow localization copy
martian56 Mar 24, 2026
0370efa
[FEAT] Add workspace invitation token domain and services
martian56 Mar 24, 2026
9547414
[FEAT] Add workspace invitations migration wiring
martian56 Mar 24, 2026
b43b66f
[CHORE] Update backend dependencies for invitation flow
martian56 Mar 24, 2026
390a2ba
[CHORE] Update gitignore rules
martian56 Mar 24, 2026
85da97d
[FEAT] Add pending workspace invitations listing endpoint
martian56 Mar 24, 2026
f93dc11
[FEAT] Replace invite link block with pending invitations UX
martian56 Mar 24, 2026
5ecd8e8
[FEAT] Add localization for pending invitations labels
martian56 Mar 24, 2026
bd54259
[FIX] Fix format and type issues
martian56 Mar 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
.env.local
.env.*.local
!.env.example

scripts/
# IDE and editor
.cursor/
.vscode/
Expand Down
6 changes: 5 additions & 1 deletion api/app/alembic/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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 ###
26 changes: 18 additions & 8 deletions api/app/app/core/redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
24 changes: 18 additions & 6 deletions api/app/app/modules/auth/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))


Expand All @@ -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:
Expand All @@ -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))


Expand All @@ -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:
Expand All @@ -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)
122 changes: 122 additions & 0 deletions api/app/app/modules/workspaces/invitations.py
Original file line number Diff line number Diff line change
@@ -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 "",
)
34 changes: 34 additions & 0 deletions api/app/app/modules/workspaces/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Loading
Loading