diff --git a/backend/alembic/versions/76b6cea54557_add_jurisdiction_to_user_profiles.py b/backend/alembic/versions/76b6cea54557_add_jurisdiction_to_user_profiles.py new file mode 100644 index 0000000..5ea6dca --- /dev/null +++ b/backend/alembic/versions/76b6cea54557_add_jurisdiction_to_user_profiles.py @@ -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') diff --git a/backend/app/api/routes/admin.py b/backend/app/api/routes/admin.py index fb3cbe9..e418508 100644 --- a/backend/app/api/routes/admin.py +++ b/backend/app/api/routes/admin.py @@ -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 @@ -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 @@ -22,7 +23,9 @@ AdminCaseAssignmentResponse, AdminCreateOrganizationRequest, AdminCreateUserRequest, + AdminCreateUserResponse, AdminOperationsResponse, + SendInvitationEmailRequest, AdminOpsCaseItem, AdminOpsInvitationItem, AdminOpsLawyerWorkloadItem, @@ -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"]) @@ -584,6 +588,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")), @@ -642,14 +664,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, @@ -670,10 +698,13 @@ 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, @@ -681,7 +712,7 @@ def create_user( 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() @@ -693,6 +724,23 @@ 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, @@ -700,13 +748,13 @@ def create_user( 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}", @@ -714,6 +762,7 @@ def create_user( organization_id=new_user.organization_id, created_at=new_user.created_at, roles=[payload.role_slug.strip().lower()], + invitation_url=invitation_url, ) diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 20e5e8c..abb229e 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -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 @@ -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, diff --git a/backend/app/api/routes/lawyer.py b/backend/app/api/routes/lawyer.py index 4c74d47..12a9480 100644 --- a/backend/app/api/routes/lawyer.py +++ b/backend/app/api/routes/lawyer.py @@ -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 @@ -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, @@ -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, ) @@ -504,7 +509,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, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 2b018ef..0dccd4f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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]: diff --git a/backend/app/schemas/admin.py b/backend/app/schemas/admin.py index dd0230f..b13a0d7 100644 --- a/backend/app/schemas/admin.py +++ b/backend/app/schemas/admin.py @@ -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 @@ -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] diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index 024ce37..0dbaa84 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/lawyer.py b/backend/app/schemas/lawyer.py index d4eb6a8..1ca622e 100644 --- a/backend/app/schemas/lawyer.py +++ b/backend/app/schemas/lawyer.py @@ -58,4 +58,5 @@ class LawyerClientCreateResponse(BaseModel): email: str full_name: str status: str + organization_name: str invitation_url: Optional[str] = None diff --git a/backend/app/services/email.py b/backend/app/services/email.py new file mode 100644 index 0000000..20b3b21 --- /dev/null +++ b/backend/app/services/email.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +import logging + +import resend + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class EmailNotConfiguredError(Exception): + pass + + +def send_invitation_email( + to_email: str, + recipient_name: str, + invitation_url: str, + organization_name: str = "VisaTrack", +) -> None: + """Send an invitation email via Resend. + + Raises EmailNotConfiguredError if RESEND_API_KEY is not set. + Raises resend.exceptions.ResendError on API/delivery failure. + """ + if not settings.resend_api_key: + raise EmailNotConfiguredError( + "Resend is not configured. Set RESEND_API_KEY in your environment." + ) + + resend.api_key = settings.resend_api_key + + from_address = f"{settings.resend_from_name} <{settings.resend_from_email}>" + + plain = ( + f"Hi {recipient_name},\n\n" + f"You have been invited to join {organization_name} on VisaTrack. " + f"Click the link below to set your password and activate your account.\n\n" + f"{invitation_url}\n\n" + f"This link expires in 72 hours. If you did not expect this invitation, " + f"you can safely ignore this email.\n\n" + f"— The {organization_name} Team" + ) + + html = f""" + +
+Hi {recipient_name},
++ You have been invited to join {organization_name} on VisaTrack. + Click the button below to set your password and activate your account. +
+ ++ This link expires in 72 hours. If you did not expect this invitation you can + safely ignore this email. +
++ — The {organization_name} Team +
+ +""" + + resend.Emails.send({ + "from": from_address, + "to": [to_email], + "subject": f"You have been invited to join {organization_name}", + "text": plain, + "html": html, + "click_tracking": False, + }) + + logger.info("Invitation email sent to %s via Resend", to_email) diff --git a/backend/requirements.txt b/backend/requirements.txt index e2a9518..bfb35b8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,5 +5,6 @@ psycopg2-binary>=2.9,<3 pydantic>=2,<3 python-dotenv>=1,<2 PyJWT>=2.8,<3 +resend>=2,<3 SQLAlchemy>=2,<3 uvicorn>=0.27,<1 diff --git a/frontend/app/invite/page.tsx b/frontend/app/invite/page.tsx new file mode 100644 index 0000000..6659b95 --- /dev/null +++ b/frontend/app/invite/page.tsx @@ -0,0 +1,192 @@ +"use client"; + +import { FormEvent, Suspense, useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { acceptInvitation, verifyInvitation, type VerifyInvitationResult } from "@/lib/api"; + +type PageState = + | { kind: "loading" } + | { kind: "invalid"; reason: string } + | { kind: "ready"; info: VerifyInvitationResult } + | { kind: "submitting"; info: VerifyInvitationResult } + | { kind: "success"; email: string }; + +function InviteForm() { + const router = useRouter(); + const searchParams = useSearchParams(); + const token = searchParams.get("token") ?? ""; + + const [state, setState] = useStateVerifying invitation…
+{state.reason}
+ ++ Your account for {state.email} is ready. You can now sign in. +
+ +Invitation expires {expiresAt}
+ ++ Name: + {info.full_name} +
++ Email: + {info.email} +
+Loading…
+ + } + > ++ Share this link with the user so they can set their password and activate their + account. It expires in 72 hours. +
+Send via email
+Email sent successfully.
+ )} + {emailError ?{emailError}
: null} ++ No password needed — the user will set one via the invitation link. +
+