chore: merge main into notifications branch
Resolve conflicts in app/auth.py and app/api/admin_users.py: - auth.py: combine admin-aware profile creation (from main, adding is_complimentary/highest-tier defaults for admins) with signup notification/webhook (from our branch). Admin users skip the signup notification since they are the ones being notified. - admin_users.py: combine is_complimentary assignment (from main) with tier_changed/new_tier tracking variables (from our branch) to fire plan-change notifications when an admin updates a user.
This commit is contained in:
@@ -133,6 +133,11 @@ ADMIN_GROUP_NAME=admin
|
||||
# When enabled, each user has their own document space with isolated uploads,
|
||||
# search, and file management. Requires AUTH_ENABLED=true.
|
||||
MULTI_USER_ENABLED=false
|
||||
# Allow users to self-register with an email address and password.
|
||||
# Set to true to enable the /signup page. Requires MULTI_USER_ENABLED=true.
|
||||
# When SMTP is configured, a verification email is sent before the account is activated.
|
||||
# Without SMTP, accounts are activated immediately upon registration.
|
||||
# ALLOW_LOCAL_SIGNUP=false
|
||||
# Default upload limit per user per day (0 = unlimited)
|
||||
DEFAULT_DAILY_UPLOAD_LIMIT=0
|
||||
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2026-03-07T17:41:57Z
|
||||
2026-03-07T20:45:41Z
|
||||
|
||||
@@ -10,6 +10,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
<!-- version list -->
|
||||
|
||||
## v0.86.0 (2026-03-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **ui**: Address code review feedback on complimentary badge and aria attributes
|
||||
([`064ba72`](https://github.com/christianlouis/DocuElevate/commit/064ba72d361215c1370f0d3fd81ac04ff1624d2e))
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Auto-create admin user profiles with highest tier and complimentary flag
|
||||
([`97f85ce`](https://github.com/christianlouis/DocuElevate/commit/97f85ce74ed5d7970f330c923c7ae60ec299b7fd))
|
||||
|
||||
|
||||
## v0.85.0 (2026-03-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Restore get_user function body lost in refactor; fix button period placement
|
||||
([`19c1ccb`](https://github.com/christianlouis/DocuElevate/commit/19c1ccb11c10698259fa01b408979b4fac158cd5))
|
||||
|
||||
- **ui**: Update plan descriptions to reflect per-user pricing
|
||||
([`9d11d74`](https://github.com/christianlouis/DocuElevate/commit/9d11d741f4e6c0f29529d7dafc93980eea955aeb))
|
||||
|
||||
### Features
|
||||
|
||||
- **auth**: Enable local user signup without SMTP, add admin user creation
|
||||
([`aa6e2fe`](https://github.com/christianlouis/DocuElevate/commit/aa6e2fe00157ed924d42ae305c932b04ad2c1a81))
|
||||
|
||||
|
||||
## v0.84.0 (2026-03-07)
|
||||
|
||||
### Features
|
||||
|
||||
- **ui**: Show marketing landing page for unauthenticated multi-user visitors
|
||||
([`68e8af9`](https://github.com/christianlouis/DocuElevate/commit/68e8af95545b3cda94e1b84383fc42ae584705b7))
|
||||
|
||||
|
||||
## v0.83.0 (2026-03-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+6
-6
@@ -1,10 +1,10 @@
|
||||
DocuElevate Build Information
|
||||
==============================
|
||||
Version: 0.83.0
|
||||
Build Date: 2026-03-07T17:41:57Z
|
||||
Git Commit: 5b4c8cdb608c90769f5c26673d0ebff7c4b4b36c
|
||||
Git Short SHA: 5b4c8cd
|
||||
Version: 0.86.0
|
||||
Build Date: 2026-03-07T20:45:41Z
|
||||
Git Commit: 93b4dcf641ce830405396b955af929a352920f1d
|
||||
Git Short SHA: 93b4dcf
|
||||
Git Branch: main
|
||||
Commit Date: 2026-03-07T18:41:35+01:00
|
||||
Build Timestamp: 2026-03-07T17:41:57Z
|
||||
Commit Date: 2026-03-07T21:45:21+01:00
|
||||
Build Timestamp: 2026-03-07T20:45:41Z
|
||||
==============================
|
||||
|
||||
+143
-1
@@ -2,6 +2,8 @@
|
||||
|
||||
Provides CRUD operations for user profiles and aggregate statistics so that
|
||||
administrators can inspect, configure, and manage users in multi-user mode.
|
||||
Also provides endpoints for admins to create and manage local (email/password)
|
||||
user accounts directly, without requiring email verification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,7 +16,8 @@ from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import FileRecord, UserProfile
|
||||
from app.models import FileRecord, LocalUser, UserProfile
|
||||
from app.utils.local_auth import hash_password
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
||||
@@ -59,6 +62,11 @@ class UserProfileUpsert(BaseModel):
|
||||
subscription_billing_cycle: str = Field(default="monthly", pattern="^(monthly|yearly)$")
|
||||
subscription_period_start: datetime | None = None
|
||||
allow_overage: bool = False
|
||||
is_complimentary: bool = Field(
|
||||
default=False,
|
||||
description="When True the user is on a complimentary (uncharged) plan — they keep all tier "
|
||||
"quota benefits but are never billed via Stripe.",
|
||||
)
|
||||
|
||||
|
||||
class PaymentIssueBody(BaseModel):
|
||||
@@ -80,6 +88,7 @@ class UserProfileResponse(BaseModel):
|
||||
subscription_billing_cycle: str
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
created_at: str | None
|
||||
updated_at: str | None
|
||||
|
||||
@@ -98,11 +107,36 @@ class UserSummary(BaseModel):
|
||||
subscription_billing_cycle: str | None
|
||||
subscription_period_start: str | None
|
||||
allow_overage: bool
|
||||
is_complimentary: bool
|
||||
profile_id: int | None
|
||||
document_count: int
|
||||
last_upload: str | None
|
||||
|
||||
|
||||
class LocalUserCreate(BaseModel):
|
||||
"""Body for admin-creating a local (email/password) user account."""
|
||||
|
||||
email: str = Field(..., max_length=255, description="Email address for the new user")
|
||||
username: str = Field(..., min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_-]+$")
|
||||
display_name: str | None = Field(default=None, max_length=255)
|
||||
password: str = Field(..., min_length=8, max_length=128)
|
||||
is_admin: bool = Field(default=False, description="Grant admin privileges")
|
||||
|
||||
|
||||
class LocalUserResponse(BaseModel):
|
||||
"""Summary of a local user account."""
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
display_name: str | None
|
||||
is_active: bool
|
||||
is_admin: bool
|
||||
created_at: str | None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -127,6 +161,7 @@ def _profile_to_dict(profile: UserProfile) -> dict[str, Any]:
|
||||
if profile.subscription_period_start
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage),
|
||||
"is_complimentary": bool(profile.is_complimentary),
|
||||
"created_at": profile.created_at.isoformat() if profile.created_at else None,
|
||||
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
|
||||
}
|
||||
@@ -200,6 +235,7 @@ def list_users(
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_row.doc_count if doc_row else 0,
|
||||
"last_upload": doc_row.last_upload.isoformat() if (doc_row and doc_row.last_upload) else None,
|
||||
@@ -215,6 +251,110 @@ def list_users(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local user management (admin-only)
|
||||
# ---------------------------------------------------------------------------
|
||||
# NOTE: These routes MUST be defined before /{user_id:path} to avoid being
|
||||
# swallowed by the catch-all path parameter.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/local", summary="List all local (email/password) user accounts")
|
||||
def list_local_users(db: DbSession, _admin: AdminUser) -> list[dict[str, Any]]:
|
||||
"""Return every local user account with basic metadata."""
|
||||
users = db.query(LocalUser).order_by(LocalUser.created_at.desc()).all()
|
||||
return [
|
||||
{
|
||||
"id": u.id,
|
||||
"email": u.email,
|
||||
"username": u.username,
|
||||
"display_name": u.display_name,
|
||||
"is_active": u.is_active,
|
||||
"is_admin": u.is_admin,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@router.post("/local", status_code=status.HTTP_201_CREATED, summary="Create a local user account")
|
||||
def create_local_user(body: LocalUserCreate, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Create a new local (email/password) user account.
|
||||
|
||||
The account is immediately active — no email verification is required when
|
||||
created by an administrator. A matching UserProfile row is also created.
|
||||
|
||||
Raises:
|
||||
409: Email or username already registered.
|
||||
"""
|
||||
if db.query(LocalUser).filter(LocalUser.email == body.email).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered.")
|
||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=True,
|
||||
is_admin=body.is_admin,
|
||||
)
|
||||
db.add(user)
|
||||
|
||||
# Ensure a UserProfile exists for the new user
|
||||
if not db.query(UserProfile).filter(UserProfile.user_id == body.email).first():
|
||||
db.add(UserProfile(user_id=body.email, display_name=body.display_name or body.username))
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Admin created local user account: %s", body.email)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"is_active": user.is_active,
|
||||
"is_admin": user.is_admin,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/local/{local_user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
summary="Delete a local user account",
|
||||
)
|
||||
def delete_local_user(local_user_id: int, db: DbSession, _admin: AdminUser) -> None:
|
||||
"""Delete a local user account by its numeric ID.
|
||||
|
||||
The associated UserProfile is also removed. Documents owned by this user
|
||||
are **not** deleted.
|
||||
"""
|
||||
user = db.query(LocalUser).filter(LocalUser.id == local_user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local user not found.")
|
||||
|
||||
# Remove associated profile if present
|
||||
profile = db.query(UserProfile).filter(UserProfile.user_id == user.email).first()
|
||||
if profile:
|
||||
db.delete(profile)
|
||||
|
||||
try:
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
logger.info("Admin deleted local user account: %s", user.email)
|
||||
|
||||
|
||||
@router.get("/{user_id:path}", summary="Get details for a single user")
|
||||
def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
"""Return profile and document statistics for a specific user."""
|
||||
@@ -241,6 +381,7 @@ def get_user(user_id: str, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
|
||||
if (profile and profile.subscription_period_start)
|
||||
else None,
|
||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
||||
"is_complimentary": bool(profile.is_complimentary) if profile else False,
|
||||
"profile_id": profile.id if profile else None,
|
||||
"document_count": doc_count,
|
||||
"last_upload": last_upload,
|
||||
@@ -272,6 +413,7 @@ def upsert_user_profile(
|
||||
profile.subscription_billing_cycle = body.subscription_billing_cycle
|
||||
profile.subscription_period_start = body.subscription_period_start
|
||||
profile.allow_overage = body.allow_overage
|
||||
profile.is_complimentary = body.is_complimentary
|
||||
tier_changed = False
|
||||
new_tier: str | None = None
|
||||
if body.subscription_tier is not None:
|
||||
|
||||
+50
-32
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
|
||||
|
||||
|
||||
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
|
||||
"""Create a new local user account and send a verification email.
|
||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
|
||||
"""Create a new local user account.
|
||||
|
||||
When SMTP is configured the account is inactive until the user clicks the
|
||||
verification link sent to their email. When SMTP is **not** configured the
|
||||
account is activated immediately so that deployments without email can still
|
||||
use the self-registration flow.
|
||||
|
||||
The account is inactive until the user clicks the email link.
|
||||
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
|
||||
|
||||
Raises:
|
||||
403: Multi-user mode or local signup is disabled.
|
||||
503: SMTP is not configured.
|
||||
422: Passwords do not match.
|
||||
409: Email or username already registered.
|
||||
"""
|
||||
@@ -144,11 +147,6 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
|
||||
if not settings.allow_local_signup:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
|
||||
if not settings.email_host:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Email (SMTP) must be configured before local signup can be enabled.",
|
||||
)
|
||||
if body.password != body.password_confirm:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
|
||||
|
||||
@@ -157,16 +155,30 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
smtp_configured = bool(settings.email_host)
|
||||
|
||||
if smtp_configured:
|
||||
token = generate_token()
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=False,
|
||||
email_verification_token=token,
|
||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
||||
)
|
||||
else:
|
||||
# No SMTP configured — activate the account immediately.
|
||||
token = None
|
||||
user = LocalUser(
|
||||
email=body.email,
|
||||
username=body.username,
|
||||
display_name=body.display_name,
|
||||
hashed_password=hash_password(body.password),
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
|
||||
profile = UserProfile(
|
||||
@@ -185,22 +197,28 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
||||
db.rollback()
|
||||
raise
|
||||
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
|
||||
) from exc
|
||||
if smtp_configured and token:
|
||||
base_url = str(request.base_url).rstrip("/")
|
||||
try:
|
||||
send_verification_email(body.email, body.username, token, base_url)
|
||||
except Exception as exc:
|
||||
# Email failed — roll back so no unverifiable user row persists.
|
||||
# The user can simply try registering again once SMTP is fixed.
|
||||
db.rollback()
|
||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=(
|
||||
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
|
||||
),
|
||||
) from exc
|
||||
|
||||
db.commit()
|
||||
logger.info("New local user registered: %s", body.email)
|
||||
return {"message": "Verification email sent. Please check your inbox."}
|
||||
|
||||
if smtp_configured:
|
||||
return {"message": "Verification email sent. Please check your inbox.", "email_verification_required": True}
|
||||
return {"message": "Account created successfully. You can now log in.", "email_verification_required": False}
|
||||
|
||||
|
||||
@router.get("/verify-email", include_in_schema=False)
|
||||
|
||||
+73
-24
@@ -127,18 +127,35 @@ async def oauth_login(request: Request):
|
||||
return await oauth.authentik.authorize_redirect(request, redirect_uri)
|
||||
|
||||
|
||||
def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
"""Create a UserProfile row for *user_data* if one does not yet exist.
|
||||
def _ensure_user_profile(db: Session, user_data: dict, is_admin: bool = False) -> None:
|
||||
"""Create or update a UserProfile row for *user_data*.
|
||||
|
||||
Uses the same identifier priority as ``get_current_owner_id`` (sub →
|
||||
preferred_username → email → id) so that the profile's ``user_id`` matches
|
||||
``FileRecord.owner_id`` for every document the user uploads.
|
||||
|
||||
If a profile already exists it is left unchanged; only missing profiles
|
||||
are created so that admin-managed settings (tier, limits, etc.) are
|
||||
preserved across logins.
|
||||
For regular users, an existing profile is left unchanged so that
|
||||
admin-managed settings (tier, limits, etc.) are preserved across logins.
|
||||
|
||||
For admin users (*is_admin=True*) the following rules apply:
|
||||
- If no profile exists: one is created with the highest subscription tier,
|
||||
``is_complimentary=True``, and ``onboarding_completed=True`` so that
|
||||
admins skip the first-time setup wizard.
|
||||
- If a profile already exists: ``is_complimentary`` is set to ``True``
|
||||
and, when the current tier is ``"free"``, the tier is upgraded to the
|
||||
highest available plan. Other admin-managed settings are left intact.
|
||||
|
||||
Args:
|
||||
db: Active database session.
|
||||
user_data: Mapping of user attributes as returned by the OAuth provider
|
||||
or built by :func:`app.utils.local_auth.build_session_user`.
|
||||
is_admin: When ``True``, apply admin-specific defaults on first login
|
||||
and ensure the complimentary flag is always set.
|
||||
"""
|
||||
from app.models import UserProfile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
highest_tier = TIER_ORDER[-1]
|
||||
|
||||
user_id = (
|
||||
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
|
||||
@@ -152,29 +169,58 @@ def _ensure_user_profile(db: Session, user_data: dict) -> None:
|
||||
if existing is None:
|
||||
display_name = user_data.get("name") or user_data.get("preferred_username") or user_data.get("email")
|
||||
email = user_data.get("email")
|
||||
profile = UserProfile(user_id=user_id, display_name=display_name)
|
||||
profile = UserProfile(
|
||||
user_id=user_id,
|
||||
display_name=display_name,
|
||||
subscription_tier=highest_tier if is_admin else "free",
|
||||
is_complimentary=is_admin,
|
||||
onboarding_completed=is_admin,
|
||||
)
|
||||
db.add(profile)
|
||||
db.commit()
|
||||
logger.info("Auto-created UserProfile for user_id=%s", user_id)
|
||||
# Notify admins and fire webhook for new signup
|
||||
try:
|
||||
from app.utils.notification import notify_user_signup
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
logger.info(
|
||||
"Auto-created UserProfile for user_id=%s (admin=%s, tier=%s)",
|
||||
user_id,
|
||||
is_admin,
|
||||
highest_tier if is_admin else "free",
|
||||
)
|
||||
# Notify admins and fire webhook for new (non-admin) user signup
|
||||
if not is_admin:
|
||||
try:
|
||||
from app.utils.notification import notify_user_signup
|
||||
from app.utils.webhook import dispatch_webhook_event
|
||||
|
||||
notify_user_signup(user_id, display_name=display_name, email=email)
|
||||
dispatch_webhook_event(
|
||||
"user.signup",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
},
|
||||
notify_user_signup(user_id, display_name=display_name, email=email)
|
||||
dispatch_webhook_event(
|
||||
"user.signup",
|
||||
{
|
||||
"user_id": user_id,
|
||||
"display_name": display_name,
|
||||
"email": email,
|
||||
},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
|
||||
elif is_admin:
|
||||
# Ensure existing admin profiles always have complimentary flag set.
|
||||
# Also upgrade from free tier to highest if still on default.
|
||||
changed = False
|
||||
if not existing.is_complimentary:
|
||||
existing.is_complimentary = True
|
||||
changed = True
|
||||
if (existing.subscription_tier or "free") == "free":
|
||||
existing.subscription_tier = highest_tier
|
||||
changed = True
|
||||
if changed:
|
||||
db.commit()
|
||||
logger.info(
|
||||
"Updated admin UserProfile for user_id=%s (complimentary=True, tier=%s)",
|
||||
user_id,
|
||||
existing.subscription_tier,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send signup notification/webhook for user_id=%s", user_id)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
logger.exception("Failed to auto-create UserProfile for user_id=%s", user_id)
|
||||
logger.exception("Failed to auto-create/update UserProfile for user_id=%s", user_id)
|
||||
|
||||
|
||||
async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
@@ -210,7 +256,7 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
|
||||
request.session["user"] = user_data
|
||||
|
||||
# Auto-create or update UserProfile so the user appears in admin user management
|
||||
_ensure_user_profile(db, user_data)
|
||||
_ensure_user_profile(db, user_data, is_admin=is_admin)
|
||||
|
||||
# Log the successful authentication
|
||||
logger.info("[SECURITY] OAUTH_LOGIN_SUCCESS user=%s admin=%s", user_data.get("email", "unknown"), is_admin)
|
||||
@@ -268,6 +314,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
user_data = _build_session_user(local_user)
|
||||
request.session["user"] = user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
|
||||
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
|
||||
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
|
||||
if profile and not profile.onboarding_completed:
|
||||
post_onboarding = request.session.pop("redirect_after_login", "/upload")
|
||||
@@ -278,7 +325,7 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
# --- Admin credentials (always available as a fallback / single-user mode) ---
|
||||
if username == settings.admin_username and password == settings.admin_password:
|
||||
request.session["user"] = {
|
||||
admin_user_data = {
|
||||
"id": "admin",
|
||||
"name": "Administrator",
|
||||
"email": f"{username}@local.docuelevate",
|
||||
@@ -286,7 +333,9 @@ async def auth(request: Request, db: Session = Depends(get_db)):
|
||||
"picture": "/static/images/default-avatar.svg",
|
||||
"is_admin": True,
|
||||
}
|
||||
request.session["user"] = admin_user_data
|
||||
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
|
||||
_ensure_user_profile(db, admin_user_data, is_admin=True)
|
||||
redirect_url = request.session.pop("redirect_after_login", "/upload")
|
||||
return RedirectResponse(url=redirect_url, status_code=302)
|
||||
else:
|
||||
|
||||
@@ -238,6 +238,10 @@ class UserProfile(Base):
|
||||
subscription_period_start = Column(DateTime(timezone=True), nullable=True)
|
||||
allow_overage = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# When True, the user is on a complimentary (uncharged) plan — they keep all tier
|
||||
# quota benefits but are never billed via Stripe. Automatically set for admin users.
|
||||
is_complimentary = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# Onboarding tracking (added in migration 017)
|
||||
onboarding_completed = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||
onboarding_completed_at = Column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
+18
-14
@@ -1,11 +1,12 @@
|
||||
"""
|
||||
Subscription tier definitions and enforcement utilities for DocuElevate SaaS.
|
||||
|
||||
All plans are priced per user per month (or per year with ~20 % discount).
|
||||
Four tiers (prices ex-VAT; German customers +19 % MwSt):
|
||||
- free $0/mo — 50 lifetime docs, 150 lifetime OCR pages, 1 dest
|
||||
- starter $2.99/mo — 50/mo, 300 OCR pp/mo, 2 dests, 1 mailbox
|
||||
- professional $5.99/mo — 150/mo, 750 OCR pp/mo, 5 dests, 3 mailboxes
|
||||
- business $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
- power $7.99/mo — 300/mo, 1500 OCR pp/mo, 10 dests, unlimited mailboxes
|
||||
|
||||
Limits use 0 to represent "unlimited".
|
||||
All paid tiers include a 30-day free trial (trial_days field).
|
||||
@@ -14,14 +15,14 @@ All paid tiers include a 30-day free trial (trial_days field).
|
||||
Infrastructure: CX32 (app+Redis €7.59) + CX22 (worker €3.79) + BX21 (storage €7.22) ≈ $24/mo
|
||||
At 100 users infra share ≈ $0.24/user/mo.
|
||||
|
||||
Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
|
||||
Starter : OCR $0.45 + AI $0.012 + infra $0.24 + Stripe $0.34 = $1.04 → 65 % gross margin
|
||||
Professional: OCR $1.13 + AI $0.035 + infra $0.24 + Stripe $0.42 = $1.82 → 70 % gross margin
|
||||
Business : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
|
||||
Power : OCR $2.25 + AI $0.069 + infra $0.24 + Stripe $0.48 = $3.04 → 62 % gross margin
|
||||
|
||||
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Business 43 %.
|
||||
After ~30 % German corporate tax: Starter 45 %, Professional 49 %, Power 43 %.
|
||||
At average usage (~40 % of quota) margins improve to 55-65 % after tax.
|
||||
|
||||
⚠ If GPT-4o (not mini) is configured, Business AI cost at max rises to ~$1.92/user,
|
||||
⚠ If GPT-4o (not mini) is configured, Power AI cost at max rises to ~$1.92/user,
|
||||
reducing after-tax margin to ~33 %. Recommend GPT-4o mini as default in production.
|
||||
"""
|
||||
|
||||
@@ -46,7 +47,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"free": {
|
||||
"id": "free",
|
||||
"name": "Free",
|
||||
"tagline": "Explore DocuElevate at no cost",
|
||||
"tagline": "Try DocuElevate free — no credit card needed",
|
||||
"price_monthly": 0,
|
||||
"price_yearly": 0,
|
||||
"trial_days": 0,
|
||||
@@ -75,7 +76,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"starter": {
|
||||
"id": "starter",
|
||||
"name": "Starter",
|
||||
"tagline": "Perfect for individuals getting started",
|
||||
# Use case: freelancer sending ~50 invoices, contracts, or scanned receipts a month
|
||||
"tagline": "Perfect for freelancers and side-project owners",
|
||||
"price_monthly": 2.99,
|
||||
"price_yearly": 28.99, # ≈ 80 % of monthly × 12 — save ~19 % (≈ 2½ months free)
|
||||
"trial_days": 30,
|
||||
@@ -89,7 +91,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 1,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"50 documents / month",
|
||||
"50 documents / month — invoices, contracts, receipts",
|
||||
"2 storage destinations",
|
||||
"300 OCR pages / month",
|
||||
"25 MB max file size",
|
||||
@@ -104,7 +106,8 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"professional": {
|
||||
"id": "professional",
|
||||
"name": "Professional",
|
||||
"tagline": "For growing teams that need more power",
|
||||
# Use case: consultant or knowledge worker handling ~150 docs/month across multiple platforms
|
||||
"tagline": "For knowledge workers managing documents daily",
|
||||
"price_monthly": 5.99,
|
||||
"price_yearly": 57.99, # ≈ 80 % of monthly × 12 — save ~19 %
|
||||
"trial_days": 30,
|
||||
@@ -118,7 +121,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 3,
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"150 documents / month",
|
||||
"150 documents / month — reports, contracts, invoices",
|
||||
"5 storage destinations",
|
||||
"750 OCR pages / month",
|
||||
"100 MB max file size",
|
||||
@@ -133,8 +136,9 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
},
|
||||
"business": {
|
||||
"id": "business",
|
||||
"name": "Business",
|
||||
"tagline": "High-volume processing for organisations",
|
||||
"name": "Power",
|
||||
# Use case: power user — real estate agent, bookkeeper, or researcher processing ~10 docs/day
|
||||
"tagline": "For power users with high-volume document workflows",
|
||||
"price_monthly": 7.99,
|
||||
"price_yearly": 76.99, # ≈ 80 % of monthly × 12 — save ~20 %
|
||||
"trial_days": 30,
|
||||
@@ -148,7 +152,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"max_mailboxes": 0, # unlimited mailboxes
|
||||
"api_access": True,
|
||||
"features": [
|
||||
"300 documents / month",
|
||||
"300 documents / month — ~10 documents per day",
|
||||
"10 storage destinations",
|
||||
"1,500 OCR pages / month",
|
||||
"Unlimited file size",
|
||||
@@ -156,7 +160,7 @@ TIER_DEFAULTS: dict[str, dict[str, Any]] = {
|
||||
"Unlimited email ingestion mailboxes",
|
||||
"All ingestion methods",
|
||||
"Webhooks & full API access",
|
||||
"Dedicated support",
|
||||
"Priority support",
|
||||
],
|
||||
"cta": "Start free trial",
|
||||
"badge": "Best Value",
|
||||
|
||||
@@ -32,6 +32,10 @@ def _inject_global_context(ctx: dict) -> None:
|
||||
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
||||
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
||||
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
|
||||
ctx.setdefault(
|
||||
"allow_signup",
|
||||
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
||||
)
|
||||
|
||||
req = ctx.get("request")
|
||||
if req is not None:
|
||||
|
||||
@@ -123,6 +123,7 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
|
||||
"user_tier": user_tier,
|
||||
"multi_user_enabled": settings.multi_user_enabled,
|
||||
"is_admin": is_admin,
|
||||
"allow_signup": settings.multi_user_enabled and settings.allow_local_signup,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+12
-2
@@ -19,14 +19,14 @@ This guide covers how to configure Stripe billing and local user sign-up in Docu
|
||||
|
||||
By default, user accounts are created by an administrator. To allow users to self-register with an email address and password, set `ALLOW_LOCAL_SIGNUP=true`.
|
||||
|
||||
> **Note:** SMTP must be configured before enabling local sign-up. New accounts require email verification before they can log in.
|
||||
> **Note:** SMTP is **optional** for local sign-up. When SMTP is configured, new accounts require email verification before they can log in. Without SMTP, accounts are activated immediately upon registration — useful for self-hosted deployments without email infrastructure.
|
||||
|
||||
### Configuration
|
||||
|
||||
```bash
|
||||
ALLOW_LOCAL_SIGNUP=true
|
||||
|
||||
# SMTP (required for verification emails)
|
||||
# SMTP (optional — enables email verification and password reset)
|
||||
EMAIL_HOST=smtp.example.com
|
||||
EMAIL_PORT=587
|
||||
EMAIL_USERNAME=noreply@example.com
|
||||
@@ -37,11 +37,21 @@ EMAIL_SENDER=DocuElevate <noreply@example.com>
|
||||
|
||||
### Sign-up Flow
|
||||
|
||||
**With SMTP configured (recommended):**
|
||||
1. User visits `/signup` and fills out the registration form.
|
||||
2. DocuElevate sends a verification email with a 24-hour token link.
|
||||
3. User clicks the link — their account is activated and they are signed in.
|
||||
4. First-time users are redirected to the onboarding wizard.
|
||||
|
||||
**Without SMTP:**
|
||||
1. User visits `/signup` and fills out the registration form.
|
||||
2. Account is activated immediately — no email verification required.
|
||||
3. User is redirected to the login page to sign in straight away.
|
||||
|
||||
### Admin-Created Accounts
|
||||
|
||||
Administrators can create local user accounts directly from the **Admin → User Management** page without requiring self-registration. Admin-created accounts are immediately active regardless of SMTP configuration.
|
||||
|
||||
### Password Reset Flow
|
||||
|
||||
1. User clicks "Forgot password?" on the login page.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
DocuElevate uses database-backed subscription plans that are fully configurable by admins via the **Plan Designer** at `/admin/plans`. Four default tiers are seeded automatically on first startup.
|
||||
|
||||
All plans are priced **per user, per month** (or per year with ~20 % discount). There are no team, business, or enterprise tiers — every plan is a single-user subscription.
|
||||
|
||||
## Default Plans
|
||||
|
||||
| Plan | Monthly | Yearly | Docs/Month | Lifetime Docs | OCR Pages/Mo | Max File | Mailboxes | Destinations |
|
||||
@@ -9,12 +11,21 @@ DocuElevate uses database-backed subscription plans that are fully configurable
|
||||
| **Free** | $0 | $0 | — | 50 total | 150 total | 5 MB | 0 | 1 |
|
||||
| **Starter** | $2.99 | $28.99 | 50 | — | 300 | 25 MB | 1 | 2 |
|
||||
| **Professional** | $5.99 | $57.99 | 150 | — | 750 | 100 MB | 3 | 5 |
|
||||
| **Business** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 |
|
||||
| **Power** | $7.99 | $76.99 | 300 | — | 1,500 | Unlimited | Unlimited | 10 |
|
||||
|
||||
> Prices ex-VAT. German customers add 19% MwSt.
|
||||
|
||||
All paid plans include a **30-day free trial**.
|
||||
|
||||
### Intended Use Cases
|
||||
|
||||
- **Free** — Try DocuElevate with no commitment. Good for one-off experiments or evaluating the service.
|
||||
- **Starter** — Freelancers and side-project owners sending ~50 invoices, contracts, or scanned receipts a month.
|
||||
- **Professional** — Knowledge workers (consultants, paralegals, accountants) handling ~150 multi-page documents a month across several cloud destinations.
|
||||
- **Power** — Power users with heavy daily workloads: real estate agents, bookkeepers, or researchers processing ~10 documents a day (≈ 300/month) with no file-size restrictions.
|
||||
|
||||
> The **plan_id** in the database remains `"business"` for the Power tier to preserve backwards compatibility. The display name shown to users is "Power".
|
||||
|
||||
## How Plans Are Stored
|
||||
|
||||
Plans are stored in the `subscription_plans` database table. On application startup, `seed_default_plans()` is called automatically — if the table is empty, the four built-in defaults are inserted. If plans already exist, the seed is a no-op.
|
||||
@@ -55,6 +66,35 @@ When a user's `subscription_billing_cycle` is set to `yearly`:
|
||||
|
||||
Setting `UserProfile.allow_overage = True` bypasses monthly quota checks entirely for that user. Usage is still tracked so future billing integrations can charge retroactively. This field is not yet exposed in the admin UI.
|
||||
|
||||
## is_complimentary Flag (Complimentary Plans)
|
||||
|
||||
Setting `UserProfile.is_complimentary = True` marks a user as being on a **complimentary (uncharged) plan**. The user retains all quota benefits of their assigned subscription tier but is **never billed via Stripe**. This is useful for:
|
||||
|
||||
- **Admin accounts** — automatically set on every admin user profile at login time.
|
||||
- **Gifted access** — granting full plan benefits to partners, testers, or sponsored users.
|
||||
|
||||
### Admin Auto-Provisioning
|
||||
|
||||
When an admin user logs in for the first time (via OAuth, local account, or the built-in admin credentials), DocuElevate automatically:
|
||||
|
||||
1. Creates a `UserProfile` row if one does not already exist.
|
||||
2. Assigns the **highest available subscription tier** (currently `business`).
|
||||
3. Sets `is_complimentary = True` so the account is never billed.
|
||||
4. Sets `onboarding_completed = True` so admins skip the first-time setup wizard.
|
||||
|
||||
On subsequent logins for existing admin profiles:
|
||||
- `is_complimentary` is ensured to be `True`.
|
||||
- If the profile was still on the `free` tier it is upgraded to the highest tier.
|
||||
- All other admin-managed settings (custom limits, notes, etc.) are preserved.
|
||||
|
||||
### Managing via Admin UI
|
||||
|
||||
The **User Management** page (`/admin/users`) shows a green gift icon (🎁) next to the plan badge for any user with `is_complimentary = True`. The toggle is available in the user edit modal under **Billing**.
|
||||
|
||||
### API Field
|
||||
|
||||
`is_complimentary` is exposed in the `PUT /api/admin/users/{user_id}` body and in all user detail responses.
|
||||
|
||||
## Plan Designer
|
||||
|
||||
Navigate to `/admin/plans` (admin only) to:
|
||||
|
||||
@@ -304,11 +304,14 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
||||
|
||||
/**
|
||||
* Render the login / get-started buttons for unauthenticated visitors.
|
||||
* Reads the data-multi-user attribute that the server injects on <body> to
|
||||
* decide whether to show a prominent "Get Started" CTA alongside the login link.
|
||||
* Reads the data-multi-user and data-allow-signup attributes that the server
|
||||
* injects on <body> to decide whether to show a prominent "Get Started" CTA
|
||||
* alongside the login link, and whether it should link to /signup or /pricing.
|
||||
*/
|
||||
function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
const multiUser = document.body.getAttribute('data-multi-user') === 'true';
|
||||
const allowSignup = document.body.getAttribute('data-allow-signup') === 'true';
|
||||
const startHref = allowSignup ? '/signup' : '/pricing';
|
||||
|
||||
if (authSection) {
|
||||
authSection.textContent = '';
|
||||
@@ -324,10 +327,10 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
|
||||
if (multiUser) {
|
||||
const startLink = document.createElement('a');
|
||||
startLink.href = '/pricing';
|
||||
startLink.href = startHref;
|
||||
startLink.className =
|
||||
'px-3 py-1.5 rounded-md text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500';
|
||||
startLink.textContent = 'Get Started';
|
||||
startLink.textContent = allowSignup ? 'Sign Up' : 'Get Started';
|
||||
row.appendChild(startLink);
|
||||
}
|
||||
|
||||
@@ -350,14 +353,14 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||
|
||||
if (multiUser) {
|
||||
const startLink = document.createElement('a');
|
||||
startLink.href = '/pricing';
|
||||
startLink.href = startHref;
|
||||
startLink.className =
|
||||
'block px-3 py-3 rounded-md text-base font-medium text-white bg-blue-600 hover:text-white hover:bg-blue-700 mt-1';
|
||||
const startIcon = document.createElement('i');
|
||||
startIcon.className = 'fas fa-arrow-right mr-2';
|
||||
startIcon.setAttribute('aria-hidden', 'true');
|
||||
startLink.appendChild(startIcon);
|
||||
startLink.appendChild(document.createTextNode('Get Started'));
|
||||
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
|
||||
mobileAuthSection.appendChild(startLink);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
>
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateLocalUserModal()"
|
||||
class="inline-flex items-center px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-user-lock mr-2" aria-hidden="true"></i> Create Local Account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
|
||||
@@ -140,6 +147,13 @@
|
||||
></i>
|
||||
<span x-text="user.subscription_tier || 'free'"></span>
|
||||
</span>
|
||||
<span
|
||||
x-show="user.is_complimentary"
|
||||
class="ml-1 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold bg-green-100 text-green-700"
|
||||
aria-label="Complimentary plan — not billed"
|
||||
>
|
||||
<i class="fas fa-gift" aria-hidden="true"></i>
|
||||
</span>
|
||||
</td>
|
||||
<!-- Upload limit -->
|
||||
<td class="px-4 py-3 text-sm text-center text-gray-700">
|
||||
@@ -375,6 +389,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Complimentary plan toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
x-model="form.is_complimentary"
|
||||
class="sr-only peer"
|
||||
id="modal-complimentary"
|
||||
role="switch"
|
||||
:aria-checked="form.is_complimentary.toString()"
|
||||
/>
|
||||
<div class="w-10 h-6 bg-gray-200 peer-focus:ring-2 peer-focus:ring-blue-400 rounded-full peer peer-checked:bg-green-500 after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:after:translate-x-4"></div>
|
||||
</label>
|
||||
<label for="modal-complimentary" class="text-sm font-medium text-gray-700">
|
||||
Complimentary plan
|
||||
<span class="text-xs text-gray-400 font-normal">(user keeps tier benefits but is never billed — set automatically for admin accounts)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
@@ -399,6 +432,200 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Local Accounts section ────────────────────────────────────────────── -->
|
||||
<div class="bg-white shadow rounded-lg mt-8">
|
||||
<div class="px-6 py-4 border-b flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 flex items-center gap-2">
|
||||
<i class="fas fa-user-lock text-green-600" aria-hidden="true"></i>
|
||||
Local User Accounts
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 mt-0.5">
|
||||
Email/password accounts created directly on this server.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="openCreateLocalUserModal()"
|
||||
class="inline-flex items-center px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
|
||||
>
|
||||
<i class="fas fa-plus mr-1" aria-hidden="true"></i> New Account
|
||||
</button>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200" aria-label="Local user accounts">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Username</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Display Name</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Created</th>
|
||||
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<template x-if="localUsersLoading">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i> Loading…
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-if="!localUsersLoading && localUsers.length === 0">
|
||||
<tr>
|
||||
<td colspan="7" class="px-4 py-6 text-center text-gray-400">
|
||||
No local accounts yet.
|
||||
<button type="button" @click="openCreateLocalUserModal()" class="text-green-600 hover:underline ml-1">Create one.</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<template x-for="lu in localUsers" :key="lu.id">
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-mono text-gray-800" x-text="lu.username"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.email"></td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600" x-text="lu.display_name || '—'"></td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span
|
||||
:class="lu.is_active ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
x-text="lu.is_active ? 'Active' : 'Unverified'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-center">
|
||||
<span
|
||||
:class="lu.is_admin ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600'"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
x-text="lu.is_admin ? 'Admin' : 'User'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 whitespace-nowrap" x-text="lu.created_at ? formatDate(lu.created_at) : '—'"></td>
|
||||
<td class="px-4 py-3 text-sm text-right">
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmDeleteLocalUser(lu)"
|
||||
class="text-red-600 hover:text-red-800 focus:outline-none"
|
||||
:aria-label="`Delete account for ${lu.username}`"
|
||||
>
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Create local user modal ────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="localUserModal.open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-local-user-title"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg" @click.outside="localUserModal.open = false">
|
||||
<div class="px-6 py-4 border-b flex items-center justify-between">
|
||||
<h2 id="create-local-user-title" class="text-lg font-semibold text-gray-900">Create Local Account</h2>
|
||||
<button type="button" @click="localUserModal.open = false" aria-label="Close" class="text-gray-400 hover:text-gray-600">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form @submit.prevent="submitCreateLocalUser" class="px-6 py-5 space-y-4">
|
||||
<div>
|
||||
<label for="lu-email" class="block text-sm font-medium text-gray-700">Email <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="email" id="lu-email" x-model="localUserModal.form.email" required autocomplete="off"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-username" class="block text-sm font-medium text-gray-700">Username <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="text" id="lu-username" x-model="localUserModal.form.username" required autocomplete="off"
|
||||
pattern="^[a-zA-Z0-9_-]+$" minlength="3" maxlength="64"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true" aria-describedby="lu-username-hint">
|
||||
<p id="lu-username-hint" class="mt-1 text-xs text-gray-500">3–64 characters. Letters, numbers, hyphens and underscores only.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-display-name" class="block text-sm font-medium text-gray-700">Display Name <span class="text-gray-400">(optional)</span></label>
|
||||
<input type="text" id="lu-display-name" x-model="localUserModal.form.display_name" autocomplete="off" maxlength="255"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;">
|
||||
</div>
|
||||
<div>
|
||||
<label for="lu-password" class="block text-sm font-medium text-gray-700">Password <span aria-hidden="true" class="text-red-500">*</span></label>
|
||||
<input type="password" id="lu-password" x-model="localUserModal.form.password" required autocomplete="new-password"
|
||||
minlength="8" maxlength="128"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring focus:ring-green-500 focus:ring-opacity-50 text-sm"
|
||||
style="min-height:40px;" aria-required="true" aria-describedby="lu-password-hint">
|
||||
<p id="lu-password-hint" class="mt-1 text-xs text-gray-500">Minimum 8 characters.</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="lu-is-admin" x-model="localUserModal.form.is_admin"
|
||||
class="h-4 w-4 rounded border-gray-300 text-green-600 focus:ring-green-500">
|
||||
<label for="lu-is-admin" class="text-sm text-gray-700">Grant admin privileges</label>
|
||||
</div>
|
||||
<div x-show="localUserModal.error" x-cloak
|
||||
class="bg-red-50 border-l-4 border-red-500 text-red-700 p-3 rounded text-sm"
|
||||
role="alert" aria-live="assertive" x-text="localUserModal.error">
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" @click="localUserModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" :disabled="localUserModal.saving"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-green-500 disabled:opacity-50">
|
||||
<span x-show="!localUserModal.saving">Create Account</span>
|
||||
<span x-show="localUserModal.saving" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Creating…</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete local user confirmation modal ───────────────────────────────── -->
|
||||
<div
|
||||
x-show="deleteLocalUserModal.open"
|
||||
x-transition:enter="transition ease-out duration-100"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-local-user-title"
|
||||
>
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md" @click.outside="deleteLocalUserModal.open = false">
|
||||
<div class="px-6 py-4 border-b">
|
||||
<h2 id="delete-local-user-title" class="text-lg font-semibold text-gray-900">Delete Local Account</h2>
|
||||
</div>
|
||||
<div class="px-6 py-5">
|
||||
<p class="text-sm text-gray-700">
|
||||
Are you sure you want to delete the account for
|
||||
<strong class="font-mono" x-text="deleteLocalUserModal.username"></strong>
|
||||
(<span class="font-mono" x-text="deleteLocalUserModal.email"></span>)?
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-2">This cannot be undone. Documents owned by this user are <strong>not</strong> deleted.</p>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t flex justify-end gap-3">
|
||||
<button type="button" @click="deleteLocalUserModal.open = false"
|
||||
class="px-4 py-2 text-sm font-medium border border-gray-300 rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" @click="executeDeleteLocalUser()" :disabled="deleteLocalUserModal.deleting"
|
||||
class="px-4 py-2 text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">
|
||||
<span x-show="!deleteLocalUserModal.deleting">Delete Account</span>
|
||||
<span x-show="deleteLocalUserModal.deleting" x-cloak><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i> Deleting…</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete confirmation modal ──────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="deleteModal.open"
|
||||
@@ -472,6 +699,7 @@ function adminUsersApp() {
|
||||
subscription_tier: 'free',
|
||||
subscription_billing_cycle: 'monthly',
|
||||
subscription_period_start: null,
|
||||
is_complimentary: false,
|
||||
},
|
||||
|
||||
// Delete modal
|
||||
@@ -481,6 +709,23 @@ function adminUsersApp() {
|
||||
deleting: false,
|
||||
},
|
||||
|
||||
// Local users
|
||||
localUsers: [],
|
||||
localUsersLoading: true,
|
||||
localUserModal: {
|
||||
open: false,
|
||||
saving: false,
|
||||
error: '',
|
||||
form: { email: '', username: '', display_name: '', password: '', is_admin: false },
|
||||
},
|
||||
deleteLocalUserModal: {
|
||||
open: false,
|
||||
id: null,
|
||||
username: '',
|
||||
email: '',
|
||||
deleting: false,
|
||||
},
|
||||
|
||||
// Alert
|
||||
alert: { show: false, type: 'success', title: '', message: '' },
|
||||
|
||||
@@ -491,7 +736,7 @@ function adminUsersApp() {
|
||||
},
|
||||
|
||||
async init() {
|
||||
await this.fetchUsers(1);
|
||||
await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
|
||||
},
|
||||
|
||||
async fetchUsers(page) {
|
||||
@@ -524,7 +769,7 @@ function adminUsersApp() {
|
||||
openCreateModal() {
|
||||
this.isCreate = true;
|
||||
this.modalTitle = 'Add User Profile';
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null };
|
||||
this.form = { user_id: '', display_name: '', daily_upload_limit: null, notes: '', is_blocked: false, subscription_tier: 'free', subscription_billing_cycle: 'monthly', subscription_period_start: null, is_complimentary: false };
|
||||
this.modalOpen = true;
|
||||
},
|
||||
|
||||
@@ -541,6 +786,7 @@ function adminUsersApp() {
|
||||
subscription_tier: user.subscription_tier || 'free',
|
||||
subscription_billing_cycle: user.subscription_billing_cycle || 'monthly',
|
||||
subscription_period_start: user.subscription_period_start ? user.subscription_period_start.substring(0, 10) : null,
|
||||
is_complimentary: !!user.is_complimentary,
|
||||
};
|
||||
this.modalOpen = true;
|
||||
},
|
||||
@@ -555,6 +801,7 @@ function adminUsersApp() {
|
||||
notes: this.form.notes || null,
|
||||
is_blocked: !!this.form.is_blocked,
|
||||
subscription_tier: this.form.subscription_tier || 'free',
|
||||
is_complimentary: !!this.form.is_complimentary,
|
||||
};
|
||||
const uid = encodeURIComponent(this.form.user_id);
|
||||
const resp = await fetch(`/api/admin/users/${uid}`, {
|
||||
@@ -633,6 +880,90 @@ function adminUsersApp() {
|
||||
this.alert = { show: true, type, title, message };
|
||||
setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000);
|
||||
},
|
||||
|
||||
async fetchLocalUsers() {
|
||||
this.localUsersLoading = true;
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users/local', {
|
||||
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this.showAlert('error', 'Failed to load local users', resp.statusText);
|
||||
return;
|
||||
}
|
||||
this.localUsers = await resp.json();
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
} finally {
|
||||
this.localUsersLoading = false;
|
||||
}
|
||||
},
|
||||
|
||||
openCreateLocalUserModal() {
|
||||
this.localUserModal.form = { email: '', username: '', display_name: '', password: '', is_admin: false };
|
||||
this.localUserModal.error = '';
|
||||
this.localUserModal.saving = false;
|
||||
this.localUserModal.open = true;
|
||||
},
|
||||
|
||||
async submitCreateLocalUser() {
|
||||
this.localUserModal.error = '';
|
||||
this.localUserModal.saving = true;
|
||||
try {
|
||||
const resp = await fetch('/api/admin/users/local', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '',
|
||||
},
|
||||
body: JSON.stringify(this.localUserModal.form),
|
||||
});
|
||||
if (resp.ok) {
|
||||
this.localUserModal.open = false;
|
||||
this.showAlert('success', 'Account created', `Local account for "${this.localUserModal.form.username}" was created successfully.`);
|
||||
await this.fetchLocalUsers();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.localUserModal.error = err.detail || 'Failed to create account.';
|
||||
}
|
||||
} catch (e) {
|
||||
this.localUserModal.error = 'Network error: ' + e.message;
|
||||
} finally {
|
||||
this.localUserModal.saving = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteLocalUser(lu) {
|
||||
this.deleteLocalUserModal.id = lu.id;
|
||||
this.deleteLocalUserModal.username = lu.username;
|
||||
this.deleteLocalUserModal.email = lu.email;
|
||||
this.deleteLocalUserModal.deleting = false;
|
||||
this.deleteLocalUserModal.open = true;
|
||||
},
|
||||
|
||||
async executeDeleteLocalUser() {
|
||||
this.deleteLocalUserModal.deleting = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/users/local/${this.deleteLocalUserModal.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || '' },
|
||||
});
|
||||
if (resp.status === 204) {
|
||||
this.deleteLocalUserModal.open = false;
|
||||
this.showAlert('success', 'Deleted', `Account for "${this.deleteLocalUserModal.username}" has been removed.`);
|
||||
await this.fetchLocalUsers();
|
||||
} else {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
this.showAlert('error', 'Delete failed', err.detail || resp.statusText);
|
||||
this.deleteLocalUserModal.open = false;
|
||||
}
|
||||
} catch (e) {
|
||||
this.showAlert('error', 'Network error', e.message);
|
||||
this.deleteLocalUserModal.open = false;
|
||||
} finally {
|
||||
this.deleteLocalUserModal.deleting = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col"
|
||||
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}">
|
||||
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"
|
||||
data-allow-signup="{{ 'true' if allow_signup else 'false' }}">
|
||||
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
|
||||
|
||||
@@ -1,12 +1,143 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Dashboard – DocuElevate{% endblock %}
|
||||
{% block title %}{% if multi_user_enabled and not is_logged_in %}DocuElevate – Intelligent Document Processing{% else %}Dashboard – DocuElevate{% endif %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="{% if multi_user_enabled and not is_logged_in %}bg-gray-50 min-h-screen{% else %}container mx-auto px-4 py-8{% endif %}">
|
||||
|
||||
{% if multi_user_enabled %}
|
||||
{% if multi_user_enabled and not is_logged_in %}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
{# MULTI-USER / SAAS DASHBOARD #}
|
||||
{# PUBLIC LANDING PAGE (multi-user, visitor not signed in) #}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="bg-gradient-to-br from-blue-700 via-indigo-700 to-purple-700 text-white py-20 px-4">
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<span class="inline-block bg-white/20 text-white text-xs font-semibold uppercase tracking-widest px-3 py-1 rounded-full mb-4">
|
||||
Intelligent Document Processing
|
||||
</span>
|
||||
<h1 class="text-4xl sm:text-5xl font-extrabold mb-4 leading-tight">
|
||||
From upload to insight — automatically.
|
||||
</h1>
|
||||
<p class="text-indigo-100 text-lg max-w-2xl mx-auto mb-8">
|
||||
DocuElevate ingests your documents, runs OCR, extracts metadata with AI, and routes files to
|
||||
Dropbox, Google Drive, OneDrive, S3, Nextcloud, and more — all in one seamless pipeline.
|
||||
</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
{% if allow_signup %}
|
||||
<a href="/signup"
|
||||
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Get Started — it's free
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/login"
|
||||
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i> Log In
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/pricing"
|
||||
class="bg-transparent border-2 border-white text-white hover:bg-white hover:text-indigo-700 font-bold py-3 px-8 rounded-xl transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-tags mr-2" aria-hidden="true"></i> View Plans & Pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Feature grid -->
|
||||
<div class="max-w-6xl mx-auto px-4 py-16">
|
||||
<h2 class="text-2xl font-bold text-center text-gray-800 mb-10">Everything you need for smart document workflows</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-blue-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-file-alt text-blue-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">OCR & Text Extraction</h3>
|
||||
<p class="text-gray-500 text-sm">Azure Document Intelligence converts scanned PDFs and images into fully searchable text automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-indigo-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-brain text-indigo-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">AI Metadata Extraction</h3>
|
||||
<p class="text-gray-500 text-sm">OpenAI, Claude, Gemini, and other pluggable AI providers classify documents and pull out key fields like dates, amounts, and subjects.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-green-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-cloud-upload-alt text-green-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">Multi-Cloud Storage</h3>
|
||||
<p class="text-gray-500 text-sm">Route processed files to Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, Paperless NGX, WebDAV, FTP/SFTP, and more.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-yellow-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-mail-bulk text-yellow-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">Email & IMAP Ingestion</h3>
|
||||
<p class="text-gray-500 text-sm">Automatically pull documents from Gmail or any IMAP mailbox — no manual uploads needed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-purple-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-search text-purple-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">Full-Text Search</h3>
|
||||
<p class="text-gray-500 text-sm">Instantly find any document by content, metadata, or tags across your entire archive.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow p-6 flex gap-4">
|
||||
<div class="h-12 w-12 rounded-xl bg-pink-100 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-project-diagram text-pink-600 text-xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 mb-1">Custom Pipelines</h3>
|
||||
<p class="text-gray-500 text-sm">Build processing pipelines with configurable steps — OCR, AI extraction, format conversion, and storage routing in any order.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA banner -->
|
||||
<div class="bg-indigo-700 text-white py-14 px-4">
|
||||
<div class="max-w-2xl mx-auto text-center">
|
||||
<h2 class="text-3xl font-extrabold mb-4">Ready to elevate your document workflow?</h2>
|
||||
<p class="text-indigo-200 mb-8">Join teams already automating their document processing with DocuElevate.</p>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
{% if allow_signup %}
|
||||
<a href="/signup"
|
||||
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-rocket mr-2" aria-hidden="true"></i> Create a free account
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="/login"
|
||||
class="bg-white text-indigo-700 hover:bg-indigo-50 font-bold py-3 px-8 rounded-xl shadow-lg transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-sign-in-alt mr-2" aria-hidden="true"></i> Log In
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/pricing"
|
||||
class="bg-transparent border-2 border-white text-white hover:bg-white hover:text-indigo-700 font-bold py-3 px-8 rounded-xl transition duration-200 flex items-center min-h-[44px]">
|
||||
<i class="fas fa-tags mr-2" aria-hidden="true"></i> See pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif multi_user_enabled %}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
{# MULTI-USER / SAAS DASHBOARD (logged-in user) #}
|
||||
{# ══════════════════════════════════════════════════════════════════════════ #}
|
||||
|
||||
<!-- Hero -->
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
Choose the plan that's right for you
|
||||
</h1>
|
||||
<p class="text-indigo-100 text-lg max-w-2xl mx-auto">
|
||||
From free exploration to unlimited enterprise processing — scale as your document workflows grow.
|
||||
One price per person, per month — from casual exploration to power-user workflows. No team plans, no per-seat tiers.
|
||||
</p>
|
||||
|
||||
<!-- Annual / Monthly toggle (cosmetic — actual billing handled separately) -->
|
||||
@@ -318,7 +318,7 @@
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-500">Community</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-700">Email</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-gray-700 font-medium">Priority email</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-indigo-700 font-bold">Dedicated</td>
|
||||
<td class="px-4 py-3 text-center text-xs text-indigo-700 font-bold">Priority</td>
|
||||
</tr>
|
||||
|
||||
</tbody>
|
||||
|
||||
@@ -50,7 +50,12 @@
|
||||
})
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.href = '/verify-email-sent';
|
||||
const data = await resp.json();
|
||||
if (data.email_verification_required) {
|
||||
window.location.href = '/verify-email-sent';
|
||||
} else {
|
||||
window.location.href = '/login?message=Account+created+successfully.+You+can+now+log+in.';
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.error = data.detail || 'Registration failed. Please try again.';
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{% if tier_id == 'free' %}fa-seedling text-gray-500
|
||||
{% elif tier_id == 'starter' %}fa-rocket text-blue-600
|
||||
{% elif tier_id == 'professional' %}fa-star text-indigo-600
|
||||
{% else %}fa-building text-purple-600{% endif %}
|
||||
{% else %}fa-bolt text-purple-600{% endif %}
|
||||
text-2xl" aria-hidden="true"></i>
|
||||
</div>
|
||||
<p class="text-xs font-semibold text-gray-400 uppercase tracking-widest">Current Plan</p>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Add is_complimentary column to user_profiles
|
||||
|
||||
Revision ID: 019_add_is_complimentary
|
||||
Revises: 018_add_local_users_and_billing
|
||||
Create Date: 2026-03-07
|
||||
|
||||
"""
|
||||
|
||||
from typing import Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "019_add_is_complimentary"
|
||||
down_revision: Union[str, None] = "018_add_local_users_and_billing"
|
||||
depends_on: Union[str, None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add is_complimentary column to user_profiles."""
|
||||
op.add_column(
|
||||
"user_profiles",
|
||||
sa.Column("is_complimentary", sa.Boolean(), nullable=False, server_default="0"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove is_complimentary column from user_profiles."""
|
||||
op.drop_column("user_profiles", "is_complimentary")
|
||||
@@ -477,3 +477,189 @@ class TestUserProfileModel:
|
||||
with pytest.raises(IntegrityError):
|
||||
au_session.commit()
|
||||
au_session.rollback()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Complimentary plan tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComplimentaryPlan:
|
||||
"""Tests for the is_complimentary field and admin auto-creation logic."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_create_profile_with_complimentary_flag(self, au_client, au_session):
|
||||
"""PUT can create a profile with is_complimentary=True."""
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/comp@example.com",
|
||||
json={"subscription_tier": "business", "is_complimentary": True, "is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_complimentary"] is True
|
||||
assert data["subscription_tier"] == "business"
|
||||
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="comp@example.com").first()
|
||||
assert profile is not None
|
||||
assert profile.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_update_profile_set_complimentary(self, au_client, au_session):
|
||||
"""PUT can toggle is_complimentary on an existing profile."""
|
||||
_make_profile(au_session, "toggle@example.com", is_complimentary=False)
|
||||
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/toggle@example.com",
|
||||
json={"is_blocked": False, "is_complimentary": True},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_list_users_includes_complimentary_field(self, au_client, au_session):
|
||||
"""GET /api/admin/users/ returns is_complimentary per user."""
|
||||
_make_profile(au_session, "complist@example.com", is_complimentary=True)
|
||||
|
||||
resp = au_client.get("/api/admin/users/")
|
||||
assert resp.status_code == 200
|
||||
users = {u["user_id"]: u for u in resp.json()["users"]}
|
||||
assert "complist@example.com" in users
|
||||
assert users["complist@example.com"]["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_get_user_includes_complimentary_field(self, au_client, au_session):
|
||||
"""GET /api/admin/users/<id> returns is_complimentary in profile."""
|
||||
_make_profile(au_session, "getcomp@example.com", is_complimentary=True, subscription_tier="business")
|
||||
|
||||
resp = au_client.get("/api/admin/users/getcomp%40example.com")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["is_complimentary"] is True
|
||||
assert data["profile"]["is_complimentary"] is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_complimentary_defaults_to_false(self, au_client, au_session):
|
||||
"""Newly created profiles have is_complimentary=False by default."""
|
||||
resp = au_client.put(
|
||||
"/api/admin/users/nocomp@example.com",
|
||||
json={"is_blocked": False},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["is_complimentary"] is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_profile_model_complimentary_column(self, au_session):
|
||||
"""UserProfile model stores is_complimentary correctly."""
|
||||
profile = UserProfile(user_id="modelcomp@example.com", is_complimentary=True)
|
||||
au_session.add(profile)
|
||||
au_session.commit()
|
||||
au_session.refresh(profile)
|
||||
assert profile.is_complimentary is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _ensure_user_profile admin auto-creation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEnsureUserProfileAdmin:
|
||||
"""Tests for _ensure_user_profile admin-specific behaviour."""
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_creates_highest_tier_profile(self, au_session):
|
||||
"""Admin first login creates a profile with the highest subscription tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
user_data = {
|
||||
"preferred_username": "admin",
|
||||
"email": "admin@local.docuelevate",
|
||||
"name": "Administrator",
|
||||
"is_admin": True,
|
||||
}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
# user_id uses preferred_username (sub not provided)
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="admin").first()
|
||||
assert profile is not None
|
||||
assert profile.subscription_tier == TIER_ORDER[-1]
|
||||
assert profile.is_complimentary is True
|
||||
assert profile.onboarding_completed is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_regular_user_login_creates_free_profile(self, au_session):
|
||||
"""Regular user login creates a profile with the free tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
user_data = {
|
||||
"preferred_username": "regular",
|
||||
"email": "user@example.com",
|
||||
"name": "Regular User",
|
||||
}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=False)
|
||||
|
||||
# user_id uses preferred_username (sub not provided)
|
||||
profile = au_session.query(UserProfile).filter_by(user_id="regular").first()
|
||||
assert profile is not None
|
||||
assert profile.subscription_tier == "free"
|
||||
assert profile.is_complimentary is False
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_sets_complimentary_on_existing_profile(self, au_session):
|
||||
"""Existing admin profile gets is_complimentary=True on login."""
|
||||
existing = UserProfile(user_id="existadmin", is_complimentary=False, subscription_tier="starter")
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
user_data = {"preferred_username": "existadmin", "email": "ea@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_does_not_downgrade_existing_tier(self, au_session):
|
||||
"""Existing admin profile with the highest tier keeps that tier on re-login."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
highest = TIER_ORDER[-1]
|
||||
existing = UserProfile(user_id="toptieradmin", is_complimentary=False, subscription_tier=highest)
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
user_data = {"preferred_username": "toptieradmin", "email": "tt@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.subscription_tier == highest
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_admin_login_upgrades_free_tier_on_existing_profile(self, au_session):
|
||||
"""Existing admin profile on free tier gets upgraded to highest tier."""
|
||||
from app.auth import _ensure_user_profile
|
||||
from app.utils.subscription import TIER_ORDER
|
||||
|
||||
existing = UserProfile(user_id="freeadmin", is_complimentary=False, subscription_tier="free")
|
||||
au_session.add(existing)
|
||||
au_session.commit()
|
||||
|
||||
user_data = {"preferred_username": "freeadmin", "email": "fa@example.com"}
|
||||
_ensure_user_profile(au_session, user_data, is_admin=True)
|
||||
|
||||
au_session.refresh(existing)
|
||||
assert existing.subscription_tier == TIER_ORDER[-1]
|
||||
assert existing.is_complimentary is True
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_ensure_user_profile_no_identifier_logs_warning(self, au_session):
|
||||
"""_ensure_user_profile logs a warning when no stable user id is present."""
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
_ensure_user_profile(au_session, {}, is_admin=False)
|
||||
# No profile should have been created
|
||||
count = au_session.query(UserProfile).count()
|
||||
assert count == 0
|
||||
|
||||
@@ -278,8 +278,10 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "secret123"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
with patch("app.auth._ensure_user_profile"):
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to upload page
|
||||
assert isinstance(result, RedirectResponse)
|
||||
@@ -305,8 +307,9 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "wrong_password"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to login with error
|
||||
assert isinstance(result, RedirectResponse)
|
||||
@@ -327,8 +330,10 @@ class TestAuthEndpoint:
|
||||
mock_form_data = {"username": "admin", "password": "secret123"}
|
||||
mock_request.form = AsyncMock(return_value=mock_form_data)
|
||||
mock_request.session = {"redirect_after_login": "/protected/page"}
|
||||
mock_db = MagicMock()
|
||||
|
||||
result = await auth(mock_request)
|
||||
with patch("app.auth._ensure_user_profile"):
|
||||
result = await auth(mock_request, db=mock_db)
|
||||
|
||||
# Verify redirect to saved URL
|
||||
assert isinstance(result, RedirectResponse)
|
||||
|
||||
+128
-2
@@ -208,7 +208,7 @@ def test_signup_disabled(la_client):
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_signup_smtp_not_configured(la_client):
|
||||
"""POST /api/auth/signup returns 503 when SMTP is not configured."""
|
||||
"""POST /api/auth/signup succeeds without SMTP and activates the account immediately."""
|
||||
with patch("app.api.local_auth.settings") as mock_settings:
|
||||
mock_settings.allow_local_signup = True
|
||||
mock_settings.multi_user_enabled = True
|
||||
@@ -222,7 +222,10 @@ def test_signup_smtp_not_configured(la_client):
|
||||
"password_confirm": "password1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 503
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["email_verification_required"] is False
|
||||
assert "now log in" in data["message"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
@@ -266,6 +269,7 @@ def test_signup_success(la_client):
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert "Verification email sent" in resp.json()["message"]
|
||||
assert resp.json()["email_verification_required"] is True
|
||||
mock_send.assert_called_once()
|
||||
|
||||
|
||||
@@ -675,3 +679,125 @@ async def test_single_user_mode_skips_local_user_table(la_session, active_user):
|
||||
# Admin path sets is_admin=True and id="admin"
|
||||
assert mock_request.session["user"]["is_admin"] is True
|
||||
assert mock_request.session["user"]["id"] == "admin"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: admin local user management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def admin_session_client(la_engine):
|
||||
"""TestClient with admin access via dependency override."""
|
||||
from app.api.admin_users import _require_admin
|
||||
from app.main import app
|
||||
|
||||
Session = sessionmaker(bind=la_engine)
|
||||
|
||||
def override_get_db():
|
||||
db = Session()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def override_require_admin():
|
||||
return {"id": "admin@example.com", "is_admin": True, "display_name": "Admin"}
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
app.dependency_overrides[_require_admin] = override_require_admin
|
||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=True) as client:
|
||||
yield client
|
||||
app.dependency_overrides.pop(get_db, None)
|
||||
app.dependency_overrides.pop(_require_admin, None)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_list_local_users_empty(admin_session_client):
|
||||
"""GET /api/admin/users/local returns an empty list when no local users exist."""
|
||||
resp = admin_session_client.get("/api/admin/users/local")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == []
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_create_local_user(admin_session_client, la_session):
|
||||
"""POST /api/admin/users/local creates a new active local user."""
|
||||
resp = admin_session_client.post(
|
||||
"/api/admin/users/local",
|
||||
json={
|
||||
"email": "newuser@example.com",
|
||||
"username": "newuser",
|
||||
"password": "password1",
|
||||
"is_admin": False,
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert data["email"] == "newuser@example.com"
|
||||
assert data["username"] == "newuser"
|
||||
assert data["is_active"] is True
|
||||
assert data["is_admin"] is False
|
||||
|
||||
user = la_session.query(LocalUser).filter(LocalUser.email == "newuser@example.com").first()
|
||||
assert user is not None
|
||||
assert user.is_active is True
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_create_local_user_duplicate_email(admin_session_client, active_user):
|
||||
"""POST /api/admin/users/local returns 409 when email already exists."""
|
||||
resp = admin_session_client.post(
|
||||
"/api/admin/users/local",
|
||||
json={
|
||||
"email": "active@example.com",
|
||||
"username": "differentuser",
|
||||
"password": "password1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_create_local_user_duplicate_username(admin_session_client, active_user):
|
||||
"""POST /api/admin/users/local returns 409 when username already taken."""
|
||||
resp = admin_session_client.post(
|
||||
"/api/admin/users/local",
|
||||
json={
|
||||
"email": "different@example.com",
|
||||
"username": "activeuser",
|
||||
"password": "password1",
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_delete_local_user(admin_session_client, la_session, active_user):
|
||||
"""DELETE /api/admin/users/local/{id} removes the account."""
|
||||
user_id = active_user.id
|
||||
resp = admin_session_client.delete(f"/api/admin/users/local/{user_id}")
|
||||
assert resp.status_code == 204
|
||||
|
||||
user = la_session.query(LocalUser).filter(LocalUser.id == user_id).first()
|
||||
assert user is None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_delete_local_user_not_found(admin_session_client):
|
||||
"""DELETE /api/admin/users/local/{id} returns 404 for unknown ID."""
|
||||
resp = admin_session_client.delete("/api/admin/users/local/99999")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_admin_local_user_list_after_create(admin_session_client):
|
||||
"""GET /api/admin/users/local returns the created user."""
|
||||
admin_session_client.post(
|
||||
"/api/admin/users/local",
|
||||
json={"email": "listed@example.com", "username": "listeduser", "password": "password1"},
|
||||
)
|
||||
resp = admin_session_client.get("/api/admin/users/local")
|
||||
assert resp.status_code == 200
|
||||
users = resp.json()
|
||||
assert any(u["email"] == "listed@example.com" for u in users)
|
||||
|
||||
@@ -107,7 +107,7 @@ def test_free_tier_has_no_mailboxes():
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_business_tier_has_highest_limits():
|
||||
"""Business tier must have the highest limits of all paid tiers."""
|
||||
"""Power tier (plan_id 'business') must have the highest limits of all paid tiers."""
|
||||
t = TIERS["business"]
|
||||
# lifetime: no hard cap (0 = unlimited)
|
||||
assert t["lifetime_file_limit"] == 0
|
||||
@@ -121,9 +121,15 @@ def test_business_tier_has_highest_limits():
|
||||
assert t["max_file_size_mb"] == 0
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_business_tier_display_name_is_power():
|
||||
"""The 'business' plan_id must display as 'Power'."""
|
||||
assert TIERS["business"]["name"] == "Power"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_mailbox_limits_increase_by_tier():
|
||||
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, business=0(inf)."""
|
||||
"""Mailbox limits must increase across tiers: free=0, starter=1, professional=3, power/business=0(inf)."""
|
||||
assert TIERS["free"]["max_mailboxes"] == 0
|
||||
assert TIERS["starter"]["max_mailboxes"] == 1
|
||||
assert TIERS["professional"]["max_mailboxes"] == 3
|
||||
@@ -144,7 +150,7 @@ def test_free_tier_has_no_trial():
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_pricing_order():
|
||||
"""Paid tier prices must increase in order: starter < professional < business."""
|
||||
"""Paid tier prices must increase in order: starter < professional < power."""
|
||||
assert TIERS["starter"]["price_monthly"] < TIERS["professional"]["price_monthly"]
|
||||
assert TIERS["professional"]["price_monthly"] < TIERS["business"]["price_monthly"]
|
||||
|
||||
|
||||
@@ -610,6 +610,27 @@ class TestSignupNotificationFromAuth:
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_no_signup_notification_for_new_admin_user(self, mocker):
|
||||
"""Admin users never receive a signup notification even on first login."""
|
||||
mock_notify = mocker.patch("app.utils.notification.notify_user_signup")
|
||||
mock_dispatch = mocker.patch("app.utils.webhook.dispatch_webhook_event")
|
||||
|
||||
from app.auth import _ensure_user_profile
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value.first.return_value = None # no existing profile
|
||||
|
||||
user_data = {
|
||||
"sub": "admin-user-sub",
|
||||
"name": "Admin User",
|
||||
"email": "admin@example.com",
|
||||
}
|
||||
|
||||
_ensure_user_profile(mock_db, user_data, is_admin=True)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_dispatch.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: webhook events listed via the API include new user events
|
||||
|
||||
Reference in New Issue
Block a user