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
+55
View File
@@ -0,0 +1,55 @@
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from app.modules.clients.ui import router as clients_ui_router, portal_router as client_portal_router
from app.modules.consultants.ui import router as consultants_ui_router, portal_router as consultant_portal_router
from app.modules.employees.ui import router as employees_ui_router, portal_router as employee_portal_router
from app.modules.managers.ui import router as managers_ui_router
from app.modules.partners.ui import router as partners_ui_router
from app.modules.core.audit.ui import router as audit_ui_router
from app.modules.core.iam.ui import router as iam_ui_router
from app.modules.core.rbac.ui import router as rbac_ui_router
from app.modules.services.ui import router as services_ui_router
from app.modules.services.engagements_ui import router as engagements_ui_router
from app.modules.services.work_tracker_ui import router as work_tracker_ui_router
from app.modules.billing.ui import router as billing_ui_router
from app.modules.platform_billing.ui import router as platform_billing_ui_router
from app.modules.marketplace.ui import router as marketplace_ui_router, public_router as marketplace_public_router
from app.modules.documents.ui import router as documents_ui_router
from app.modules.alerts.ui import router as alerts_ui_router
from app.modules.work_detail.ui import router as work_detail_ui_router
from app.modules.system_settings.ui import router as system_settings_router
from app.modules.email_integration.ui import router as email_integration_router
from app.modules.domain_management.ui import router as domain_management_router
from app.modules.notice_cases.ui import router as notice_cases_router
from app.ui.routes.auth import router as auth_router
def mount_ui(app: FastAPI) -> None:
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
app.include_router(marketplace_public_router)
app.include_router(auth_router)
app.include_router(system_settings_router)
app.include_router(email_integration_router)
app.include_router(domain_management_router)
app.include_router(iam_ui_router)
app.include_router(rbac_ui_router)
app.include_router(audit_ui_router)
app.include_router(services_ui_router)
app.include_router(work_tracker_ui_router)
app.include_router(billing_ui_router)
app.include_router(platform_billing_ui_router)
app.include_router(marketplace_ui_router)
app.include_router(documents_ui_router)
app.include_router(alerts_ui_router)
app.include_router(notice_cases_router)
app.include_router(work_detail_ui_router)
app.include_router(clients_ui_router)
app.include_router(employees_ui_router)
app.include_router(managers_ui_router)
app.include_router(partners_ui_router)
app.include_router(employee_portal_router)
app.include_router(consultants_ui_router)
app.include_router(engagements_ui_router)
app.include_router(client_portal_router)
app.include_router(consultant_portal_router)
View File
+837
View File
@@ -0,0 +1,837 @@
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Form, Request
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from app.core.db.common import CommonSessionLocal
from app.core.security.csrf import get_or_create_csrf_token, validate_csrf
from app.core.security.otp import start_otp, verify_otp
from app.core.security.passwords import verify_password, hash_password
from app.core.security.session_auth import (
SESSION_LOGIN_AT_KEY,
SESSION_USER_ID_KEY,
get_current_user,
)
from app.core.templating import templates
from app.core.settings import get_settings
from app.modules.core.iam.invite_service import accept_invite, reset_password_with_token
from app.modules.core.iam.models import LoginAttempt, User
from app.modules.core.rbac.models import Permission, Role, RolePermission, UserRole
from app.modules.core.tenancy.models import Branch, FinancialYear, Tenant
from app.modules.core.tenancy.settings_models import BranchSettings
from app.modules.email_integration.services import send_auth_otp_email, send_password_changed_email
router = APIRouter()
def _dev_otp_print_enabled() -> bool:
settings = get_settings()
return bool(getattr(settings, "DEV_AUTH_OTP_PRINT", False)) and (settings.ENV or "").lower() in {"dev", "local", "development"}
def _log_dev_otp(label: str, email: str, code: str) -> None:
if _dev_otp_print_enabled():
print(f"[DEV OTP] {label} user={email} code={code}")
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _attempt_key(email: str, ip: str) -> str:
return f"{email.lower().strip()}|{ip}"
def _get_branch_security_policy(db, user: User) -> BranchSettings | None:
if not getattr(user, "branch_id", None):
return None
return db.execute(
select(BranchSettings).where(BranchSettings.branch_id == user.branch_id)
).scalar_one_or_none()
def _user_roles(db, 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 _user_permissions(db, user_id: int) -> set[str]:
q = (
select(Permission.code)
.join(RolePermission, RolePermission.permission_id == Permission.id)
.join(UserRole, UserRole.role_id == RolePermission.role_id)
.where(UserRole.user_id == user_id, Permission.is_active.is_(True))
)
return set(db.execute(q).scalars().all())
def _otp_required(bs: BranchSettings | None, roles: list[str]) -> bool:
if not bs:
return False
required = {
x.strip() for x in (bs.otp_required_roles_csv or "").split(",") if x.strip()
}
return any(r in required for r in roles)
def _default_financial_year_code(db, tenant_id: int | None) -> str | None:
if not tenant_id:
return None
fy = db.execute(
select(FinancialYear).where(
FinancialYear.tenant_id == tenant_id,
FinancialYear.is_current.is_(True),
)
).scalar_one_or_none()
if fy:
return fy.year_code
fy = db.execute(
select(FinancialYear)
.where(FinancialYear.tenant_id == tenant_id)
.order_by(FinancialYear.start_date.desc(), FinancialYear.year_code.desc())
).scalars().first()
return fy.year_code if fy else None
def _tenant_code(db, tenant_id: int | None) -> str | None:
if not tenant_id:
return None
tenant = db.get(Tenant, int(tenant_id))
return tenant.code if tenant else None
def _branch_code(db, branch_id: int | None) -> str | None:
if not branch_id:
return None
branch = db.get(Branch, int(branch_id))
return branch.code if branch else None
def _post_login_redirect(must_change_password: bool, permissions: set[str], roles: list[str]) -> str:
if must_change_password:
return "/change-password-required"
if "Client" in roles:
return "/client/dashboard"
if "Consultant" in roles:
return "/consultant/dashboard"
role_set = set(roles or [])
# Phase 7K refinement:
# Dedicated Partner users should land directly on Partner Workspace.
# System/Firm Admin users are not forced here because they keep broader admin context.
if "Partner" in role_set and not role_set.intersection({"System Admin", "Firm Admin"}):
return "/partner/dashboard"
# Phase 7J refinement:
# Dedicated manager users should land directly on Manager Workspace.
# Higher management roles are intentionally not redirected here because
# they may later get their own Firm Admin dashboards.
if role_set.intersection({"Manager", "Branch Manager"}) and not role_set.intersection({"System Admin", "Firm Admin", "Partner"}):
return "/manager/dashboard"
# Phase 7I refinement:
# For internal firm users, make My Workspace the default landing page.
# This keeps Client/Consultant portal routing unchanged and only falls back
# to System Settings where the login has no employee/self-service access.
if (
"employees.ess.view" in permissions
or "employees.work.view_self" in permissions
or "employees.attendance.view_self" in permissions
or "employees.leave.view_self" in permissions
or "employees.documents.view_self" in permissions
or "employees.payroll.view_self" in permissions
or {"System Admin", "Firm Admin", "Partner", "Staff"}.intersection(role_set)
):
return "/employee/dashboard"
if "system.settings.view" in permissions or "users.view" in permissions:
return "/system-settings"
return "/employee/dashboard"
def _is_user_login_allowed(user: User) -> tuple[bool, str | None]:
if not user:
return False, "Invalid credentials"
if not getattr(user, "is_active", True):
return False, "User account is inactive."
if hasattr(user, "allow_login") and not bool(getattr(user, "allow_login", True)):
return False, "Login is disabled for this account."
if hasattr(user, "is_locked") and bool(getattr(user, "is_locked", False)):
return False, "User account is locked."
if hasattr(user, "deleted_at") and getattr(user, "deleted_at", None) is not None:
return False, "User account is deleted."
return True, None
def _template_context(
request: Request,
db=None,
*,
title: str,
flash: str | None = None,
extra: dict | None = None,
) -> dict:
ctx = {
"request": request,
"csrf_token": get_or_create_csrf_token(request),
"flash": flash,
"title": title,
}
if db is not None:
current_user = get_current_user(request, db=db)
if current_user:
ctx.update(
{
"current_user": current_user,
"current_user_roles": _user_roles(db, int(current_user.id)),
"current_user_permissions": list(
_user_permissions(db, int(current_user.id))
),
}
)
if extra:
ctx.update(extra)
return ctx
def _render_login(request: Request, flash: str | None = None, status_code: int = 200):
return templates.TemplateResponse(
"modules/core/iam/templates/login.html",
_template_context(request, title="Login", flash=flash),
status_code=status_code,
)
def _render_otp(request: Request, flash: str | None = None, status_code: int = 200):
db = CommonSessionLocal()
try:
return templates.TemplateResponse(
"modules/core/iam/templates/otp.html",
_template_context(
request,
db=db,
title="OTP Verification",
flash=flash,
),
status_code=status_code,
)
finally:
db.close()
def _render_change_password(
request: Request, flash: str | None = None, status_code: int = 200
):
db = CommonSessionLocal()
try:
return templates.TemplateResponse(
"modules/core/iam/templates/change_password.html",
_template_context(
request,
db=db,
title="Change Password",
flash=flash,
),
status_code=status_code,
)
finally:
db.close()
def _render_change_password_otp(
request: Request, flash: str | None = None, status_code: int = 200
):
db = CommonSessionLocal()
try:
return templates.TemplateResponse(
"modules/core/iam/templates/change_password_otp.html",
_template_context(
request,
db=db,
title="Confirm Password Change",
flash=flash,
),
status_code=status_code,
)
finally:
db.close()
def _render_forgot_password(
request: Request, flash: str | None = None, status_code: int = 200
):
return templates.TemplateResponse(
"modules/core/iam/templates/forgot_password.html",
_template_context(
request,
title="Forgot Password",
flash=flash,
),
status_code=status_code,
)
def _render_reset_password(
request: Request, flash: str | None = None, status_code: int = 200
):
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password.html",
_template_context(
request,
title="Reset Password",
flash=flash,
),
status_code=status_code,
)
def _render_invite_accept(
request: Request, token: str, flash: str | None = None, status_code: int = 200
):
return templates.TemplateResponse(
"modules/core/iam/templates/invite_accept.html",
_template_context(
request,
title="Accept Invite",
flash=flash,
extra={"token": token},
),
status_code=status_code,
)
@router.get("/invite/accept")
def invite_accept_page(request: Request, token: str = ""):
if not token.strip():
return RedirectResponse(url="/login", status_code=303)
return _render_invite_accept(request, token=token.strip())
@router.post("/invite/accept")
def invite_accept_submit(
request: Request,
token: str = Form(...),
password: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
token_clean = token.strip()
if not token_clean:
return RedirectResponse(url="/login", status_code=303)
db = CommonSessionLocal()
try:
try:
user = accept_invite(db, token_clean, password.strip())
except ValueError as exc:
return _render_invite_accept(request, token=token_clean, flash=str(exc), status_code=400)
if not user:
return _render_invite_accept(request, token=token_clean, flash="Invalid or expired invite link.", status_code=400)
return RedirectResponse(url="/login", status_code=303)
finally:
db.close()
@router.get("/login")
def login_page(request: Request):
return _render_login(request)
@router.post("/login")
def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
email_clean = email.strip().lower()
ip = _client_ip(request)
key = _attempt_key(email_clean, ip)
db = CommonSessionLocal()
try:
la = db.execute(
select(LoginAttempt).where(LoginAttempt.key == key)
).scalar_one_or_none()
now = datetime.now(timezone.utc)
if la and la.locked_until_utc and la.locked_until_utc.replace(
tzinfo=timezone.utc
) > now:
return _render_login(
request,
flash=f"Account temporarily locked. Try again after {la.locked_until_utc}.",
status_code=429,
)
user = db.execute(
select(User).where(User.email == email_clean)
).scalar_one_or_none()
can_login, blocked_reason = _is_user_login_allowed(user)
password_ok = bool(user and verify_password(password, user.password_hash))
if not user or not can_login or not password_ok:
lock_attempts = 5
lock_minutes = 15
if user:
bs = _get_branch_security_policy(db, user)
if bs:
lock_attempts = bs.lockout_attempts
lock_minutes = bs.lockout_minutes
if not la:
la = LoginAttempt(key=key, attempts=0, updated_at_utc=now)
db.add(la)
la.attempts = int(la.attempts or 0) + 1
la.updated_at_utc = now
if la.attempts >= lock_attempts:
la.locked_until_utc = now + timedelta(minutes=lock_minutes)
la.attempts = 0
db.commit()
flash = blocked_reason or "Invalid credentials"
return _render_login(request, flash=flash, status_code=400)
if la:
la.attempts = 0
la.locked_until_utc = None
la.updated_at_utc = now
db.commit()
user_id = int(user.id)
user_email = str(user.email)
tenant_id = getattr(user, "tenant_id", None)
branch_id = getattr(user, "branch_id", None)
must_change_password = bool(getattr(user, "must_change_password", False))
roles = _user_roles(db, user_id)
permissions = _user_permissions(db, user_id)
bs = _get_branch_security_policy(db, user)
request.session[SESSION_USER_ID_KEY] = user_id
request.session[SESSION_LOGIN_AT_KEY] = now.isoformat()
request.session["user_email"] = user_email
tenant_code = _tenant_code(db, tenant_id)
branch_code = _branch_code(db, branch_id)
request.session["tenant_id"] = tenant_id
request.session["branch_id"] = branch_id
request.session["active_tenant_id"] = tenant_id
request.session["active_branch_id"] = branch_id
if tenant_code:
request.session["tenant_code"] = tenant_code
request.session["active_tenant_code"] = tenant_code
else:
request.session.pop("tenant_code", None)
request.session.pop("active_tenant_code", None)
if branch_code:
request.session["branch_code"] = branch_code
request.session["active_branch_code"] = branch_code
else:
request.session.pop("branch_code", None)
request.session.pop("active_branch_code", None)
active_financial_year = _default_financial_year_code(db, tenant_id)
if active_financial_year:
request.session["active_financial_year"] = active_financial_year
request.session["must_change_password"] = must_change_password
request.session["post_login_redirect"] = _post_login_redirect(
must_change_password, permissions, roles
)
if _otp_required(bs, roles):
code = start_otp(request)
try:
send_auth_otp_email(db, user=user, otp_code=code, purpose="login")
db.commit()
except Exception as exc:
print(f"[EMAIL OTP ERROR] user={user_email} error={exc}")
_log_dev_otp("login", user_email, code)
request.session["otp_verified"] = False
return RedirectResponse(url="/otp", status_code=303)
request.session["otp_verified"] = True
return RedirectResponse(
url=request.session.get("post_login_redirect", "/system-settings"),
status_code=303,
)
finally:
db.close()
@router.get("/otp")
def otp_page(request: Request):
if not request.session.get(SESSION_USER_ID_KEY):
return RedirectResponse(url="/login", status_code=303)
return _render_otp(request)
@router.post("/otp")
def otp_submit(
request: Request,
otp: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
if not request.session.get(SESSION_USER_ID_KEY):
return RedirectResponse(url="/login", status_code=303)
if verify_otp(request, otp):
request.session["otp_verified"] = True
return RedirectResponse(
url=request.session.get("post_login_redirect", "/system-settings"),
status_code=303,
)
return _render_otp(
request,
flash="Invalid OTP. Please check the OTP sent to your registered email.",
status_code=400,
)
@router.get("/change-password")
def change_password_page(request: Request):
if not request.session.get(SESSION_USER_ID_KEY):
return RedirectResponse(url="/login", status_code=303)
return _render_change_password(request)
@router.post("/change-password")
def change_password_submit(
request: Request,
current_password: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
user_id = request.session.get(SESSION_USER_ID_KEY)
if not user_id:
return RedirectResponse(url="/login", status_code=303)
if new_password != confirm_password:
return _render_change_password(
request,
flash="New password and confirm password do not match.",
status_code=400,
)
if len(new_password.strip()) < 8:
return _render_change_password(
request,
flash="New password must be at least 8 characters.",
status_code=400,
)
db = CommonSessionLocal()
try:
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
if not verify_password(current_password, user.password_hash):
return _render_change_password(
request,
flash="Current password is incorrect.",
status_code=400,
)
request.session["pending_password_change_hash"] = hash_password(
new_password.strip()
)
request.session["pending_password_change_user_id"] = int(user.id)
code = start_otp(request)
try:
send_auth_otp_email(db, user=user, otp_code=code, purpose="password_change")
db.commit()
except Exception as exc:
print(f"[EMAIL OTP ERROR] password-change user={user.email} error={exc}")
_log_dev_otp("password-change", str(user.email), code)
return RedirectResponse(url="/change-password/otp", status_code=303)
finally:
db.close()
@router.get("/change-password/otp")
def change_password_otp_page(request: Request):
if not request.session.get(SESSION_USER_ID_KEY):
return RedirectResponse(url="/login", status_code=303)
if not request.session.get("pending_password_change_hash"):
return RedirectResponse(url="/change-password", status_code=303)
return _render_change_password_otp(request)
@router.post("/change-password/otp")
def change_password_otp_submit(
request: Request,
otp: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
user_id = request.session.get(SESSION_USER_ID_KEY)
pending_user_id = request.session.get("pending_password_change_user_id")
pending_hash = request.session.get("pending_password_change_hash")
if not user_id:
return RedirectResponse(url="/login", status_code=303)
if not pending_hash or not pending_user_id or int(user_id) != int(pending_user_id):
return RedirectResponse(url="/change-password", status_code=303)
if not verify_otp(request, otp):
return _render_change_password_otp(
request,
flash="Invalid OTP. Please check the OTP sent to your registered email.",
status_code=400,
)
db = CommonSessionLocal()
try:
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
user.password_hash = pending_hash
user.must_change_password = False
user.password_changed_at_utc = datetime.now(timezone.utc)
try:
send_password_changed_email(db, user=user)
except Exception as exc:
print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}")
db.commit()
roles = _user_roles(db, int(user.id))
permissions = _user_permissions(db, int(user.id))
request.session.pop("pending_password_change_hash", None)
request.session.pop("pending_password_change_user_id", None)
request.session["must_change_password"] = False
request.session["post_login_redirect"] = _post_login_redirect(False, permissions, roles)
return RedirectResponse(url=request.session.get("post_login_redirect", "/system-settings"), status_code=303)
finally:
db.close()
@router.get("/forgot-password")
def forgot_password_page(request: Request):
return _render_forgot_password(request)
@router.post("/forgot-password")
def forgot_password_submit(
request: Request,
email: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
email_clean = email.strip().lower()
db = CommonSessionLocal()
try:
user = db.execute(
select(User).where(User.email == email_clean)
).scalar_one_or_none()
request.session.pop("password_reset_user_id", None)
request.session.pop("password_reset_email", None)
if not user:
return _render_forgot_password(
request,
flash="If the login ID exists, password reset instructions have been sent to the registered email.",
status_code=200,
)
can_login, _ = _is_user_login_allowed(user)
if not can_login:
return _render_forgot_password(
request,
flash="If the login ID exists, password reset instructions have been sent to the registered email.",
status_code=200,
)
request.session["password_reset_user_id"] = int(user.id)
request.session["password_reset_email"] = str(user.email)
code = start_otp(request)
try:
send_auth_otp_email(db, user=user, otp_code=code, purpose="password_reset")
db.commit()
except Exception as exc:
print(f"[EMAIL OTP ERROR] forgot-password user={user.email} error={exc}")
_log_dev_otp("forgot-password", str(user.email), code)
return RedirectResponse(url="/reset-password", status_code=303)
finally:
db.close()
@router.get("/reset-password")
def reset_password_page(request: Request):
if not request.session.get("password_reset_user_id"):
return RedirectResponse(url="/forgot-password", status_code=303)
return _render_reset_password(request)
@router.post("/reset-password")
def reset_password_submit(
request: Request,
otp: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
user_id = request.session.get("password_reset_user_id")
if not user_id:
return RedirectResponse(url="/forgot-password", status_code=303)
if not verify_otp(request, otp):
return _render_reset_password(
request,
flash="Invalid OTP. Please check the OTP sent to your registered email.",
status_code=400,
)
if new_password != confirm_password:
return _render_reset_password(
request,
flash="New password and confirm password do not match.",
status_code=400,
)
if len(new_password.strip()) < 8:
return _render_reset_password(
request,
flash="New password must be at least 8 characters.",
status_code=400,
)
db = CommonSessionLocal()
try:
user = db.execute(select(User).where(User.id == user_id)).scalar_one_or_none()
if not user:
request.session.pop("password_reset_user_id", None)
request.session.pop("password_reset_email", None)
return RedirectResponse(url="/forgot-password", status_code=303)
user.password_hash = hash_password(new_password.strip())
user.must_change_password = False
user.password_changed_at_utc = datetime.now(timezone.utc)
try:
send_password_changed_email(db, user=user)
except Exception as exc:
print(f"[EMAIL PASSWORD CHANGED ERROR] user={user.email} error={exc}")
db.commit()
request.session.pop("password_reset_user_id", None)
request.session.pop("password_reset_email", None)
return RedirectResponse(url="/login", status_code=303)
finally:
db.close()
@router.get("/password-reset/accept")
def password_reset_token_page(request: Request, token: str = ""):
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password_token.html",
_template_context(request, title="Reset Password", extra={"token": token.strip()}),
)
@router.post("/password-reset/accept")
def password_reset_token_submit(
request: Request,
token: str = Form(...),
new_password: str = Form(...),
confirm_password: str = Form(...),
csrf_token: str = Form(...),
):
validate_csrf(request, csrf_token)
token_clean = token.strip()
if new_password != confirm_password:
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password_token.html",
_template_context(request, title="Reset Password", flash="New password and confirm password do not match.", extra={"token": token_clean}),
status_code=400,
)
if len(new_password.strip()) < get_settings().PASSWORD_MIN_LENGTH:
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password_token.html",
_template_context(request, title="Reset Password", flash=f"New password must be at least {get_settings().PASSWORD_MIN_LENGTH} characters.", extra={"token": token_clean}),
status_code=400,
)
db = CommonSessionLocal()
try:
try:
user = reset_password_with_token(db, token_clean, new_password.strip())
except ValueError as exc:
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password_token.html",
_template_context(request, title="Reset Password", flash=str(exc), extra={"token": token_clean}),
status_code=400,
)
if not user:
return templates.TemplateResponse(
"modules/core/iam/templates/reset_password_token.html",
_template_context(request, title="Reset Password", flash="Invalid or expired password reset link.", extra={"token": token_clean}),
status_code=400,
)
try:
send_password_changed_email(db, user=user)
except Exception as exc:
print(f"[EMAIL PASSWORD CHANGED ERROR] user={getattr(user, 'email', '')} error={exc}")
db.commit()
return RedirectResponse(url="/login", status_code=303)
finally:
db.close()
@router.get("/change-password-required")
def change_password_required(request: Request):
return RedirectResponse(url="/change-password", status_code=303)
@router.get("/logout")
def logout(request: Request):
request.session.clear()
return RedirectResponse(url="/login", status_code=303)
+357
View File
@@ -0,0 +1,357 @@
/* Audit Firm ERP Phase 7Q.1 — Standard Theme Tokens
Purpose: one consistent colour, card, button, badge and form language across dashboards.
This file is plain CSS and works with the current Tailwind CDN setup. */
:root {
/* Brand / CA professional blue */
--af-color-brand-50: #eff6ff;
--af-color-brand-100: #dbeafe;
--af-color-brand-200: #bfdbfe;
--af-color-brand-300: #93c5fd;
--af-color-brand-400: #60a5fa;
--af-color-brand-500: #2563eb;
--af-color-brand-600: #1d4ed8;
--af-color-brand-700: #1e40af;
--af-color-brand-800: #1e3a8a;
--af-color-brand-900: #172554;
/* Neutral system */
--af-color-bg: #f8fafc;
--af-color-surface: #ffffff;
--af-color-surface-muted: #f1f5f9;
--af-color-border: #e2e8f0;
--af-color-text: #0f172a;
--af-color-muted: #64748b;
/* Status system */
--af-color-success-50: #ecfdf5;
--af-color-success-600: #059669;
--af-color-success-700: #047857;
--af-color-warning-50: #fffbeb;
--af-color-warning-600: #d97706;
--af-color-warning-700: #b45309;
--af-color-danger-50: #fef2f2;
--af-color-danger-600: #dc2626;
--af-color-danger-700: #b91c1c;
--af-color-info-50: #eff6ff;
--af-color-info-600: #2563eb;
--af-color-info-700: #1d4ed8;
/* Shape and elevation */
--af-radius-card: 1rem;
--af-radius-control: 0.75rem;
--af-shadow-soft: 0 10px 30px rgba(15, 23, 42, 0.08);
--af-shadow-card: 0 18px 45px rgba(15, 23, 42, 0.10);
}
body {
background: var(--af-color-bg);
color: var(--af-color-text);
}
/* Reusable UI classes for upcoming dashboard refinements */
.af-page-shell {
max-width: 90rem;
margin-left: auto;
margin-right: auto;
}
.af-page-title {
font-size: 1.5rem;
line-height: 2rem;
font-weight: 700;
letter-spacing: -0.025em;
color: var(--af-color-text);
}
.af-page-subtitle {
margin-top: 0.25rem;
font-size: 0.875rem;
color: var(--af-color-muted);
}
.af-card {
border: 1px solid var(--af-color-border);
background: var(--af-color-surface);
border-radius: var(--af-radius-card);
box-shadow: var(--af-shadow-soft);
}
.af-card-muted {
border: 1px solid var(--af-color-border);
background: linear-gradient(180deg, #ffffff 0%, var(--af-color-surface-muted) 100%);
border-radius: var(--af-radius-card);
}
.af-metric-card {
border: 1px solid var(--af-color-border);
background: var(--af-color-surface);
border-radius: var(--af-radius-card);
box-shadow: var(--af-shadow-soft);
padding: 1rem;
}
.af-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border-radius: var(--af-radius-control);
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
font-weight: 600;
transition: background-color 150ms ease, border-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
}
.af-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.16);
}
.af-btn-primary {
background: var(--af-color-brand-600);
color: #ffffff;
}
.af-btn-primary:hover {
background: var(--af-color-brand-700);
}
.af-btn-secondary {
border: 1px solid var(--af-color-border);
background: #ffffff;
color: #334155;
}
.af-btn-secondary:hover {
background: #f8fafc;
color: #0f172a;
}
.af-btn-danger {
background: var(--af-color-danger-600);
color: #ffffff;
}
.af-btn-danger:hover {
background: var(--af-color-danger-700);
}
.af-input,
.af-select,
.af-textarea {
width: 100%;
border: 1px solid #cbd5e1;
background: #ffffff;
color: var(--af-color-text);
border-radius: var(--af-radius-control);
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.af-input:focus,
.af-select:focus,
.af-textarea:focus {
outline: none;
border-color: var(--af-color-brand-500);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.12);
}
.af-badge {
display: inline-flex;
align-items: center;
border-radius: 9999px;
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
font-weight: 700;
line-height: 1rem;
}
.af-badge-neutral { background: #f1f5f9; color: #334155; }
.af-badge-brand { background: var(--af-color-brand-50); color: var(--af-color-brand-700); }
.af-badge-success { background: var(--af-color-success-50); color: var(--af-color-success-700); }
.af-badge-warning { background: var(--af-color-warning-50); color: var(--af-color-warning-700); }
.af-badge-danger { background: var(--af-color-danger-50); color: var(--af-color-danger-700); }
.af-badge-info { background: var(--af-color-info-50); color: var(--af-color-info-700); }
.af-section-heading {
font-size: 0.75rem;
line-height: 1rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.af-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
}
.af-table th {
background: #f8fafc;
color: #475569;
font-size: 0.75rem;
font-weight: 700;
text-align: left;
padding: 0.75rem;
border-bottom: 1px solid var(--af-color-border);
}
.af-table td {
padding: 0.75rem;
border-bottom: 1px solid var(--af-color-border);
font-size: 0.875rem;
}
.af-kanban-column {
border: 1px solid var(--af-color-border);
background: #f8fafc;
border-radius: var(--af-radius-card);
padding: 0.75rem;
}
.af-kanban-card {
border: 1px solid var(--af-color-border);
background: #ffffff;
border-radius: 0.875rem;
padding: 0.75rem;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.06);
}
/* Phase 7Q common dashboard alignment refinement
Low-specificity padding/layout helpers for all dashboards.
Tailwind padding classes such as p-4/p-6 will still override these defaults. */
:where(.af-card) {
padding: 1.25rem;
overflow: hidden;
}
:where(.af-card > .flex:first-child),
:where(.af-card > .grid:first-child) {
min-width: 0;
}
:where(.af-card h1, .af-card h2, .af-card h3) {
line-height: 1.35;
}
:where(.af-card .af-btn) {
white-space: nowrap;
}
:where(.af-metric-card) {
min-height: 7rem;
overflow: hidden;
}
:where(.af-dashboard-grid) {
display: grid;
gap: 1.5rem;
}
@media (min-width: 1280px) {
:where(.af-dashboard-grid-2) {
grid-template-columns: minmax(0, 1fr) minmax(20rem, 24rem);
align-items: start;
}
}
:where(.af-panel-header) {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
padding-bottom: 1rem;
border-bottom: 1px solid #f1f5f9;
}
@media (max-width: 640px) {
:where(.af-panel-header) {
flex-direction: column;
}
:where(.af-card .af-btn) {
width: 100%;
}
}
/* Phase 7Q dashboard alignment strong fix
Purpose: ensure all dashboard cards have safe internal spacing even when templates
use only class="af-card" without Tailwind p-* classes. */
.af-card {
padding: 1.25rem !important;
overflow: hidden;
box-sizing: border-box;
}
.af-card > .flex:first-child,
.af-card > .grid:first-child {
min-width: 0;
}
.af-card > .flex:first-child {
gap: 0.875rem;
flex-wrap: wrap;
}
.af-card > .flex:first-child > div,
.af-card > .flex:first-child > section,
.af-card > .flex:first-child > article {
min-width: 0;
}
.af-card > .flex:first-child .af-btn,
.af-card > .flex:first-child a[class*="rounded"],
.af-card > .flex:first-child button[class*="rounded"] {
flex-shrink: 0;
}
.af-card h1,
.af-card h2,
.af-card h3,
.af-card h4 {
margin-top: 0;
overflow-wrap: anywhere;
}
.af-card dl,
.af-card p,
.af-card table,
.af-card form {
min-width: 0;
}
.af-card .af-btn,
.af-card a.af-btn,
.af-card button.af-btn {
max-width: 100%;
white-space: nowrap;
}
.af-dashboard-two-col {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.5rem;
}
@media (min-width: 1280px) {
.af-dashboard-two-col {
grid-template-columns: minmax(0, 1fr) minmax(22rem, 24rem);
align-items: start;
}
}
@media (max-width: 640px) {
.af-card {
padding: 1rem !important;
}
.af-card > .flex:first-child {
align-items: stretch;
}
.af-card > .flex:first-child .af-btn,
.af-card > .flex:first-child a[class*="rounded"],
.af-card > .flex:first-child button[class*="rounded"] {
width: 100%;
}
}
+617
View File
@@ -0,0 +1,617 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
{% set __title_user = current_user if current_user is defined else None %}
{% set __firm_branding = get_current_firm_branding(request, __title_user) %}
<meta name="theme-color" content="{{ __firm_branding.primary_color or '#1e3a8a' }}" />
{% if __firm_branding.favicon_url %}<link rel="icon" href="{{ __firm_branding.favicon_url }}" />{% endif %}
{% set __title_auth = __title_user and request.session.get("otp_verified", False) %}
{% if __title_auth %}
{% set __title_firm = get_current_tenant_name(request, __title_user) %}
{% set __title_branch = get_current_branch_name(request, __title_user) %}
<title>{{ title or "Workspace" }} | {{ __title_firm }}{% if __title_branch and __title_branch != "-" %} - {{ __title_branch }}{% endif %}</title>
{% else %}
<title>{{ title or "Welcome" }} | {{ __firm_branding.firm_name or "Audit Firm ERP" }}</title>
{% endif %}
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
brand: {
50: 'var(--af-color-brand-50)',
100: 'var(--af-color-brand-100)',
200: 'var(--af-color-brand-200)',
300: 'var(--af-color-brand-300)',
400: 'var(--af-color-brand-400)',
500: 'var(--af-color-brand-500)',
600: 'var(--af-color-brand-600)',
700: 'var(--af-color-brand-700)',
800: 'var(--af-color-brand-800)',
900: 'var(--af-color-brand-900)'
},
afsuccess: {
50: 'var(--af-color-success-50)',
600: 'var(--af-color-success-600)',
700: 'var(--af-color-success-700)'
},
afwarning: {
50: 'var(--af-color-warning-50)',
600: 'var(--af-color-warning-600)',
700: 'var(--af-color-warning-700)'
},
afdanger: {
50: 'var(--af-color-danger-50)',
600: 'var(--af-color-danger-600)',
700: 'var(--af-color-danger-700)'
},
afinfo: {
50: 'var(--af-color-info-50)',
600: 'var(--af-color-info-600)',
700: 'var(--af-color-info-700)'
}
},
boxShadow: {
soft: 'var(--af-shadow-soft)',
card: 'var(--af-shadow-card)',
focus: '0 0 0 4px rgba(37, 99, 235, 0.12)'
},
borderRadius: {
card: 'var(--af-radius-card)',
control: 'var(--af-radius-control)'
}
}
}
}
</script>
<link rel="stylesheet" href="/static/css/theme_tokens.css?v=7q-dashboard-align-strong" />
<style>
:root {
--af-color-brand-500: {{ __firm_branding.primary_color or '#2563eb' }};
--af-color-brand-600: {{ __firm_branding.primary_color or '#1d4ed8' }};
--af-color-brand-700: {{ __firm_branding.accent_color or '#1e40af' }};
--af-color-brand-800: {{ __firm_branding.accent_color or '#1e3a8a' }};
--af-color-brand-900: {{ __firm_branding.accent_color or '#172554' }};
}
</style>
</head>
<body class="min-h-screen bg-slate-100 text-slate-900">
{% set otp_ok = request.session.get("otp_verified", False) %}
{% set full_auth = current_user and otp_ok %}
{% set ui_perms = current_user_permissions if full_auth else [] %}
{% set ui_roles = current_user_roles if full_auth else [] %}
{% set active_tenant_id = get_active_tenant_id(request, current_user) if full_auth else None %}
{% set active_branch_id = get_active_branch_id(request, current_user) if full_auth else None %}
{% set active_financial_year = get_active_financial_year(request, current_user) if full_auth else None %}
{% set active_assessment_year = get_active_assessment_year(request, current_user) if full_auth else None %}
{% set unread_alert_count = get_unread_alert_count(request, current_user) if full_auth else 0 %}
{% set current_path = request.url.path %}
{% set document_menu_roles = ["System Admin", "Firm Admin", "Partner"] %}
{% set can_view_documents_menu = full_auth and can_view_documents(current_user, ui_perms, ui_roles) and (ui_roles|select("in", document_menu_roles)|list|length > 0) %}
{% set management_menu_roles = ["System Admin", "Firm Admin", "Partner", "Manager", "Branch Manager"] %}
{% set can_view_management_menus = full_auth and (ui_roles|select("in", management_menu_roles)|list|length > 0) %}
{% set firm_branding = get_current_firm_branding(request, current_user) if full_auth else __firm_branding %}
{% set current_user_photo_url = get_user_profile_photo_url(current_user) if full_auth else None %}
{% set current_user_initials = get_user_initials(current_user) if full_auth else "U" %}
{% set current_firm_name = firm_branding.firm_name if firm_branding else "Audit Firm ERP" %}
{% set current_branch_name = firm_branding.branch_name if firm_branding else "" %}
{% set domain_context = get_domain_context(request) %}
{% set is_system_admin_user = full_auth and ("System Admin" in ui_roles) %}
{% set can_manage_local_storage_agent = full_auth and can_view_documents(current_user, ui_perms, ui_roles) and can_upload_documents(current_user, ui_perms, ui_roles) and (ui_roles|select("in", ["Firm Admin", "Partner", "Branch Manager"])|list|length > 0) %}
<div class="min-h-screen lg:grid lg:grid-cols-[260px_minmax(0,1fr)]">
<aside class="border-b border-slate-200 bg-slate-900 text-slate-100 lg:min-h-screen lg:border-b-0 lg:border-r lg:border-slate-800">
<div class="flex items-center gap-3 px-5 py-5">
{% if firm_branding.logo_url %}<img src="{{ firm_branding.logo_url }}" alt="{{ current_firm_name }} logo" class="h-11 w-11 rounded-2xl bg-white object-contain p-1 shadow-soft" />{% else %}<div class="flex h-11 w-11 items-center justify-center rounded-2xl bg-brand-500 font-bold text-white shadow-soft">{{ (current_firm_name[:2] if current_firm_name else 'AF')|upper }}</div>{% endif %}
<div class="min-w-0">
<div class="truncate text-sm font-semibold tracking-wide">{{ current_firm_name }}</div>
<div class="truncate text-xs text-slate-400">{% if full_auth %}{{ current_branch_name }}{% if current_branch_name and current_branch_name != "-" %} Branch{% endif %} • Workspace{% elif domain_context.is_resolved %}{{ current_branch_name or 'Domain Workspace' }}{% else %}Secure Practice Workspace{% endif %}</div>
</div>
</div>
{% if full_auth and ('Client' in ui_roles) %}
{% set sidebar_auditor = get_client_sidebar_auditor_card(request, current_user) %}
{% if sidebar_auditor %}
<div class="mx-4 mb-4 rounded-2xl border border-slate-700 bg-slate-800/80 p-3 shadow-soft">
<div class="text-[10px] font-semibold uppercase tracking-[0.20em] text-brand-200">Your Auditor</div>
<div class="mt-3 flex items-center gap-3">
{% if sidebar_auditor.photo_url %}
<img src="{{ sidebar_auditor.photo_url }}" alt="{{ sidebar_auditor.name }}" class="h-11 w-11 rounded-xl border border-white/10 object-cover" />
{% else %}
<div class="flex h-11 w-11 items-center justify-center rounded-xl border border-white/10 bg-brand-600 text-sm font-bold text-white">{{ sidebar_auditor.initials or 'AU' }}</div>
{% endif %}
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-white">{{ sidebar_auditor.name or 'Firm team' }}</div>
<div class="truncate text-xs text-slate-300">{{ sidebar_auditor.designation or 'Auditor' }}</div>
{% if sidebar_auditor.qualification %}<div class="truncate text-[11px] text-slate-400">{{ sidebar_auditor.qualification }}</div>{% endif %}
</div>
</div>
<div class="mt-3 space-y-1 text-[11px] leading-5 text-slate-300">
{% if sidebar_auditor.mobile %}<div class="truncate">Mobile: {{ sidebar_auditor.mobile }}</div>{% endif %}
{% if sidebar_auditor.email %}<a href="mailto:{{ sidebar_auditor.email }}" class="block truncate text-brand-200 hover:text-white">{{ sidebar_auditor.email }}</a>{% endif %}
</div>
</div>
{% endif %}
{% endif %}
{% if full_auth %}
<nav class="space-y-4 px-4 pb-6">
{% set has_employee_self_menu = can_view_employee_portal(current_user, ui_perms, ui_roles) or can_view_own_employee_work(current_user, ui_perms, ui_roles) or can_view_own_employee_attendance(current_user, ui_perms, ui_roles) or can_view_own_employee_leave(current_user, ui_perms, ui_roles) or can_view_own_employee_documents(current_user, ui_perms, ui_roles) or can_view_own_employee_payslips(current_user, ui_perms, ui_roles) or can_request_own_employee_offboarding(current_user, ui_perms, ui_roles) %}
{% set has_team_workspace_menu = can_manage_employee_work(current_user, ui_perms, ui_roles) or can_view_employee_progress(current_user, ui_perms, ui_roles) or can_view_all_employee_attendance(current_user, ui_perms, ui_roles) or can_view_all_employee_leave(current_user, ui_perms, ui_roles) %}
{% set has_team_admin_menu = can_view_employee_dashboard(current_user, ui_perms, ui_roles) or can_view_employees(current_user, ui_perms, ui_roles) or can_approve_employee_registrations(current_user, ui_perms, ui_roles) or can_import_employee_hr(current_user, ui_perms, ui_roles) or can_manage_employee_leave_types(current_user, ui_perms, ui_roles) or can_manage_employee_leave_balances(current_user, ui_perms, ui_roles) or can_view_all_employee_documents(current_user, ui_perms, ui_roles) or can_manage_employee_document_types(current_user, ui_perms, ui_roles) or can_view_employee_onboarding(current_user, ui_perms, ui_roles) or can_manage_employee_onboarding(current_user, ui_perms, ui_roles) or can_view_employee_offboarding(current_user, ui_perms, ui_roles) or can_manage_employee_payroll_structures(current_user, ui_perms, ui_roles) or can_run_employee_payroll(current_user, ui_perms, ui_roles) or can_view_employee_payroll(current_user, ui_perms, ui_roles) %}
{% set has_firm_users_menu = is_system_admin_user and (can_view_users(current_user, ui_perms, ui_roles) or can_manage_users(current_user, ui_perms, ui_roles) or can_view_rbac(current_user, ui_perms, ui_roles)) %}
{% set has_firm_services_menu = can_view_services(current_user, ui_perms, ui_roles) %}
{% set has_firm_clients_menu = can_view_clients(current_user, ui_perms, ui_roles) or can_manage_clients(current_user, ui_perms, ui_roles) or can_export_clients(current_user, ui_perms, ui_roles) %}
{% set has_firm_consultants_menu = can_view_consultants(current_user, ui_perms, ui_roles) or can_manage_consultants(current_user, ui_perms, ui_roles) or can_manage_consultant_service_requests(current_user, ui_perms, ui_roles) or can_manage_consultant_conversions(current_user, ui_perms, ui_roles) %}
{% set has_email_settings_menu = ("Firm Admin" in ui_roles) or is_system_admin_user %}
{% set has_firm_admin_menu = has_firm_users_menu or has_firm_services_menu or has_firm_clients_menu or has_firm_consultants_menu or can_manage_local_storage_agent or has_email_settings_menu or ("Firm Admin" in ui_roles) or is_system_admin_user %}
{% if has_employee_self_menu %}
<details class="group rounded-xl" {% if current_path.startswith('/employee') or current_path.startswith('/alerts') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>My Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_employee_portal(current_user, ui_perms, ui_roles) %}
<a href="/employee/dashboard" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/employee/dashboard' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Overview</a>
{% endif %}
{% if can_view_own_employee_work(current_user, ui_perms, ui_roles) %}
<a href="/employee/work" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/work') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Work Board</a>
{% endif %}
{% if can_view_own_employee_attendance(current_user, ui_perms, ui_roles) %}
<a href="/employee/attendance" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/attendance') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Attendance</a>
{% endif %}
{% if can_view_own_employee_leave(current_user, ui_perms, ui_roles) %}
<a href="/employee/leave" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/leave') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Leave</a>
{% endif %}
{% if can_view_own_employee_documents(current_user, ui_perms, ui_roles) %}
<a href="/employee/documents" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/documents') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Documents</a>
{% endif %}
{% if can_view_own_employee_payslips(current_user, ui_perms, ui_roles) %}
<a href="/employee/payslips" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/payslips') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Payslips</a>
{% endif %}
<a href="/alerts" class="flex items-center justify-between rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/alerts') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}"><span>My Alert</span>{% if unread_alert_count > 0 %}<span class="rounded-full bg-brand-600 px-2 py-0.5 text-[10px] font-semibold text-white">{{ unread_alert_count }}</span>{% endif %}</a>
{% if can_request_own_employee_offboarding(current_user, ui_perms, ui_roles) %}
<a href="/employee/offboarding" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employee/offboarding') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Offboarding</a>
{% endif %}
</div>
</details>
{% endif %}
{% if 'Partner' in ui_roles %}
<details class="group rounded-xl" {% if current_path.startswith('/partner') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Partner Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<a href="/partner/dashboard" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/partner/dashboard' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Overview</a>
<a href="/partner/reviews" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/partner/reviews') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Review Board</a>
<a href="/partner/clients" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/partner/clients') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Clients</a>
<a href="/alerts" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/alerts') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">My Alert</a>
</div>
</details>
{% endif %}
{% if has_team_workspace_menu %}
<details class="group rounded-xl" {% if current_path.startswith('/manager') or current_path.startswith('/employees/work') or current_path.startswith('/employees/progress') or current_path.startswith('/employees/attendance') or current_path.startswith('/employees/leave') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Team Workspace</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_manage_employee_work(current_user, ui_perms, ui_roles) %}
<a href="/manager/dashboard" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/manager/dashboard' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Manager Dashboard</a>
<a href="/manager/work" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/manager/work') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Team Work Board</a>
<a href="/employees/work" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employees/work') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Detailed Allocation</a>
{% endif %}
{% if can_view_employee_progress(current_user, ui_perms, ui_roles) %}
<a href="/employees/progress" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employees/progress') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Engagement Progress</a>
{% endif %}
{% if can_view_all_employee_attendance(current_user, ui_perms, ui_roles) %}
<a href="/employees/attendance" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/employees/attendance') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Team Attendance</a>
{% endif %}
{% if can_view_all_employee_leave(current_user, ui_perms, ui_roles) %}
<a href="/employees/leave" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/employees/leave' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Team Leave</a>
{% endif %}
</div>
</details>
{% endif %}
{% if can_view_billing(current_user, ui_perms, ui_roles) %}
<details class="group rounded-xl" {% if current_path.startswith('/billing') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Billing</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<a href="/billing" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/billing' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Invoices</a>
<a href="/billing/settings" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/billing/settings') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Billing Settings</a>
{% if can_create_billing(current_user, ui_perms, ui_roles) %}<a href="/billing/new" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/billing/new' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">New Invoice</a>{% endif %}
{% if can_generate_billing_invoices(current_user, ui_perms, ui_roles) %}<a href="/billing/generate" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Generate Bills</a>{% endif %}
{% if can_view_billing_fee_structure(current_user, ui_perms, ui_roles) %}<a href="/billing/fee-structures/list" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Fee Structure</a>{% endif %}
{% if can_import_billing_fee_structure(current_user, ui_perms, ui_roles) %}<a href="/billing/fee-structures/template" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Download Fee Template</a><a href="/billing/fee-structures/import" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Import Fee Structure</a>{% endif %}
</div>
</details>
{% endif %}
{% if has_team_admin_menu %}
<details class="group rounded-xl" {% if current_path.startswith('/employees') and not (current_path.startswith('/employees/work') or current_path.startswith('/employees/progress') or current_path.startswith('/employees/attendance') or current_path == '/employees/leave') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Team Administration</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_employee_dashboard(current_user, ui_perms, ui_roles) %}<a href="/employees/dashboard" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">HR Dashboard</a>{% endif %}
{% if can_view_employees(current_user, ui_perms, ui_roles) %}<a href="/employees" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Master</a>{% endif %}
{% if can_approve_employee_registrations(current_user, ui_perms, ui_roles) %}<a href="/employees/registration-requests" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Registrations</a>{% endif %}
{% if can_import_employee_hr(current_user, ui_perms, ui_roles) %}<a href="/employees/imports" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">HR Imports</a>{% endif %}
{% if can_manage_employee_leave_types(current_user, ui_perms, ui_roles) or can_manage_employee_leave_balances(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Attendance &amp; Leave Setup</div>
{% if can_manage_employee_leave_types(current_user, ui_perms, ui_roles) %}<a href="/employees/leave-types" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Leave Types</a>{% endif %}
{% if can_manage_employee_leave_balances(current_user, ui_perms, ui_roles) %}<a href="/employees/leave-balances" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Leave Balances</a>{% endif %}
{% endif %}
{% if can_view_all_employee_documents(current_user, ui_perms, ui_roles) or can_manage_employee_document_types(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Employee Documents</div>
{% if can_view_all_employee_documents(current_user, ui_perms, ui_roles) %}<a href="/employees/documents" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Documents</a>{% endif %}
{% if can_manage_employee_document_types(current_user, ui_perms, ui_roles) %}<a href="/employees/document-types" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Document Types</a>{% endif %}
{% endif %}
{% if can_view_employee_onboarding(current_user, ui_perms, ui_roles) or can_manage_employee_onboarding(current_user, ui_perms, ui_roles) or can_view_employee_offboarding(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Employee Lifecycle</div>
{% if can_view_employee_onboarding(current_user, ui_perms, ui_roles) %}<a href="/employees/onboarding" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Onboarding</a>{% endif %}
{% if can_manage_employee_onboarding(current_user, ui_perms, ui_roles) %}<a href="/employees/onboarding-checklist" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Onboarding Checklist</a>{% endif %}
{% if can_view_employee_offboarding(current_user, ui_perms, ui_roles) %}<a href="/employees/offboarding" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Offboarding</a>{% endif %}
{% endif %}
{% if can_manage_employee_payroll_structures(current_user, ui_perms, ui_roles) or can_run_employee_payroll(current_user, ui_perms, ui_roles) or can_view_employee_payroll(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Payroll</div>
{% if can_manage_employee_payroll_structures(current_user, ui_perms, ui_roles) %}<a href="/employees/payroll/structures" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Salary Structures</a>{% endif %}
{% if can_run_employee_payroll(current_user, ui_perms, ui_roles) %}<a href="/employees/payroll/runs" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Payroll Runs</a>{% endif %}
{% if can_view_employee_payroll(current_user, ui_perms, ui_roles) %}<a href="/employees/payroll/payslips" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Employee Payslips</a>{% endif %}
{% endif %}
</div>
</details>
{% endif %}
{% if has_firm_admin_menu %}
<details class="group rounded-xl" {% if current_path.startswith('/system-settings/users') or current_path.startswith('/system-settings/rbac') or current_path.startswith('/services') or current_path.startswith('/clients') or current_path.startswith('/consultants') or current_path.startswith('/email') or current_path.startswith('/notice-cases') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Firm Administration</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if "Firm Admin" in ui_roles or is_system_admin_user %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Firm Setup</div>
<a href="/system-settings/branding" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/branding') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Branding & Identity</a>
{% if has_email_settings_menu %}<a href="/email/settings" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/email') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Email Settings</a>{% endif %}
{% endif %}
{% if has_firm_users_menu %}
<div class="pt-2 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Users</div>
{% if can_view_users(current_user, ui_perms, ui_roles) %}<a href="/system-settings/users" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/system-settings/users' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">User List</a>{% endif %}
{% if can_manage_users(current_user, ui_perms, ui_roles) %}<a href="/system-settings/users/new" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/users/new') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Create User</a>{% endif %}
{% if can_view_rbac(current_user, ui_perms, ui_roles) %}<a href="/system-settings/rbac/roles" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Roles &amp; Permissions</a>{% endif %}
{% endif %}
{% if can_view_notice_cases(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Compliance</div>
<a href="/notice-cases" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/notice-cases') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Notices &amp; Cases</a>
{% if can_manage_notice_cases(current_user, ui_perms, ui_roles) %}<a href="/notice-cases/new" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">New Notice / Case</a>{% endif %}
{% endif %}
{% if has_firm_services_menu %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Services</div>
<a href="/services" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Firm Services</a>
<a href="/services/catalogue" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Service Catalogue</a>
{% if is_system_admin_user %}<a href="/services/catalogue/new" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Create Catalogue Service</a>{% endif %}
<a href="/services/categories" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Service Categories</a>
{% if is_system_admin_user %}<a href="/services/defaults" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">System Default Tasks</a>{% endif %}
<a href="/services/templates" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Firm Task Templates</a>
<a href="/services/bulk-imports" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Bulk Imports</a>
{% endif %}
{% if has_firm_clients_menu %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Clients</div>
{% if can_view_clients(current_user, ui_perms, ui_roles) %}<a href="/clients" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Client List</a>{% endif %}
{% if can_manage_clients(current_user, ui_perms, ui_roles) %}<a href="/clients/new" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Add Client</a>{% endif %}
{% if can_manage_clients(current_user, ui_perms, ui_roles) %}<a href="/clients/import" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Import Clients</a>{% endif %}
{% if can_export_clients(current_user, ui_perms, ui_roles) %}<a href="/clients/export" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Export Clients</a>{% endif %}
{% endif %}
{% if has_firm_consultants_menu %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Consultants</div>
{% if can_view_consultants(current_user, ui_perms, ui_roles) %}<a href="/consultants" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Consultant List</a>{% endif %}
{% if can_manage_consultants(current_user, ui_perms, ui_roles) %}<a href="/consultants/new" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Add Consultant</a>{% endif %}
{% if can_manage_consultant_service_requests(current_user, ui_perms, ui_roles) %}<a href="/consultants/service-requests" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Service Requests</a>{% endif %}
{% if can_manage_consultant_conversions(current_user, ui_perms, ui_roles) %}<a href="/consultants/conversion-requests" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Conversions</a>{% endif %}
{% endif %}
{% if can_manage_local_storage_agent %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Local Storage</div>
<a href="/documents/storage-nodes" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/documents/storage-nodes') or current_path.startswith('/documents/branch-storage-dashboard') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Local Storage Agent</a>
{% endif %}
</div>
</details>
{% endif %}
{% if is_system_admin_user %}
<details class="group rounded-xl" {% if current_path.startswith('/domains') or current_path.startswith('/platform-billing') or current_path.startswith('/marketplace') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Platform</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Domain Configuration</div>
<a href="/domains" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/domains' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Domain Mappings</a>
<a href="/domains/tenant-subdomains" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/tenant-subdomains') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Tenant Subdomains</a>
<a href="/domains/firm-domain" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/firm-domain') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Firm Domains</a>
<a href="/domains/consultant-domains" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/consultant-domains') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Consultant Domains</a>
<a href="/domains/verification" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/verification') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">DNS Verification</a>
<a href="/domains/ssl" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/domains/ssl') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">SSL Status</a>
{% if can_view_platform_billing(current_user, ui_perms, ui_roles) or can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}
<div class="pt-3 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-500">Platform Operations</div>
{% if can_view_platform_billing(current_user, ui_perms, ui_roles) %}<a href="/platform-billing" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Platform Billing</a>{% endif %}
{% if can_view_marketplace_leads(current_user, ui_perms, ui_roles) %}<a href="/marketplace" class="block rounded-lg px-3 py-1.5 text-sm text-slate-300 transition hover:bg-slate-800 hover:text-white">Marketplace</a>{% endif %}
{% endif %}
</div>
</details>
{% endif %}
{% if can_view_management_menus and (can_view_settings(current_user, ui_perms, ui_roles) or can_view_audit(current_user, ui_perms, ui_roles)) %}
<details class="group rounded-xl" {% if current_path.startswith('/system-settings') %}open{% endif %}>
<summary class="flex cursor-pointer list-none items-center justify-between rounded-xl px-3 py-2 text-sm font-semibold transition hover:bg-slate-800">
<span>Core Setup</span>
<span class="text-xs text-slate-400 transition group-open:rotate-90"></span>
</summary>
<div class="mt-1 space-y-1 border-l border-slate-700/70 pl-3">
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
<a href="/system-settings" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path == '/system-settings' %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Dashboard</a>
{% endif %}
{% if can_view_tenants(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/tenants" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/tenants') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Audit Firms</a>
{% endif %}
{% if can_view_branches(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/branches" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/branches') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Branches</a>
{% endif %}
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/financial-years" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/financial-years') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Financial Years</a>
{% endif %}
{% if can_view_audit(current_user, ui_perms, ui_roles) %}
<a href="/system-settings/audit-logs" class="block rounded-lg px-3 py-1.5 text-sm transition {% if current_path.startswith('/system-settings/audit-logs') %}bg-brand-600 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">Audit Logs</a>
{% endif %}
</div>
</details>
{% endif %}
</nav>
{% endif %}
</aside>
<div class="min-h-screen">
<header class="border-b border-slate-200 bg-white/90 backdrop-blur">
<div class="mx-auto max-w-7xl px-4 py-4 sm:px-6 lg:px-8">
<div class="flex items-start justify-between gap-4">
<div>
<h1 class="text-lg font-semibold text-slate-900">{{ title or "Module Workspace" }}</h1>
</div>
<div class="text-right text-sm">
{% if full_auth %}
<div class="flex items-start justify-end gap-3">
<div class="min-w-0">
<div class="font-medium text-slate-800">{{ current_user.full_name or current_user.email }}</div>
<div class="text-slate-500">{{ current_user.email }}</div>
{% if current_user.qualification or current_user.designation %}
<div class="text-xs text-slate-500">{{ current_user.qualification or '' }}{% if current_user.qualification and current_user.designation %} • {% endif %}{{ current_user.designation or '' }}</div>
{% endif %}
<div class="text-xs text-slate-400">
Your Firm: {{ current_firm_name }}
• Branch: {{ current_branch_name }}{% if active_financial_year %} • FY: {{ active_financial_year }}{% endif %}
</div>
<div class="mt-2 flex justify-end gap-2">
{% if "Consultant" in ui_roles %}
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/consultant/profile">My Profile</a>
{% elif "Client" in ui_roles %}
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/client/profile">My Profile</a>
{% else %}
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/employee/profile">My Profile</a>
{% endif %}
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/change-password">Change Password</a>
<a class="inline-flex rounded-lg border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50" href="/logout">Logout</a>
</div>
</div>
{% if current_user_photo_url %}
<img src="{{ current_user_photo_url }}" alt="Profile photo" class="h-12 w-12 shrink-0 rounded-2xl border border-slate-200 object-cover shadow-soft">
{% else %}
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-brand-600 text-sm font-bold text-white shadow-soft">{{ current_user_initials }}</div>
{% endif %}
</div>
{% else %}
{% if domain_context.is_resolved %}
<div class="mb-2 text-xs text-slate-500">{{ current_firm_name }}{% if current_branch_name %} • {{ current_branch_name }}{% endif %}</div>
{% endif %}
<a class="inline-flex rounded-lg bg-brand-600 px-3 py-2 text-sm font-medium text-white hover:bg-brand-700" href="/login">Login</a>
{% endif %}
</div>
</div>
{% if full_auth and (can_switch_service_tenant(current_user, ui_perms, ui_roles) or can_switch_service_branch(current_user, ui_perms, ui_roles) or can_switch_employee_tenant(current_user, ui_perms, ui_roles) or can_switch_employee_branch(current_user, ui_perms, ui_roles) or can_view_settings(current_user, ui_perms, ui_roles)) %}
<div class="mt-4 flex flex-wrap items-end gap-4 border-t border-slate-200 pt-4">
{% if can_switch_service_tenant(current_user, ui_perms, ui_roles) or can_switch_employee_tenant(current_user, ui_perms, ui_roles) %}
{% set context_tenants = get_context_tenants(request, current_user, ui_perms, ui_roles) %}
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Active Audit Firm Context</label>
<select onchange="if(this.value){window.location.href=this.value;}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for tenant in context_tenants %}
<option value="/system-settings/context/tenant/{{ tenant.id }}" {% if tenant.id == active_tenant_id %}selected{% endif %}>{{ tenant.name }} ({{ tenant.code }})</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if can_switch_service_branch(current_user, ui_perms, ui_roles) or can_switch_employee_branch(current_user, ui_perms, ui_roles) %}
{% set context_branches = get_context_branches(request, current_user, ui_perms, ui_roles) %}
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Active Branch Context</label>
<select onchange="if(this.value){window.location.href=this.value;}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
<option value="/system-settings/context/branch/0" {% if not active_branch_id %}selected{% endif %}>All Branches</option>
{% for branch in context_branches %}
<option value="/system-settings/context/branch/{{ branch.id }}" {% if branch.id == active_branch_id %}selected{% endif %}>{{ branch.name }} ({{ branch.code }})</option>
{% endfor %}
</select>
</div>
{% endif %}
{% if can_view_settings(current_user, ui_perms, ui_roles) %}
{% set context_financial_years = get_context_financial_years(request, current_user, ui_perms, ui_roles) %}
<div>
<label class="mb-1 block text-xs font-semibold uppercase tracking-wide text-slate-500">Active Financial Year</label>
<select onchange="if(this.value){window.location.href=this.value;}" class="rounded-xl border border-slate-300 px-3 py-2 text-sm">
{% for fy in context_financial_years %}
<option value="/system-settings/context/financial-year/{{ fy.year_code }}" {% if fy.year_code == active_financial_year %}selected{% endif %}>FY {{ fy.year_code }}{% if fy.is_current %} (Current){% endif %}{% if fy.is_locked %} - Locked{% endif %}</option>
{% endfor %}
</select>
</div>
{% endif %}
<div class="text-xs text-slate-500">
Active Scope:
{{ current_firm_name }}
• {{ current_branch_name if active_branch_id else "All Branches" }}{% if active_financial_year %} • FY {{ active_financial_year }}{% endif %}
</div>
</div>
{% endif %}
</div>
</header>
<main class="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8">
{% if flash %}
<div class="mb-6 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900 shadow-soft">
{{ flash }}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
</div>
</div>
{% if full_auth %}
<div id="af-alert-toast-container" class="fixed right-4 top-4 z-50 flex w-[min(24rem,calc(100vw-2rem))] flex-col gap-3"></div>
<script>
(function () {
const POLL_URL = "/alerts/poll";
const POLL_INTERVAL_MS = 45000;
const STORAGE_KEY = "af_popup_seen_alert_ids_v1";
const MAX_STORED_IDS = 100;
function readSeenIds() {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed.map(String) : [];
} catch (err) {
return [];
}
}
function writeSeenIds(ids) {
try {
const unique = Array.from(new Set(ids.map(String))).slice(-MAX_STORED_IDS);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(unique));
} catch (err) {
// Ignore localStorage issues; alerts page remains available.
}
}
function updateHeaderCount(count) {
const badge = document.getElementById("af-alerts-header-count");
if (!badge) return;
const n = Number(count || 0);
badge.textContent = String(n);
if (n > 0) {
badge.classList.remove("hidden");
} else {
badge.classList.add("hidden");
}
}
function priorityClasses(priority) {
if (priority === "critical") return "border-red-200 bg-red-50 text-red-900";
if (priority === "high") return "border-amber-200 bg-amber-50 text-amber-900";
if (priority === "low") return "border-slate-200 bg-white text-slate-800";
return "border-blue-200 bg-blue-50 text-blue-900";
}
function showToast(alert) {
const container = document.getElementById("af-alert-toast-container");
if (!container || !alert) return;
const wrapper = document.createElement("div");
wrapper.className = "overflow-hidden rounded-2xl border shadow-soft " + priorityClasses(alert.priority);
wrapper.innerHTML = `
<div class="flex items-start gap-3 p-4">
<div class="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-white/80 text-base">🔔</div>
<div class="min-w-0 flex-1">
<div class="text-xs font-semibold uppercase tracking-wide opacity-75">New Alert</div>
<div class="mt-0.5 line-clamp-2 text-sm font-semibold"></div>
<div class="mt-1 line-clamp-3 text-xs opacity-80"></div>
<div class="mt-3 flex items-center gap-2">
<a class="inline-flex rounded-lg bg-white/90 px-3 py-1.5 text-xs font-semibold text-slate-800 hover:bg-white" href="#">View</a>
<button type="button" class="inline-flex rounded-lg px-3 py-1.5 text-xs font-semibold opacity-70 hover:bg-white/50">Dismiss</button>
</div>
</div>
</div>`;
wrapper.querySelector(".text-sm.font-semibold").textContent = alert.title || "Alert";
wrapper.querySelector(".text-xs.opacity-80").textContent = alert.message || "You have a new notification.";
const viewLink = wrapper.querySelector("a");
viewLink.href = alert.target_url || "/alerts";
wrapper.querySelector("button").addEventListener("click", function () { wrapper.remove(); });
container.prepend(wrapper);
window.setTimeout(function () {
if (wrapper && wrapper.parentNode) wrapper.remove();
}, 12000);
}
async function pollAlerts() {
try {
const response = await fetch(POLL_URL, {
method: "GET",
credentials: "same-origin",
headers: { "Accept": "application/json" }
});
if (!response.ok) return;
const data = await response.json();
updateHeaderCount(data.unread_count || 0);
const alerts = Array.isArray(data.alerts) ? data.alerts : [];
if (!alerts.length) return;
const seen = readSeenIds();
const newlySeen = seen.slice();
alerts.reverse().forEach(function (alert) {
const id = String(alert.id);
if (!seen.includes(id)) {
showToast(alert);
newlySeen.push(id);
}
});
writeSeenIds(newlySeen);
} catch (err) {
// Keep polling silent; the /alerts page remains the fallback.
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", function () {
pollAlerts();
window.setInterval(pollAlerts, POLL_INTERVAL_MS);
});
} else {
pollAlerts();
window.setInterval(pollAlerts, POLL_INTERVAL_MS);
}
})();
</script>
{% endif %}
</body>
</html>
+63
View File
@@ -0,0 +1,63 @@
{% macro page_shell(title, subtitle='', actions='') -%}
<div class="flex flex-col gap-4 rounded-3xl bg-white p-6 shadow-soft sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 class="text-xl font-semibold">{{ title }}</h2>
{% if subtitle %}<p class="mt-1 text-sm text-slate-500">{{ subtitle }}</p>{% endif %}
</div>
{% if actions %}<div>{{ actions | safe }}</div>{% endif %}
</div>
{%- endmacro %}
{% macro alert(message, tone='amber') -%}
<div class="rounded-2xl border px-4 py-3 text-sm shadow-soft {% if tone == 'rose' %}border-rose-200 bg-rose-50 text-rose-900{% elif tone == 'emerald' %}border-emerald-200 bg-emerald-50 text-emerald-900{% else %}border-amber-200 bg-amber-50 text-amber-900{% endif %}">
{{ message }}
</div>
{%- endmacro %}
{% macro badge(text, tone='slate') -%}
<span class="rounded-full px-2.5 py-1 text-xs font-medium {% if tone == 'emerald' %}bg-emerald-50 text-emerald-700{% elif tone == 'rose' %}bg-rose-50 text-rose-700{% elif tone == 'amber' %}bg-amber-50 text-amber-700{% elif tone == 'sky' %}bg-sky-50 text-sky-700{% elif tone == 'brand' %}bg-brand-50 text-brand-700{% else %}bg-slate-100 text-slate-700{% endif %}">{{ text }}</span>
{%- endmacro %}
{% macro search_bar(action, q='', per_page=10, extra='') -%}
<form method="get" action="{{ action }}" class="flex flex-col gap-3 border-b border-slate-200 bg-slate-50/80 px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-1 gap-3">
<input type="text" name="q" value="{{ q }}" placeholder="Search" class="w-full rounded-2xl border border-slate-300 bg-white px-4 py-2.5 text-sm" />
<select name="per_page" class="rounded-2xl border border-slate-300 bg-white px-3 py-2.5 text-sm">
{% for size in [10,15,25,50] %}
<option value="{{ size }}" {% if per_page == size %}selected{% endif %}>{{ size }}/page</option>
{% endfor %}
</select>
{% if extra %}{{ extra | safe }}{% endif %}
</div>
<div class="flex gap-2">
<button type="submit" class="rounded-xl bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700">Apply</button>
</div>
</form>
{%- endmacro %}
{% macro empty_state(title, subtitle='') -%}
<div class="rounded-3xl border border-dashed border-slate-300 bg-white px-6 py-10 text-center text-slate-500 shadow-soft">
<div class="text-sm font-semibold text-slate-700">{{ title }}</div>
{% if subtitle %}<div class="mt-1 text-sm">{{ subtitle }}</div>{% endif %}
</div>
{%- endmacro %}
{% macro pagination(page_obj, base_url, query='') -%}
{% if page_obj and page_obj.pages > 1 %}
<div class="flex items-center justify-between border-t border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
<div>Page {{ page_obj.page }} of {{ page_obj.pages }} • {{ page_obj.total }} records</div>
<div class="flex items-center gap-2">
{% if page_obj.has_prev %}
<a href="{{ build_page_url(base_url, page_obj.page - 1, query) }}" class="rounded-xl border border-slate-300 px-3 py-2 hover:bg-slate-50">Previous</a>
{% else %}
<span class="rounded-xl border border-slate-200 px-3 py-2 text-slate-300">Previous</span>
{% endif %}
{% if page_obj.has_next %}
<a href="{{ build_page_url(base_url, page_obj.page + 1, query) }}" class="rounded-xl border border-slate-300 px-3 py-2 hover:bg-slate-50">Next</a>
{% else %}
<span class="rounded-xl border border-slate-200 px-3 py-2 text-slate-300">Next</span>
{% endif %}
</div>
</div>
{% endif %}
{%- endmacro %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base/layout.html" %}
{% block content %}
<div class="max-w-md rounded-2xl bg-white border p-6">
<h1 class="text-2xl font-semibold">Login</h1>
<p class="text-slate-600 mt-1 text-sm">Use bootstrap admin (first run). Lockout + CSRF are enabled.</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">Email</span>
<input class="border rounded-xl px-3 py-2" name="email" type="email" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Password</span>
<input class="border rounded-xl px-3 py-2" name="password" type="password" required />
</label>
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm mt-2" type="submit">Sign in</button>
</form>
</div>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "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">Dev mode: OTP is printed in console.</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,226 @@
{% extends "base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Edit Branch</h1>
<form class="mt-4 rounded-2xl bg-white border p-5 max-w-3xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-5">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Branch Name</span>
<input class="border rounded-xl px-3 py-2" name="name" value="{{ branch.name }}" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Timezone</span>
<input class="border rounded-xl px-3 py-2" name="timezone" value="{{ branch.timezone }}" />
{% if settings.timezone_locked %}
<span class="text-xs text-amber-700">Timezone is locked by policy.</span>
{% endif %}
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office Start Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_start_time" value="{{ branch.office_start_time or '' }}" placeholder="09:30" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Office End Time (HH:MM)</span>
<input class="border rounded-xl px-3 py-2" name="office_end_time" value="{{ branch.office_end_time or '' }}" placeholder="18:30" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">SMTP Credentials</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Host</span>
<input class="border rounded-xl px-3 py-2" name="smtp_host" value="{{ branch.smtp_host or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Port</span>
<input class="border rounded-xl px-3 py-2" name="smtp_port" value="{{ branch.smtp_port or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Username</span>
<input class="border rounded-xl px-3 py-2" name="smtp_username" value="{{ branch.smtp_username or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">SMTP Password</span>
<input class="border rounded-xl px-3 py-2" name="smtp_password" value="{{ branch.smtp_password or '' }}" />
</label>
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" name="smtp_use_tls" {% if branch.smtp_use_tls %}checked{% endif %} />
<span class="text-sm text-slate-700">Use TLS</span>
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Local Storage Root</div>
<label class="grid gap-1 mt-3">
<span class="text-sm text-slate-600">Local Storage Path</span>
<input class="border rounded-xl px-3 py-2" name="local_storage_path" value="{{ branch.local_storage_path or '' }}" placeholder="D:\AuditFirm\AuditFirm\Branch" />
</label>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Branch Identity (Compliance)</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">GSTIN</span>
<input class="border rounded-xl px-3 py-2" name="gstin" value="{{ settings.gstin or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PAN</span>
<input class="border rounded-xl px-3 py-2" name="pan" value="{{ settings.pan or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 1</span>
<input class="border rounded-xl px-3 py-2" name="address_line1" value="{{ settings.address_line1 or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Address Line 2</span>
<input class="border rounded-xl px-3 py-2" name="address_line2" value="{{ settings.address_line2 or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">City</span>
<input class="border rounded-xl px-3 py-2" name="city" value="{{ settings.city or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">State</span>
<input class="border rounded-xl px-3 py-2" name="state" value="{{ settings.state or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">PIN Code</span>
<input class="border rounded-xl px-3 py-2" name="pin_code" value="{{ settings.pin_code or '' }}" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-4">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Letterhead Logo Path</span>
<input class="border rounded-xl px-3 py-2" name="letterhead_logo_path" value="{{ settings.letterhead_logo_path or '' }}" placeholder="D:\AuditFirm\Assets\logo.png" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Signature Image Path</span>
<input class="border rounded-xl px-3 py-2" name="letterhead_signature_path" value="{{ settings.letterhead_signature_path or '' }}" placeholder="D:\AuditFirm\Assets\sign.png" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Stamp Image Path</span>
<input class="border rounded-xl px-3 py-2" name="letterhead_stamp_path" value="{{ settings.letterhead_stamp_path or '' }}" placeholder="D:\AuditFirm\Assets\stamp.png" />
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Working Days & Holidays</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Working Days CSV</span>
<input class="border rounded-xl px-3 py-2" name="working_days_csv" value="{{ settings.working_days_csv }}" />
</label>
<label class="flex items-center gap-2 mt-7">
<input type="checkbox" name="timezone_locked" {% if settings.timezone_locked %}checked{% endif %} />
<span class="text-sm text-slate-700">Lock Timezone</span>
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Holidays JSON</span>
<textarea class="border rounded-xl px-3 py-2 h-28" name="holidays_json">{{ settings.holidays_json }}</textarea>
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Email Policy</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">From Name</span>
<input class="border rounded-xl px-3 py-2" name="email_from_name" value="{{ settings.email_from_name or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">From Email</span>
<input class="border rounded-xl px-3 py-2" name="email_from_email" value="{{ settings.email_from_email or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Reply-To</span>
<input class="border rounded-xl px-3 py-2" name="email_reply_to" value="{{ settings.email_reply_to or '' }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Default CC (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="default_cc_csv" value="{{ settings.default_cc_csv or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Default BCC (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="default_bcc_csv" value="{{ settings.default_bcc_csv or '' }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Email Signature (HTML)</span>
<textarea class="border rounded-xl px-3 py-2 h-28" name="email_signature_html">{{ settings.email_signature_html or '' }}</textarea>
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Storage Policy</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Storage Mode</span>
<select class="border rounded-xl px-3 py-2" name="storage_mode">
{% set sm = settings.storage_mode %}
<option value="local_only" {% if sm == "local_only" %}selected{% endif %}>local_only</option>
<option value="cloud_only" {% if sm == "cloud_only" %}selected{% endif %}>cloud_only</option>
<option value="hybrid" {% if sm == "hybrid" %}selected{% endif %}>hybrid</option>
</select>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Max File Size (MB)</span>
<input class="border rounded-xl px-3 py-2" name="max_file_mb" value="{{ settings.max_file_mb }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Allowed Extensions (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="allowed_ext_csv" value="{{ settings.allowed_ext_csv }}" />
</label>
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">Folder Template</span>
<input class="border rounded-xl px-3 py-2" name="folder_template" value="{{ settings.folder_template }}" />
<span class="text-xs text-slate-500">Tokens: {root}, {client_code}, {fy}, {service}</span>
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Retention Years</span>
<input class="border rounded-xl px-3 py-2" name="retention_years" value="{{ settings.retention_years }}" />
</label>
</div>
</div>
<div class="border-t pt-4">
<div class="font-semibold">Security Policy</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<label class="grid gap-1 md:col-span-2">
<span class="text-sm text-slate-600">OTP Required Roles (CSV)</span>
<input class="border rounded-xl px-3 py-2" name="otp_required_roles_csv" value="{{ settings.otp_required_roles_csv }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Session Duration (minutes)</span>
<input class="border rounded-xl px-3 py-2" name="session_duration_minutes" value="{{ settings.session_duration_minutes }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Lockout Attempts</span>
<input class="border rounded-xl px-3 py-2" name="lockout_attempts" value="{{ settings.lockout_attempts }}" />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Lockout Minutes</span>
<input class="border rounded-xl px-3 py-2" name="lockout_minutes" value="{{ settings.lockout_minutes }}" />
</label>
</div>
</div>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/branches">Back</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Branches</h1>
<div class="mt-4 rounded-2xl bg-white border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="text-left p-3">Audit Firm ID</th>
<th class="text-left p-3">Audit Firm</th>
<th class="text-left p-3">Code</th>
<th class="text-left p-3">Name</th>
<th class="text-left p-3">Timezone</th>
<th class="text-left p-3">Actions</th>
</tr>
</thead>
<tbody>
{% for b in branches %}
<tr class="border-t">
<td class="p-3">{{ b.id }}</td>
<td class="p-3">{{ b.tenant_id }}</td>
<td class="p-3">{{ b.code }}</td>
<td class="p-3">{{ b.name }}</td>
<td class="p-3">{{ b.timezone }}</td>
<td class="p-3">
<a class="text-slate-900 underline" href="/system-settings/branches/{{ b.id }}/edit">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">System Settings</h1>
<p class="text-slate-600 mt-1">Super-admin tools (audit firms, branches, policies).</p>
<div class="mt-6 grid grid-cols-1 md:grid-cols-2 gap-4">
<a href="/system-settings/tenants" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Audit Firms</div>
<div class="text-sm text-slate-600 mt-1">Create and manage audit firms.</div>
</a>
<a href="/system-settings/branches" class="rounded-2xl bg-white border p-5 hover:shadow-sm">
<div class="font-semibold">Branches</div>
<div class="text-sm text-slate-600 mt-1">SMTP, storage, identity, calendar and security policies.</div>
</a>
</div>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "base/layout.html" %}
{% block content %}
<h1 class="text-2xl font-semibold">Create Audit Firm</h1>
<form class="mt-4 rounded-2xl bg-white border p-5 max-w-xl" method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="grid gap-3">
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Audit Firm Code</span>
<input class="border rounded-xl px-3 py-2" name="code" required />
</label>
<label class="grid gap-1">
<span class="text-sm text-slate-600">Audit Audit Firm Name</span>
<input class="border rounded-xl px-3 py-2" name="name" required />
</label>
<div class="flex gap-3">
<button class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm" type="submit">Save</button>
<a class="rounded-xl border px-4 py-2 text-sm" href="/system-settings/tenants">Cancel</a>
</div>
</div>
</form>
{% endblock %}
@@ -0,0 +1,30 @@
{% extends "base/layout.html" %}
{% block content %}
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Audit Firms</h1>
<a href="/system-settings/tenants/new" class="rounded-xl bg-slate-900 text-white px-4 py-2 text-sm">Add Audit Firm</a>
</div>
<div class="mt-4 rounded-2xl bg-white border overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-slate-50 text-slate-600">
<tr>
<th class="text-left p-3">Audit Firm ID</th>
<th class="text-left p-3">Audit Firm Code</th>
<th class="text-left p-3">Audit Firm Name</th>
<th class="text-left p-3">Active</th>
</tr>
</thead>
<tbody>
{% for t in tenants %}
<tr class="border-t">
<td class="p-3">{{ t.id }}</td>
<td class="p-3">{{ t.code }}</td>
<td class="p-3">{{ t.name }}</td>
<td class="p-3">{{ "Yes" if t.is_active else "No" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}