Prepare ERP source for Gitea deployment
This commit is contained in:
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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 & 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 & 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 & 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user