Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
74d5cfc
config routes updated
vprashrex Mar 27, 2026
bd48eb8
code update
vprashrex Mar 27, 2026
10fd7c3
made query optional
vprashrex Mar 27, 2026
f73c22a
code updated
vprashrex Mar 27, 2026
0c7175b
added has_more functionality
vprashrex Mar 27, 2026
a363f45
refactor: update config CRUD methods to include query parameter and h…
vprashrex Mar 30, 2026
abd1fc8
Add projects-by-org endpoint and pagination for organizations list
vprashrex Mar 30, 2026
84ce2e8
Enhance organization validation: return 503 status code for inactive …
vprashrex Mar 31, 2026
6144d98
feat(*): google integration flow
Ayush8923 Mar 31, 2026
7f98414
Merge branch 'main' into feat/adding-query-params-to-config
Ayush8923 Mar 31, 2026
a7e9139
Merge branch 'feat/adding-query-params-to-config' of https://github.c…
Ayush8923 Mar 31, 2026
4cf576b
fix(*): update the js comment
Ayush8923 Mar 31, 2026
0b3b30e
fix(*): update the uv.lock
Ayush8923 Mar 31, 2026
7e46f7d
feat(*): API for create user corresponding to projects
Ayush8923 Apr 1, 2026
a6b6281
fix(*): few updates on user projects
Ayush8923 Apr 1, 2026
6731b1d
Merge branch 'main' of https://github.com/ProjectTech4DevAI/kaapi-bac…
Ayush8923 Apr 1, 2026
8b3e5e9
Merge branch 'feat/google-integration-auth-flow' of https://github.co…
Ayush8923 Apr 1, 2026
6bb875c
fix(*): update the test cases
Ayush8923 Apr 1, 2026
b1082a5
Merge branch 'feat/google-integration-auth-flow' of https://github.co…
Ayush8923 Apr 1, 2026
3a1c9c8
fix(*): update test coverage
Ayush8923 Apr 1, 2026
9e833d8
Merge branch 'feat/google-integration-auth-flow' of https://github.co…
Ayush8923 Apr 1, 2026
b22588f
fix(*): update the test cases
Ayush8923 Apr 1, 2026
a57f829
Merge branch 'feat/google-integration-auth-flow' of https://github.co…
Ayush8923 Apr 1, 2026
d1c9416
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 4, 2026
b211652
Merge branch 'feat/google-integration-auth-flow' into feat/add-user-p…
Ayush8923 Apr 4, 2026
fd377e8
fix(*): some of the edge cases implementation
Ayush8923 Apr 4, 2026
781fccc
Merge branch 'feat/add-user-project' of https://github.com/ProjectTec…
Ayush8923 Apr 4, 2026
7133fa1
fix(*): remove the unused vairbales
Ayush8923 Apr 4, 2026
5b8af37
fix(*): for the response used the APIResponses utils function
Ayush8923 Apr 4, 2026
833bda2
Merge branch 'feat/google-integration-auth-flow' into feat/add-user-p…
Ayush8923 Apr 4, 2026
b3eb1fd
fix(*): update the test cases
Ayush8923 Apr 4, 2026
ccb11cc
Merge branch 'feat/google-integration-auth-flow' into feat/add-user-p…
Ayush8923 Apr 4, 2026
8166675
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 7, 2026
a81d18e
Merge branch 'feat/google-integration-auth-flow' into feat/add-user-p…
Ayush8923 Apr 7, 2026
280b254
sugg(*): made the changes as per the suggestion
Ayush8923 Apr 8, 2026
4e391f8
cleanups(*): stt and tts flow
Ayush8923 Apr 8, 2026
e62ed10
fix(*): update the invitation user email html file
Ayush8923 Apr 8, 2026
8aa31a0
fix(*): update the invite verify md file
Ayush8923 Apr 8, 2026
20fa939
Revert "fix(*): update the invite verify md file"
Ayush8923 Apr 8, 2026
eba128a
fix(*): update the invite verify md file
Ayush8923 Apr 8, 2026
944a67c
fix(*): added the env example
Ayush8923 Apr 9, 2026
74d252b
Add/Delete User in Org/Project (#737)
Ayush8923 Apr 9, 2026
ac700af
Merge branch 'main' into feat/google-integration-auth-flow
Ayush8923 Apr 9, 2026
71720a3
fix(*): update the test cases
Ayush8923 Apr 9, 2026
d9c00c2
fix(*): added the test cases for user project
Ayush8923 Apr 9, 2026
6970446
fix(*): update the invite user template
Ayush8923 Apr 9, 2026
8810300
Merge branch 'feat/google-integration-auth-flow' of https://github.co…
Ayush8923 Apr 9, 2026
254fadc
Merge branch 'main' of https://github.com/ProjectTech4DevAI/kaapi-bac…
Ayush8923 Apr 10, 2026
3fec131
fix(*): added the test cases
Ayush8923 Apr 10, 2026
e3db12c
fix(*): test cases updates
Ayush8923 Apr 10, 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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,12 @@ OPENAI_API_KEY=""

KAAPI_GUARDRAILS_AUTH=""
KAAPI_GUARDRAILS_URL=""

SMTP_HOST=
SMTP_PORT=
SMTP_TLS=True
SMTP_USER=
SMTP_PASSWORD=
EMAILS_FROM_EMAIL=
EMAILS_FROM_NAME=Kaapi
FRONTEND_HOST=
20 changes: 20 additions & 0 deletions backend/app/api/docs/auth/invite_verify.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Verify Invitation

Verify an invitation token from a magic link email and log the user in.

## Query Parameters

- **token** (required): The invitation JWT token from the email link.

## Behavior

1. Validates the invitation token (checks signature, expiry, and type).
2. Looks up the user by the email embedded in the token.
3. If the user exists and is inactive (first login), activates the account.
4. Returns a JWT access token with the org/project from the invitation embedded.
5. Sets `access_token` and `refresh_token` as HTTP-only cookies.

## Error Responses

- **400**: Invalid or expired invitation link.
- **404**: User account not found.
45 changes: 45 additions & 0 deletions backend/app/api/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
build_token_response,
clear_auth_cookies,
validate_refresh_token,
verify_invite_token,
)
from app.utils import APIResponse, load_description

Expand Down Expand Up @@ -198,3 +199,47 @@ def logout() -> JSONResponse:
response = JSONResponse(content=api_response.model_dump())
clear_auth_cookies(response)
return response


@router.get(
"/invite/verify",
description=load_description("auth/invite_verify.md"),
response_model=APIResponse[Token],
)
def verify_invitation(session: SessionDep, token: str) -> JSONResponse:
"""Verify an invitation token, activate the user, and log them in."""

invite_data = verify_invite_token(token)
if not invite_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired invitation link",
)

user = get_user_by_email(session=session, email=invite_data["email"])
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User account not found. Please contact support.",
)

# Activate user if not already active
if not user.is_active:
user.is_active = True
session.add(user)
session.commit()
session.refresh(user)
logger.info(
f"[verify_invitation] User activated via invite | user_id: {user.id}"
)

response = build_token_response(
user_id=user.id,
organization_id=invite_data["organization_id"],
project_id=invite_data["project_id"],
)
Comment on lines +219 to +240
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Re-check current project access before issuing the scoped JWT.

Line 236 builds the session directly from invite_data["organization_id"] / ["project_id"]. If that invite is later revoked or the user-project mapping changes after the email is sent, this endpoint still mints a token for the stale project. Please resolve current access from the DB first, like select_project already does on Lines 142-156, and reject stale invites.

Suggested guard
     user = get_user_by_email(session=session, email=invite_data["email"])
     if not user:
         raise HTTPException(
             status_code=status.HTTP_404_NOT_FOUND,
             detail="User account not found. Please contact support.",
         )

+    available_projects = get_user_accessible_projects(session=session, user_id=user.id)
+    matching = [
+        p
+        for p in available_projects
+        if p["organization_id"] == invite_data["organization_id"]
+        and p["project_id"] == invite_data["project_id"]
+    ]
+    if not matching:
+        raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Invitation is no longer valid",
+        )
+
     # Activate user if not already active
     if not user.is_active:
         user.is_active = True
         session.add(user)
         session.commit()
         session.refresh(user)
         logger.info(
             f"[verify_invitation] User activated via invite | user_id: {user.id}"
         )

+    proj = matching[0]
     response = build_token_response(
         user_id=user.id,
-        organization_id=invite_data["organization_id"],
-        project_id=invite_data["project_id"],
+        organization_id=proj["organization_id"],
+        project_id=proj["project_id"],
     )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/api/routes/auth.py` around lines 219 - 240, The token is minted
directly from invite_data which can be stale; before calling
build_token_response in the verify-invite flow, re-resolve current access from
the DB (using the same logic as select_project on lines ~142-156) — e.g., query
the user's current organization/project membership with the session (or call
select_project(user.id, invite_data["organization_id"],
invite_data["project_id"], session)) and if the project/organization access no
longer exists or the mapping changed, raise an HTTPException (403 or 404)
rejecting the stale invite; only call build_token_response when the DB-confirmed
project access is present.


logger.info(
f"[verify_invitation] Invitation verified | user_id: {user.id}, project_id: {invite_data['project_id']}"
)
return response
42 changes: 41 additions & 1 deletion backend/app/api/routes/user_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@

from app.api.deps import AuthContextDep, SessionDep
from app.api.permissions import Permission, require_permission
from app.core.config import settings
from app.crud.organization import get_organization_by_id
from app.crud.project import get_project_by_id
from app.crud.user_project import (
add_user_to_project,
get_users_by_project,
Expand All @@ -15,7 +18,13 @@
Message,
UserProjectPublic,
)
from app.utils import APIResponse, load_description
from app.services.auth import generate_invite_token
from app.utils import (
APIResponse,
generate_invite_email,
load_description,
send_email,
)

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -83,6 +92,37 @@ def add_project_users(

session.commit()

# Send invitation emails
organization = get_organization_by_id(session=session, org_id=body.organization_id)
project = get_project_by_id(session=session, project_id=body.project_id)

if settings.emails_enabled and organization and project:
for entry in body.users:
try:
invite_token = generate_invite_token(
email=str(entry.email),
organization_id=body.organization_id,
project_id=body.project_id,
)
email_data = generate_invite_email(
email_to=str(entry.email),
project_name=project.name,
organization_name=organization.name,
invite_token=invite_token,
)
send_email(
email_to=str(entry.email),
subject=email_data.subject,
html_content=email_data.html_content,
)
logger.info(
f"[add_project_users] Invitation email sent | email: {entry.email}"
)
except Exception as e:
logger.error(
f"[add_project_users] Failed to send invitation email | email: {entry.email}, error: {e}"
)

# Re-fetch all users for this project to return the full list
results = get_users_by_project(session=session, project_id=body.project_id)

Expand Down
21 changes: 21 additions & 0 deletions backend/app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,27 @@ class Settings(BaseSettings):
# Google OAuth
GOOGLE_CLIENT_ID: str = ""

# Frontend URL for magic links
FRONTEND_HOST: str = "http://localhost:3000"

# Invitation token expiry (default 7 days)
INVITE_TOKEN_EXPIRE_HOURS: int = 24 * 7

# SMTP / Email
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_TLS: bool = True
SMTP_SSL: bool = False
EMAILS_FROM_EMAIL: str = ""
EMAILS_FROM_NAME: str = ""

@computed_field # type: ignore[prop-decorator]
@property
def emails_enabled(self) -> bool:
return bool(self.SMTP_HOST and self.EMAILS_FROM_EMAIL)

@computed_field # type: ignore[prop-decorator]
@property
def SQLALCHEMY_DATABASE_URI(self) -> PostgresDsn:
Expand Down
52 changes: 52 additions & 0 deletions backend/app/email-templates/build/invite_user.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>You're invited to {{ project_name }}</title>
</head>
<body style="margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; background-color: #f4f4f5;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td style="padding: 60px 20px;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width: 520px; margin: 0 auto;">
<tr>
<td align="center" style="background-color: #ffffff; padding: 48px 40px; text-align: center;">
<h1 style="margin: 0 0 4px; font-size: 22px; font-weight: 600; color: #18181b;">
Kaapi Konsole
</h1>
<p style="margin: 0 0 20px; font-size: 14px; color: #a1a1aa;">
{{ organization_name }}
</p>
<table role="presentation" width="40" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto 20px;">
<tr>
<td style="border-top: 2px solid #e4e4e7;"></td>
</tr>
</table>
<p style="margin: 0 0 5px; font-size: 15px; line-height: 1.7; color: #3f3f46;">
You have been invited to join the <strong>{{ project_name }}</strong> project on {{ app_name }}.
</p>
<p style="margin: 0 0 20px; font-size: 15px; line-height: 1.7; color: #3f3f46;">
Click the button below to accept the invitation and get started.
</p>
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin: 0 auto;">
<tr>
<td align="center" style="background-color: #2563eb; border-radius: 10px;">
<a href="{{ link }}" target="_blank" style="display: inline-block; padding: 10px 30px; font-size: 15px; font-weight: 600; color: #ffffff; text-decoration: none;">
Accept Invitation
</a>
</td>
</tr>
</table>
<p style="margin: 20px 0 0; font-size: 12px; color: #a1a1aa; line-height: 1.6;">
This invitation expires in {{ valid_days }} days.<br>
If you did not expect this invitation, you can safely ignore this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
43 changes: 42 additions & 1 deletion backend/app/services/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import logging
from datetime import timedelta
from datetime import datetime, timedelta, timezone

import jwt as pyjwt
from fastapi import HTTPException, status
Expand Down Expand Up @@ -176,3 +176,44 @@ def validate_refresh_token(
raise HTTPException(status_code=403, detail="Inactive user")

return user, token_data


def generate_invite_token(
email: str,
organization_id: int,
project_id: int,
) -> str:
"""Generate a JWT invitation token for a user."""
delta = timedelta(hours=settings.INVITE_TOKEN_EXPIRE_HOURS)
now = datetime.now(timezone.utc)
expires = now + delta
to_encode = {
"exp": expires.timestamp(),
"nbf": now,
"sub": email,
"org_id": organization_id,
"project_id": project_id,
"type": "invite",
}
return pyjwt.encode(to_encode, settings.SECRET_KEY, algorithm=security.ALGORITHM)


def verify_invite_token(token: str) -> dict | None:
"""
Verify an invitation token and return the payload.

Returns dict with email, org_id, project_id or None if invalid.
"""
try:
payload = pyjwt.decode(
token, settings.SECRET_KEY, algorithms=[security.ALGORITHM]
)
if payload.get("type") != "invite":
return None
return {
"email": payload["sub"],
"organization_id": payload["org_id"],
"project_id": payload["project_id"],
}
except (InvalidTokenError, KeyError):
return None
80 changes: 77 additions & 3 deletions backend/app/tests/api/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,18 @@

from app.core.config import settings
from app.core.security import create_access_token, create_refresh_token
from app.services.auth import (
generate_invite_token,
verify_invite_token,
)
from app.tests.utils.auth import TestAuthContext
from app.tests.utils.user import create_random_user

GOOGLE_AUTH_URL = f"{settings.API_V1_STR}/auth/google"
SELECT_PROJECT_URL = f"{settings.API_V1_STR}/auth/select-project"
REFRESH_URL = f"{settings.API_V1_STR}/auth/refresh"
LOGOUT_URL = f"{settings.API_V1_STR}/auth/logout"
INVITE_VERIFY_URL = f"{settings.API_V1_STR}/auth/invite/verify"

MOCK_GOOGLE_PROFILE = {
"email": None, # set per test
Expand Down Expand Up @@ -107,9 +112,6 @@ def test_google_auth_activates_inactive_user(
resp = client.post(GOOGLE_AUTH_URL, json={"token": "fake"})
assert resp.status_code == 200

db.refresh(user)
assert user.is_active is True

@patch("app.api.routes.auth.id_token.verify_oauth2_token")
@patch("app.api.routes.auth.settings")
def test_google_auth_success_no_projects(
Expand Down Expand Up @@ -298,3 +300,75 @@ def test_logout_success(self, client: TestClient):
body = resp.json()
assert body["success"] is True
assert body["data"]["message"] == "Logged out successfully"


class TestInviteVerify:
"""Test suite for GET /auth/invite/verify endpoint."""

def test_verify_invalid_token(self, client: TestClient):
"""Test returns 400 for invalid invite token."""
resp = client.get(f"{INVITE_VERIFY_URL}?token=invalid.token")
assert resp.status_code == 400

def test_verify_user_not_found(self, client: TestClient):
"""Test returns 404 when invited user doesn't exist."""
token = generate_invite_token(
email="ghost@example.com", organization_id=1, project_id=1
)
resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
assert resp.status_code == 404

def test_verify_activates_inactive_user(
self, db: Session, client: TestClient, user_api_key: TestAuthContext
):
"""Test invite verification activates inactive user."""
user = create_random_user(db)
user.is_active = False
db.add(user)
db.commit()
db.refresh(user)

token = generate_invite_token(
email=user.email,
organization_id=user_api_key.organization.id,
project_id=user_api_key.project.id,
)
resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
assert resp.status_code == 200

db.refresh(user)
assert user.is_active is True
assert "access_token" in resp.json()["data"]

def test_verify_success_active_user(
self, db: Session, client: TestClient, user_api_key: TestAuthContext
):
"""Test invite verification works for already active user."""
user = create_random_user(db)
token = generate_invite_token(
email=user.email,
organization_id=user_api_key.organization.id,
project_id=user_api_key.project.id,
)
resp = client.get(f"{INVITE_VERIFY_URL}?token={token}")
assert resp.status_code == 200
assert "access_token" in resp.json()["data"]


class TestTokenGeneration:
"""Test suite for services/auth.py token generation functions."""

def test_generate_and_verify_invite_token(self):
"""Test invite token roundtrip."""
token = generate_invite_token(
email="test@example.com", organization_id=1, project_id=2
)
result = verify_invite_token(token)
assert result is not None
assert result["email"] == "test@example.com"
assert result["organization_id"] == 1
assert result["project_id"] == 2

def test_verify_invite_token_invalid(self):
"""Test invite verify returns None for garbage tokens."""
assert verify_invite_token("garbage") is None
Comment on lines +305 to +374
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify defs without return type annotations in this file.
rg -nP '^\s*def\s+\w+\([^)]*\):\s*$' backend/app/tests/api/test_auth.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 922


🏁 Script executed:

#!/bin/bash
# Extract the exact content from lines 305-374 to see complete method definitions
sed -n '305,374p' backend/app/tests/api/test_auth.py

Repository: ProjectTech4DevAI/kaapi-backend

Length of output: 2747


Add return type annotations (-> None) to all test methods in this block.

The 6 test methods lack explicit -> None return type annotations, which violates the repository's mandatory typing standard for all Python files. The methods are:

  • test_verify_invalid_token
  • test_verify_user_not_found
  • test_verify_activates_inactive_user
  • test_verify_success_active_user
  • test_generate_and_verify_invite_token
  • test_verify_invite_token_invalid
Proposed diff
 class TestInviteVerify:
     """Test suite for GET /auth/invite/verify endpoint."""

     def test_verify_invalid_token(self, client: TestClient):
+    def test_verify_invalid_token(self, client: TestClient) -> None:
         """Test returns 400 for invalid invite token."""

     def test_verify_user_not_found(self, client: TestClient):
+    def test_verify_user_not_found(self, client: TestClient) -> None:
         """Test returns 404 when invited user doesn't exist."""

     def test_verify_activates_inactive_user(
         self, db: Session, client: TestClient, user_api_key: TestAuthContext
     ):
+    ) -> None:
         """Test invite verification activates inactive user."""

     def test_verify_success_active_user(
         self, db: Session, client: TestClient, user_api_key: TestAuthContext
     ):
+    ) -> None:
         """Test invite verification works for already active user."""

 class TestTokenGeneration:
     """Test suite for services/auth.py token generation functions."""

     def test_generate_and_verify_invite_token(self):
+    def test_generate_and_verify_invite_token(self) -> None:
         """Test invite token roundtrip."""

     def test_verify_invite_token_invalid(self):
+    def test_verify_invite_token_invalid(self) -> None:
         """Test invite verify returns None for garbage tokens."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/app/tests/api/test_auth.py` around lines 305 - 374, All six test
methods in TestInviteVerify and TestTokenGeneration are missing explicit return
type annotations; add "-> None" to the signatures of test_verify_invalid_token,
test_verify_user_not_found, test_verify_activates_inactive_user,
test_verify_success_active_user, test_generate_and_verify_invite_token, and
test_verify_invite_token_invalid so each method signature declares a None return
type to satisfy the repository typing standard.

Loading
Loading