Initial project upload

This commit is contained in:
Mohamed Mathar Irfan
2026-07-28 17:57:02 +05:30
commit ed6610d5d8
23919 changed files with 3003316 additions and 0 deletions

View File

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,60 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
app_name: str = "VerifyPack"
environment: str = "development"
secret_key: str = "change-me-in-production-please-32chars-min"
access_token_expire_minutes: int = 1440
algorithm: str = "HS256"
frontend_url: str = "http://localhost:3000"
# Database
mongodb_url: str = "mongodb://localhost:27017"
mongodb_db: str = "verifypack"
mock_db: bool = False
# Mock toggles
mock_email: bool = True
mock_storage: bool = True
# AWS
aws_region: str = "ap-south-1"
aws_access_key_id: str = ""
aws_secret_access_key: str = ""
ses_from_email: str = "no-reply@verifypack.example"
s3_bucket: str = "verifypack-uploads"
# Google OAuth
google_client_id: str = ""
google_client_secret: str = ""
google_redirect_uri: str = "http://localhost:8000/auth/google/callback"
# Razorpay
razorpay_key_id: str = ""
razorpay_key_secret: str = ""
razorpay_webhook_secret: str = ""
gst_rate: float = 0.18
@property
def google_configured(self) -> bool:
return bool(self.google_client_id and self.google_client_secret)
@property
def razorpay_configured(self) -> bool:
return bool(self.razorpay_key_id and self.razorpay_key_secret)
@property
def ses_configured(self) -> bool:
return bool(self.aws_access_key_id and self.aws_secret_access_key)
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

111
backend/app/core/deps.py Normal file
View File

@@ -0,0 +1,111 @@
"""Auth dependencies + role-based access control.
Every protected endpoint depends on `current_user`. Client endpoints derive `org_id`
from the authenticated session's OrgMember record — never trust org_id from the body.
"""
from dataclasses import dataclass
from typing import Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from app.core.security import decode_token
from app.models import User, OrgMember, Organization
from app.models.enums import ProductAdminRole, ClientRole, SubscriptionStatus
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
@dataclass
class Principal:
user: User
org: Optional[Organization]
member: Optional[OrgMember]
client_role: Optional[ClientRole]
admin_role: Optional[ProductAdminRole]
@property
def is_product_admin(self) -> bool:
return self.user.is_product_admin
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
if not token:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not authenticated")
payload = decode_token(token)
if not payload or payload.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid token")
user = await User.get(payload["sub"])
if not user or user.disabled:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled")
return user
async def current_principal(user: User = Depends(current_user)) -> Principal:
"""Resolve org context for the current user.
Picks the user's membership (preferring an active one, but accepting any
non-disabled membership) so org context is consistent with /me's has_org.
"""
members = await OrgMember.find(OrgMember.user_id == str(user.id)).to_list()
# prefer active, then anything not explicitly disabled
member = next((m for m in members if m.status == "active"), None) \
or next((m for m in members if m.status != "disabled"), None)
org = None
if member:
try:
org = await Organization.get(member.org_id)
except Exception:
org = None
return Principal(
user=user, org=org, member=member,
client_role=member.role if member else None,
admin_role=user.product_admin_role if user.is_product_admin else None,
)
# ---------- guards ----------
async def require_org(p: Principal = Depends(current_principal)) -> Principal:
"""Ensure the caller has an organization context (any client role).
Returns a clean 403 instead of letting org-scoped routes crash with a 500
when a Product Admin (who has no OrgMember) hits a client endpoint.
"""
if not p.org or not p.client_role:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"This account has no organization. Use the Super Admin Panel.",
)
return p
def require_client_roles(*roles: ClientRole):
async def _guard(p: Principal = Depends(current_principal)) -> Principal:
if not p.org or not p.client_role:
raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization context")
if roles and p.client_role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient role")
return p
return _guard
def require_admin_roles(*roles: ProductAdminRole):
async def _guard(p: Principal = Depends(current_principal)) -> Principal:
if not p.is_product_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Product admin only")
if roles and p.admin_role not in roles:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Insufficient admin role")
return p
return _guard
def require_write_access(p: Principal):
"""Block writes when org is in grace/archived/deleted state."""
if not p.org:
raise HTTPException(status.HTTP_403_FORBIDDEN, "No organization")
if p.org.subscription_status != SubscriptionStatus.active:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
f"Organization is {p.org.subscription_status.value} — read-only",
)

View File

@@ -0,0 +1,42 @@
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from jose import jwt, JWTError
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(subject: str, claims: Optional[dict] = None,
expires_minutes: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(
minutes=expires_minutes or settings.access_token_expire_minutes
)
payload = {"sub": subject, "exp": expire, "type": "access"}
if claims:
payload.update(claims)
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def create_token(subject: str, token_type: str, expires_minutes: int) -> str:
"""Generic signed token for email-verify / password-reset."""
expire = datetime.now(timezone.utc) + timedelta(minutes=expires_minutes)
payload = {"sub": subject, "exp": expire, "type": token_type}
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
return None