feat(auth): enable local user signup without SMTP, add admin user creation
- Remove SMTP hard-requirement from /api/auth/signup: when SMTP is not
configured accounts are activated immediately (no email verification).
When SMTP is configured the existing email-verification flow is kept.
- Inject allow_signup into global template context via app/views/base.py
- Add data-allow-signup attribute to base.html body tag
- Update common.js _renderLoggedOutAuth to show Sign Up (→ /signup) when
signup is enabled, otherwise Get Started (→ /pricing)
- Add admin API endpoints before the /{user_id:path} catch-all:
GET /api/admin/users/local – list all local accounts
POST /api/admin/users/local – admin-create local account (active immediately)
DELETE /api/admin/users/local/{id} – delete local account + profile
- Add LocalUserCreate / LocalUserResponse Pydantic schemas
- Update admin_users.html with Local User Accounts section and modals
- Update .env.demo to document ALLOW_LOCAL_SIGNUP
- Update docs/BillingSetup.md: SMTP is optional, document both flows
- Update tests: test_signup_smtp_not_configured now asserts 201 + immediate
activation; add 7 new integration tests for admin local user endpoints
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
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,
|
# When enabled, each user has their own document space with isolated uploads,
|
||||||
# search, and file management. Requires AUTH_ENABLED=true.
|
# search, and file management. Requires AUTH_ENABLED=true.
|
||||||
MULTI_USER_ENABLED=false
|
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 upload limit per user per day (0 = unlimited)
|
||||||
DEFAULT_DAILY_UPLOAD_LIMIT=0
|
DEFAULT_DAILY_UPLOAD_LIMIT=0
|
||||||
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
|
# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false)
|
||||||
|
|||||||
+126
-27
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Provides CRUD operations for user profiles and aggregate statistics so that
|
Provides CRUD operations for user profiles and aggregate statistics so that
|
||||||
administrators can inspect, configure, and manage users in multi-user mode.
|
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
|
import logging
|
||||||
@@ -14,7 +16,8 @@ from sqlalchemy import func
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.database import get_db
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
||||||
@@ -97,6 +100,30 @@ class UserSummary(BaseModel):
|
|||||||
last_upload: str | None
|
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
|
# Helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -209,39 +236,111 @@ def list_users(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@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]:
|
# Local user management (admin-only)
|
||||||
"""Return profile and document statistics for a specific user."""
|
# ---------------------------------------------------------------------------
|
||||||
doc_count = db.query(func.count(FileRecord.id)).filter(FileRecord.owner_id == user_id).scalar() or 0
|
# NOTE: These routes MUST be defined before /{user_id:path} to avoid being
|
||||||
last_row = (
|
# swallowed by the catch-all path parameter.
|
||||||
db.query(FileRecord.created_at)
|
# ---------------------------------------------------------------------------
|
||||||
.filter(FileRecord.owner_id == user_id)
|
|
||||||
.order_by(FileRecord.created_at.desc())
|
|
||||||
.first()
|
@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,
|
||||||
)
|
)
|
||||||
last_upload = last_row[0].isoformat() if last_row and last_row[0] else None
|
db.add(user)
|
||||||
|
|
||||||
profile = _get_or_none(db, user_id)
|
# 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 {
|
return {
|
||||||
"user_id": user_id,
|
"id": user.id,
|
||||||
"display_name": profile.display_name if profile else None,
|
"email": user.email,
|
||||||
"daily_upload_limit": profile.daily_upload_limit if profile else None,
|
"username": user.username,
|
||||||
"notes": profile.notes if profile else None,
|
"display_name": user.display_name,
|
||||||
"is_blocked": profile.is_blocked if profile else False,
|
"is_active": user.is_active,
|
||||||
"subscription_tier": (profile.subscription_tier or "free") if profile else "free",
|
"is_admin": user.is_admin,
|
||||||
"subscription_billing_cycle": (profile.subscription_billing_cycle or "monthly") if profile else "monthly",
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"subscription_period_start": profile.subscription_period_start.isoformat()
|
|
||||||
if (profile and profile.subscription_period_start)
|
|
||||||
else None,
|
|
||||||
"allow_overage": bool(profile.allow_overage) if profile else False,
|
|
||||||
"profile_id": profile.id if profile else None,
|
|
||||||
"document_count": doc_count,
|
|
||||||
"last_upload": last_upload,
|
|
||||||
"profile": _profile_to_dict(profile) if profile 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")
|
||||||
@router.put("/{user_id:path}", summary="Create or update a user profile")
|
@router.put("/{user_id:path}", summary="Create or update a user profile")
|
||||||
def upsert_user_profile(
|
def upsert_user_profile(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
+29
-11
@@ -128,15 +128,18 @@ async def reset_password_page(request: Request) -> Any:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
|
@router.post("/api/auth/signup", status_code=status.HTTP_201_CREATED)
|
||||||
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str]:
|
async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str, str | bool]:
|
||||||
"""Create a new local user account and send a verification email.
|
"""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``.
|
Both ``MULTI_USER_ENABLED`` and ``ALLOW_LOCAL_SIGNUP`` must be ``True``.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
403: Multi-user mode or local signup is disabled.
|
403: Multi-user mode or local signup is disabled.
|
||||||
503: SMTP is not configured.
|
|
||||||
422: Passwords do not match.
|
422: Passwords do not match.
|
||||||
409: Email or username already registered.
|
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.")
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Multi-user mode is not enabled.")
|
||||||
if not settings.allow_local_signup:
|
if not settings.allow_local_signup:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Registration is not enabled.")
|
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:
|
if body.password != body.password_confirm:
|
||||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="Passwords do not match.")
|
||||||
|
|
||||||
@@ -157,6 +155,9 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
|||||||
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
if db.query(LocalUser).filter(LocalUser.username == body.username).first():
|
||||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Username already taken.")
|
||||||
|
|
||||||
|
smtp_configured = bool(settings.email_host)
|
||||||
|
|
||||||
|
if smtp_configured:
|
||||||
token = generate_token()
|
token = generate_token()
|
||||||
user = LocalUser(
|
user = LocalUser(
|
||||||
email=body.email,
|
email=body.email,
|
||||||
@@ -167,6 +168,17 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
|||||||
email_verification_token=token,
|
email_verification_token=token,
|
||||||
email_verification_sent_at=datetime.now(tz=timezone.utc),
|
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)
|
db.add(user)
|
||||||
|
|
||||||
profile = UserProfile(
|
profile = UserProfile(
|
||||||
@@ -185,6 +197,7 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
|||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
if smtp_configured and token:
|
||||||
base_url = str(request.base_url).rstrip("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
try:
|
try:
|
||||||
send_verification_email(body.email, body.username, token, base_url)
|
send_verification_email(body.email, body.username, token, base_url)
|
||||||
@@ -195,12 +208,17 @@ async def signup(request: Request, body: SignupBody, db: DbSession) -> dict[str,
|
|||||||
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
logger.warning("Signup email failed for %s: %s", body.email, exc)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail=("Failed to send verification email. Please check that SMTP is correctly configured and try again."),
|
detail=(
|
||||||
|
"Failed to send verification email. Please check that SMTP is correctly configured and try again."
|
||||||
|
),
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info("New local user registered: %s", body.email)
|
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)
|
@router.get("/verify-email", include_in_schema=False)
|
||||||
|
|||||||
@@ -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("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
||||||
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
||||||
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
|
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")
|
req = ctx.get("request")
|
||||||
if req is not None:
|
if req is not None:
|
||||||
|
|||||||
+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`.
|
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
|
### Configuration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ALLOW_LOCAL_SIGNUP=true
|
ALLOW_LOCAL_SIGNUP=true
|
||||||
|
|
||||||
# SMTP (required for verification emails)
|
# SMTP (optional — enables email verification and password reset)
|
||||||
EMAIL_HOST=smtp.example.com
|
EMAIL_HOST=smtp.example.com
|
||||||
EMAIL_PORT=587
|
EMAIL_PORT=587
|
||||||
EMAIL_USERNAME=noreply@example.com
|
EMAIL_USERNAME=noreply@example.com
|
||||||
@@ -37,11 +37,21 @@ EMAIL_SENDER=DocuElevate <noreply@example.com>
|
|||||||
|
|
||||||
### Sign-up Flow
|
### Sign-up Flow
|
||||||
|
|
||||||
|
**With SMTP configured (recommended):**
|
||||||
1. User visits `/signup` and fills out the registration form.
|
1. User visits `/signup` and fills out the registration form.
|
||||||
2. DocuElevate sends a verification email with a 24-hour token link.
|
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.
|
3. User clicks the link — their account is activated and they are signed in.
|
||||||
4. First-time users are redirected to the onboarding wizard.
|
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
|
### Password Reset Flow
|
||||||
|
|
||||||
1. User clicks "Forgot password?" on the login page.
|
1. User clicks "Forgot password?" on the login page.
|
||||||
|
|||||||
@@ -304,11 +304,14 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the login / get-started buttons for unauthenticated visitors.
|
* Render the login / get-started buttons for unauthenticated visitors.
|
||||||
* Reads the data-multi-user attribute that the server injects on <body> to
|
* Reads the data-multi-user and data-allow-signup attributes that the server
|
||||||
* decide whether to show a prominent "Get Started" CTA alongside the login link.
|
* 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) {
|
function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
||||||
const multiUser = document.body.getAttribute('data-multi-user') === 'true';
|
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) {
|
if (authSection) {
|
||||||
authSection.textContent = '';
|
authSection.textContent = '';
|
||||||
@@ -324,10 +327,10 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
|||||||
|
|
||||||
if (multiUser) {
|
if (multiUser) {
|
||||||
const startLink = document.createElement('a');
|
const startLink = document.createElement('a');
|
||||||
startLink.href = '/pricing';
|
startLink.href = startHref;
|
||||||
startLink.className =
|
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';
|
'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);
|
row.appendChild(startLink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,14 +353,14 @@ function _renderLoggedOutAuth(authSection, mobileAuthSection) {
|
|||||||
|
|
||||||
if (multiUser) {
|
if (multiUser) {
|
||||||
const startLink = document.createElement('a');
|
const startLink = document.createElement('a');
|
||||||
startLink.href = '/pricing';
|
startLink.href = startHref;
|
||||||
startLink.className =
|
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';
|
'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');
|
const startIcon = document.createElement('i');
|
||||||
startIcon.className = 'fas fa-arrow-right mr-2';
|
startIcon.className = 'fas fa-arrow-right mr-2';
|
||||||
startIcon.setAttribute('aria-hidden', 'true');
|
startIcon.setAttribute('aria-hidden', 'true');
|
||||||
startLink.appendChild(startIcon);
|
startLink.appendChild(startIcon);
|
||||||
startLink.appendChild(document.createTextNode('Get Started'));
|
startLink.appendChild(document.createTextNode(allowSignup ? 'Sign Up' : 'Get Started'));
|
||||||
mobileAuthSection.appendChild(startLink);
|
mobileAuthSection.appendChild(startLink);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,13 @@
|
|||||||
>
|
>
|
||||||
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
|
<i class="fas fa-user-plus mr-2" aria-hidden="true"></i> Add User Profile
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
|
<!-- ── Alert banner ───────────────────────────────────────────────────────── -->
|
||||||
@@ -399,6 +406,200 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ──────────────────────────────────────────── -->
|
<!-- ── Delete confirmation modal ──────────────────────────────────────────── -->
|
||||||
<div
|
<div
|
||||||
x-show="deleteModal.open"
|
x-show="deleteModal.open"
|
||||||
@@ -481,6 +682,23 @@ function adminUsersApp() {
|
|||||||
deleting: false,
|
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
|
||||||
alert: { show: false, type: 'success', title: '', message: '' },
|
alert: { show: false, type: 'success', title: '', message: '' },
|
||||||
|
|
||||||
@@ -491,7 +709,7 @@ function adminUsersApp() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
await this.fetchUsers(1);
|
await Promise.all([this.fetchUsers(1), this.fetchLocalUsers()]);
|
||||||
},
|
},
|
||||||
|
|
||||||
async fetchUsers(page) {
|
async fetchUsers(page) {
|
||||||
@@ -633,6 +851,90 @@ function adminUsersApp() {
|
|||||||
this.alert = { show: true, type, title, message };
|
this.alert = { show: true, type, title, message };
|
||||||
setTimeout(() => { this.alert.show = false; }, type === 'success' ? 5000 : 10000);
|
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>
|
</script>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col"
|
<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 -->
|
<!-- Skip to main content link for keyboard/screen reader users -->
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,12 @@
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
if (resp.ok) {
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.email_verification_required) {
|
||||||
window.location.href = '/verify-email-sent';
|
window.location.href = '/verify-email-sent';
|
||||||
|
} else {
|
||||||
|
window.location.href = '/login?message=Account+created+successfully.+You+can+now+log+in.';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
this.error = data.detail || 'Registration failed. Please try again.';
|
this.error = data.detail || 'Registration failed. Please try again.';
|
||||||
|
|||||||
+128
-2
@@ -208,7 +208,7 @@ def test_signup_disabled(la_client):
|
|||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
def test_signup_smtp_not_configured(la_client):
|
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:
|
with patch("app.api.local_auth.settings") as mock_settings:
|
||||||
mock_settings.allow_local_signup = True
|
mock_settings.allow_local_signup = True
|
||||||
mock_settings.multi_user_enabled = True
|
mock_settings.multi_user_enabled = True
|
||||||
@@ -222,7 +222,10 @@ def test_signup_smtp_not_configured(la_client):
|
|||||||
"password_confirm": "password1",
|
"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
|
@pytest.mark.integration
|
||||||
@@ -266,6 +269,7 @@ def test_signup_success(la_client):
|
|||||||
)
|
)
|
||||||
assert resp.status_code == 201
|
assert resp.status_code == 201
|
||||||
assert "Verification email sent" in resp.json()["message"]
|
assert "Verification email sent" in resp.json()["message"]
|
||||||
|
assert resp.json()["email_verification_required"] is True
|
||||||
mock_send.assert_called_once()
|
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"
|
# Admin path sets is_admin=True and id="admin"
|
||||||
assert mock_request.session["user"]["is_admin"] is True
|
assert mock_request.session["user"]["is_admin"] is True
|
||||||
assert mock_request.session["user"]["id"] == "admin"
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user