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
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""add_jurisdiction_to_user_profiles

Revision ID: 76b6cea54557
Revises: e92b3ac1b42b
Create Date: 2026-03-14 22:33:03.677232

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = '76b6cea54557'
down_revision: Union[str, Sequence[str], None] = 'e92b3ac1b42b'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column('user_profiles', sa.Column('jurisdiction', sa.String(100), nullable=True))


def downgrade() -> None:
op.drop_column('user_profiles', 'jurisdiction')
71 changes: 60 additions & 11 deletions backend/app/api/routes/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import re
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Optional
from uuid import UUID

Expand All @@ -8,7 +8,8 @@
from sqlalchemy.orm import Session, aliased

from app.api.deps.auth import AuthContext, require_permissions, require_roles
from app.core.security import hash_password
from app.core.config import settings
from app.core.security import generate_invitation_token, hash_invitation_token, hash_password
from app.db.deps import get_db
from app.models.case import Case
from app.models.organization import Organization
Expand All @@ -22,7 +23,9 @@
AdminCaseAssignmentResponse,
AdminCreateOrganizationRequest,
AdminCreateUserRequest,
AdminCreateUserResponse,
AdminOperationsResponse,
SendInvitationEmailRequest,
AdminOpsCaseItem,
AdminOpsInvitationItem,
AdminOpsLawyerWorkloadItem,
Expand All @@ -36,6 +39,7 @@
from app.schemas.organization import OrganizationRead
from app.schemas.user import UserListItem
from app.services.audit import log_activity
from app.services.email import EmailNotConfiguredError, send_invitation_email
from app.services.rbac import assign_role_to_user, canonical_role_slug, revoke_role_from_user

router = APIRouter(prefix="/admin", tags=["admin"])
Expand Down Expand Up @@ -583,6 +587,24 @@ def revoke_invitation(
db.commit()


@router.post("/send-invitation-email", status_code=status.HTTP_204_NO_CONTENT)
def send_invitation_email_endpoint(
payload: SendInvitationEmailRequest,
auth: AuthContext = Depends(require_roles("lawyer", "org_admin", "super_admin")),
) -> None:
try:
send_invitation_email(
to_email=payload.to_email,
recipient_name=payload.recipient_name,
invitation_url=payload.invitation_url,
organization_name=payload.organization_name or "VisaTrack",
)
except EmailNotConfiguredError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"Failed to send email: {exc}") from exc


@router.get("/roles", response_model=list[AdminRoleItem])
def list_roles(
auth: AuthContext = Depends(require_permissions("roles:manage")),
Expand Down Expand Up @@ -641,14 +663,20 @@ def list_admin_users(
]


@router.post("/users", response_model=UserListItem, status_code=status.HTTP_201_CREATED)
@router.post("/users", response_model=AdminCreateUserResponse, status_code=status.HTTP_201_CREATED)
def create_user(
payload: AdminCreateUserRequest,
auth: AuthContext = Depends(require_permissions("users:manage")),
db: Session = Depends(get_db),
) -> UserListItem:
) -> AdminCreateUserResponse:
_require_org_scope(auth, payload.organization_id)

normalized_status = payload.status.strip().lower()
is_invited = normalized_status == "invited"

if not is_invited and not payload.password:
raise HTTPException(status_code=400, detail="Password is required for non-invited users")

organization_exists = db.scalar(
select(Organization.id).where(
Organization.id == payload.organization_id,
Expand All @@ -669,18 +697,21 @@ def create_user(
if existing_user is not None:
raise HTTPException(status_code=409, detail="User email already exists in organization")

try:
password_hash = hash_password(payload.password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if is_invited:
password_hash = hash_password(generate_invitation_token())
else:
try:
password_hash = hash_password(payload.password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

new_user = User(
organization_id=payload.organization_id,
email=normalized_email,
password_hash=password_hash,
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
status=payload.status.strip().lower(),
status=normalized_status,
)
db.add(new_user)
db.flush()
Expand All @@ -692,27 +723,45 @@ def create_user(
assigned_by=auth.user_id,
)

invitation_url: str | None = None
if is_invited:
now = datetime.now(timezone.utc)
plain_token = generate_invitation_token()
invitation = UserInvitation(
organization_id=payload.organization_id,
user_id=new_user.id,
email=normalized_email,
role_slug=payload.role_slug.strip().lower(),
token_hash=hash_invitation_token(plain_token),
expires_at=now + timedelta(hours=settings.invitation_expiry_hours),
invited_by=auth.user_id,
)
db.add(invitation)
primary_origin = settings.frontend_origin.split(",")[0].strip().rstrip("/")
invitation_url = f"{primary_origin}/invite?token={plain_token}"

log_activity(
db,
organization_id=payload.organization_id,
user_id=auth.user_id,
action="created",
entity_type="user",
entity_id=new_user.id,
new_values={"email": new_user.email, "role": payload.role_slug.strip().lower()},
new_values={"email": new_user.email, "role": payload.role_slug.strip().lower(), "invited": is_invited},
)

db.commit()
db.refresh(new_user)

return UserListItem(
return AdminCreateUserResponse(
id=new_user.id,
email=new_user.email,
full_name=f"{new_user.first_name} {new_user.last_name}",
status=new_user.status,
organization_id=new_user.organization_id,
created_at=new_user.created_at,
roles=[payload.role_slug.strip().lower()],
invitation_url=invitation_url,
)


Expand Down
36 changes: 36 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
SwitchActiveRoleRequest,
SwitchActiveRoleResponse,
UpdateCurrentUserSettingsRequest,
VerifyInvitationResponse,
)
from app.services.audit import log_activity
from app.services.rbac import canonical_role_slug, select_default_active_role
Expand Down Expand Up @@ -314,6 +315,41 @@ def change_password(
db.commit()


@router.get("/verify-invitation", response_model=VerifyInvitationResponse)
def verify_invitation(
token: str,
db: Session = Depends(get_db),
) -> VerifyInvitationResponse:
now = datetime.now(timezone.utc)
token_hash = hash_invitation_token(token)

invitation = db.scalar(
select(UserInvitation).where(
UserInvitation.token_hash == token_hash,
UserInvitation.accepted_at.is_(None),
UserInvitation.revoked_at.is_(None),
)
)
if invitation is None:
raise HTTPException(status_code=400, detail="Invitation is invalid")

if invitation.expires_at < now:
raise HTTPException(status_code=400, detail="Invitation has expired")

user = db.scalar(
select(User).where(User.id == invitation.user_id, User.deleted_at.is_(None))
)
if user is None:
raise HTTPException(status_code=400, detail="Invited user no longer exists")

return VerifyInvitationResponse(
email=user.email,
full_name=f"{user.first_name} {user.last_name}",
organization_id=user.organization_id,
expires_at=invitation.expires_at,
)


@router.post("/accept-invitation", response_model=AcceptInvitationResponse)
def accept_invitation(
payload: AcceptInvitationRequest,
Expand Down
9 changes: 7 additions & 2 deletions backend/app/api/routes/lawyer.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from app.db.deps import get_db
from app.models.case import Case
from app.models.case_client import CaseClient
from app.models.organization import Organization
from app.models.role import Role
from app.models.user import User
from app.models.user_invitation import UserInvitation
Expand Down Expand Up @@ -217,7 +218,7 @@ def create_lawyer_client(
invited_by=auth.user_id,
)
db.add(invitation)
invitation_url = f"{settings.frontend_origin.rstrip('/')}/invite?token={plain_token}"
invitation_url = f"{settings.frontend_origin.split(',')[0].strip().rstrip('/')}/invite?token={plain_token}"

log_activity(
db,
Expand All @@ -235,11 +236,15 @@ def create_lawyer_client(

db.commit()

org = db.scalar(select(Organization).where(Organization.id == auth.organization_id))
org_name = org.name if org else "VisaTrack"

return LawyerClientCreateResponse(
user_id=user.id,
email=user.email,
full_name=f"{user.first_name} {user.last_name}",
status=user.status,
organization_name=org_name,
invitation_url=invitation_url,
)

Expand Down Expand Up @@ -500,7 +505,7 @@ def invite_client_to_case(

db.commit()

invitation_url = f"{settings.frontend_origin.rstrip('/')}/invite?token={plain_token}"
invitation_url = f"{settings.frontend_origin.split(',')[0].strip().rstrip('/')}/invite?token={plain_token}"
return InviteClientResponse(
case_number=case.case_number,
client_email=email,
Expand Down
3 changes: 3 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ class Settings:
s3_endpoint_url: str = os.getenv("S3_ENDPOINT_URL", "")
s3_presign_expires_seconds: int = int(os.getenv("S3_PRESIGN_EXPIRES_SECONDS", "900"))
s3_max_upload_bytes: int = int(os.getenv("S3_MAX_UPLOAD_BYTES", str(25 * 1024 * 1024)))
resend_api_key: str = os.getenv("RESEND_API_KEY", "")
resend_from_email: str = os.getenv("RESEND_FROM_EMAIL", "noreply@visatrace.tech")
resend_from_name: str = os.getenv("RESEND_FROM_NAME", "VisaTrack")

@property
def frontend_origins(self) -> list[str]:
Expand Down
20 changes: 19 additions & 1 deletion backend/app/schemas/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@ class AdminCreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=255)
first_name: str = Field(min_length=1, max_length=100)
last_name: str = Field(min_length=1, max_length=100)
password: str = Field(min_length=8, max_length=128)
password: Optional[str] = Field(default=None, min_length=8, max_length=128)
status: str = Field(default="active", min_length=2, max_length=50)
role_slug: str = Field(default="lawyer", min_length=2, max_length=100)


class AdminCreateUserResponse(BaseModel):
id: UUID
email: str
full_name: str
status: str
organization_id: Optional[UUID]
created_at: datetime
roles: list[str] = Field(default_factory=list)
invitation_url: Optional[str] = None


class AdminOverviewStats(BaseModel):
organizations: int
users: int
Expand Down Expand Up @@ -120,6 +131,13 @@ class AdminOpsInvitationItem(BaseModel):
invited_by_name: Optional[str]


class SendInvitationEmailRequest(BaseModel):
to_email: str = Field(min_length=3, max_length=255)
recipient_name: str = Field(min_length=1, max_length=200)
invitation_url: str = Field(min_length=10, max_length=2000)
organization_name: Optional[str] = Field(default=None, max_length=255)


class AdminOperationsResponse(BaseModel):
organization_id: UUID
unassigned_cases: list[AdminOpsCaseItem]
Expand Down
7 changes: 7 additions & 0 deletions backend/app/schemas/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,10 @@ class AcceptInvitationResponse(BaseModel):
message: str
email: str
organization_id: UUID


class VerifyInvitationResponse(BaseModel):
email: str
full_name: str
organization_id: UUID
expires_at: datetime
1 change: 1 addition & 0 deletions backend/app/schemas/lawyer.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,4 +58,5 @@ class LawyerClientCreateResponse(BaseModel):
email: str
full_name: str
status: str
organization_name: str
invitation_url: Optional[str] = None
Loading
Loading