Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,5 +71,7 @@ Key points:

## Misc rules

- Version control operations are for humans, not agents.
- Git commits and pushes are for humans, not agents.
- No blank lines in functions.
- API endpoint functions should start with their REST verbs,
e.g., `post_something` or `get_something`.
678 changes: 678 additions & 0 deletions LATEX_EDITOR_PLAN.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Add project membership and invitations

Native (non-GitHub) project membership plus shareable invite links, so users
without GitHub accounts can be granted collaborator access.

Revision ID: b7e2f4a1c9d8
Revises: f3a9c1d2b4e6
Create Date: 2026-06-17 00:00:00.000000

"""

from alembic import op
import sqlalchemy as sa
import sqlmodel.sql.sqltypes


# revision identifiers, used by Alembic.
revision = "b7e2f4a1c9d8"
down_revision = "f3a9c1d2b4e6"
branch_labels = None
depends_on = None


def upgrade():
op.create_table(
"projectmembership",
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column("role_id", sa.Integer(), nullable=False),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column(
"updated",
sa.DateTime(),
server_default=sa.func.now(),
nullable=False,
),
sa.Column("invited_by_user_id", sa.Uuid(), nullable=True),
sa.ForeignKeyConstraint(
["user_id"], ["user.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["project_id"], ["project.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["invited_by_user_id"], ["user.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("user_id", "project_id"),
)
op.create_table(
"projectinvitation",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("project_id", sa.Uuid(), nullable=False),
sa.Column(
"token_hash",
sqlmodel.sql.sqltypes.AutoString(),
nullable=False,
),
sa.Column("role_id", sa.Integer(), nullable=False),
sa.Column("created_by_user_id", sa.Uuid(), nullable=True),
sa.Column("created", sa.DateTime(), nullable=False),
sa.Column("expires", sa.DateTime(), nullable=True),
sa.Column("max_uses", sa.Integer(), nullable=True),
sa.Column("use_count", sa.Integer(), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(
["project_id"], ["project.id"], ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["created_by_user_id"], ["user.id"], ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_projectinvitation_token_hash"),
"projectinvitation",
["token_hash"],
unique=True,
)


def downgrade():
op.drop_index(
op.f("ix_projectinvitation_token_hash"),
table_name="projectinvitation",
)
op.drop_table("projectinvitation")
op.drop_table("projectmembership")
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Make account github_name nullable

Allows accounts created without GitHub (email/Google signup). Project owners
must still have a github_name (enforced in the app layer) until git hosting is
decoupled from GitHub; collaborators need not.

Revision ID: f3a9c1d2b4e6
Revises: dcef842dee10
Create Date: 2026-06-17 00:00:00.000000

"""

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "f3a9c1d2b4e6"
down_revision = "dcef842dee10"
branch_labels = None
depends_on = None


def upgrade():
op.alter_column(
"account", "github_name", existing_type=sa.VARCHAR(), nullable=True
)


def downgrade():
# Note: rows with NULL github_name (GitHub-less accounts) must be handled
# before downgrading, or this will fail.
op.alter_column(
"account", "github_name", existing_type=sa.VARCHAR(), nullable=False
)
2 changes: 1 addition & 1 deletion backend/app/api/routes/accounts.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

class AccountPublic(SQLModel):
name: str
github_name: str
github_name: str | None
display_name: str
kind: Literal["user", "org"]
role: Literal["self", "read", "write", "admin", "owner"] | None = None
Expand Down
170 changes: 169 additions & 1 deletion backend/app/api/routes/projects/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import uuid
import zipfile
from copy import deepcopy
from datetime import datetime
from datetime import datetime, timedelta
from fnmatch import fnmatch
from io import StringIO
from pathlib import Path
Expand Down Expand Up @@ -48,6 +48,7 @@
)
from app.api.routes.orgs import OrgPost, post_org
from app.config import settings
from app.security import generate_refresh_token, hash_refresh_token
from app.core import (
CATEGORIES_PLURAL_TO_SINGULAR,
CATEGORIES_SINGULAR_TO_PLURAL,
Expand Down Expand Up @@ -88,6 +89,12 @@
ProjectComment,
ProjectCommentPatch,
ProjectCommentPost,
ProjectInvitation,
ProjectInvitationCreated,
ProjectInvitationPost,
ProjectInvitationPublic,
ProjectInvitationRedeemed,
ProjectMembership,
ProjectPost,
ProjectPublic,
ProjectsPublic,
Expand All @@ -98,6 +105,7 @@
UserOrgMembership,
UserProjectAccess,
)
from app.models.core import ROLE_IDS
from app.models.projects import (
Showcase,
ShowcaseFigure,
Expand Down Expand Up @@ -269,6 +277,13 @@ def post_project(
project_in: ProjectPost,
) -> ProjectPublic:
"""Create new project."""
# Project owners must have a linked GitHub account until git hosting is
# decoupled from GitHub. GitHub-less users can still collaborate.
if current_user.account.github_name is None:
raise HTTPException(
403,
"A linked GitHub account is required to create or own projects.",
)
project_in.name = project_in.name.lower()
if project_in.git_repo_exists and project_in.git_repo_url is None:
raise HTTPException(
Expand Down Expand Up @@ -3787,6 +3802,159 @@ def delete_project_collaborator(
return Message(message="Success")


@router.post("/projects/{owner_name}/{project_name}/invitations")
def post_project_invitation(
owner_name: str,
project_name: str,
req: ProjectInvitationPost,
current_user: CurrentUser,
session: SessionDep,
) -> ProjectInvitationCreated:
"""Create a shareable invite link granting native project membership.

The raw token is returned only here; the DB stores its hash. Invites can
grant up to admin, never ownership.
"""
project = app.projects.get_project(
owner_name=owner_name,
project_name=project_name,
session=session,
current_user=current_user,
min_access_level="admin",
)
token = generate_refresh_token()
expires = (
utcnow() + timedelta(days=req.expires_days)
if req.expires_days is not None
else None
)
invitation = ProjectInvitation(
project_id=project.id,
token_hash=hash_refresh_token(token),
role_id=ROLE_IDS[req.role],
created_by_user_id=current_user.id,
expires=expires,
max_uses=req.max_uses,
)
session.add(invitation)
session.commit()
session.refresh(invitation)
url = f"{settings.frontend_host.rstrip('/')}/join/{token}"
return ProjectInvitationCreated(
id=invitation.id,
role_name=invitation.role_name,
created=invitation.created,
expires=invitation.expires,
max_uses=invitation.max_uses,
use_count=invitation.use_count,
revoked=invitation.revoked,
token=token,
url=url,
)


@router.get("/projects/{owner_name}/{project_name}/invitations")
def get_project_invitations(
owner_name: str,
project_name: str,
current_user: CurrentUser,
session: SessionDep,
) -> list[ProjectInvitationPublic]:
project = app.projects.get_project(
owner_name=owner_name,
project_name=project_name,
session=session,
current_user=current_user,
min_access_level="admin",
)
invitations = session.exec(
select(ProjectInvitation)
.where(ProjectInvitation.project_id == project.id)
.order_by(sqlalchemy.desc(ProjectInvitation.created)) # type: ignore
).all()
return list(invitations) # type: ignore[return-value]


@router.delete(
"/projects/{owner_name}/{project_name}/invitations/{invitation_id}"
)
def delete_project_invitation(
owner_name: str,
project_name: str,
invitation_id: uuid.UUID,
current_user: CurrentUser,
session: SessionDep,
) -> Message:
project = app.projects.get_project(
owner_name=owner_name,
project_name=project_name,
session=session,
current_user=current_user,
min_access_level="admin",
)
invitation = session.get(ProjectInvitation, invitation_id)
if invitation is None or invitation.project_id != project.id:
raise HTTPException(404, "Invitation not found")
invitation.revoked = True
session.add(invitation)
session.commit()
return Message(message="Invitation revoked")


@router.post("/project-invitations/{token}")
def post_project_invitation_redemption(
token: str,
current_user: CurrentUser,
session: SessionDep,
) -> ProjectInvitationRedeemed:
"""Redeem an invite link, granting the current user native membership."""
invitation = session.exec(
select(ProjectInvitation).where(
ProjectInvitation.token_hash == hash_refresh_token(token)
)
).first()
if invitation is None:
raise HTTPException(404, "Invitation not found")
if not invitation.is_valid:
raise HTTPException(410, "Invitation is no longer valid")
project = session.get(Project, invitation.project_id)
if project is None:
raise HTTPException(404, "Project not found")
# Project owners already have full access; don't create a lesser membership.
if project.owner_account.user_id == current_user.id:
return ProjectInvitationRedeemed(
owner_name=project.owner_account.name,
project_name=project.name,
role_name="owner",
)
existing = session.exec(
select(ProjectMembership)
.where(ProjectMembership.project_id == project.id)
.where(ProjectMembership.user_id == current_user.id)
).first()
if existing is None:
session.add(
ProjectMembership(
user_id=current_user.id,
project_id=project.id,
role_id=invitation.role_id,
invited_by_user_id=invitation.created_by_user_id,
)
)
elif invitation.role_id > existing.role_id:
# Upgrade if the invite grants more than they already have.
existing.role_id = invitation.role_id
session.add(existing)
invitation.use_count += 1
session.add(invitation)
session.commit()
return ProjectInvitationRedeemed(
owner_name=project.owner_account.name,
project_name=project.name,
role_name=invitation.role_name,
)


class Issue(BaseModel):
id: int
number: int
Expand Down
7 changes: 5 additions & 2 deletions backend/app/api/routes/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,11 @@ def delete_current_user(

@router.post("/users/signup")
def register_user(session: SessionDep, user_in: UserRegister) -> UserPublic:
"""Create new user without the need to be logged in."""
raise HTTPException(501)
"""Create a new user with email + password, without a GitHub account.

Such users can collaborate on projects (e.g. via invite links) but cannot
own projects until git hosting is decoupled from GitHub.
"""
user = users.get_user_by_email(session=session, email=user_in.email)
if user:
raise HTTPException(
Expand Down
Loading