all working
This commit is contained in:
13
README.md
13
README.md
@@ -187,6 +187,12 @@ delphi-database/
|
|||||||
- Password hashing with bcrypt
|
- Password hashing with bcrypt
|
||||||
- Token expiration and refresh
|
- Token expiration and refresh
|
||||||
|
|
||||||
|
JWT details:
|
||||||
|
|
||||||
|
- Access token: returned by `POST /api/auth/login`, use in `Authorization: Bearer` header
|
||||||
|
- Refresh token: also returned on login; use `POST /api/auth/refresh` with body `{ "refresh_token": "..." }` to obtain a new access token. On refresh, the provided refresh token is revoked and a new one is issued.
|
||||||
|
- Legacy compatibility: `POST /api/auth/refresh` called without a body (but with Authorization header) will issue a new access token only.
|
||||||
|
|
||||||
## 🗄️ Data Management
|
## 🗄️ Data Management
|
||||||
- CSV import/export functionality
|
- CSV import/export functionality
|
||||||
- Database backup and restore
|
- Database backup and restore
|
||||||
@@ -194,14 +200,17 @@ delphi-database/
|
|||||||
- Automatic financial calculations (matching legacy system)
|
- Automatic financial calculations (matching legacy system)
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
Environment variables (create `.env` file):
|
Environment variables (create `.env` file). Real environment variables override `.env` which override defaults:
|
||||||
```bash
|
```bash
|
||||||
# Database
|
# Database
|
||||||
DATABASE_URL=sqlite:///./delphi_database.db
|
DATABASE_URL=sqlite:///./delphi_database.db
|
||||||
|
|
||||||
# Security
|
# Security
|
||||||
SECRET_KEY=your-secret-key-change-in-production
|
SECRET_KEY=your-secret-key-change-in-production
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
# Optional previous key to allow rotation
|
||||||
|
PREVIOUS_SECRET_KEY=
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=240
|
||||||
|
REFRESH_TOKEN_EXPIRE_MINUTES=43200
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
DEBUG=False
|
DEBUG=False
|
||||||
|
|||||||
108
app/api/auth.py
108
app/api/auth.py
@@ -12,16 +12,21 @@ from app.models.user import User
|
|||||||
from app.auth.security import (
|
from app.auth.security import (
|
||||||
authenticate_user,
|
authenticate_user,
|
||||||
create_access_token,
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_refresh_token,
|
||||||
|
is_refresh_token_revoked,
|
||||||
|
revoke_refresh_token,
|
||||||
get_password_hash,
|
get_password_hash,
|
||||||
get_current_user,
|
get_current_user,
|
||||||
get_admin_user
|
get_admin_user,
|
||||||
)
|
)
|
||||||
from app.auth.schemas import (
|
from app.auth.schemas import (
|
||||||
Token,
|
Token,
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserResponse,
|
UserResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
ThemePreferenceUpdate
|
ThemePreferenceUpdate,
|
||||||
|
RefreshRequest,
|
||||||
)
|
)
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.core.logging import get_logger, log_auth_attempt
|
from app.core.logging import get_logger, log_auth_attempt
|
||||||
@@ -71,6 +76,12 @@ async def login(login_data: LoginRequest, request: Request, db: Session = Depend
|
|||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
|
refresh_token = create_refresh_token(
|
||||||
|
user=user,
|
||||||
|
user_agent=request.headers.get("user-agent", ""),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
log_auth_attempt(
|
log_auth_attempt(
|
||||||
username=login_data.username,
|
username=login_data.username,
|
||||||
@@ -85,7 +96,7 @@ async def login(login_data: LoginRequest, request: Request, db: Session = Depend
|
|||||||
client_ip=client_ip
|
client_ip=client_ip
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer", "refresh_token": refresh_token}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=UserResponse)
|
@router.post("/register", response_model=UserResponse)
|
||||||
@@ -130,20 +141,74 @@ async def read_users_me(current_user: User = Depends(get_current_user)):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/refresh", response_model=Token)
|
@router.post("/refresh", response_model=Token)
|
||||||
async def refresh_token(
|
async def refresh_token_endpoint(
|
||||||
current_user: User = Depends(get_current_user),
|
request: Request,
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db),
|
||||||
|
body: RefreshRequest | None = None,
|
||||||
):
|
):
|
||||||
"""Refresh access token for current user"""
|
"""Issue a new access token using a valid, non-revoked refresh token.
|
||||||
# Update last login timestamp
|
|
||||||
current_user.last_login = datetime.utcnow()
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# Create new token with full expiration time
|
For backwards compatibility with existing clients that may call this without a body,
|
||||||
|
consider falling back to Authorization header in the future if needed.
|
||||||
|
"""
|
||||||
|
# New flow: refresh token in body
|
||||||
|
if body and body.refresh_token:
|
||||||
|
payload = decode_refresh_token(body.refresh_token)
|
||||||
|
if not payload:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
|
||||||
|
|
||||||
|
jti = payload.get("jti")
|
||||||
|
username = payload.get("sub")
|
||||||
|
if not jti or not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token payload")
|
||||||
|
|
||||||
|
# Verify token not revoked/expired
|
||||||
|
if is_refresh_token_revoked(jti, db):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token revoked or expired")
|
||||||
|
|
||||||
|
# Load user
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||||
|
|
||||||
|
# Rotate refresh token on use
|
||||||
|
revoke_refresh_token(jti, db)
|
||||||
|
new_refresh_token = create_refresh_token(
|
||||||
|
user=user,
|
||||||
|
user_agent=request.headers.get("user-agent", ""),
|
||||||
|
ip_address=request.client.host if request.client else None,
|
||||||
|
db=db,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Issue new access token
|
||||||
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
access_token = create_access_token(
|
access_token = create_access_token(
|
||||||
data={"sub": current_user.username},
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
expires_delta=access_token_expires
|
)
|
||||||
|
|
||||||
|
return {"access_token": access_token, "token_type": "bearer", "refresh_token": new_refresh_token}
|
||||||
|
|
||||||
|
# Legacy flow: Authorization header-based refresh
|
||||||
|
auth_header = request.headers.get("authorization") or request.headers.get("Authorization")
|
||||||
|
if not auth_header or not auth_header.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing credentials")
|
||||||
|
|
||||||
|
token = auth_header.split(" ", 1)[1].strip()
|
||||||
|
from app.auth.security import verify_token # local import to avoid circular
|
||||||
|
username = verify_token(token)
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
if not user or not user.is_active:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found or inactive")
|
||||||
|
|
||||||
|
user.last_login = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
access_token_expires = timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
|
access_token = create_access_token(
|
||||||
|
data={"sub": user.username}, expires_delta=access_token_expires
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"access_token": access_token, "token_type": "bearer"}
|
return {"access_token": access_token, "token_type": "bearer"}
|
||||||
@@ -159,6 +224,23 @@ async def list_users(
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout")
|
||||||
|
async def logout(body: RefreshRequest | None = None, db: Session = Depends(get_db)):
|
||||||
|
"""Revoke the provided refresh token. Idempotent and safe to call multiple times.
|
||||||
|
|
||||||
|
The client should send a JSON body: { "refresh_token": "..." }.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if body and body.refresh_token:
|
||||||
|
payload = decode_refresh_token(body.refresh_token)
|
||||||
|
if payload and payload.get("jti"):
|
||||||
|
revoke_refresh_token(payload["jti"], db)
|
||||||
|
except Exception:
|
||||||
|
# Don't leak details; logout should be best-effort
|
||||||
|
pass
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/theme-preference")
|
@router.post("/theme-preference")
|
||||||
async def update_theme_preference(
|
async def update_theme_preference(
|
||||||
theme_data: ThemePreferenceUpdate,
|
theme_data: ThemePreferenceUpdate,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Document Management API endpoints - QDROs, Templates, and General Documents
|
Document Management API endpoints - QDROs, Templates, and General Documents
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional, Dict, Any
|
from typing import List, Optional, Dict, Any
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, UploadFile, File, Form, Request
|
||||||
from sqlalchemy.orm import Session, joinedload
|
from sqlalchemy.orm import Session, joinedload
|
||||||
from sqlalchemy import or_, func, and_, desc, asc, text
|
from sqlalchemy import or_, func, and_, desc, asc, text
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
@@ -18,6 +18,8 @@ from app.models.lookups import FormIndex, FormList, Footer, Employee
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.auth.security import get_current_user
|
from app.auth.security import get_current_user
|
||||||
from app.models.additional import Document
|
from app.models.additional import Document
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.services.audit import audit_service
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -666,6 +668,78 @@ def _merge_template_variables(content: str, variables: Dict[str, Any]) -> str:
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
# --- Client Error Logging (for Documents page) ---
|
||||||
|
class ClientErrorLog(BaseModel):
|
||||||
|
"""Payload for client-side error logging"""
|
||||||
|
message: str
|
||||||
|
action: Optional[str] = None
|
||||||
|
stack: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
line: Optional[int] = None
|
||||||
|
column: Optional[int] = None
|
||||||
|
user_agent: Optional[str] = None
|
||||||
|
extra: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/client-error")
|
||||||
|
async def log_client_error(
|
||||||
|
payload: ClientErrorLog,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: Optional[User] = Depends(lambda: None)
|
||||||
|
):
|
||||||
|
"""Accept client-side error logs from the Documents page.
|
||||||
|
|
||||||
|
This endpoint is lightweight and safe to call; it records the error to the
|
||||||
|
application logs and best-effort to the audit log without interrupting the UI.
|
||||||
|
"""
|
||||||
|
logger = get_logger("client.documents")
|
||||||
|
client_ip = request.headers.get("x-forwarded-for")
|
||||||
|
if client_ip:
|
||||||
|
client_ip = client_ip.split(",")[0].strip()
|
||||||
|
else:
|
||||||
|
client_ip = request.client.host if request.client else None
|
||||||
|
|
||||||
|
logger.error(
|
||||||
|
"Client error reported",
|
||||||
|
action=payload.action,
|
||||||
|
message=payload.message,
|
||||||
|
stack=payload.stack,
|
||||||
|
page="/documents",
|
||||||
|
url=payload.url or str(request.url),
|
||||||
|
line=payload.line,
|
||||||
|
column=payload.column,
|
||||||
|
user=getattr(current_user, "username", None),
|
||||||
|
user_id=getattr(current_user, "id", None),
|
||||||
|
user_agent=payload.user_agent or request.headers.get("user-agent"),
|
||||||
|
client_ip=client_ip,
|
||||||
|
extra=payload.extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Best-effort audit log; do not raise on failure
|
||||||
|
try:
|
||||||
|
audit_service.log_action(
|
||||||
|
db=db,
|
||||||
|
action="CLIENT_ERROR",
|
||||||
|
resource_type="DOCUMENTS",
|
||||||
|
user=current_user,
|
||||||
|
resource_id=None,
|
||||||
|
details={
|
||||||
|
"action": payload.action,
|
||||||
|
"message": payload.message,
|
||||||
|
"url": payload.url or str(request.url),
|
||||||
|
"line": payload.line,
|
||||||
|
"column": payload.column,
|
||||||
|
"extra": payload.extra,
|
||||||
|
},
|
||||||
|
request=request,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {"status": "logged"}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload/{file_no}")
|
@router.post("/upload/{file_no}")
|
||||||
async def upload_document(
|
async def upload_document(
|
||||||
file_no: str,
|
file_no: str,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ class Token(BaseModel):
|
|||||||
"""Token response schema"""
|
"""Token response schema"""
|
||||||
access_token: str
|
access_token: str
|
||||||
token_type: str
|
token_type: str
|
||||||
|
refresh_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
@@ -56,3 +57,8 @@ class LoginRequest(BaseModel):
|
|||||||
"""Login request schema"""
|
"""Login request schema"""
|
||||||
username: str
|
username: str
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshRequest(BaseModel):
|
||||||
|
"""Refresh token submission"""
|
||||||
|
refresh_token: str
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
Authentication and security utilities
|
Authentication and security utilities
|
||||||
"""
|
"""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union, Tuple
|
||||||
|
from uuid import uuid4
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -12,6 +13,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database.base import get_db
|
from app.database.base import get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.models.auth import RefreshToken
|
||||||
|
|
||||||
# Password hashing
|
# Password hashing
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
@@ -30,39 +32,107 @@ def get_password_hash(password: str) -> str:
|
|||||||
return pwd_context.hash(password)
|
return pwd_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_with_rotation(payload: dict) -> str:
|
||||||
|
"""Encode JWT with active secret and algorithm."""
|
||||||
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_with_rotation(token: str) -> dict:
|
||||||
|
"""Decode JWT trying current then previous secret if set."""
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
|
||||||
|
except JWTError:
|
||||||
|
# Try previous secret to allow seamless rotation
|
||||||
|
if settings.previous_secret_key:
|
||||||
|
try:
|
||||||
|
return jwt.decode(token, settings.previous_secret_key, algorithms=[settings.algorithm])
|
||||||
|
except JWTError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||||
"""Create JWT access token"""
|
"""Create JWT access token"""
|
||||||
to_encode = data.copy()
|
to_encode = data.copy()
|
||||||
if expires_delta:
|
expire = datetime.utcnow() + (
|
||||||
expire = datetime.utcnow() + expires_delta
|
expires_delta if expires_delta else timedelta(minutes=settings.access_token_expire_minutes)
|
||||||
else:
|
)
|
||||||
expire = datetime.utcnow() + timedelta(minutes=settings.access_token_expire_minutes)
|
|
||||||
|
|
||||||
to_encode.update({
|
to_encode.update({
|
||||||
"exp": expire,
|
"exp": expire,
|
||||||
"iat": datetime.utcnow(),
|
"iat": datetime.utcnow(),
|
||||||
|
"type": "access",
|
||||||
})
|
})
|
||||||
encoded_jwt = jwt.encode(to_encode, settings.secret_key, algorithm=settings.algorithm)
|
return _encode_with_rotation(to_encode)
|
||||||
return encoded_jwt
|
|
||||||
|
|
||||||
|
def create_refresh_token(user: User, user_agent: Optional[str], ip_address: Optional[str], db: Session) -> str:
|
||||||
|
"""Create refresh token, store its JTI in DB for revocation."""
|
||||||
|
jti = uuid4().hex
|
||||||
|
expire = datetime.utcnow() + timedelta(minutes=settings.refresh_token_expire_minutes)
|
||||||
|
payload = {
|
||||||
|
"sub": user.username,
|
||||||
|
"uid": user.id,
|
||||||
|
"jti": jti,
|
||||||
|
"type": "refresh",
|
||||||
|
"exp": expire,
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
}
|
||||||
|
token = _encode_with_rotation(payload)
|
||||||
|
|
||||||
|
db_token = RefreshToken(
|
||||||
|
user_id=user.id,
|
||||||
|
jti=jti,
|
||||||
|
user_agent=user_agent,
|
||||||
|
ip_address=ip_address,
|
||||||
|
issued_at=datetime.utcnow(),
|
||||||
|
expires_at=expire,
|
||||||
|
revoked=False,
|
||||||
|
)
|
||||||
|
db.add(db_token)
|
||||||
|
db.commit()
|
||||||
|
return token
|
||||||
|
|
||||||
|
|
||||||
def verify_token(token: str) -> Optional[str]:
|
def verify_token(token: str) -> Optional[str]:
|
||||||
"""Verify JWT token and return username"""
|
"""Verify JWT token and return username"""
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(
|
payload = _decode_with_rotation(token)
|
||||||
token,
|
|
||||||
settings.secret_key,
|
|
||||||
algorithms=[settings.algorithm],
|
|
||||||
leeway=30 # allow small clock skew
|
|
||||||
)
|
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
|
token_type: str = payload.get("type")
|
||||||
if username is None:
|
if username is None:
|
||||||
return None
|
return None
|
||||||
|
# Only accept access tokens for auth
|
||||||
|
if token_type and token_type != "access":
|
||||||
|
return None
|
||||||
return username
|
return username
|
||||||
except JWTError:
|
except JWTError:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_refresh_token(token: str) -> Optional[dict]:
|
||||||
|
"""Decode refresh token and return payload if valid and not revoked."""
|
||||||
|
try:
|
||||||
|
payload = _decode_with_rotation(token)
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
return None
|
||||||
|
return payload
|
||||||
|
except JWTError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_refresh_token_revoked(jti: str, db: Session) -> bool:
|
||||||
|
token_row = db.query(RefreshToken).filter(RefreshToken.jti == jti).first()
|
||||||
|
return not token_row or token_row.revoked or token_row.expires_at <= datetime.utcnow()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_refresh_token(jti: str, db: Session) -> None:
|
||||||
|
token_row = db.query(RefreshToken).filter(RefreshToken.jti == jti).first()
|
||||||
|
if token_row and not token_row.revoked:
|
||||||
|
token_row.revoked = True
|
||||||
|
token_row.revoked_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
def authenticate_user(db: Session, username: str, password: str) -> Optional[User]:
|
||||||
"""Authenticate user credentials"""
|
"""Authenticate user credentials"""
|
||||||
user = db.query(User).filter(User.username == username).first()
|
user = db.query(User).filter(User.username == username).first()
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""
|
"""
|
||||||
Delphi Consulting Group Database System - Configuration
|
Delphi Consulting Group Database System - Configuration
|
||||||
"""
|
"""
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
"""Application configuration"""
|
"""Application configuration (env and .env driven).
|
||||||
|
|
||||||
|
Environment precedence: real environment variables take priority over .env,
|
||||||
|
which take priority over defaults.
|
||||||
|
"""
|
||||||
|
|
||||||
# Application
|
# Application
|
||||||
app_name: str = "Delphi Consulting Group Database System"
|
app_name: str = "Delphi Consulting Group Database System"
|
||||||
@@ -16,10 +22,15 @@ class Settings(BaseSettings):
|
|||||||
# Database
|
# Database
|
||||||
database_url: str = "sqlite:///./data/delphi_database.db"
|
database_url: str = "sqlite:///./data/delphi_database.db"
|
||||||
|
|
||||||
# Authentication
|
# Authentication / JWT
|
||||||
secret_key: str = "your-secret-key-change-in-production"
|
# Require SECRET_KEY to be provided via environment/.env (no insecure default)
|
||||||
|
secret_key: str = Field(..., min_length=32)
|
||||||
|
# Optional previous secret key to allow seamless rotation
|
||||||
|
previous_secret_key: Optional[str] = None
|
||||||
algorithm: str = "HS256"
|
algorithm: str = "HS256"
|
||||||
access_token_expire_minutes: int = 240 # 4 hours
|
access_token_expire_minutes: int = 240 # 4 hours
|
||||||
|
# Long-lived refresh token expiration (default 30 days)
|
||||||
|
refresh_token_expire_minutes: int = 43200
|
||||||
|
|
||||||
# Admin account settings
|
# Admin account settings
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
@@ -46,8 +57,14 @@ class Settings(BaseSettings):
|
|||||||
log_rotation: str = "10 MB"
|
log_rotation: str = "10 MB"
|
||||||
log_retention: str = "30 days"
|
log_retention: str = "30 days"
|
||||||
|
|
||||||
class Config:
|
# pydantic-settings v2 configuration
|
||||||
env_file = ".env"
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_prefix="",
|
||||||
|
case_sensitive=False,
|
||||||
|
env_ignore_empty=True,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
@@ -14,6 +14,7 @@ from app.models.user import User
|
|||||||
from app.auth.security import get_admin_user
|
from app.auth.security import get_admin_user
|
||||||
from app.core.logging import setup_logging, get_logger
|
from app.core.logging import setup_logging, get_logger
|
||||||
from app.middleware.logging import LoggingMiddleware
|
from app.middleware.logging import LoggingMiddleware
|
||||||
|
from app.middleware.errors import register_exception_handlers
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -35,6 +36,10 @@ app = FastAPI(
|
|||||||
logger.info("Adding request logging middleware")
|
logger.info("Adding request logging middleware")
|
||||||
app.add_middleware(LoggingMiddleware, log_requests=True, log_responses=settings.debug)
|
app.add_middleware(LoggingMiddleware, log_requests=True, log_responses=settings.debug)
|
||||||
|
|
||||||
|
# Register global exception handlers
|
||||||
|
logger.info("Registering global exception handlers")
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
logger.info("Configuring CORS middleware")
|
logger.info("Configuring CORS middleware")
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
132
app/middleware/errors.py
Normal file
132
app/middleware/errors.py
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
Global exception handlers that return a consistent JSON error envelope
|
||||||
|
and propagate the X-Correlation-ID header.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from starlette import status as http_status
|
||||||
|
from starlette.requests import Request as StarletteRequest
|
||||||
|
from starlette.responses import JSONResponse as StarletteJSONResponse
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Prefer project logger if available
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
logger = get_logger("middleware.errors")
|
||||||
|
except Exception: # pragma: no cover - fallback simple logger
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger("middleware.errors")
|
||||||
|
|
||||||
|
|
||||||
|
ERROR_HEADER_NAME = "X-Correlation-ID"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_correlation_id(request: Request) -> str:
|
||||||
|
"""Resolve correlation ID from request state, headers, or generate a new one."""
|
||||||
|
# From middleware
|
||||||
|
correlation_id: Optional[str] = getattr(getattr(request, "state", object()), "correlation_id", None)
|
||||||
|
if correlation_id:
|
||||||
|
return correlation_id
|
||||||
|
|
||||||
|
# From incoming headers
|
||||||
|
correlation_id = (
|
||||||
|
request.headers.get("x-correlation-id")
|
||||||
|
or request.headers.get("x-request-id")
|
||||||
|
)
|
||||||
|
if correlation_id:
|
||||||
|
return correlation_id
|
||||||
|
|
||||||
|
# Generate a new one if not present
|
||||||
|
return str(uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _build_error_response(
|
||||||
|
request: Request,
|
||||||
|
*,
|
||||||
|
status_code: int,
|
||||||
|
message: str,
|
||||||
|
code: str,
|
||||||
|
details: Any | None = None,
|
||||||
|
) -> JSONResponse:
|
||||||
|
correlation_id = _get_correlation_id(request)
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"success": False,
|
||||||
|
"error": {
|
||||||
|
"status": status_code,
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
},
|
||||||
|
"correlation_id": correlation_id,
|
||||||
|
}
|
||||||
|
if details is not None:
|
||||||
|
body["error"]["details"] = details
|
||||||
|
|
||||||
|
response = JSONResponse(content=body, status_code=status_code)
|
||||||
|
response.headers[ERROR_HEADER_NAME] = correlation_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
|
||||||
|
"""Handle FastAPI HTTPException with envelope and correlation id."""
|
||||||
|
message = exc.detail if isinstance(exc.detail, str) else "HTTP error"
|
||||||
|
logger.warning(
|
||||||
|
"HTTPException raised",
|
||||||
|
status_code=exc.status_code,
|
||||||
|
detail=message,
|
||||||
|
path=request.url.path,
|
||||||
|
)
|
||||||
|
return _build_error_response(
|
||||||
|
request,
|
||||||
|
status_code=exc.status_code,
|
||||||
|
message=message,
|
||||||
|
code="http_error",
|
||||||
|
details=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
"""Handle validation errors from FastAPI/Pydantic."""
|
||||||
|
logger.info(
|
||||||
|
"Validation error",
|
||||||
|
path=request.url.path,
|
||||||
|
errors=exc.errors(),
|
||||||
|
)
|
||||||
|
return _build_error_response(
|
||||||
|
request,
|
||||||
|
status_code=http_status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
message="Validation error",
|
||||||
|
code="validation_error",
|
||||||
|
details=exc.errors(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
"""Catch-all handler for unexpected exceptions (500)."""
|
||||||
|
# Log full exception for diagnostics without leaking internals to clients
|
||||||
|
try:
|
||||||
|
logger.exception("Unhandled exception", path=request.url.path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return _build_error_response(
|
||||||
|
request,
|
||||||
|
status_code=http_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
message="Internal Server Error",
|
||||||
|
code="internal_error",
|
||||||
|
details=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_exception_handlers(app: FastAPI) -> None:
|
||||||
|
"""Register global exception handlers on the provided FastAPI app."""
|
||||||
|
app.add_exception_handler(HTTPException, http_exception_handler)
|
||||||
|
app.add_exception_handler(RequestValidationError, validation_exception_handler)
|
||||||
|
app.add_exception_handler(Exception, unhandled_exception_handler)
|
||||||
|
|
||||||
|
|
||||||
@@ -4,6 +4,7 @@ Request/Response Logging Middleware
|
|||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
from uuid import uuid4
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
from starlette.responses import StreamingResponse
|
from starlette.responses import StreamingResponse
|
||||||
@@ -21,10 +22,19 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
self.log_responses = log_responses
|
self.log_responses = log_responses
|
||||||
|
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||||
# Skip logging for static files and health checks
|
# Correlation ID: use incoming header or generate a new one
|
||||||
|
correlation_id = request.headers.get("x-correlation-id") or request.headers.get("x-request-id") or str(uuid4())
|
||||||
|
request.state.correlation_id = correlation_id
|
||||||
|
|
||||||
|
# Skip logging for static files and health checks (still attach correlation id)
|
||||||
skip_paths = ["/static/", "/uploads/", "/health", "/favicon.ico"]
|
skip_paths = ["/static/", "/uploads/", "/health", "/favicon.ico"]
|
||||||
if any(request.url.path.startswith(path) for path in skip_paths):
|
if any(request.url.path.startswith(path) for path in skip_paths):
|
||||||
return await call_next(request)
|
response = await call_next(request)
|
||||||
|
try:
|
||||||
|
response.headers["X-Correlation-ID"] = correlation_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return response
|
||||||
|
|
||||||
# Record start time
|
# Record start time
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -41,7 +51,8 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
query_params=str(request.query_params) if request.query_params else None,
|
query_params=str(request.query_params) if request.query_params else None,
|
||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
user_agent=user_agent
|
user_agent=user_agent,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process request
|
# Process request
|
||||||
@@ -56,7 +67,8 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
error=str(e),
|
error=str(e),
|
||||||
client_ip=client_ip
|
client_ip=client_ip,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -74,7 +86,8 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
user_id=user_id
|
user_id=user_id,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log response details if enabled
|
# Log response details if enabled
|
||||||
@@ -84,7 +97,8 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
headers=dict(response.headers),
|
headers=dict(response.headers),
|
||||||
size_bytes=response.headers.get("content-length"),
|
size_bytes=response.headers.get("content-length"),
|
||||||
content_type=response.headers.get("content-type")
|
content_type=response.headers.get("content-type"),
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log slow requests as warnings
|
# Log slow requests as warnings
|
||||||
@@ -94,7 +108,8 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
method=request.method,
|
method=request.method,
|
||||||
path=request.url.path,
|
path=request.url.path,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
status_code=response.status_code
|
status_code=response.status_code,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Log authentication-related requests to auth log
|
# Log authentication-related requests to auth log
|
||||||
@@ -106,9 +121,16 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
client_ip=client_ip,
|
client_ip=client_ip,
|
||||||
user_agent=user_agent
|
user_agent=user_agent,
|
||||||
|
correlation_id=correlation_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Attach correlation id header to all responses
|
||||||
|
try:
|
||||||
|
response.headers["X-Correlation-ID"] = correlation_id
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
def get_client_ip(self, request: Request) -> str:
|
def get_client_ip(self, request: Request) -> str:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .files import File
|
|||||||
from .ledger import Ledger
|
from .ledger import Ledger
|
||||||
from .qdro import QDRO
|
from .qdro import QDRO
|
||||||
from .audit import AuditLog, LoginAttempt
|
from .audit import AuditLog, LoginAttempt
|
||||||
|
from .auth import RefreshToken
|
||||||
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable, Document
|
from .additional import Deposit, Payment, FileNote, FormVariable, ReportVariable, Document
|
||||||
from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory
|
from .support import SupportTicket, TicketResponse, TicketStatus, TicketPriority, TicketCategory
|
||||||
from .pensions import (
|
from .pensions import (
|
||||||
@@ -22,7 +23,7 @@ from .lookups import (
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
|
"BaseModel", "User", "Rolodex", "Phone", "File", "Ledger", "QDRO",
|
||||||
"AuditLog", "LoginAttempt",
|
"AuditLog", "LoginAttempt", "RefreshToken",
|
||||||
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Document",
|
"Deposit", "Payment", "FileNote", "FormVariable", "ReportVariable", "Document",
|
||||||
"SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory",
|
"SupportTicket", "TicketResponse", "TicketStatus", "TicketPriority", "TicketCategory",
|
||||||
"Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit",
|
"Pension", "PensionSchedule", "MarriageHistory", "DeathBenefit",
|
||||||
|
|||||||
34
app/models/auth.py
Normal file
34
app/models/auth.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
"""
|
||||||
|
Authentication-related persistence models
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
|
||||||
|
from app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(BaseModel):
|
||||||
|
"""Persisted refresh tokens for revocation and auditing."""
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
|
||||||
|
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
jti = Column(String(64), nullable=False, unique=True, index=True)
|
||||||
|
user_agent = Column(String(255), nullable=True)
|
||||||
|
ip_address = Column(String(45), nullable=True)
|
||||||
|
issued_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||||
|
expires_at = Column(DateTime, nullable=False, index=True)
|
||||||
|
revoked = Column(Boolean, default=False, nullable=False)
|
||||||
|
revoked_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
# relationships
|
||||||
|
user = relationship("User")
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("jti", name="uq_refresh_tokens_jti"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
102
scripts/rotate-secret-key.py
Normal file
102
scripts/rotate-secret-key.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Rotate SECRET_KEY in .env with seamless fallback using PREVIOUS_SECRET_KEY.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/rotate-secret-key.py [--env-path .env]
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
- Reads the .env file (default .env)
|
||||||
|
- Sets PREVIOUS_SECRET_KEY to current SECRET_KEY
|
||||||
|
- Generates a new SECRET_KEY
|
||||||
|
- Preserves other variables
|
||||||
|
- Writes back atomically and sets file mode 600
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def generate_secret_key(length: int = 32) -> str:
|
||||||
|
return secrets.token_urlsafe(length)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_env(contents: str) -> dict[str, str]:
|
||||||
|
env: dict[str, str] = {}
|
||||||
|
for line in contents.splitlines():
|
||||||
|
if not line or line.strip().startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
env[key.strip()] = value.strip()
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
def render_env(original: str, updates: dict[str, str]) -> str:
|
||||||
|
lines = original.splitlines()
|
||||||
|
seen_keys: set[str] = set()
|
||||||
|
out_lines: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not line or line.strip().startswith("#") or "=" not in line:
|
||||||
|
out_lines.append(line)
|
||||||
|
continue
|
||||||
|
key, _ = line.split("=", 1)
|
||||||
|
k = key.strip()
|
||||||
|
if k in updates:
|
||||||
|
out_lines.append(f"{k}={updates[k]}")
|
||||||
|
seen_keys.add(k)
|
||||||
|
else:
|
||||||
|
out_lines.append(line)
|
||||||
|
seen_keys.add(k)
|
||||||
|
# Append any new keys not present originally
|
||||||
|
for k, v in updates.items():
|
||||||
|
if k not in seen_keys:
|
||||||
|
out_lines.append(f"{k}={v}")
|
||||||
|
return "\n".join(out_lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(description="Rotate SECRET_KEY in .env")
|
||||||
|
parser.add_argument("--env-path", default=".env", help="Path to .env file")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
env_path = Path(args.env_path)
|
||||||
|
if not env_path.exists():
|
||||||
|
raise SystemExit(f".env file not found at {env_path}")
|
||||||
|
|
||||||
|
original = env_path.read_text()
|
||||||
|
env = parse_env(original)
|
||||||
|
|
||||||
|
current_secret = env.get("SECRET_KEY")
|
||||||
|
if not current_secret:
|
||||||
|
raise SystemExit("SECRET_KEY not found in .env")
|
||||||
|
|
||||||
|
new_secret = generate_secret_key(32)
|
||||||
|
updates = {
|
||||||
|
"PREVIOUS_SECRET_KEY": current_secret,
|
||||||
|
"SECRET_KEY": new_secret,
|
||||||
|
}
|
||||||
|
|
||||||
|
rendered = render_env(original, updates)
|
||||||
|
|
||||||
|
# Atomic write
|
||||||
|
with tempfile.NamedTemporaryFile("w", delete=False, dir=str(env_path.parent)) as tmp:
|
||||||
|
tmp.write(rendered)
|
||||||
|
temp_name = tmp.name
|
||||||
|
os.replace(temp_name, env_path)
|
||||||
|
os.chmod(env_path, 0o600)
|
||||||
|
|
||||||
|
print("✅ SECRET_KEY rotated successfully.")
|
||||||
|
print(" PREVIOUS_SECRET_KEY updated for seamless token validation.")
|
||||||
|
print(" Restart the application to apply the new key.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
||||||
|
|
||||||
@@ -63,7 +63,10 @@ DATABASE_URL=sqlite:///data/delphi_database.db
|
|||||||
|
|
||||||
# ===== SECURITY SETTINGS - GENERATED =====
|
# ===== SECURITY SETTINGS - GENERATED =====
|
||||||
SECRET_KEY={secret_key}
|
SECRET_KEY={secret_key}
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
# Optional previous key for seamless rotation (leave blank initially)
|
||||||
|
PREVIOUS_SECRET_KEY=
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=240
|
||||||
|
REFRESH_TOKEN_EXPIRE_MINUTES=43200
|
||||||
ALGORITHM=HS256
|
ALGORITHM=HS256
|
||||||
|
|
||||||
# ===== ADMIN USER CREATION =====
|
# ===== ADMIN USER CREATION =====
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
// ... add the JS content ...
|
// ... add the JS content ...
|
||||||
|
|
||||||
// Modify modal showing/hiding to use classList.add/remove('hidden') instead of Bootstrap modal
|
// Modify modal showing/hiding to use classList.add/remove('hidden')
|
||||||
|
|
||||||
// For example:
|
// For example:
|
||||||
function showQuickTimeModal() {
|
function showQuickTimeModal() {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Global application state
|
// Global application state
|
||||||
const app = {
|
const app = {
|
||||||
token: localStorage.getItem('auth_token'),
|
token: localStorage.getItem('auth_token'),
|
||||||
|
refreshToken: localStorage.getItem('refresh_token'),
|
||||||
user: null,
|
user: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
refreshTimerId: null,
|
refreshTimerId: null,
|
||||||
@@ -13,9 +14,98 @@ const app = {
|
|||||||
|
|
||||||
// Initialize application
|
// Initialize application
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
try { setupGlobalErrorHandlers(); } catch (_) {}
|
||||||
initializeApp();
|
initializeApp();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Theme Management (centralized)
|
||||||
|
function applyTheme(theme) {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const isDark = theme === 'dark';
|
||||||
|
html.classList.toggle('dark', isDark);
|
||||||
|
html.setAttribute('data-theme', isDark ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
const nextTheme = html.classList.contains('dark') ? 'light' : 'dark';
|
||||||
|
applyTheme(nextTheme);
|
||||||
|
try { localStorage.setItem('theme-preference', nextTheme); } catch (_) {}
|
||||||
|
saveThemePreference(nextTheme);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeTheme() {
|
||||||
|
// Check for saved theme preference
|
||||||
|
let savedTheme = null;
|
||||||
|
try { savedTheme = localStorage.getItem('theme-preference'); } catch (_) {}
|
||||||
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||||
|
|
||||||
|
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
|
||||||
|
applyTheme(theme);
|
||||||
|
|
||||||
|
// Load from server if available
|
||||||
|
loadUserThemePreference();
|
||||||
|
|
||||||
|
// Listen for OS theme changes if no explicit preference is set
|
||||||
|
attachSystemThemeListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveThemePreference(theme) {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token || isLoginPage()) return;
|
||||||
|
try {
|
||||||
|
await fetch('/api/auth/theme-preference', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ theme_preference: theme })
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Could not save theme preference to server:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSystemThemeListener() {
|
||||||
|
if (!('matchMedia' in window)) return;
|
||||||
|
const media = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const handleChange = (e) => {
|
||||||
|
let savedTheme = null;
|
||||||
|
try { savedTheme = localStorage.getItem('theme-preference'); } catch (_) {}
|
||||||
|
if (!savedTheme || savedTheme === 'system') {
|
||||||
|
applyTheme(e.matches ? 'dark' : 'light');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof media.addEventListener === 'function') {
|
||||||
|
media.addEventListener('change', handleChange);
|
||||||
|
} else if (typeof media.addListener === 'function') {
|
||||||
|
media.addListener(handleChange); // Safari fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserThemePreference() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token || isLoginPage()) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const user = await response.json();
|
||||||
|
if (user.theme_preference) {
|
||||||
|
applyTheme(user.theme_preference);
|
||||||
|
try { localStorage.setItem('theme-preference', user.theme_preference); } catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Could not load theme preference from server:', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply theme immediately on script load
|
||||||
|
try { initializeTheme(); } catch (_) {}
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
// Initialize keyboard shortcuts
|
// Initialize keyboard shortcuts
|
||||||
if (window.keyboardShortcuts) {
|
if (window.keyboardShortcuts) {
|
||||||
@@ -30,6 +120,11 @@ async function initializeApp() {
|
|||||||
// Initialize API helpers
|
// Initialize API helpers
|
||||||
setupAPIHelpers();
|
setupAPIHelpers();
|
||||||
|
|
||||||
|
// Initialize authentication manager (centralized)
|
||||||
|
if (typeof initializeAuthManager === 'function') {
|
||||||
|
initializeAuthManager();
|
||||||
|
}
|
||||||
|
|
||||||
app.initialized = true;
|
app.initialized = true;
|
||||||
console.log('Delphi Database System initialized');
|
console.log('Delphi Database System initialized');
|
||||||
}
|
}
|
||||||
@@ -103,6 +198,14 @@ async function apiCall(url, options = {}) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
let response = await fetch(url, config);
|
let response = await fetch(url, config);
|
||||||
|
const updateCorrelationFromResponse = (resp) => {
|
||||||
|
try {
|
||||||
|
const cid = resp && resp.headers ? resp.headers.get('X-Correlation-ID') : null;
|
||||||
|
if (cid) { window.app.lastCorrelationId = cid; }
|
||||||
|
return cid;
|
||||||
|
} catch (_) { return null; }
|
||||||
|
};
|
||||||
|
let lastCorrelationId = updateCorrelationFromResponse(response);
|
||||||
|
|
||||||
if (response.status === 401 && app.token) {
|
if (response.status === 401 && app.token) {
|
||||||
// Attempt one refresh then retry once
|
// Attempt one refresh then retry once
|
||||||
@@ -113,6 +216,7 @@ async function apiCall(url, options = {}) {
|
|||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
response = await fetch(url, retryConfig);
|
response = await fetch(url, retryConfig);
|
||||||
|
lastCorrelationId = updateCorrelationFromResponse(response);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// fall through to logout below
|
// fall through to logout below
|
||||||
}
|
}
|
||||||
@@ -124,7 +228,10 @@ async function apiCall(url, options = {}) {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({ detail: 'Request failed' }));
|
const errorData = await response.json().catch(() => ({ detail: 'Request failed' }));
|
||||||
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
const err = new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
err.status = response.status;
|
||||||
|
err.correlationId = lastCorrelationId || null;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -158,16 +265,263 @@ async function apiDelete(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authentication functions
|
// Authentication functions
|
||||||
function setAuthToken(token) {
|
function setAuthTokens(accessToken, newRefreshToken = null) {
|
||||||
app.token = token;
|
if (accessToken) {
|
||||||
localStorage.setItem('auth_token', token);
|
app.token = accessToken;
|
||||||
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
localStorage.setItem('auth_token', accessToken);
|
||||||
|
window.apiHeaders['Authorization'] = `Bearer ${accessToken}`;
|
||||||
|
}
|
||||||
|
if (newRefreshToken) {
|
||||||
|
app.refreshToken = newRefreshToken;
|
||||||
|
localStorage.setItem('refresh_token', newRefreshToken);
|
||||||
|
}
|
||||||
// Reschedule refresh on token update
|
// Reschedule refresh on token update
|
||||||
|
if (accessToken) {
|
||||||
scheduleTokenRefresh();
|
scheduleTokenRefresh();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setAuthToken(token) {
|
||||||
|
// Backwards compatibility
|
||||||
|
setAuthTokens(token, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page helpers
|
||||||
|
function isLoginPage() {
|
||||||
|
const path = window.location.pathname;
|
||||||
|
return path === '/login' || path === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the current access token by hitting /api/auth/me
|
||||||
|
async function checkTokenValidity() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
// Invalid token
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Cache user for later UI updates
|
||||||
|
try { app.user = await response.json(); } catch (_) {}
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking token validity:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to refresh token if refresh token is present; fallback to validity check+logout
|
||||||
|
async function refreshTokenIfNeeded() {
|
||||||
|
const refreshTokenValue = localStorage.getItem('refresh_token');
|
||||||
|
if (!refreshTokenValue) return;
|
||||||
|
app.refreshToken = refreshTokenValue;
|
||||||
|
try {
|
||||||
|
await refreshToken();
|
||||||
|
console.log('Token refreshed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
const stillValid = await checkTokenValidity();
|
||||||
|
if (!stillValid) {
|
||||||
|
await logout('Session expired or invalid token');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update UI elements that are permission/user dependent
|
||||||
|
async function checkUserPermissions() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token || isLoginPage()) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/me', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const user = await response.json();
|
||||||
|
app.user = user;
|
||||||
|
if (user.is_admin) {
|
||||||
|
const adminItem = document.getElementById('admin-menu-item');
|
||||||
|
const adminDivider = document.getElementById('admin-menu-divider');
|
||||||
|
if (adminItem) adminItem.classList.remove('hidden');
|
||||||
|
if (adminDivider) adminDivider.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
const userDropdownName = document.querySelector('#userDropdown button span');
|
||||||
|
if (user.full_name && userDropdownName) {
|
||||||
|
userDropdownName.textContent = user.full_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking user permissions:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inactivity monitoring & session extension UI
|
||||||
|
async function getInactivityWarningMinutes() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
if (!token) return 240;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings/inactivity_warning_minutes', {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
if (!resp.ok) return 240;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (typeof data.minutes === 'number') return data.minutes;
|
||||||
|
const parsed = parseInt(data.setting_value || data.minutes, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : 240;
|
||||||
|
} catch (_) {
|
||||||
|
return 240;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSessionExtendedNotification() {
|
||||||
|
if (window.alerts && typeof window.alerts.success === 'function') {
|
||||||
|
window.alerts.success('Your session has been refreshed successfully.', {
|
||||||
|
title: 'Session Extended',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Fallback
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = 'fixed top-4 right-4 bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg shadow-lg z-50 max-w-sm';
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-check-circle text-green-500"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<p class="text-sm font-medium">Session Extended</p>
|
||||||
|
<p class="text-xs mt-1">Your session has been refreshed successfully.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
setTimeout(() => notification.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupActivityMonitoring() {
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
let warningShown = false;
|
||||||
|
let inactivityWarningMinutes = 240; // default 4 hours
|
||||||
|
const inactivityGraceMinutes = 5; // auto-logout after warning + 5 minutes
|
||||||
|
|
||||||
|
// Fetch setting (best effort)
|
||||||
|
getInactivityWarningMinutes().then(minutes => {
|
||||||
|
if (Number.isFinite(minutes) && minutes > 0) {
|
||||||
|
inactivityWarningMinutes = minutes;
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
function hideInactivityWarning() {
|
||||||
|
const el = document.getElementById('inactivity-warning');
|
||||||
|
if (el && el.remove) el.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function extendSession() {
|
||||||
|
refreshTokenIfNeeded();
|
||||||
|
hideInactivityWarning();
|
||||||
|
showSessionExtendedNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showInactivityWarning() {
|
||||||
|
hideInactivityWarning();
|
||||||
|
const msg = `You've been inactive. Your session may expire due to inactivity.`;
|
||||||
|
if (window.alerts && typeof window.alerts.show === 'function') {
|
||||||
|
window.alerts.show(msg, 'warning', {
|
||||||
|
title: 'Session Warning',
|
||||||
|
html: false,
|
||||||
|
duration: 0,
|
||||||
|
dismissible: true,
|
||||||
|
id: 'inactivity-warning',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
label: 'Stay Logged In',
|
||||||
|
classes: 'bg-warning-600 hover:bg-warning-700 text-white text-xs px-3 py-1 rounded',
|
||||||
|
onClick: () => extendSession(),
|
||||||
|
autoClose: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Dismiss',
|
||||||
|
classes: 'bg-neutral-200 hover:bg-neutral-300 text-neutral-800 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-200 text-xs px-3 py-1 rounded',
|
||||||
|
onClick: () => hideInactivityWarning(),
|
||||||
|
autoClose: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Session Warning: ' + msg);
|
||||||
|
}
|
||||||
|
// Auto-hide after 2 minutes
|
||||||
|
setTimeout(() => hideInactivityWarning(), 2 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track user activity
|
||||||
|
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
||||||
|
activityEvents.forEach(event => {
|
||||||
|
document.addEventListener(event, () => {
|
||||||
|
lastActivity = Date.now();
|
||||||
|
warningShown = false;
|
||||||
|
const el = document.getElementById('inactivity-warning');
|
||||||
|
if (el && el.remove) el.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check every 5 minutes for inactivity
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
const warningMs = inactivityWarningMinutes * 60 * 1000;
|
||||||
|
const logoutMs = (inactivityWarningMinutes + inactivityGraceMinutes) * 60 * 1000;
|
||||||
|
const timeSinceActivity = now - lastActivity;
|
||||||
|
if (timeSinceActivity > warningMs && !warningShown) {
|
||||||
|
showInactivityWarning();
|
||||||
|
warningShown = true;
|
||||||
|
}
|
||||||
|
if (timeSinceActivity > logoutMs) {
|
||||||
|
logout('Session expired due to inactivity');
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Central initializer for auth
|
||||||
|
async function initializeAuthManager() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
// If on the login page, do nothing
|
||||||
|
if (isLoginPage()) return;
|
||||||
|
if (token) {
|
||||||
|
// Align in-memory/app state with stored tokens
|
||||||
|
app.token = token;
|
||||||
|
const storedRefresh = localStorage.getItem('refresh_token');
|
||||||
|
if (storedRefresh) app.refreshToken = storedRefresh;
|
||||||
|
|
||||||
|
// Verify token and schedule refresh
|
||||||
|
checkTokenValidity();
|
||||||
|
scheduleTokenRefresh();
|
||||||
|
// Start inactivity monitoring
|
||||||
|
setupActivityMonitoring();
|
||||||
|
// Update UI according to user permissions
|
||||||
|
checkUserPermissions();
|
||||||
|
} else {
|
||||||
|
// No token and not on login page - redirect to login
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout(reason = null) {
|
||||||
|
// Best-effort revoke refresh token server-side
|
||||||
|
const rtoken = localStorage.getItem('refresh_token');
|
||||||
|
try {
|
||||||
|
if (rtoken) {
|
||||||
|
await fetch('/api/auth/logout', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: rtoken })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
|
||||||
function logout() {
|
|
||||||
if (app.refreshTimerId) {
|
if (app.refreshTimerId) {
|
||||||
clearTimeout(app.refreshTimerId);
|
clearTimeout(app.refreshTimerId);
|
||||||
app.refreshTimerId = null;
|
app.refreshTimerId = null;
|
||||||
@@ -176,7 +530,12 @@ function logout() {
|
|||||||
app.token = null;
|
app.token = null;
|
||||||
app.user = null;
|
app.user = null;
|
||||||
localStorage.removeItem('auth_token');
|
localStorage.removeItem('auth_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
delete window.apiHeaders['Authorization'];
|
delete window.apiHeaders['Authorization'];
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
try { sessionStorage.setItem('logout_reason', reason); } catch (_) {}
|
||||||
|
}
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +763,70 @@ window.apiPut = apiPut;
|
|||||||
window.apiDelete = apiDelete;
|
window.apiDelete = apiDelete;
|
||||||
window.formatCurrency = formatCurrency;
|
window.formatCurrency = formatCurrency;
|
||||||
window.formatDate = formatDate;
|
window.formatDate = formatDate;
|
||||||
|
window.toggleTheme = toggleTheme;
|
||||||
|
window.initializeTheme = initializeTheme;
|
||||||
|
window.saveThemePreference = saveThemePreference;
|
||||||
|
window.loadUserThemePreference = loadUserThemePreference;
|
||||||
|
|
||||||
|
// Global error handling
|
||||||
|
function setupGlobalErrorHandlers() {
|
||||||
|
// Handle unexpected runtime errors
|
||||||
|
window.addEventListener('error', function(event) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
message: event && event.message ? String(event.message) : 'Unhandled error',
|
||||||
|
action: 'window.onerror',
|
||||||
|
stack: event && event.error && event.error.stack ? String(event.error.stack) : null,
|
||||||
|
url: (event && event.filename) ? String(event.filename) : String(window.location.href),
|
||||||
|
line: event && typeof event.lineno === 'number' ? event.lineno : null,
|
||||||
|
column: event && typeof event.colno === 'number' ? event.colno : null,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
extra: {
|
||||||
|
page: window.location.pathname,
|
||||||
|
lastCorrelationId: (window.app && window.app.lastCorrelationId) || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
postClientError(payload);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle unhandled promise rejections
|
||||||
|
window.addEventListener('unhandledrejection', function(event) {
|
||||||
|
try {
|
||||||
|
const reason = event && event.reason ? event.reason : null;
|
||||||
|
const payload = {
|
||||||
|
message: reason && reason.message ? String(reason.message) : 'Unhandled promise rejection',
|
||||||
|
action: 'window.unhandledrejection',
|
||||||
|
stack: reason && reason.stack ? String(reason.stack) : null,
|
||||||
|
url: String(window.location.href),
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
extra: {
|
||||||
|
page: window.location.pathname,
|
||||||
|
reasonType: reason ? (reason.name || typeof reason) : null,
|
||||||
|
status: reason && typeof reason.status === 'number' ? reason.status : null,
|
||||||
|
correlationId: reason && reason.correlationId ? reason.correlationId : ((window.app && window.app.lastCorrelationId) || null)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
postClientError(payload);
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postClientError(payload) {
|
||||||
|
try {
|
||||||
|
const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' };
|
||||||
|
const token = (window.app && window.app.token) || localStorage.getItem('auth_token');
|
||||||
|
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||||
|
// Fire-and-forget; do not block UI
|
||||||
|
fetch('/api/documents/client-error', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
}).catch(() => {});
|
||||||
|
} catch (_) {
|
||||||
|
// no-op
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// JWT utilities and refresh handling
|
// JWT utilities and refresh handling
|
||||||
function decodeJwt(token) {
|
function decodeJwt(token) {
|
||||||
@@ -448,13 +871,14 @@ function scheduleTokenRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshToken() {
|
async function refreshToken() {
|
||||||
if (!app.token) throw new Error('No token to refresh');
|
if (!app.refreshToken) throw new Error('No refresh token available');
|
||||||
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
if (app.refreshInProgress) return; // Avoid parallel refreshes
|
||||||
app.refreshInProgress = true;
|
app.refreshInProgress = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/auth/refresh', {
|
const response = await fetch('/api/auth/refresh', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...window.apiHeaders }
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: app.refreshToken })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Refresh failed');
|
throw new Error('Refresh failed');
|
||||||
@@ -463,7 +887,8 @@ async function refreshToken() {
|
|||||||
if (!data || !data.access_token) {
|
if (!data || !data.access_token) {
|
||||||
throw new Error('Invalid refresh response');
|
throw new Error('Invalid refresh response');
|
||||||
}
|
}
|
||||||
setAuthToken(data.access_token);
|
// Handle refresh token rotation if provided
|
||||||
|
setAuthTokens(data.access_token, data.refresh_token || null);
|
||||||
} finally {
|
} finally {
|
||||||
app.refreshInProgress = false;
|
app.refreshInProgress = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,25 +55,25 @@
|
|||||||
|
|
||||||
<!-- Main Navigation Tabs -->
|
<!-- Main Navigation Tabs -->
|
||||||
<ul id="adminTabs" class="flex border-b border-neutral-200 dark:border-neutral-700 mb-6" role="tablist">
|
<ul id="adminTabs" class="flex border-b border-neutral-200 dark:border-neutral-700 mb-6" role="tablist">
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors active" id="overview-tab" data-target="#overview" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors active" id="overview-tab" data-tab-target="#overview" type="button" role="tab">
|
||||||
<i class="fas fa-tachometer-alt"></i> Overview
|
<i class="fas fa-tachometer-alt"></i> Overview
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="users-tab" data-target="#users" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="users-tab" data-tab-target="#users" type="button" role="tab">
|
||||||
<i class="fas fa-users"></i> User Management
|
<i class="fas fa-users"></i> User Management
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="settings-tab" data-target="#settings" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="settings-tab" data-tab-target="#settings" type="button" role="tab">
|
||||||
<i class="fas fa-sliders-h"></i> System Settings
|
<i class="fas fa-sliders-h"></i> System Settings
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="maintenance-tab" data-target="#maintenance" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="maintenance-tab" data-tab-target="#maintenance" type="button" role="tab">
|
||||||
<i class="fas fa-tools"></i> Maintenance
|
<i class="fas fa-tools"></i> Maintenance
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="issues-tab" data-target="#issues" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="issues-tab" data-tab-target="#issues" type="button" role="tab">
|
||||||
<i class="fas fa-bug"></i> Issue Tracking
|
<i class="fas fa-bug"></i> Issue Tracking
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="import-tab" data-target="#import" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="import-tab" data-tab-target="#import" type="button" role="tab">
|
||||||
<i class="fa-solid fa-upload"></i> Data Import
|
<i class="fa-solid fa-upload"></i> Data Import
|
||||||
</li>
|
</li>
|
||||||
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="backup-tab" data-target="#backup" type="button" role="tab">
|
<li class="px-4 py-2 -mb-px border-b-2 border-transparent hover:border-primary-600 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 transition-colors" id="backup-tab" data-tab-target="#backup" type="button" role="tab">
|
||||||
<i class="fas fa-hdd"></i> Backup & Restore
|
<i class="fas fa-hdd"></i> Backup & Restore
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -951,7 +951,7 @@ function initializeTabs() {
|
|||||||
const panes = document.querySelectorAll('#adminTabContent > div[role="tabpanel"]');
|
const panes = document.querySelectorAll('#adminTabContent > div[role="tabpanel"]');
|
||||||
tabs.forEach(tab => {
|
tabs.forEach(tab => {
|
||||||
tab.addEventListener('click', () => {
|
tab.addEventListener('click', () => {
|
||||||
const target = tab.getAttribute('data-target');
|
const target = tab.getAttribute('data-tab-target');
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
// deactivate
|
// deactivate
|
||||||
tabs.forEach(t => t.classList.remove('active'));
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
@@ -996,7 +996,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
loadSettings();
|
loadSettings();
|
||||||
loadLookupTables();
|
loadLookupTables();
|
||||||
loadBackups();
|
loadBackups();
|
||||||
// Tabs setup (no Bootstrap JS)
|
// Tabs setup
|
||||||
initializeTabs();
|
initializeTabs();
|
||||||
|
|
||||||
// Auto-refresh every 5 minutes
|
// Auto-refresh every 5 minutes
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -347,12 +347,7 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle escape key for modal
|
// Escape handling is centralized in keyboard-shortcuts.js
|
||||||
document.addEventListener('keydown', function(event) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
closeShortcutsModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
// Global modal helpers for Tailwind-based modals
|
// Global modal helpers for Tailwind-based modals
|
||||||
@@ -367,20 +362,18 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Custom JavaScript -->
|
<!-- Custom JavaScript -->
|
||||||
|
<!-- Load main.js first so global handlers are registered before other scripts -->
|
||||||
|
<script src="/static/js/main.js"></script>
|
||||||
<script src="/static/js/alerts.js"></script>
|
<script src="/static/js/alerts.js"></script>
|
||||||
<script src="/static/js/keyboard-shortcuts.js"></script>
|
<script src="/static/js/keyboard-shortcuts.js"></script>
|
||||||
<script src="/static/js/main.js"></script>
|
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Initialize keyboard shortcuts on page load
|
// Initialize page meta on load (keyboard shortcuts initialized centrally in main.js)
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
initializeKeyboardShortcuts();
|
|
||||||
updateCurrentPageDisplay();
|
updateCurrentPageDisplay();
|
||||||
updateCurrentYear();
|
updateCurrentYear();
|
||||||
initializeAuthManager();
|
|
||||||
checkUserPermissions();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update current page display in footer
|
// Update current page display in footer
|
||||||
@@ -414,357 +407,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check user permissions and show/hide admin menu
|
// Auth helpers and theme management are centralized in /static/js/main.js
|
||||||
async function checkUserPermissions() {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token || isLoginPage()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const user = await response.json();
|
|
||||||
if (user.is_admin) {
|
|
||||||
// Show admin menu items
|
|
||||||
document.getElementById('admin-menu-item').classList.remove('hidden');
|
|
||||||
document.getElementById('admin-menu-divider').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update user display name if available
|
|
||||||
const userDropdown = document.querySelector('#userDropdown button span');
|
|
||||||
if (user.full_name && userDropdown) {
|
|
||||||
userDropdown.textContent = user.full_name;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking user permissions:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Authentication Manager
|
|
||||||
function initializeAuthManager() {
|
|
||||||
// Check if we have a valid token on page load
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (token && !isLoginPage()) {
|
|
||||||
// Verify token is still valid
|
|
||||||
checkTokenValidity();
|
|
||||||
|
|
||||||
// Set up periodic token refresh (every hour)
|
|
||||||
setInterval(refreshTokenIfNeeded, 3600000); // 1 hour
|
|
||||||
|
|
||||||
// Set up activity monitoring for auto-refresh
|
|
||||||
setupActivityMonitoring();
|
|
||||||
} else if (!isLoginPage() && !token) {
|
|
||||||
// No token and not on login page - redirect to login
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoginPage() {
|
|
||||||
return window.location.pathname === '/login' || window.location.pathname === '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkTokenValidity() {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
// Token is invalid, remove it and redirect to login
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
if (!isLoginPage()) {
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error checking token validity:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTokenIfNeeded() {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try to get a new token
|
|
||||||
const response = await fetch('/api/auth/refresh', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const data = await response.json();
|
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
|
||||||
console.log('Token refreshed successfully');
|
|
||||||
} else {
|
|
||||||
// If refresh fails, check if current token is still valid
|
|
||||||
const isValid = await checkTokenValidity();
|
|
||||||
if (!isValid) {
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error refreshing token:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setupActivityMonitoring() {
|
|
||||||
let lastActivity = Date.now();
|
|
||||||
let warningShown = false;
|
|
||||||
let inactivityWarningMinutes = 240; // default 4 hours
|
|
||||||
const inactivityGraceMinutes = 5; // auto-logout after warning + 5 minutes
|
|
||||||
let inactivityAlertEl = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const minutes = await getInactivityWarningMinutes();
|
|
||||||
if (Number.isFinite(minutes) && minutes > 0) {
|
|
||||||
inactivityWarningMinutes = minutes;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug('Using default inactivity warning minutes');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track user activity
|
|
||||||
const activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
|
||||||
activityEvents.forEach(event => {
|
|
||||||
document.addEventListener(event, () => {
|
|
||||||
lastActivity = Date.now();
|
|
||||||
warningShown = false; // Reset warning flag on activity
|
|
||||||
hideInactivityWarning();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function showInactivityWarning() {
|
|
||||||
hideInactivityWarning();
|
|
||||||
|
|
||||||
const msg = `You've been inactive. Your session may expire due to inactivity.`;
|
|
||||||
if (window.alerts && typeof window.alerts.show === 'function') {
|
|
||||||
inactivityAlertEl = window.alerts.show(msg, 'warning', {
|
|
||||||
title: 'Session Warning',
|
|
||||||
html: false,
|
|
||||||
duration: 0,
|
|
||||||
dismissible: true,
|
|
||||||
id: 'inactivity-warning',
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
label: 'Stay Logged In',
|
|
||||||
classes: 'bg-warning-600 hover:bg-warning-700 text-white text-xs px-3 py-1 rounded',
|
|
||||||
onClick: () => extendSession(),
|
|
||||||
autoClose: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Dismiss',
|
|
||||||
classes: 'bg-neutral-200 hover:bg-neutral-300 text-neutral-800 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-200 text-xs px-3 py-1 rounded',
|
|
||||||
onClick: () => hideInactivityWarning(),
|
|
||||||
autoClose: true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// Fallback
|
|
||||||
alert('Session Warning: ' + msg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-hide after 2 minutes if no action taken
|
|
||||||
setTimeout(() => {
|
|
||||||
hideInactivityWarning();
|
|
||||||
}, 2 * 60 * 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideInactivityWarning() {
|
|
||||||
const el = document.getElementById('inactivity-warning');
|
|
||||||
if (el && el.remove) el.remove();
|
|
||||||
inactivityAlertEl = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function extendSession() {
|
|
||||||
refreshTokenIfNeeded();
|
|
||||||
hideInactivityWarning();
|
|
||||||
showSessionExtendedNotification();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check every 5 minutes for inactivity
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
const warningMs = inactivityWarningMinutes * 60 * 1000;
|
|
||||||
const logoutMs = (inactivityWarningMinutes + inactivityGraceMinutes) * 60 * 1000;
|
|
||||||
const timeSinceActivity = now - lastActivity;
|
|
||||||
|
|
||||||
if (timeSinceActivity > warningMs && !warningShown) {
|
|
||||||
showInactivityWarning();
|
|
||||||
warningShown = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeSinceActivity > logoutMs) {
|
|
||||||
logout('Session expired due to inactivity');
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000); // Check every 5 minutes
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getInactivityWarningMinutes() {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token) return 240;
|
|
||||||
const resp = await fetch('/api/settings/inactivity_warning_minutes', {
|
|
||||||
headers: { 'Authorization': `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (!resp.ok) return 240;
|
|
||||||
const data = await resp.json();
|
|
||||||
if (typeof data.minutes === 'number') return data.minutes;
|
|
||||||
const parsed = parseInt(data.setting_value || data.minutes, 10);
|
|
||||||
return Number.isFinite(parsed) ? parsed : 240;
|
|
||||||
}
|
|
||||||
|
|
||||||
function showSessionExtendedNotification() {
|
|
||||||
if (window.alerts && typeof window.alerts.success === 'function') {
|
|
||||||
window.alerts.success('Your session has been refreshed successfully.', {
|
|
||||||
title: 'Session Extended',
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Fallback
|
|
||||||
const notification = document.createElement('div');
|
|
||||||
notification.className = 'fixed top-4 right-4 bg-green-100 border-l-4 border-green-500 text-green-700 p-4 rounded-lg shadow-lg z-50 max-w-sm';
|
|
||||||
notification.innerHTML = `
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0">
|
|
||||||
<i class="fas fa-check-circle text-green-500"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-3">
|
|
||||||
<p class="text-sm font-medium">Session Extended</p>
|
|
||||||
<p class="text-xs mt-1">Your session has been refreshed successfully.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
document.body.appendChild(notification);
|
|
||||||
setTimeout(() => notification.remove(), 3000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enhanced logout function
|
|
||||||
function logout(reason = null) {
|
|
||||||
localStorage.removeItem('auth_token');
|
|
||||||
if (reason) {
|
|
||||||
// Store logout reason to show on login page
|
|
||||||
sessionStorage.setItem('logout_reason', reason);
|
|
||||||
}
|
|
||||||
window.location.href = '/login';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme Management
|
|
||||||
function toggleTheme() {
|
|
||||||
const html = document.documentElement;
|
|
||||||
const isDark = html.classList.contains('dark');
|
|
||||||
|
|
||||||
if (isDark) {
|
|
||||||
html.classList.remove('dark');
|
|
||||||
localStorage.setItem('theme-preference', 'light');
|
|
||||||
saveThemePreference('light');
|
|
||||||
} else {
|
|
||||||
html.classList.add('dark');
|
|
||||||
localStorage.setItem('theme-preference', 'dark');
|
|
||||||
saveThemePreference('dark');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initializeTheme() {
|
|
||||||
// Check for saved theme preference
|
|
||||||
const savedTheme = localStorage.getItem('theme-preference');
|
|
||||||
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
|
||||||
|
|
||||||
// Use saved theme, or default to system preference
|
|
||||||
const theme = savedTheme || (prefersDark ? 'dark' : 'light');
|
|
||||||
|
|
||||||
if (theme === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load user's theme preference from server if authenticated
|
|
||||||
loadUserThemePreference();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveThemePreference(theme) {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token || isLoginPage()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await fetch('/api/auth/theme-preference', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ theme_preference: theme })
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Could not save theme preference to server:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadUserThemePreference() {
|
|
||||||
const token = localStorage.getItem('auth_token');
|
|
||||||
if (!token || isLoginPage()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/auth/me', {
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Bearer ${token}`
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const user = await response.json();
|
|
||||||
if (user.theme_preference) {
|
|
||||||
if (user.theme_preference === 'dark') {
|
|
||||||
document.documentElement.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
document.documentElement.classList.remove('dark');
|
|
||||||
}
|
|
||||||
localStorage.setItem('theme-preference', user.theme_preference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Could not load theme preference from server:', error.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize theme before other scripts
|
|
||||||
initializeTheme();
|
|
||||||
|
|
||||||
// Make functions globally available
|
|
||||||
window.authManager = {
|
|
||||||
checkTokenValidity,
|
|
||||||
refreshTokenIfNeeded,
|
|
||||||
logout
|
|
||||||
};
|
|
||||||
|
|
||||||
window.themeManager = {
|
|
||||||
toggleTheme,
|
|
||||||
initializeTheme,
|
|
||||||
saveThemePreference,
|
|
||||||
loadUserThemePreference
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -435,9 +435,25 @@
|
|||||||
<script>
|
<script>
|
||||||
// Document Management JavaScript
|
// Document Management JavaScript
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Initialize page
|
// Check authentication first
|
||||||
loadTemplates();
|
const token = localStorage.getItem('auth_token');
|
||||||
loadQdros();
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for API helpers to be ready, then initialize
|
||||||
|
function initializeDocuments() {
|
||||||
|
if (typeof apiGet === 'function') {
|
||||||
|
// Ensure API headers are set up with token
|
||||||
|
if (window.apiHeaders && token) {
|
||||||
|
window.apiHeaders['Authorization'] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the first tab as active
|
||||||
|
document.getElementById('templates-tab').click();
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
loadCategories();
|
loadCategories();
|
||||||
|
|
||||||
// Set up keyboard shortcuts
|
// Set up keyboard shortcuts
|
||||||
@@ -448,12 +464,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
// Auto-refresh every 30 seconds
|
||||||
setInterval(function() {
|
setInterval(function() {
|
||||||
if (document.querySelector('#templates-tab').classList.contains('active')) {
|
const templatesTab = document.querySelector('#templates-tab');
|
||||||
|
const qdrosTab = document.querySelector('#qdros-tab');
|
||||||
|
|
||||||
|
if (templatesTab.classList.contains('text-blue-500')) {
|
||||||
loadTemplates();
|
loadTemplates();
|
||||||
} else if (document.querySelector('#qdros-tab').classList.contains('active')) {
|
} else if (qdrosTab.classList.contains('text-blue-500')) {
|
||||||
loadQdros();
|
loadQdros();
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
} else {
|
||||||
|
// API helpers not ready yet, try again in 100ms
|
||||||
|
setTimeout(initializeDocuments, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeDocuments();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setupKeyboardShortcuts() {
|
function setupKeyboardShortcuts() {
|
||||||
@@ -568,8 +594,18 @@ function setupEventHandlers() {
|
|||||||
document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros);
|
document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function for authenticated API calls
|
||||||
|
function getAuthHeaders() {
|
||||||
|
const token = localStorage.getItem('auth_token');
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function loadTemplates() {
|
async function loadTemplates() {
|
||||||
try {
|
try {
|
||||||
|
console.log('🔍 DEBUG: loadTemplates() called');
|
||||||
const search = document.getElementById('templateSearch').value;
|
const search = document.getElementById('templateSearch').value;
|
||||||
const category = document.getElementById('categoryFilter').value;
|
const category = document.getElementById('categoryFilter').value;
|
||||||
|
|
||||||
@@ -577,14 +613,34 @@ async function loadTemplates() {
|
|||||||
if (search) url += `search=${encodeURIComponent(search)}&`;
|
if (search) url += `search=${encodeURIComponent(search)}&`;
|
||||||
if (category) url += `category=${encodeURIComponent(category)}&`;
|
if (category) url += `category=${encodeURIComponent(category)}&`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
// Ensure we have auth token for this API call
|
||||||
if (!response.ok) throw new Error('Failed to load templates');
|
const token = localStorage.getItem('auth_token');
|
||||||
|
console.log('🔍 DEBUG: Token exists:', !!token, 'Length:', token?.length);
|
||||||
|
if (!token) {
|
||||||
|
console.log('🔍 DEBUG: No token found, redirecting to login');
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 DEBUG: Making API call to:', url);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
console.log('🔍 DEBUG: Response status:', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const templates = await response.json();
|
const templates = await response.json();
|
||||||
|
console.log('🔍 DEBUG: Templates loaded:', templates.length, 'items');
|
||||||
displayTemplates(templates);
|
displayTemplates(templates);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading templates:', error);
|
console.error('🔍 DEBUG: Error in loadTemplates:', error);
|
||||||
showAlert('Error loading templates: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error loading templates', action: 'loadTemplates', error }); } catch (_) {}
|
||||||
|
showAlert('Error loading templates: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,14 +690,27 @@ async function loadQdros() {
|
|||||||
if (search) url += `search=${encodeURIComponent(search)}&`;
|
if (search) url += `search=${encodeURIComponent(search)}&`;
|
||||||
if (status) url += `status_filter=${encodeURIComponent(status)}&`;
|
if (status) url += `status_filter=${encodeURIComponent(status)}&`;
|
||||||
|
|
||||||
const response = await fetch(url);
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!response.ok) throw new Error('Failed to load QDROs');
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const qdros = await response.json();
|
const qdros = await response.json();
|
||||||
displayQdros(qdros);
|
displayQdros(qdros);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading QDROs:', error);
|
console.error('Error loading QDROs:', error);
|
||||||
showAlert('Error loading QDROs: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error loading QDROs', action: 'loadQdros', error }); } catch (_) {}
|
||||||
|
showAlert('Error loading QDROs: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,10 +762,23 @@ function getStatusBadgeClass(status) {
|
|||||||
|
|
||||||
async function loadCategories() {
|
async function loadCategories() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents/categories/');
|
const token = localStorage.getItem('auth_token');
|
||||||
if (!response.ok) throw new Error('Failed to load categories');
|
if (!token) {
|
||||||
|
window.location.href = '/login';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/documents/categories/', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||||
|
throw new Error(errorData.detail || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
const categories = await response.json();
|
const categories = await response.json();
|
||||||
|
|
||||||
const select = document.getElementById('categoryFilter');
|
const select = document.getElementById('categoryFilter');
|
||||||
const templateSelect = document.getElementById('templateCategory');
|
const templateSelect = document.getElementById('templateCategory');
|
||||||
|
|
||||||
@@ -711,6 +793,7 @@ async function loadCategories() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading categories:', error);
|
console.error('Error loading categories:', error);
|
||||||
|
try { logClientError({ message: 'Error loading categories', action: 'loadCategories', error }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -736,7 +819,9 @@ function openTemplateModal(templateId = null) {
|
|||||||
|
|
||||||
async function loadTemplateForEditing(templateId) {
|
async function loadTemplateForEditing(templateId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/documents/templates/${templateId}`);
|
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Failed to load template');
|
if (!response.ok) throw new Error('Failed to load template');
|
||||||
|
|
||||||
const template = await response.json();
|
const template = await response.json();
|
||||||
@@ -750,7 +835,8 @@ async function loadTemplateForEditing(templateId) {
|
|||||||
updateVariableCount();
|
updateVariableCount();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading template:', error);
|
console.error('Error loading template:', error);
|
||||||
showAlert('Error loading template: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error loading template for edit', action: 'loadTemplateForEditing', error, extra: { templateId } }); } catch (_) {}
|
||||||
|
showAlert('Error loading template: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +874,8 @@ async function saveTemplate() {
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving template:', error);
|
console.error('Error saving template:', error);
|
||||||
showAlert('Error saving template: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error saving template', action: 'saveTemplate', error }); } catch (_) {}
|
||||||
|
showAlert('Error saving template: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -814,7 +901,9 @@ function extractVariables(content) {
|
|||||||
|
|
||||||
async function loadDocumentStats() {
|
async function loadDocumentStats() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents/stats/summary');
|
const response = await fetch('/api/documents/stats/summary', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Failed to load statistics');
|
if (!response.ok) throw new Error('Failed to load statistics');
|
||||||
|
|
||||||
const stats = await response.json();
|
const stats = await response.json();
|
||||||
@@ -855,7 +944,8 @@ async function loadDocumentStats() {
|
|||||||
openModal('statsModal');
|
openModal('statsModal');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading statistics:', error);
|
console.error('Error loading statistics:', error);
|
||||||
showAlert('Error loading statistics: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error loading statistics', action: 'loadDocumentStats', error }); } catch (_) {}
|
||||||
|
showAlert('Error loading statistics: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -882,6 +972,27 @@ function showAlert(message, type = 'info') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lightweight client error logger specific to Documents page
|
||||||
|
async function logClientError({ message, action = null, error = null, extra = null }) {
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
message: String(message || (error && error.message) || 'Unknown error'),
|
||||||
|
action,
|
||||||
|
stack: error && error.stack ? String(error.stack) : null,
|
||||||
|
url: window.location.href,
|
||||||
|
user_agent: navigator.userAgent,
|
||||||
|
extra
|
||||||
|
};
|
||||||
|
await fetch('/api/documents/client-error', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// swallow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Placeholder functions for additional features
|
// Placeholder functions for additional features
|
||||||
async function editTemplate(templateId) {
|
async function editTemplate(templateId) {
|
||||||
openTemplateModal(templateId);
|
openTemplateModal(templateId);
|
||||||
@@ -900,7 +1011,8 @@ async function deleteTemplate(templateId) {
|
|||||||
if (confirm('Are you sure you want to delete this template?')) {
|
if (confirm('Are you sure you want to delete this template?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
headers: getAuthHeaders()
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete template');
|
if (!response.ok) throw new Error('Failed to delete template');
|
||||||
@@ -909,7 +1021,8 @@ async function deleteTemplate(templateId) {
|
|||||||
loadTemplates();
|
loadTemplates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting template:', error);
|
console.error('Error deleting template:', error);
|
||||||
showAlert('Error deleting template: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error deleting template', action: 'deleteTemplate', error, extra: { templateId } }); } catch (_) {}
|
||||||
|
showAlert('Error deleting template: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -921,7 +1034,9 @@ function openGenerateModal() {
|
|||||||
|
|
||||||
async function loadTemplatesForGeneration() {
|
async function loadTemplatesForGeneration() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/documents/templates/');
|
const response = await fetch('/api/documents/templates/', {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Failed to load templates');
|
if (!response.ok) throw new Error('Failed to load templates');
|
||||||
|
|
||||||
const templates = await response.json();
|
const templates = await response.json();
|
||||||
@@ -936,18 +1051,22 @@ async function loadTemplatesForGeneration() {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading templates:', error);
|
console.error('Error loading templates:', error);
|
||||||
|
try { logClientError({ message: 'Error loading templates for generation', action: 'loadTemplatesForGeneration', error }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadTemplatePreview(templateId) {
|
async function loadTemplatePreview(templateId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/documents/templates/${templateId}`);
|
const response = await fetch(`/api/documents/templates/${templateId}`, {
|
||||||
|
headers: getAuthHeaders()
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error('Failed to load template');
|
if (!response.ok) throw new Error('Failed to load template');
|
||||||
|
|
||||||
const template = await response.json();
|
const template = await response.json();
|
||||||
document.getElementById('templatePreview').value = template.content;
|
document.getElementById('templatePreview').value = template.content;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading template preview:', error);
|
console.error('Error loading template preview:', error);
|
||||||
|
try { logClientError({ message: 'Error loading template preview', action: 'loadTemplatePreview', error, extra: { templateId } }); } catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,9 +1102,7 @@ async function generateDocument() {
|
|||||||
|
|
||||||
const response = await fetch(`/api/documents/generate/${templateId}`, {
|
const response = await fetch(`/api/documents/generate/${templateId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: getAuthHeaders(),
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestData)
|
body: JSON.stringify(requestData)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1005,7 +1122,8 @@ async function generateDocument() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating document:', error);
|
console.error('Error generating document:', error);
|
||||||
showAlert('Error generating document: ' + error.message, 'danger');
|
try { logClientError({ message: 'Error generating document', action: 'generateDocument', error, extra: { templateId: (document.getElementById('generateTemplate')?.value || null) } }); } catch (_) {}
|
||||||
|
showAlert('Error generating document: ' + (error && error.message ? error.message : 'Unknown error'), 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,5 +1180,36 @@ async function deleteQdro(qdroId) {
|
|||||||
showAlert('QDRO delete functionality will be implemented', 'info');
|
showAlert('QDRO delete functionality will be implemented', 'info');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Tab navigation functionality
|
||||||
|
function openTab(evt, tabName) {
|
||||||
|
// Declare all variables
|
||||||
|
var i, tabcontent, tablinks;
|
||||||
|
|
||||||
|
// Get all elements with class="tabcontent" and hide them
|
||||||
|
tabcontent = document.getElementsByClassName("tabcontent");
|
||||||
|
for (i = 0; i < tabcontent.length; i++) {
|
||||||
|
tabcontent[i].classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all elements with class="tablinks" and remove the "active" class
|
||||||
|
tablinks = document.querySelectorAll('nav button');
|
||||||
|
for (i = 0; i < tablinks.length; i++) {
|
||||||
|
tablinks[i].classList.remove('border-blue-500', 'text-blue-500', 'dark:text-blue-400');
|
||||||
|
tablinks[i].classList.add('border-transparent');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show the current tab, and add an "active" class to the button that opened the tab
|
||||||
|
document.getElementById(tabName).classList.remove('hidden');
|
||||||
|
evt.currentTarget.classList.add('border-blue-500', 'text-blue-500', 'dark:text-blue-400');
|
||||||
|
evt.currentTarget.classList.remove('border-transparent');
|
||||||
|
|
||||||
|
// Load data for the active tab
|
||||||
|
if (tabName === 'templates') {
|
||||||
|
loadTemplates();
|
||||||
|
} else if (tabName === 'qdros') {
|
||||||
|
loadQdros();
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en" data-theme="light">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -124,8 +124,11 @@
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log('Login successful, token:', data.access_token);
|
console.log('Login successful, token:', data.access_token);
|
||||||
|
|
||||||
// Store token
|
// Store tokens
|
||||||
localStorage.setItem('auth_token', data.access_token);
|
localStorage.setItem('auth_token', data.access_token);
|
||||||
|
if (data.refresh_token) {
|
||||||
|
localStorage.setItem('refresh_token', data.refresh_token);
|
||||||
|
}
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
showAlert('Login successful! Redirecting...', 'success');
|
showAlert('Login successful! Redirecting...', 'success');
|
||||||
|
|||||||
@@ -3,11 +3,29 @@
|
|||||||
Test script for the customers module
|
Test script for the customers module
|
||||||
"""
|
"""
|
||||||
import requests
|
import requests
|
||||||
|
import pytest
|
||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
BASE_URL = "http://localhost:6920"
|
BASE_URL = "http://localhost:6920"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def token():
|
||||||
|
"""Obtain an access token from the running server, or skip if unavailable."""
|
||||||
|
try:
|
||||||
|
response = requests.post(f"{BASE_URL}/api/auth/login", json={
|
||||||
|
"username": "admin",
|
||||||
|
"password": "admin123"
|
||||||
|
}, timeout=3)
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
if data and data.get("access_token"):
|
||||||
|
return data["access_token"]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pytest.skip("Auth server not available; skipping integration tests")
|
||||||
|
|
||||||
def test_auth():
|
def test_auth():
|
||||||
"""Test authentication"""
|
"""Test authentication"""
|
||||||
print("🔐 Testing authentication...")
|
print("🔐 Testing authentication...")
|
||||||
|
|||||||
87
tests/test_auth.py
Normal file
87
tests/test_auth.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from jose import jwt
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.config import Settings, settings
|
||||||
|
from app.database.base import Base
|
||||||
|
from app.models.user import User
|
||||||
|
from app.auth.security import (
|
||||||
|
create_access_token,
|
||||||
|
create_refresh_token,
|
||||||
|
decode_refresh_token,
|
||||||
|
is_refresh_token_revoked,
|
||||||
|
revoke_refresh_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_env_precedence(monkeypatch):
|
||||||
|
# Ensure env var overrides .env/default
|
||||||
|
monkeypatch.setenv("SECRET_KEY", "env_secret_value_12345678901234567890123456789012")
|
||||||
|
cfg = Settings()
|
||||||
|
assert cfg.secret_key == "env_secret_value_12345678901234567890123456789012"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jwt_rotation_decode(monkeypatch):
|
||||||
|
# Simulate key rotation: token signed with previous key should validate
|
||||||
|
old_key = "old_secret_value_12345678901234567890123456789012"
|
||||||
|
new_key = "new_secret_value_12345678901234567890123456789012"
|
||||||
|
|
||||||
|
# Patch runtime settings
|
||||||
|
settings.previous_secret_key = old_key
|
||||||
|
settings.secret_key = new_key
|
||||||
|
|
||||||
|
# Sign token with old key
|
||||||
|
payload = {
|
||||||
|
"sub": "tester",
|
||||||
|
"exp": datetime.utcnow() + timedelta(minutes=5),
|
||||||
|
"iat": datetime.utcnow(),
|
||||||
|
"type": "access",
|
||||||
|
}
|
||||||
|
token = jwt.encode(payload, old_key, algorithm=settings.algorithm)
|
||||||
|
|
||||||
|
# Verify using public API verify_token via access token creation roundtrip
|
||||||
|
# Using internal decode through create_access_token is indirect; ensure no exception
|
||||||
|
from app.auth.security import verify_token
|
||||||
|
|
||||||
|
username = verify_token(token)
|
||||||
|
assert username == "tester"
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_token_lifecycle(tmp_path):
|
||||||
|
# Build isolated in-memory database
|
||||||
|
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
|
||||||
|
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
db = TestingSessionLocal()
|
||||||
|
try:
|
||||||
|
# Create a user
|
||||||
|
user = User(username="alice", email="a@example.com", hashed_password="x", is_active=True)
|
||||||
|
db.add(user)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
# Issue refresh token
|
||||||
|
rtoken = create_refresh_token(user=user, user_agent="pytest", ip_address="127.0.0.1", db=db)
|
||||||
|
payload = decode_refresh_token(rtoken)
|
||||||
|
assert payload is not None
|
||||||
|
jti = payload["jti"]
|
||||||
|
assert not is_refresh_token_revoked(jti, db)
|
||||||
|
|
||||||
|
# Revoke and assert
|
||||||
|
revoke_refresh_token(jti, db)
|
||||||
|
assert is_refresh_token_revoked(jti, db)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
82
tests/test_error_handling.py
Normal file
82
tests/test_error_handling.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import json
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.middleware.logging import LoggingMiddleware
|
||||||
|
from app.middleware.errors import register_exception_handlers
|
||||||
|
|
||||||
|
|
||||||
|
class Item(BaseModel):
|
||||||
|
name: str = Field(..., min_length=3)
|
||||||
|
quantity: int = Field(..., ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
def build_test_app() -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.add_middleware(LoggingMiddleware, log_requests=False, log_responses=False)
|
||||||
|
register_exception_handlers(app)
|
||||||
|
|
||||||
|
@app.get("/http-error")
|
||||||
|
async def http_error():
|
||||||
|
raise HTTPException(status_code=403, detail="Forbidden action")
|
||||||
|
|
||||||
|
@app.post("/validation")
|
||||||
|
async def validation_endpoint(item: Item): # noqa: F841
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
@app.get("/crash")
|
||||||
|
async def crash():
|
||||||
|
raise RuntimeError("Boom")
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def assert_envelope(resp, status: int, code: str, has_details: bool, expected_cid: Optional[str] = None):
|
||||||
|
assert resp.status_code == status
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert data["error"]["status"] == status
|
||||||
|
assert data["error"]["code"] == code
|
||||||
|
assert isinstance(data["error"]["message"], str) and data["error"]["message"]
|
||||||
|
if has_details:
|
||||||
|
assert "details" in data["error"]
|
||||||
|
else:
|
||||||
|
assert "details" not in data["error"]
|
||||||
|
|
||||||
|
# Correlation id in body and header
|
||||||
|
assert "correlation_id" in data and isinstance(data["correlation_id"], str)
|
||||||
|
header_cid = resp.headers.get("X-Correlation-ID")
|
||||||
|
assert header_cid == data["correlation_id"]
|
||||||
|
if expected_cid is not None:
|
||||||
|
assert header_cid == expected_cid
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_exception_envelope_and_correlation_id_echo():
|
||||||
|
app = build_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
cid = "abc-12345-test"
|
||||||
|
resp = client.get("/http-error", headers={"X-Correlation-ID": cid})
|
||||||
|
assert_envelope(resp, 403, "http_error", has_details=False, expected_cid=cid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_exception_envelope_and_correlation_id_echo():
|
||||||
|
app = build_test_app()
|
||||||
|
client = TestClient(app)
|
||||||
|
cid = "cid-validation-67890"
|
||||||
|
# Missing fields to trigger 422
|
||||||
|
resp = client.post("/validation", json={"name": "ab"}, headers={"X-Correlation-ID": cid})
|
||||||
|
assert_envelope(resp, 422, "validation_error", has_details=True, expected_cid=cid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unhandled_exception_envelope_and_generated_correlation_id():
|
||||||
|
app = build_test_app()
|
||||||
|
client = TestClient(app, raise_server_exceptions=False)
|
||||||
|
resp = client.get("/crash")
|
||||||
|
# Should have generated a correlation id and not echo None
|
||||||
|
assert_envelope(resp, 500, "internal_error", has_details=False)
|
||||||
|
assert resp.headers.get("X-Correlation-ID")
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user