Prepare ERP source for Gitea deployment

This commit is contained in:
A R R R Associates
2026-06-20 15:01:44 +05:30
commit 5c75eb6bd9
450 changed files with 67698 additions and 0 deletions
View File
+363
View File
@@ -0,0 +1,363 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.db.deps import get_common_db
from app.core.security.passwords import hash_password
from app.core.security.session_auth import require_login
from app.modules.core.audit.service import model_snapshot, pair_before_after, write_audit_log
from app.modules.core.iam.lifecycle import activate_user, deactivate_user, disable_login, enable_login, ensure_manageable_lifecycle, lock_user, restore_user, soft_delete_user, unlock_user, LifecycleError
from app.modules.core.iam.models import User
from app.modules.core.iam.scope import (
build_scope,
ensure_assignable_roles,
ensure_manageable_existing_user,
ensure_users_manage_scope,
ensure_users_view_scope,
list_scoped_users,
resolve_target_tenant_branch,
scope_to_http,
)
from app.modules.core.rbac.deps import require_permission
from app.modules.core.rbac.models import UserRole
router = APIRouter(prefix="/users", tags=["users"])
class UserCreateRequest(BaseModel):
email: EmailStr
full_name: str
password: str
tenant_id: int | None = None
branch_id: int | None = None
role_ids: list[int] = []
is_active: bool = True
allow_login: bool = True
class UserUpdateRequest(BaseModel):
full_name: str
tenant_id: int | None = None
branch_id: int | None = None
role_ids: list[int] = []
is_active: bool = True
allow_login: bool = True
password: str | None = None
@router.get("", dependencies=[Depends(require_permission("users.view"))])
def list_users(current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
try:
ensure_users_view_scope(scope)
except Exception as exc:
raise scope_to_http(exc)
users = list_scoped_users(db, scope)
return [
{
"id": u.id,
"email": u.email,
"full_name": u.full_name,
"tenant_id": u.tenant_id,
"branch_id": u.branch_id,
"is_active": u.is_active,
"allow_login": getattr(u, "allow_login", True),
"is_locked": getattr(u, "is_locked", False),
"deleted_at": (u.deleted_at.isoformat() if getattr(u, "deleted_at", None) else None),
}
for u in users
]
@router.post("", dependencies=[Depends(require_permission("users.manage"))])
def create_user(
payload: UserCreateRequest,
current_user: User = Depends(require_login),
db: Session = Depends(get_common_db),
):
scope = build_scope(db, current_user)
try:
ensure_users_manage_scope(scope)
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
roles = ensure_assignable_roles(db, scope, payload.role_ids)
except Exception as exc:
raise scope_to_http(exc)
email = payload.email.lower().strip()
exists = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if exists:
raise HTTPException(status_code=400, detail="Email already exists")
user = User(
email=email,
full_name=payload.full_name.strip(),
password_hash=hash_password(payload.password),
tenant_id=tenant_id,
branch_id=branch_id,
is_active=payload.is_active,
allow_login=payload.allow_login,
is_locked=False,
deleted_at=None,
)
db.add(user)
db.commit()
db.refresh(user)
for role in roles:
db.add(UserRole(user_id=user.id, role_id=role.id))
db.commit()
write_audit_log(
db,
action="user.create.api",
entity_type="user",
actor=current_user,
entity_id=user.id,
entity_name=user.email,
target_tenant_id=user.tenant_id,
target_branch_id=user.branch_id,
details={"after": model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"]), "role_ids": [role.id for role in roles]},
)
return {"status": "ok", "id": user.id}
@router.get("/{user_id}", dependencies=[Depends(require_permission("users.view"))])
def get_user(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
try:
ensure_users_view_scope(scope)
except Exception as exc:
raise scope_to_http(exc)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_existing_user(db, scope, user)
except Exception as exc:
raise scope_to_http(exc)
role_ids = db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all()
return {
"id": user.id,
"email": user.email,
"full_name": user.full_name,
"tenant_id": user.tenant_id,
"branch_id": user.branch_id,
"is_active": user.is_active,
"allow_login": getattr(user, "allow_login", True),
"is_locked": getattr(user, "is_locked", False),
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
"role_ids": list(role_ids),
}
@router.put("/{user_id}", dependencies=[Depends(require_permission("users.manage"))])
def update_user(
user_id: int,
payload: UserUpdateRequest,
current_user: User = Depends(require_login),
db: Session = Depends(get_common_db),
):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_users_manage_scope(scope)
ensure_manageable_existing_user(db, scope, user)
tenant_id, branch_id = resolve_target_tenant_branch(db, scope, payload.tenant_id, payload.branch_id)
roles = ensure_assignable_roles(db, scope, payload.role_ids)
except Exception as exc:
raise scope_to_http(exc)
before_snapshot = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
old_role_ids = list(db.execute(select(UserRole.role_id).where(UserRole.user_id == user.id)).scalars().all())
user.full_name = payload.full_name.strip()
user.tenant_id = tenant_id
user.branch_id = branch_id
user.is_active = payload.is_active
user.allow_login = payload.allow_login
if payload.password:
user.password_hash = hash_password(payload.password)
db.execute(UserRole.__table__.delete().where(UserRole.user_id == user.id))
for role in roles:
db.add(UserRole(user_id=user.id, role_id=role.id))
db.commit()
write_audit_log(
db,
action="user.update.api",
entity_type="user",
actor=current_user,
entity_id=user.id,
entity_name=user.email,
target_tenant_id=user.tenant_id,
target_branch_id=user.branch_id,
details={**pair_before_after(before_snapshot, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])), "old_role_ids": old_role_ids, "new_role_ids": [role.id for role in roles]},
)
return {"status": "ok"}
def _lifecycle_response(user: User) -> dict:
return {
"status": "ok",
"user_id": user.id,
"is_active": user.is_active,
"allow_login": getattr(user, "allow_login", True),
"is_locked": getattr(user, "is_locked", False),
"deleted_at": (user.deleted_at.isoformat() if getattr(user, "deleted_at", None) else None),
}
def _apply_lifecycle_action(db: Session, current_user: User, user: User, action: str):
before = model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])
if action == "activate":
activate_user(user)
audit_action = "user.activate.api"
elif action == "deactivate":
deactivate_user(user)
audit_action = "user.deactivate.api"
elif action == "enable-login":
enable_login(user)
audit_action = "user.enable_login.api"
elif action == "disable-login":
disable_login(user)
audit_action = "user.disable_login.api"
elif action == "lock":
lock_user(user)
audit_action = "user.lock.api"
elif action == "unlock":
unlock_user(user)
audit_action = "user.unlock.api"
elif action == "delete":
soft_delete_user(user)
audit_action = "user.soft_delete.api"
elif action == "restore":
restore_user(user)
audit_action = "user.restore.api"
else:
raise HTTPException(status_code=400, detail="Unknown action")
db.commit()
write_audit_log(
db,
action=audit_action,
entity_type="user",
actor=current_user,
entity_id=user.id,
entity_name=user.email,
target_tenant_id=user.tenant_id,
target_branch_id=user.branch_id,
details=pair_before_after(before, model_snapshot(user, ["email", "full_name", "tenant_id", "branch_id", "is_active", "allow_login", "is_locked", "deleted_at"])),
)
return _lifecycle_response(user)
@router.post("/{user_id}/activate", dependencies=[Depends(require_permission("users.manage"))])
def activate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "activate")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "activate")
@router.post("/{user_id}/deactivate", dependencies=[Depends(require_permission("users.manage"))])
def deactivate_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "deactivate")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "deactivate")
@router.post("/{user_id}/enable-login", dependencies=[Depends(require_permission("users.manage"))])
def enable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "enable login for")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "enable-login")
@router.post("/{user_id}/disable-login", dependencies=[Depends(require_permission("users.manage"))])
def disable_login_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "disable login for")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "disable-login")
@router.post("/{user_id}/lock", dependencies=[Depends(require_permission("users.manage"))])
def lock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "lock")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "lock")
@router.post("/{user_id}/unlock", dependencies=[Depends(require_permission("users.manage"))])
def unlock_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "unlock")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "unlock")
@router.post("/{user_id}/delete", dependencies=[Depends(require_permission("users.manage"))])
def delete_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "delete")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "delete")
@router.post("/{user_id}/restore", dependencies=[Depends(require_permission("users.manage"))])
def restore_user_api(user_id: int, current_user: User = Depends(require_login), db: Session = Depends(get_common_db)):
scope = build_scope(db, current_user)
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
ensure_manageable_lifecycle(scope, db, current_user, user, "restore")
except (Exception, LifecycleError) as exc:
raise scope_to_http(exc)
return _apply_lifecycle_action(db, current_user, user, "restore")
+173
View File
@@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import timedelta, timezone
import hashlib
import secrets
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from sqlalchemy import select
from app.core.db.deps import get_common_db
from app.core.settings import get_settings
from app.core.security.passwords import verify_password
from app.core.security.jwt_tokens import encode_access_token, decode_token, utcnow
from app.modules.core.audit.service import write_audit_log
from app.modules.core.iam.invite_service import accept_invite, issue_password_reset_token, reset_password_with_token
from app.modules.email_integration.services import send_password_reset_link_email, send_password_changed_email
from app.modules.core.iam.models import User
from app.modules.core.iam.tokens_models import RefreshToken
from app.modules.core.rbac.models import Role, UserRole
router = APIRouter(prefix="/auth", tags=["auth"])
class TokenRequest(BaseModel):
email: EmailStr
password: str
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
expires_in: int
refresh_token: str
class ForgotPasswordRequest(BaseModel):
email: EmailStr
class ResetPasswordRequest(BaseModel):
token: str
new_password: str
class AcceptInviteRequest(BaseModel):
token: str
password: str
def _hash_refresh(rt: str) -> str:
return hashlib.sha256(rt.encode("utf-8")).hexdigest()
def _roles(db: Session, user_id: int) -> list[str]:
q = select(Role.name).join(UserRole, UserRole.role_id == Role.id).where(UserRole.user_id == user_id)
return [r for (r,) in db.execute(q).all()]
def _issue_tokens(db: Session, user: User) -> TokenResponse:
s = get_settings()
roles = _roles(db, user.id)
payload = {
"sub": str(user.id),
"email": user.email,
"tenant_id": user.tenant_id,
"branch_id": user.branch_id,
"roles": roles,
}
access = encode_access_token(payload, expires_minutes=s.JWT_ACCESS_MINUTES)
refresh_plain = secrets.token_urlsafe(48)
now = utcnow()
exp = now + timedelta(days=s.JWT_REFRESH_DAYS)
rt = RefreshToken(
user_id=user.id,
token_hash=_hash_refresh(refresh_plain),
created_at_utc=now,
expires_at_utc=exp,
revoked=False,
rotated_from_id=None,
)
db.add(rt)
db.commit()
return TokenResponse(access_token=access, expires_in=s.JWT_ACCESS_MINUTES * 60, refresh_token=refresh_plain)
@router.post("/token", response_model=TokenResponse)
def token(req: TokenRequest, db: Session = Depends(get_common_db)):
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
if (not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None or not verify_password(req.password, user.password_hash)):
write_audit_log(db, action="auth.token.failed", entity_type="api_session", actor=user, actor_email=req.email.lower().strip(), status="error", target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None), details={"reason": "invalid credentials"})
raise HTTPException(status_code=401, detail="Invalid credentials")
if getattr(user, "must_change_password", False):
raise HTTPException(status_code=403, detail="Password setup/change required before API login")
token_response = _issue_tokens(db, user)
write_audit_log(db, action="auth.token.success", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
return token_response
@router.post('/forgot-password')
def forgot_password(req: ForgotPasswordRequest, db: Session = Depends(get_common_db)):
user = db.execute(select(User).where(User.email == req.email.lower().strip())).scalar_one_or_none()
if user and user.is_active and getattr(user, "deleted_at", None) is None:
reset_token = issue_password_reset_token(db, user)
try:
send_password_reset_link_email(db, user=user, reset_token=reset_token)
except Exception as exc:
write_audit_log(db, action="auth.password_reset.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
write_audit_log(db, action="auth.password_reset.requested", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
db.commit()
return {"status": "ok"}
@router.post('/reset-password')
def reset_password(req: ResetPasswordRequest, db: Session = Depends(get_common_db)):
try:
user = reset_password_with_token(db, req.token, req.new_password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not user:
raise HTTPException(status_code=400, detail="Invalid or expired reset token")
try:
send_password_changed_email(db, user=user)
except Exception as exc:
write_audit_log(db, action="auth.password_changed.email_failed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, status="error", target_tenant_id=user.tenant_id, target_branch_id=user.branch_id, details={"error": str(exc)})
write_audit_log(db, action="auth.password_reset.completed", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
db.commit()
return {"status": "ok"}
@router.post('/invite/accept')
def invite_accept(req: AcceptInviteRequest, db: Session = Depends(get_common_db)):
try:
user = accept_invite(db, req.token, req.password)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
if not user:
raise HTTPException(status_code=400, detail="Invalid or expired invite token")
write_audit_log(db, action="auth.invite.accepted", entity_type="user", actor=user, entity_id=user.id, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
return {"status": "ok"}
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/refresh", response_model=TokenResponse)
def refresh(req: RefreshRequest, db: Session = Depends(get_common_db)):
h = _hash_refresh(req.refresh_token)
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
if not rt or rt.revoked:
raise HTTPException(status_code=401, detail="Invalid refresh token")
now = utcnow()
if rt.expires_at_utc.replace(tzinfo=timezone.utc) < now:
raise HTTPException(status_code=401, detail="Refresh token expired")
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
if not user or not user.is_active or not getattr(user, "allow_login", True) or getattr(user, "is_locked", False) or getattr(user, "deleted_at", None) is not None:
raise HTTPException(status_code=401, detail="User inactive")
rt.revoked = True
db.commit()
token_response = _issue_tokens(db, user)
write_audit_log(db, action="auth.token.refresh", entity_type="api_session", actor=user, entity_name=user.email, target_tenant_id=user.tenant_id, target_branch_id=user.branch_id)
return token_response
class LogoutRequest(BaseModel):
refresh_token: str
@router.post("/logout")
def logout(req: LogoutRequest, db: Session = Depends(get_common_db)):
h = _hash_refresh(req.refresh_token)
rt = db.execute(select(RefreshToken).where(RefreshToken.token_hash == h)).scalar_one_or_none()
if rt:
rt.revoked = True
db.commit()
user = db.execute(select(User).where(User.id == rt.user_id)).scalar_one_or_none()
write_audit_log(db, action="auth.token.logout", entity_type="api_session", actor=user, entity_name=(user.email if user else None), target_tenant_id=(user.tenant_id if user else None), target_branch_id=(user.branch_id if user else None))
return {"status": "ok"}
@router.get("/me")
def me(token: str):
return {"token": decode_token(token)}
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
from datetime import timedelta, timezone
import hashlib
import re
import secrets
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.core.security.jwt_tokens import utcnow
from app.core.security.passwords import hash_password
from app.core.settings import get_settings
from app.modules.core.iam.models import User
from app.modules.core.iam.password_flows_models import InviteToken, PasswordResetToken
def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def validate_password_policy(password: str) -> str | None:
s = get_settings()
if len(password or "") < s.PASSWORD_MIN_LENGTH:
return f"Password must be at least {s.PASSWORD_MIN_LENGTH} characters long."
if not re.search(r"[A-Za-z]", password or ""):
return "Password must include at least one letter."
if not re.search(r"\d", password or ""):
return "Password must include at least one number."
return None
def issue_invite_token(db: Session, user: User) -> str:
plain = secrets.token_urlsafe(32)
now = utcnow()
token = InviteToken(
user_id=user.id,
token_hash=_hash_token(plain),
created_at_utc=now,
expires_at_utc=now + timedelta(hours=get_settings().INVITE_TOKEN_HOURS),
used_at_utc=None,
)
db.add(token)
user.must_change_password = True
db.commit()
return plain
def issue_password_reset_token(db: Session, user: User) -> str:
plain = secrets.token_urlsafe(32)
now = utcnow()
token = PasswordResetToken(
user_id=user.id,
token_hash=_hash_token(plain),
created_at_utc=now,
expires_at_utc=now + timedelta(hours=get_settings().PASSWORD_RESET_HOURS),
used_at_utc=None,
)
db.add(token)
db.commit()
return plain
def _validate_unused(record) -> bool:
if not record or record.used_at_utc is not None:
return False
now = utcnow()
exp = record.expires_at_utc
if getattr(exp, "tzinfo", None) is None:
exp = exp.replace(tzinfo=timezone.utc)
return exp >= now
def accept_invite(db: Session, token: str, password: str) -> User | None:
err = validate_password_policy(password)
if err:
raise ValueError(err)
record = db.execute(select(InviteToken).where(InviteToken.token_hash == _hash_token(token))).scalar_one_or_none()
if not _validate_unused(record):
return None
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
if not user:
return None
user.password_hash = hash_password(password)
user.must_change_password = False
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
user.allow_login = True
user.is_active = True
record.used_at_utc = utcnow().replace(tzinfo=None)
db.commit()
return user
def reset_password_with_token(db: Session, token: str, password: str) -> User | None:
err = validate_password_policy(password)
if err:
raise ValueError(err)
record = db.execute(select(PasswordResetToken).where(PasswordResetToken.token_hash == _hash_token(token))).scalar_one_or_none()
if not _validate_unused(record):
return None
user = db.execute(select(User).where(User.id == record.user_id)).scalar_one_or_none()
if not user:
return None
user.password_hash = hash_password(password)
user.must_change_password = False
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
record.used_at_utc = utcnow().replace(tzinfo=None)
db.commit()
return user
def force_change_password(db: Session, user: User, new_password: str) -> None:
err = validate_password_policy(new_password)
if err:
raise ValueError(err)
user.password_hash = hash_password(new_password)
user.must_change_password = False
user.password_changed_at_utc = utcnow().replace(tzinfo=None)
db.commit()
+75
View File
@@ -0,0 +1,75 @@
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
from app.modules.core.iam.scope import UserScope, ensure_manageable_existing_user
class LifecycleError(Exception):
pass
def utcnow_naive() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None)
def ensure_not_self(actor: User, target: User, action_label: str) -> None:
if actor.id == target.id:
raise LifecycleError(f"You cannot {action_label} your own account.")
def ensure_not_bootstrap_admin(target: User) -> None:
if (target.email or '').strip().lower() == 'admin@auditfirm.local':
raise LifecycleError('Bootstrap system admin cannot be modified by this action.')
def ensure_manageable_lifecycle(scope: UserScope, db: Session, actor: User, target: User, action_label: str) -> None:
ensure_manageable_existing_user(db, scope, target)
ensure_not_self(actor, target, action_label)
def activate_user(user: User) -> None:
user.is_active = True
if user.deleted_at is not None:
user.deleted_at = None
def deactivate_user(user: User) -> None:
user.is_active = False
def enable_login(user: User) -> None:
user.allow_login = True
def disable_login(user: User) -> None:
user.allow_login = False
def lock_user(user: User) -> None:
user.is_locked = True
user.locked_at_utc = utcnow_naive()
def unlock_user(user: User) -> None:
user.is_locked = False
user.locked_at_utc = None
def soft_delete_user(user: User) -> None:
user.deleted_at = utcnow_naive()
user.is_active = False
user.allow_login = False
user.is_locked = True
if user.locked_at_utc is None:
user.locked_at_utc = utcnow_naive()
def restore_user(user: User) -> None:
user.deleted_at = None
user.is_active = True
user.allow_login = True
user.is_locked = False
user.locked_at_utc = None
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import String, Boolean, Integer, ForeignKey, UniqueConstraint, DateTime, Text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db.common import CommonBase
class User(CommonBase):
__tablename__ = "users"
__table_args__ = (UniqueConstraint("email", name="uq_user_email"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(255), index=True)
full_name: Mapped[str] = mapped_column(String(255), default="")
password_hash: Mapped[str] = mapped_column(String(255))
tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id"), index=True)
branch_id: Mapped[int] = mapped_column(ForeignKey("branches.id"), index=True)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
allow_login: Mapped[bool] = mapped_column(Boolean, default=True)
is_locked: Mapped[bool] = mapped_column(Boolean, default=False)
locked_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
must_change_password: Mapped[bool] = mapped_column(Boolean, default=False)
password_changed_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
# Phase 7Q.3 - common user profile/personalisation fields.
# Employee/consultant/client master records remain the source for official data;
# these fields are used for display, dashboards, client-facing contact cards and branding.
profile_photo_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
qualification: Mapped[str | None] = mapped_column(String(200), nullable=True)
designation: Mapped[str | None] = mapped_column(String(200), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(30), nullable=True)
bio: Mapped[str | None] = mapped_column(Text, nullable=True)
signature_image_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
class LoginAttempt(CommonBase):
__tablename__ = "login_attempts"
__table_args__ = (UniqueConstraint("key", name="uq_login_attempt_key"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
key: Mapped[str] = mapped_column(String(255), index=True) # email|ip
attempts: Mapped[int] = mapped_column(Integer, default=0)
locked_until_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
updated_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
@@ -0,0 +1,28 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, ForeignKey, DateTime, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class InviteToken(CommonBase):
__tablename__ = "invite_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_invite_token_hash"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), index=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
class PasswordResetToken(CommonBase):
__tablename__ = "password_reset_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_password_reset_token_hash"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), index=True)
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
used_at_utc: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+95
View File
@@ -0,0 +1,95 @@
from __future__ import annotations
import imghdr
import re
from pathlib import Path
from uuid import uuid4
from fastapi import HTTPException, UploadFile
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
STATIC_ROOT = Path("app/ui/static")
PROFILE_UPLOAD_DIR = STATIC_ROOT / "uploads" / "user_profiles"
MAX_IMAGE_BYTES = 2 * 1024 * 1024
ALLOWED_IMAGE_TYPES = {"jpeg": ".jpg", "png": ".png", "gif": ".gif", "webp": ".webp"}
def _blank_to_none(value: object) -> str | None:
value = (str(value).strip() if value is not None else "")
return value or None
def _safe_static_path(path: str | None) -> str | None:
path = (path or "").strip()
if not path:
return None
if path.startswith("/static/"):
return path
if path.startswith("app/ui/static/"):
return "/static/" + path.split("app/ui/static/", 1)[1].replace("\\", "/")
return path
def profile_photo_url(user: User | None) -> str | None:
if not user:
return None
return _safe_static_path(getattr(user, "profile_photo_path", None))
def user_initials(user: User | None) -> str:
if not user:
return "U"
name = (getattr(user, "full_name", None) or getattr(user, "email", "") or "User").strip()
if "@" in name and not getattr(user, "full_name", None):
name = name.split("@", 1)[0]
parts = [p for p in re.split(r"\s+", name) if p]
if not parts:
return "U"
if len(parts) == 1:
return parts[0][:2].upper()
return (parts[0][0] + parts[-1][0]).upper()
async def save_user_profile_photo(user: User, upload: UploadFile | None) -> str | None:
"""Persist a profile photo and return a static path, or current path if nothing uploaded."""
if not upload or not getattr(upload, "filename", None):
return getattr(user, "profile_photo_path", None)
raw = await upload.read()
if not raw:
return getattr(user, "profile_photo_path", None)
if len(raw) > MAX_IMAGE_BYTES:
raise HTTPException(status_code=400, detail="Profile photo must be 2 MB or smaller.")
detected = imghdr.what(None, raw)
suffix = ALLOWED_IMAGE_TYPES.get(detected or "")
if not suffix:
raise HTTPException(status_code=400, detail="Upload a valid JPG, PNG, GIF or WebP profile photo.")
PROFILE_UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
filename = f"user_{user.id}_{uuid4().hex}{suffix}"
target = PROFILE_UPLOAD_DIR / filename
target.write_bytes(raw)
return f"app/ui/static/uploads/user_profiles/{filename}"
def update_user_public_profile(
db: Session,
user: User,
*,
qualification: object = None,
designation: object = None,
mobile: object = None,
bio: object = None,
profile_photo_path: str | None = None,
) -> User:
user.qualification = _blank_to_none(qualification)
user.designation = _blank_to_none(designation)
user.mobile = _blank_to_none(mobile)
user.bio = _blank_to_none(bio)
if profile_photo_path is not None:
user.profile_photo_path = profile_photo_path
db.add(user)
return user
+228
View File
@@ -0,0 +1,228 @@
from __future__ import annotations
from dataclasses import dataclass
from fastapi import HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
from app.modules.core.rbac.models import Role, UserRole
from app.modules.core.tenancy.models import Branch, Tenant
ROLE_SYSTEM_ADMIN = "System Admin"
ROLE_FIRM_ADMIN = "Firm Admin"
ROLE_PARTNER = "Partner"
ROLE_BRANCH_MANAGER = "Branch Manager"
ROLE_STAFF = "Staff"
ROLE_CLIENT = "Client"
ROLE_CONSULTANT = "Consultant"
# Matrix: only System Admin and Firm Admin manage users.
MANAGEABLE_ROLES_BY_ACTOR = {
ROLE_SYSTEM_ADMIN: {
ROLE_SYSTEM_ADMIN,
ROLE_FIRM_ADMIN,
ROLE_PARTNER,
ROLE_BRANCH_MANAGER,
ROLE_STAFF,
ROLE_CLIENT,
ROLE_CONSULTANT,
},
ROLE_FIRM_ADMIN: {
ROLE_PARTNER,
ROLE_BRANCH_MANAGER,
ROLE_STAFF,
ROLE_CLIENT,
ROLE_CONSULTANT,
},
}
@dataclass
class UserScope:
actor: User
role_names: list[str]
is_system_admin: bool
is_firm_admin: bool
is_partner: bool
is_branch_manager: bool
@property
def tenant_scoped(self) -> bool:
return self.is_firm_admin or self.is_partner or self.is_branch_manager
@property
def branch_scoped(self) -> bool:
return self.is_branch_manager
class ScopeError(Exception):
pass
def get_role_names(db: Session, user_id: int) -> list[str]:
q = (
select(Role.name)
.join(UserRole, UserRole.role_id == Role.id)
.where(UserRole.user_id == user_id, Role.is_active.is_(True))
.order_by(Role.name)
)
return [name for (name,) in db.execute(q).all()]
def build_scope(db: Session, actor: User) -> UserScope:
role_names = get_role_names(db, actor.id)
return UserScope(
actor=actor,
role_names=role_names,
is_system_admin=ROLE_SYSTEM_ADMIN in role_names,
is_firm_admin=ROLE_FIRM_ADMIN in role_names,
is_partner=ROLE_PARTNER in role_names,
is_branch_manager=ROLE_BRANCH_MANAGER in role_names,
)
def ensure_users_view_scope(scope: UserScope) -> None:
if scope.is_system_admin or scope.is_firm_admin or scope.is_partner or scope.is_branch_manager:
return
raise ScopeError("You are not allowed to view users.")
def ensure_users_manage_scope(scope: UserScope) -> None:
if scope.is_system_admin or scope.is_firm_admin:
return
raise ScopeError("Only System Admin and Firm Admin can manage users.")
def list_visible_tenants(db: Session, scope: UserScope) -> list[Tenant]:
if scope.is_system_admin:
return db.execute(
select(Tenant).where(Tenant.is_active.is_(True)).order_by(Tenant.name)
).scalars().all()
tenant = db.execute(select(Tenant).where(Tenant.id == scope.actor.tenant_id)).scalar_one_or_none()
return [tenant] if tenant else []
def list_visible_branches(db: Session, scope: UserScope, tenant_id: int | None = None) -> list[Branch]:
effective_tenant_id = tenant_id or scope.actor.tenant_id
q = select(Branch).where(Branch.is_active.is_(True), Branch.tenant_id == effective_tenant_id)
if scope.branch_scoped:
q = q.where(Branch.id == scope.actor.branch_id)
return db.execute(q.order_by(Branch.name)).scalars().all()
def list_scoped_users(db: Session, scope: UserScope) -> list[User]:
q = select(User)
if not scope.is_system_admin:
q = q.where(User.tenant_id == scope.actor.tenant_id)
if scope.branch_scoped:
q = q.where(User.branch_id == scope.actor.branch_id)
return db.execute(q.order_by(User.id)).scalars().all()
def get_manageable_roles(db: Session, scope: UserScope) -> list[Role]:
if scope.is_system_admin:
return db.execute(
select(Role).where(Role.is_active.is_(True)).order_by(Role.name)
).scalars().all()
allowed_names: set[str] = set()
for role_name in scope.role_names:
allowed_names.update(MANAGEABLE_ROLES_BY_ACTOR.get(role_name, set()))
if not allowed_names:
return []
return db.execute(
select(Role).where(Role.is_active.is_(True), Role.name.in_(sorted(allowed_names))).order_by(Role.name)
).scalars().all()
def get_user_role_names(db: Session, user_id: int) -> list[str]:
return get_role_names(db, user_id)
def get_user_role_ids(db: Session, user_id: int) -> list[int]:
return db.execute(select(UserRole.role_id).where(UserRole.user_id == user_id)).scalars().all()
def can_manage_role_names(scope: UserScope, role_names: list[str]) -> bool:
if scope.is_system_admin:
return True
allowed: set[str] = set()
for actor_role in scope.role_names:
allowed.update(MANAGEABLE_ROLES_BY_ACTOR.get(actor_role, set()))
return set(role_names).issubset(allowed)
def validate_branch_matches_tenant(db: Session, tenant_id: int, branch_id: int) -> Branch:
branch = db.execute(
select(Branch).where(
Branch.id == branch_id,
Branch.tenant_id == tenant_id,
Branch.is_active.is_(True),
)
).scalar_one_or_none()
if not branch:
raise ScopeError("Selected branch does not belong to the selected tenant.")
return branch
def resolve_target_tenant_branch(
db: Session,
scope: UserScope,
tenant_id: int | None,
branch_id: int | None,
) -> tuple[int, int]:
if scope.is_system_admin:
if tenant_id is None or branch_id is None:
raise ScopeError("Tenant and branch are required.")
validate_branch_matches_tenant(db, tenant_id, branch_id)
return tenant_id, branch_id
effective_tenant_id = scope.actor.tenant_id
effective_branch_id = branch_id
if tenant_id is not None and tenant_id != scope.actor.tenant_id:
raise ScopeError("Cross-tenant user creation is not allowed.")
if effective_branch_id is None:
raise ScopeError("Branch is required.")
validate_branch_matches_tenant(db, effective_tenant_id, effective_branch_id)
return effective_tenant_id, effective_branch_id
def ensure_manageable_existing_user(db: Session, scope: UserScope, target_user: User) -> None:
if scope.is_system_admin:
return
if not scope.is_firm_admin:
raise ScopeError("Only System Admin and Firm Admin can manage users.")
if target_user.tenant_id != scope.actor.tenant_id:
raise ScopeError("You cannot manage users of another tenant.")
target_roles = get_user_role_names(db, target_user.id)
if target_roles and not can_manage_role_names(scope, target_roles):
raise ScopeError("You cannot manage the selected user's role level.")
def ensure_assignable_roles(db: Session, scope: UserScope, role_ids: list[int]) -> list[Role]:
if not role_ids:
return []
roles = db.execute(
select(Role).where(Role.id.in_(role_ids), Role.is_active.is_(True)).order_by(Role.name)
).scalars().all()
if len(roles) != len(set(role_ids)):
raise ScopeError("One or more selected roles are invalid.")
if not can_manage_role_names(scope, [r.name for r in roles]):
raise ScopeError("You cannot assign one or more selected roles.")
return roles
def assert_can_manage_role_object(scope: UserScope, role: Role) -> None:
if scope.is_system_admin:
return
raise ScopeError("Only System Admin can manage RBAC roles.")
def scope_to_http(exc: ScopeError) -> HTTPException:
return HTTPException(status_code=403, detail=str(exc))
+19
View File
@@ -0,0 +1,19 @@
"""Scope guard utilities for tenant/branch enforcement (v2.0.3.1)"""
from typing import Optional
class ScopeError(Exception):
pass
def ensure_same_tenant(actor_tenant_id: int, target_tenant_id: int):
if actor_tenant_id != target_tenant_id:
raise ScopeError("Cross-tenant operation is not allowed")
def ensure_same_branch(actor_branch_id: int, target_branch_id: int):
if actor_branch_id != target_branch_id:
raise ScopeError("Cross-branch operation is not allowed")
def validate_branch_belongs_to_tenant(branch_tenant_id: int, tenant_id: int):
if branch_tenant_id != tenant_id:
raise ScopeError("Branch does not belong to selected tenant")
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
from dataclasses import dataclass
from math import ceil
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.modules.core.iam.models import User
from app.modules.core.iam.scope import (
UserScope,
get_manageable_roles,
get_user_role_names,
list_visible_branches,
list_visible_tenants,
)
from app.modules.core.tenancy.models import Branch, Tenant
@dataclass
class PageResult:
items: list
page: int
per_page: int
total: int
pages: int
@property
def has_prev(self) -> bool:
return self.page > 1
@property
def has_next(self) -> bool:
return self.page < self.pages
def paginate_list(items: list, page: int = 1, per_page: int = 10) -> PageResult:
total = len(items)
per_page = max(1, min(per_page, 100))
pages = max(1, ceil(total / per_page)) if total else 1
page = max(1, min(page, pages))
start = (page - 1) * per_page
end = start + per_page
return PageResult(items=items[start:end], page=page, per_page=per_page, total=total, pages=pages)
def search_scoped_users(db: Session, scope: UserScope, q: str | None = None) -> list[User]:
stmt = select(User)
if not scope.is_system_admin:
stmt = stmt.where(User.tenant_id == scope.actor.tenant_id)
if scope.branch_scoped:
stmt = stmt.where(User.branch_id == scope.actor.branch_id)
query = (q or "").strip()
if query:
like = f"%{query}%"
stmt = stmt.where(or_(User.email.ilike(like), User.full_name.ilike(like)))
return db.execute(stmt.order_by(User.full_name, User.email, User.id)).scalars().all()
def build_user_listing_payload(
db: Session,
scope: UserScope,
q: str | None = None,
page: int = 1,
per_page: int = 10,
) -> dict:
users = search_scoped_users(db, scope, q=q)
paged = paginate_list(users, page=page, per_page=per_page)
user_ids = [u.id for u in paged.items]
roles = {user_id: get_user_role_names(db, user_id) for user_id in user_ids}
tenants = {t.id: t for t in list_visible_tenants(db, scope)}
if scope.is_system_admin:
branch_rows = db.execute(select(Branch).order_by(Branch.name)).scalars().all()
else:
branch_rows = list_visible_branches(db, scope, scope.actor.tenant_id)
branches = {b.id: b for b in branch_rows}
return {
"users_page": paged,
"users": paged.items,
"user_roles": roles,
"tenants": tenants,
"branches": branches,
"filters": {
"q": (q or "").strip(),
"per_page": paged.per_page,
},
}
def build_user_form_payload(
db: Session,
scope: UserScope,
actor: User,
user_obj: User | None = None,
assigned_role_ids: list[int] | None = None,
) -> dict:
tenants = list_visible_tenants(db, scope)
if user_obj:
selected_tenant_id = user_obj.tenant_id
selected_branch_id = user_obj.branch_id
else:
if scope.is_system_admin:
selected_tenant_id = actor.tenant_id or (tenants[0].id if tenants else None)
else:
selected_tenant_id = actor.tenant_id
selected_branch_id = actor.branch_id if scope.branch_scoped else None
branches = list_visible_branches(db, scope, selected_tenant_id)
selected_tenant = next((t for t in tenants if t.id == selected_tenant_id), None)
selected_branch = next((b for b in branches if b.id == selected_branch_id), None)
manageable_roles = get_manageable_roles(db, scope)
return {
"user_obj": user_obj,
"assigned_role_ids": assigned_role_ids or [],
"roles": manageable_roles,
"tenants": tenants,
"branches": branches,
"scope": scope,
"selected_tenant_id": selected_tenant_id,
"selected_branch_id": selected_branch_id,
"selected_tenant_name": selected_tenant.name if selected_tenant else "",
"selected_branch_name": selected_branch.name if selected_branch else "",
"can_change_tenant": scope.is_system_admin,
}
@@ -0,0 +1,37 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
<h1 class="text-2xl font-semibold">Change Password</h1>
<p class="mt-1 text-sm text-slate-500">Enter your current password, then confirm the password change using the OTP sent to your registered email.</p>
{% if flash %}
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{{ flash }}
</div>
{% endif %}
<form method="post" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Current Password</span>
<input type="password" name="current_password" class="w-full rounded-xl border px-3 py-2" required />
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">New Password</span>
<input type="password" name="new_password" class="w-full rounded-xl border px-3 py-2" required />
</label>
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</span>
<input type="password" name="confirm_password" class="w-full rounded-xl border px-3 py-2" required />
</label>
<div class="flex gap-3 pt-2">
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Send OTP</button>
<a href="/system-settings" class="rounded-xl border px-4 py-2 text-sm">Cancel</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-2xl rounded-2xl border bg-white p-6 shadow-soft">
<h1 class="text-2xl font-semibold">Confirm Password Change</h1>
<p class="mt-1 text-sm text-slate-500">Enter the OTP sent to your registered email to complete the password change.</p>
{% if flash %}
<div class="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
{{ flash }}
</div>
{% endif %}
<form method="post" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="block">
<span class="mb-2 block text-sm font-medium text-slate-700">OTP</span>
<input type="text" name="otp" class="w-full rounded-xl border px-3 py-2" required />
</label>
<div class="flex gap-3 pt-2">
<button type="submit" class="rounded-xl bg-slate-900 px-4 py-2 text-sm text-white">Confirm Change</button>
<a href="/change-password" class="rounded-xl border px-4 py-2 text-sm">Back</a>
</div>
</form>
</div>
{% endblock %}
@@ -0,0 +1,36 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
<h2 class="text-xl font-semibold text-slate-900">Forgot Password</h2>
<p class="mt-2 text-sm text-slate-500">
Enter your login email to receive password reset instructions by email.
</p>
<form method="post" action="/forgot-password" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Email</label>
<input
type="email"
name="email"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Enter your login email"
required
/>
</div>
<button
type="submit"
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
>
Send Reset Instructions
</button>
</form>
<div class="mt-4 text-sm">
<a class="text-brand-700 underline" href="/login">Back to login</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1 @@
{% extends "ui/templates/base/layout.html" %}{% block content %}<div class="mx-auto max-w-2xl rounded-3xl bg-white p-6 shadow-soft"><h2 class="text-xl font-semibold">Invite Link Generated</h2><p class="mt-1 text-sm text-slate-500">The invite email has been attempted through the configured firm SMTP. You may also copy and share this link manually with {{ invited_user.full_name or invited_user.email }} if required.</p><div class="mt-5 rounded-2xl border border-brand-200 bg-brand-50 p-4 text-sm text-brand-900 break-all">{{ invite_url }}</div><div class="mt-5"><a href="/system-settings/users" class="rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-medium text-slate-700 hover:bg-slate-50">Back to users</a></div></div>{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="max-w-md rounded-2xl bg-white border p-6">
<h1 class="text-2xl font-semibold">OTP Verification</h1>
<p class="text-slate-600 mt-1 text-sm">Enter the OTP sent to your registered email.</p>
<form method="post" class="mt-5 grid gap-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label class="grid gap-1">
<span class="text-sm text-slate-600">OTP Code</span>
<input class="border rounded-xl px-3 py-2" name="otp" inputmode="numeric" required />
</label>
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Verify</button>
</form>
</div>
{% endblock %}
@@ -0,0 +1,58 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
<p class="mt-2 text-sm text-slate-500">
Enter the OTP sent to your registered email and set your new password.
</p>
<form method="post" action="/reset-password" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">OTP</label>
<input
type="text"
name="otp"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Enter OTP"
required
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
<input
type="password"
name="new_password"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Enter new password"
required
/>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm Password</label>
<input
type="password"
name="confirm_password"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Re-enter new password"
required
/>
</div>
<button
type="submit"
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
>
Reset Password
</button>
</form>
<div class="mt-4 text-sm">
<a class="text-brand-700 underline" href="/login">Back to login</a>
</div>
</div>
{% endblock %}
@@ -0,0 +1,53 @@
{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="mx-auto max-w-lg rounded-3xl bg-white p-6 shadow-soft">
<h2 class="text-xl font-semibold text-slate-900">Reset Password</h2>
<p class="mt-2 text-sm text-slate-500">
Enter your new password to complete the password reset request.
</p>
{% if flash %}
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ flash }}</div>
{% endif %}
<form method="post" action="/password-reset/accept" class="mt-6 space-y-4">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<input type="hidden" name="token" value="{{ token }}" />
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">New Password</label>
<input
type="password"
name="new_password"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Enter new password"
required
/>
<p class="mt-1 text-xs text-slate-500">Use at least 8 characters with letters and numbers.</p>
</div>
<div>
<label class="mb-2 block text-sm font-medium text-slate-700">Confirm New Password</label>
<input
type="password"
name="confirm_password"
class="w-full rounded-2xl border border-slate-300 px-4 py-2.5 text-sm outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-100"
placeholder="Confirm new password"
required
/>
</div>
<button
type="submit"
class="inline-flex rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700"
>
Reset Password
</button>
</form>
<div class="mt-4 text-sm">
<a class="text-brand-700 underline" href="/login">Back to login</a>
</div>
</div>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import Integer, String, ForeignKey, DateTime, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
class RefreshToken(CommonBase):
__tablename__ = "refresh_tokens"
__table_args__ = (UniqueConstraint("token_hash", name="uq_refresh_token_hash"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
token_hash: Mapped[str] = mapped_column(String(64), index=True) # sha256 hex
created_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
expires_at_utc: Mapped[datetime] = mapped_column(DateTime, nullable=False)
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
rotated_from_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
File diff suppressed because it is too large Load Diff