Add invitations, email models, admin APIs

Introduce team invitation support and a revamped email subsystem plus admin features. Adds Invitation model and API endpoints (create, list, revoke, resend, validate) and integrates invitation validation into user registration. Replaces old email account model with richer email models (EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig) and updates models __init__. Refactors email API to manage domains, accounts, aliases, forwarding, DNS providers, spamassassin, Roundcube/Postfix services and queue/log endpoints. Adds several backend services (invitation, DNS provider, DKIM, Dovecot, Postfix, Roundcube, SpamAssassin, permission service) and a DB migration for permissions/invitations. Enhances admin API with permission management and activity dashboard endpoints, extends audit log actions, and adds a permission_required RBAC decorator. Includes frontend additions for settings/invitations UI and miscellaneous config/service updates and tests.
This commit is contained in:
Juan Denis committed 2026-03-04 02:34:49 -05:00
1 parent c5710e88b9
commit b7fab1152b
42 files changed
+6472 -2530

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
---
name: audit-less
description: Scan LESS stylesheets for variable errors — undefined variables, CSS custom properties passed to compile-time functions (fade, darken, lighten, etc.), and incorrect variable name patterns. Reports issues with file, line, and fix.
argument-hint: "[file-or-directory]"
---
Audit LESS stylesheets in ServerKit for recurring build-breaking patterns.
Scope: **${ARGUMENTS:-frontend/src/styles/}**
## What to Scan For
### Pattern 1: CSS custom properties in LESS compile-time functions
LESS functions like `fade()`, `darken()`, `lighten()`, `saturate()`, `spin()`, `mix()` require **real color values** at compile time. Variables defined as `var(--something)` will fail.
Search for calls to these functions and check if any argument is a variable that resolves to a CSS custom property.
**Broken** — these variables use `var(--...)` and cannot be evaluated by LESS:
- `@bg-body`, `@bg-sidebar`, `@bg-card`, `@bg-hover`, `@bg-elevated`, `@bg-secondary`, `@bg-tertiary`
- `@border-default`, `@border-subtle`, `@border-active`, `@border-hover`
- `@text-primary`, `@text-secondary`, `@text-tertiary`
- `@accent-primary`, `@accent-hover`, `@accent-glow`, `@accent-shadow`
- `@shadow-sm`, `@shadow-md`, `@shadow-lg`
- `@color-primary`
**Fix**: Use the corresponding `*-raw` variant instead (e.g., `@bg-hover` → `@bg-hover-raw`, `@text-tertiary` → `@text-tertiary-raw`, `@accent-primary` → `@accent-primary-raw`).
### Pattern 2: Undefined or misspelled variables
Check for variables that don't exist in `_variables.less`. Common mistakes:
- `@card-bg` → should be `@bg-card`
- `@accent-success` → should be `@success`
- `@accent-danger` → should be `@danger`
- `@accent-info` → should be `@info`
- `@accent-warning` → should be `@warning`
- `@primary-color` → should be `@accent-primary` or `@accent-primary-raw`
- `@spacing-*` → should be `@space-*`
### Pattern 3: Non-raw variables in theme-sensitive contexts
For any LESS function that manipulates color values (fade, darken, lighten, contrast, saturate, desaturate, spin, mix, tint, shade), the argument MUST be a raw hex/rgb value or a `*-raw` variable.
## Reference: Valid Variable Names
Read `frontend/src/styles/_variables.less` to get the authoritative list of defined variables. Any `@variable` used in a `.less` file that is not in `_variables.less` (and is not a local variable or LESS built-in) is a bug.
## Output Format
For each issue found, report:
```
[FILE]:[LINE] — [ISSUE]
Found: [problematic code]
Fix: [corrected code]
```
At the end, provide a summary count: `X issues found across Y files`.
If no issues are found, report: `No LESS variable issues found.`
+4
View File
@@ -206,6 +206,10 @@ def create_app(config_name=None):
from app.api.admin import admin_bp from app.api.admin import admin_bp
app.register_blueprint(admin_bp, url_prefix='/api/v1/admin') app.register_blueprint(admin_bp, url_prefix='/api/v1/admin')
# Register blueprints - Invitations
from app.api.invitations import invitations_bp
app.register_blueprint(invitations_bp, url_prefix='/api/v1/admin/invitations')
# Register blueprints - Historical Metrics # Register blueprints - Historical Metrics
from app.api.metrics import metrics_bp from app.api.metrics import metrics_bp
app.register_blueprint(metrics_bp, url_prefix='/api/v1/metrics') app.register_blueprint(metrics_bp, url_prefix='/api/v1/metrics')
+206 -1
View File
@@ -1,12 +1,14 @@
"""Admin API endpoints for user management, settings, and audit logs.""" """Admin API endpoints for user management, settings, and audit logs."""
from datetime import datetime from datetime import datetime, timedelta
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask_jwt_extended import get_jwt_identity from flask_jwt_extended import get_jwt_identity
from sqlalchemy import func
from app import db from app import db
from app.models import User, AuditLog from app.models import User, AuditLog
from app.middleware.rbac import admin_required, get_current_user from app.middleware.rbac import admin_required, get_current_user
from app.services.audit_service import AuditService from app.services.audit_service import AuditService
from app.services.settings_service import SettingsService from app.services.settings_service import SettingsService
from app.services.permission_service import PermissionService
admin_bp = Blueprint('admin', __name__) admin_bp = Blueprint('admin', __name__)
@@ -143,6 +145,15 @@ def update_user(user_id):
user.set_password(data['password']) user.set_password(data['password'])
changes['password'] = 'changed' changes['password'] = 'changed'
# Update custom permissions (inline with user update)
if 'permissions' in data and user.role != User.ROLE_ADMIN:
from app.services.permission_service import PermissionService
perm_error = PermissionService.validate_permissions(data['permissions'])
if perm_error:
return jsonify({'error': perm_error}), 400
user.set_permissions(data['permissions'])
changes['permissions'] = 'updated'
# Update active status # Update active status
if 'is_active' in data and data['is_active'] != user.is_active: if 'is_active' in data and data['is_active'] != user.is_active:
# Prevent deactivating self # Prevent deactivating self
@@ -379,3 +390,197 @@ def get_admin_stats():
}, },
'recent_logins': [log.to_dict() for log in recent_logins] 'recent_logins': [log.to_dict() for log in recent_logins]
}), 200 }), 200
# ============================================
# Permission Endpoints
# ============================================
@admin_bp.route('/users/<int:user_id>/permissions', methods=['GET'])
@admin_required
def get_user_permissions(user_id):
"""Get a user's resolved permissions."""
perms = PermissionService.get_user_permissions(user_id)
if perms is None:
return jsonify({'error': 'User not found'}), 404
return jsonify({'permissions': perms}), 200
@admin_bp.route('/users/<int:user_id>/permissions', methods=['PUT'])
@admin_required
def update_user_permissions(user_id):
"""Update a user's custom permissions."""
data = request.get_json()
current_user_id = get_jwt_identity()
if not data or 'permissions' not in data:
return jsonify({'error': 'No permissions provided'}), 400
result = PermissionService.update_user_permissions(user_id, data['permissions'])
if not result['success']:
return jsonify({'error': result['error']}), 400
AuditService.log_user_action(
action=AuditLog.ACTION_USER_PERMISSIONS_UPDATE,
user_id=current_user_id,
target_user_id=user_id,
details={'permissions': data['permissions']}
)
db.session.commit()
return jsonify({
'message': 'Permissions updated',
'permissions': result['permissions']
}), 200
@admin_bp.route('/users/<int:user_id>/permissions/reset', methods=['POST'])
@admin_required
def reset_user_permissions(user_id):
"""Reset user permissions to role defaults."""
current_user_id = get_jwt_identity()
result = PermissionService.reset_to_role_defaults(user_id)
if not result['success']:
return jsonify({'error': result['error']}), 400
AuditService.log_user_action(
action=AuditLog.ACTION_USER_PERMISSIONS_RESET,
user_id=current_user_id,
target_user_id=user_id,
)
db.session.commit()
return jsonify({
'message': 'Permissions reset to role defaults',
'permissions': result['permissions']
}), 200
@admin_bp.route('/permissions/templates', methods=['GET'])
@admin_required
def get_permission_templates():
"""Get role template definitions and feature list."""
return jsonify({
'features': User.PERMISSION_FEATURES,
'templates': User.ROLE_PERMISSION_TEMPLATES,
}), 200
# ============================================
# Activity Dashboard Endpoints
# ============================================
@admin_bp.route('/activity/summary', methods=['GET'])
@admin_required
def get_activity_summary():
"""Get activity summary: active users today, actions this week, top users."""
now = datetime.utcnow()
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
week_start = today_start - timedelta(days=7)
# Active users today (distinct users with audit log entries)
active_today = db.session.query(
func.count(func.distinct(AuditLog.user_id))
).filter(
AuditLog.created_at >= today_start,
AuditLog.user_id.isnot(None)
).scalar() or 0
# Actions this week
actions_this_week = db.session.query(
func.count(AuditLog.id)
).filter(
AuditLog.created_at >= week_start
).scalar() or 0
# Total users
total_users = User.query.filter_by(is_active=True).count()
# Top 5 most active users this week
top_users_query = db.session.query(
AuditLog.user_id,
func.count(AuditLog.id).label('action_count')
).filter(
AuditLog.created_at >= week_start,
AuditLog.user_id.isnot(None)
).group_by(AuditLog.user_id).order_by(
func.count(AuditLog.id).desc()
).limit(5).all()
top_users = []
for user_id, count in top_users_query:
user = User.query.get(user_id)
if user:
top_users.append({
'user_id': user_id,
'username': user.username,
'action_count': count
})
# Daily action counts for the past 7 days
daily_counts = []
for i in range(7):
day_start = today_start - timedelta(days=6 - i)
day_end = day_start + timedelta(days=1)
count = db.session.query(func.count(AuditLog.id)).filter(
AuditLog.created_at >= day_start,
AuditLog.created_at < day_end
).scalar() or 0
daily_counts.append({
'date': day_start.strftime('%Y-%m-%d'),
'count': count
})
return jsonify({
'active_users_today': active_today,
'actions_this_week': actions_this_week,
'total_users': total_users,
'top_users': top_users,
'daily_counts': daily_counts,
}), 200
@admin_bp.route('/activity/feed', methods=['GET'])
@admin_required
def get_activity_feed():
"""Get paginated activity feed with filters."""
page = request.args.get('page', 1, type=int)
per_page = min(request.args.get('per_page', 50, type=int), 100)
user_id = request.args.get('user_id', type=int)
action = request.args.get('action')
start_date = request.args.get('start_date')
end_date = request.args.get('end_date')
kwargs = {
'page': page,
'per_page': per_page,
'user_id': user_id,
'action': action,
}
if start_date:
try:
kwargs['start_date'] = datetime.fromisoformat(start_date)
except ValueError:
pass
if end_date:
try:
kwargs['end_date'] = datetime.fromisoformat(end_date)
except ValueError:
pass
pagination = AuditService.get_logs(**kwargs)
return jsonify({
'logs': [log.to_dict() for log in pagination.items],
'pagination': {
'page': pagination.page,
'per_page': pagination.per_page,
'total': pagination.total,
'pages': pagination.pages,
'has_next': pagination.has_next,
'has_prev': pagination.has_prev
}
}), 200
+43 -3
View File
@@ -52,9 +52,19 @@ def register():
if not data: if not data:
return jsonify({'error': 'No data provided'}), 400 return jsonify({'error': 'No data provided'}), 400
invite_token = data.get('invite_token')
invitation = None
# Validate invitation if provided
if invite_token:
from app.services.invitation_service import InvitationService
invitation = InvitationService.validate_token(invite_token)
if not invitation:
return jsonify({'error': 'Invalid or expired invitation'}), 400
# Check if registration is allowed # Check if registration is allowed
is_first_user = User.query.count() == 0 is_first_user = User.query.count() == 0
if not is_first_user and not SettingsService.is_registration_enabled(): if not is_first_user and not invitation and not SettingsService.is_registration_enabled():
return jsonify({'error': 'Registration is disabled'}), 403 return jsonify({'error': 'Registration is disabled'}), 403
email = data.get('email') email = data.get('email')
@@ -73,22 +83,52 @@ def register():
if len(password) < 8: if len(password) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400 return jsonify({'error': 'Password must be at least 8 characters'}), 400
# Determine role and permissions from invitation or defaults
if is_first_user:
role = User.ROLE_ADMIN
elif invitation:
role = invitation.role
else:
role = User.ROLE_DEVELOPER
user = User( user = User(
email=email, email=email,
username=username, username=username,
role=User.ROLE_ADMIN if is_first_user else User.ROLE_DEVELOPER role=role
) )
user.set_password(password) user.set_password(password)
# Apply custom permissions from invitation
if invitation and invitation.get_permissions():
user.set_permissions(invitation.get_permissions())
db.session.add(user) db.session.add(user)
db.session.commit() db.session.commit()
# Mark invitation accepted
if invitation:
from app.services.invitation_service import InvitationService
InvitationService.accept_invitation(invite_token, user.id)
AuditService.log(
action=AuditLog.ACTION_INVITATION_ACCEPT,
user_id=user.id,
target_type='invitation',
target_id=invitation.id,
details={'role': role}
)
# Log the user creation # Log the user creation
AuditService.log_user_action( AuditService.log_user_action(
action=AuditLog.ACTION_USER_CREATE, action=AuditLog.ACTION_USER_CREATE,
user_id=user.id, user_id=user.id,
target_user_id=user.id, target_user_id=user.id,
details={'username': username, 'role': user.role, 'self_registration': True} details={
'username': username,
'role': user.role,
'self_registration': True,
'via_invitation': invitation is not None
}
) )
db.session.commit() db.session.commit()
+352 -275
View File
@@ -1,350 +1,427 @@
"""Email Server API endpoints for managing mail services, domains, accounts, and DNS."""
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.models import User from ..middleware.rbac import admin_required, viewer_required
from app.services.email_service import EmailService from ..services.email_service import EmailService
from ..services.dns_provider_service import DNSProviderService
from ..services.spamassassin_service import SpamAssassinService
from ..services.roundcube_service import RoundcubeService
from ..services.postfix_service import PostfixService
email_bp = Blueprint('email', __name__) email_bp = Blueprint('email', __name__)
def admin_required(fn): # ── Status & Installation ──
"""Decorator to require admin role."""
from functools import wraps
@wraps(fn)
def wrapper(*args, **kwargs):
current_user_id = get_jwt_identity()
user = User.query.get(current_user_id)
if not user or user.role != 'admin':
return jsonify({'error': 'Admin access required'}), 403
return fn(*args, **kwargs)
return wrapper
# ==========================================
# STATUS & CONFIG
# ==========================================
@email_bp.route('/status', methods=['GET']) @email_bp.route('/status', methods=['GET'])
@jwt_required() @viewer_required
def get_email_status(): def get_status():
"""Get overall email server status.""" """Get aggregate email server status."""
roundcube = RoundcubeService.get_status()
status = EmailService.get_status() status = EmailService.get_status()
status['roundcube'] = roundcube
return jsonify(status), 200 return jsonify(status), 200
@email_bp.route('/config', methods=['GET']) @email_bp.route('/install', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def get_config(): def install():
"""Get email configuration.""" """Install and configure all email components."""
config = EmailService.get_config() data = request.get_json() or {}
return jsonify(config), 200 hostname = data.get('hostname')
result = EmailService.install_all(hostname)
return jsonify(result), 200 if result.get('success') else 500
@email_bp.route('/config', methods=['PUT']) @email_bp.route('/service/<component>/<action>', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def update_config(): def control_service(component, action):
"""Update email configuration.""" """Start/stop/restart an email component."""
result = EmailService.control_service(component, action)
return jsonify(result), 200 if result.get('success') else 400
# ── Domains ──
@email_bp.route('/domains', methods=['GET'])
@viewer_required
def list_domains():
"""List all email domains."""
domains = EmailService.get_domains()
return jsonify({'domains': domains}), 200
@email_bp.route('/domains', methods=['POST'])
@admin_required
def add_domain():
"""Add an email domain."""
data = request.get_json() data = request.get_json()
if not data: if not data or not data.get('name'):
return jsonify({'error': 'No data provided'}), 400 return jsonify({'success': False, 'error': 'Domain name is required'}), 400
result = EmailService.add_domain(
result = EmailService.save_config(data) data['name'],
return jsonify(result), 200 if result['success'] else 400 dns_provider_id=data.get('dns_provider_id'),
dns_zone_id=data.get('dns_zone_id'),
)
return jsonify(result), 201 if result.get('success') else 400
# ========================================== @email_bp.route('/domains/<int:domain_id>', methods=['GET'])
# POSTFIX @viewer_required
# ========================================== def get_domain(domain_id):
"""Get domain details."""
result = EmailService.get_domain(domain_id)
if result.get('success'):
return jsonify(result), 200
return jsonify(result), 404
@email_bp.route('/postfix/install', methods=['POST'])
@jwt_required() @email_bp.route('/domains/<int:domain_id>', methods=['DELETE'])
@admin_required @admin_required
def install_postfix(): def remove_domain(domain_id):
"""Install Postfix.""" """Remove an email domain."""
result = EmailService.install_postfix() result = EmailService.remove_domain(domain_id)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/postfix/config', methods=['GET'])
@jwt_required()
@admin_required
def get_postfix_config():
"""Get Postfix configuration."""
result = EmailService.get_postfix_config()
return jsonify(result), 200 if result.get('success') else 400 return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/postfix/config', methods=['PUT']) @email_bp.route('/domains/<int:domain_id>/verify-dns', methods=['POST'])
@jwt_required() @viewer_required
@admin_required def verify_dns(domain_id):
def update_postfix_config(): """Verify DNS records for a domain."""
"""Update Postfix configuration.""" result = EmailService.verify_dns(domain_id)
data = request.get_json() return jsonify(result), 200
if not data:
return jsonify({'error': 'No data provided'}), 400
result = EmailService.update_postfix_config(data)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/queue', methods=['GET']) @email_bp.route('/domains/<int:domain_id>/deploy-dns', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def get_mail_queue(): def deploy_dns(domain_id):
"""Get mail queue.""" """Deploy DNS records via provider API."""
result = EmailService.get_mail_queue() from app.models.email import EmailDomain
domain = EmailDomain.query.get(domain_id)
if not domain:
return jsonify({'success': False, 'error': 'Domain not found'}), 404
if not domain.dns_provider_id or not domain.dns_zone_id:
return jsonify({'success': False, 'error': 'No DNS provider configured for this domain'}), 400
result = DNSProviderService.deploy_email_records(
domain.dns_provider_id,
domain.dns_zone_id,
domain.name,
domain.dkim_selector or 'default',
domain.dkim_public_key or '',
)
return jsonify(result), 200 if result.get('success') else 400 return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/queue/flush', methods=['POST']) # ── Accounts ──
@jwt_required()
@admin_required
def flush_mail_queue():
"""Flush mail queue."""
result = EmailService.flush_mail_queue()
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/domains/<int:domain_id>/accounts', methods=['GET'])
@email_bp.route('/queue/<queue_id>', methods=['DELETE']) @viewer_required
@jwt_required() def list_accounts(domain_id):
@admin_required """List email accounts for a domain."""
def delete_queued_message(queue_id): accounts = EmailService.get_accounts(domain_id)
"""Delete a message from the queue."""
result = EmailService.delete_queued_message(queue_id)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# DOVECOT
# ==========================================
@email_bp.route('/dovecot/install', methods=['POST'])
@jwt_required()
@admin_required
def install_dovecot():
"""Install Dovecot."""
result = EmailService.install_dovecot()
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/dovecot/config', methods=['GET'])
@jwt_required()
@admin_required
def get_dovecot_config():
"""Get Dovecot configuration."""
result = EmailService.get_dovecot_config()
return jsonify(result), 200 if result.get('success') else 400
# ==========================================
# EMAIL ACCOUNTS
# ==========================================
@email_bp.route('/accounts', methods=['GET'])
@jwt_required()
@admin_required
def list_accounts():
"""List all email accounts."""
accounts = EmailService.list_accounts()
return jsonify({'accounts': accounts}), 200 return jsonify({'accounts': accounts}), 200
@email_bp.route('/accounts', methods=['POST']) @email_bp.route('/domains/<int:domain_id>/accounts', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def create_account(): def create_account(domain_id):
"""Create a new email account.""" """Create an email account."""
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'No data provided'}), 400 return jsonify({'success': False, 'error': 'Request body required'}), 400
username = data.get('username')
required = ['email', 'password', 'domain'] password = data.get('password')
for field in required: if not username or not password:
if field not in data: return jsonify({'success': False, 'error': 'Username and password are required'}), 400
return jsonify({'error': f'{field} is required'}), 400 result = EmailService.add_account(
domain_id,
result = EmailService.create_account( username,
email=data['email'], password,
password=data['password'], quota_mb=data.get('quota_mb', 1024),
domain=data['domain'],
quota_mb=data.get('quota_mb', 1024)
) )
return jsonify(result), 201 if result['success'] else 400 return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['GET'])
@viewer_required
def get_account(account_id):
"""Get account details."""
from app.models.email import EmailAccount
account = EmailAccount.query.get(account_id)
if not account:
return jsonify({'success': False, 'error': 'Account not found'}), 404
return jsonify({'success': True, 'account': account.to_dict()}), 200
@email_bp.route('/accounts/<int:account_id>', methods=['PUT']) @email_bp.route('/accounts/<int:account_id>', methods=['PUT'])
@jwt_required()
@admin_required @admin_required
def update_account(account_id): def update_account(account_id):
"""Update an email account.""" """Update account settings."""
data = request.get_json() data = request.get_json() or {}
if not data: result = EmailService.update_account(
return jsonify({'error': 'No data provided'}), 400 account_id,
quota_mb=data.get('quota_mb'),
result = EmailService.update_account(account_id, **data) is_active=data.get('is_active'),
return jsonify(result), 200 if result['success'] else 400 )
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/accounts/<int:account_id>', methods=['DELETE']) @email_bp.route('/accounts/<int:account_id>', methods=['DELETE'])
@jwt_required()
@admin_required @admin_required
def delete_account(account_id): def delete_account(account_id):
"""Delete an email account.""" """Delete an email account."""
result = EmailService.delete_account(account_id) result = EmailService.delete_account(account_id)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['PUT'])
@jwt_required()
@admin_required
def set_forwarding(account_id):
"""Set forwarding for an email account."""
data = request.get_json()
if not data or 'forward_to' not in data:
return jsonify({'error': 'forward_to is required'}), 400
result = EmailService.set_forwarding(
account_id,
forward_to=data['forward_to'],
keep_copy=data.get('keep_copy', True)
)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# SPAMASSASSIN
# ==========================================
@email_bp.route('/spamassassin/install', methods=['POST'])
@jwt_required()
@admin_required
def install_spamassassin():
"""Install SpamAssassin."""
result = EmailService.install_spamassassin()
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/spamassassin/config', methods=['GET'])
@jwt_required()
@admin_required
def get_spamassassin_config():
"""Get SpamAssassin configuration."""
result = EmailService.get_spamassassin_config()
return jsonify(result), 200 if result.get('success') else 400 return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/spamassassin/config', methods=['PUT']) @email_bp.route('/accounts/<int:account_id>/password', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def update_spamassassin_config(): def change_password(account_id):
"""Change account password."""
data = request.get_json()
if not data or not data.get('password'):
return jsonify({'success': False, 'error': 'New password is required'}), 400
result = EmailService.change_password(account_id, data['password'])
return jsonify(result), 200 if result.get('success') else 400
# ── Aliases ──
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['GET'])
@viewer_required
def list_aliases(domain_id):
"""List email aliases for a domain."""
aliases = EmailService.get_aliases(domain_id)
return jsonify({'aliases': aliases}), 200
@email_bp.route('/domains/<int:domain_id>/aliases', methods=['POST'])
@admin_required
def create_alias(domain_id):
"""Create an email alias."""
data = request.get_json()
if not data or not data.get('source') or not data.get('destination'):
return jsonify({'success': False, 'error': 'Source and destination are required'}), 400
result = EmailService.add_alias(domain_id, data['source'], data['destination'])
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/aliases/<int:alias_id>', methods=['DELETE'])
@admin_required
def delete_alias(alias_id):
"""Delete an email alias."""
result = EmailService.remove_alias(alias_id)
return jsonify(result), 200 if result.get('success') else 400
# ── Forwarding Rules ──
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['GET'])
@viewer_required
def list_forwarding(account_id):
"""List forwarding rules for an account."""
rules = EmailService.get_forwarding(account_id)
return jsonify({'rules': rules}), 200
@email_bp.route('/accounts/<int:account_id>/forwarding', methods=['POST'])
@admin_required
def create_forwarding(account_id):
"""Create a forwarding rule."""
data = request.get_json()
if not data or not data.get('destination'):
return jsonify({'success': False, 'error': 'Destination is required'}), 400
result = EmailService.add_forwarding(
account_id,
data['destination'],
keep_copy=data.get('keep_copy', True),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['PUT'])
@admin_required
def update_forwarding(rule_id):
"""Update a forwarding rule."""
data = request.get_json() or {}
result = EmailService.update_forwarding(
rule_id,
destination=data.get('destination'),
keep_copy=data.get('keep_copy'),
is_active=data.get('is_active'),
)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/forwarding/<int:rule_id>', methods=['DELETE'])
@admin_required
def delete_forwarding(rule_id):
"""Delete a forwarding rule."""
result = EmailService.remove_forwarding(rule_id)
return jsonify(result), 200 if result.get('success') else 400
# ── DNS Providers ──
@email_bp.route('/dns-providers', methods=['GET'])
@viewer_required
def list_dns_providers():
"""List configured DNS providers."""
providers = DNSProviderService.list_providers()
return jsonify({'providers': providers}), 200
@email_bp.route('/dns-providers', methods=['POST'])
@admin_required
def add_dns_provider():
"""Add a DNS provider."""
data = request.get_json()
if not data or not data.get('name') or not data.get('provider') or not data.get('api_key'):
return jsonify({'success': False, 'error': 'Name, provider, and api_key are required'}), 400
result = DNSProviderService.add_provider(
name=data['name'],
provider=data['provider'],
api_key=data['api_key'],
api_secret=data.get('api_secret'),
api_email=data.get('api_email'),
is_default=data.get('is_default', False),
)
return jsonify(result), 201 if result.get('success') else 400
@email_bp.route('/dns-providers/<int:provider_id>', methods=['DELETE'])
@admin_required
def remove_dns_provider(provider_id):
"""Remove a DNS provider."""
result = DNSProviderService.remove_provider(provider_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/dns-providers/<int:provider_id>/test', methods=['POST'])
@admin_required
def test_dns_provider(provider_id):
"""Test DNS provider connection."""
result = DNSProviderService.test_connection(provider_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/dns-providers/<int:provider_id>/zones', methods=['GET'])
@viewer_required
def list_dns_zones(provider_id):
"""List DNS zones from a provider."""
result = DNSProviderService.list_zones(provider_id)
return jsonify(result), 200 if result.get('success') else 400
# ── SpamAssassin Config ──
@email_bp.route('/spam/config', methods=['GET'])
@viewer_required
def get_spam_config():
"""Get SpamAssassin configuration."""
result = SpamAssassinService.get_config()
return jsonify(result), 200
@email_bp.route('/spam/config', methods=['PUT'])
@admin_required
def update_spam_config():
"""Update SpamAssassin configuration.""" """Update SpamAssassin configuration."""
data = request.get_json() data = request.get_json()
if not data: if not data:
return jsonify({'error': 'No data provided'}), 400 return jsonify({'success': False, 'error': 'Request body required'}), 400
result = EmailService.update_spamassassin_config(data) result = SpamAssassinService.configure(data)
return jsonify(result), 200 if result['success'] else 400 if result.get('success'):
SpamAssassinService.reload()
return jsonify(result), 200 if result.get('success') else 400
# ========================================== @email_bp.route('/spam/update-rules', methods=['POST'])
# DKIM / SPF / DMARC
# ==========================================
@email_bp.route('/dkim/install', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def install_dkim(): def update_spam_rules():
"""Install OpenDKIM.""" """Update SpamAssassin rules."""
result = EmailService.install_dkim() result = SpamAssassinService.update_rules()
return jsonify(result), 200 if result['success'] else 400 return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/dkim/generate', methods=['POST']) # ── Roundcube Webmail ──
@jwt_required()
@admin_required
def generate_dkim_key():
"""Generate DKIM key for a domain."""
data = request.get_json()
if not data or 'domain' not in data:
return jsonify({'error': 'domain is required'}), 400
result = EmailService.generate_dkim_key(
domain=data['domain'],
selector=data.get('selector', 'mail')
)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/dns/<domain>', methods=['GET'])
@jwt_required()
@admin_required
def get_dns_records(domain):
"""Get recommended DNS records for a domain."""
result = EmailService.get_dns_records(domain)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# SERVICE CONTROL
# ==========================================
@email_bp.route('/services/<service>/start', methods=['POST'])
@jwt_required()
@admin_required
def start_service(service):
"""Start an email service."""
result = EmailService.start_service(service)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/services/<service>/stop', methods=['POST'])
@jwt_required()
@admin_required
def stop_service(service):
"""Stop an email service."""
result = EmailService.stop_service(service)
return jsonify(result), 200 if result['success'] else 400
@email_bp.route('/services/<service>/restart', methods=['POST'])
@jwt_required()
@admin_required
def restart_service(service):
"""Restart an email service."""
result = EmailService.restart_service(service)
return jsonify(result), 200 if result['success'] else 400
# ==========================================
# WEBMAIL
# ==========================================
@email_bp.route('/webmail/status', methods=['GET']) @email_bp.route('/webmail/status', methods=['GET'])
@jwt_required() @viewer_required
def get_webmail_status(): def webmail_status():
"""Get webmail installation status.""" """Get Roundcube webmail status."""
status = EmailService.get_webmail_status() result = RoundcubeService.get_status()
return jsonify(status), 200 return jsonify(result), 200
@email_bp.route('/webmail/install', methods=['POST']) @email_bp.route('/webmail/install', methods=['POST'])
@jwt_required()
@admin_required @admin_required
def install_webmail(): def webmail_install():
"""Install Roundcube webmail.""" """Install Roundcube webmail."""
result = EmailService.install_webmail() data = request.get_json() or {}
return jsonify(result), 200 if result['success'] else 400 result = RoundcubeService.install(
imap_host=data.get('imap_host', 'host.docker.internal'),
smtp_host=data.get('smtp_host', 'host.docker.internal'),
)
return jsonify(result), 200 if result.get('success') else 400
# ========================================== @email_bp.route('/webmail/service/<action>', methods=['POST'])
# LOGS @admin_required
# ========================================== def webmail_control(action):
"""Start/stop/restart Roundcube."""
actions = {
'start': RoundcubeService.start,
'stop': RoundcubeService.stop,
'restart': RoundcubeService.restart,
}
if action not in actions:
return jsonify({'success': False, 'error': 'Invalid action'}), 400
result = actions[action]()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/webmail/configure-proxy', methods=['POST'])
@admin_required
def webmail_configure_proxy():
"""Configure Nginx reverse proxy for Roundcube."""
data = request.get_json()
if not data or not data.get('domain'):
return jsonify({'success': False, 'error': 'Domain is required'}), 400
result = RoundcubeService.configure_nginx_proxy(data['domain'])
return jsonify(result), 200 if result.get('success') else 400
# ── Mail Queue & Logs ──
@email_bp.route('/queue', methods=['GET'])
@viewer_required
def get_queue():
"""Get Postfix mail queue."""
result = PostfixService.get_queue()
return jsonify(result), 200
@email_bp.route('/queue/flush', methods=['POST'])
@admin_required
def flush_queue():
"""Flush the mail queue."""
result = PostfixService.flush_queue()
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/queue/<queue_id>', methods=['DELETE'])
@admin_required
def delete_queue_item(queue_id):
"""Delete a message from the queue."""
result = PostfixService.delete_from_queue(queue_id)
return jsonify(result), 200 if result.get('success') else 400
@email_bp.route('/logs', methods=['GET']) @email_bp.route('/logs', methods=['GET'])
@jwt_required() @viewer_required
@admin_required def get_logs():
def get_mail_logs():
"""Get mail logs.""" """Get mail logs."""
lines = request.args.get('lines', 100, type=int) lines = request.args.get('lines', 100, type=int)
result = EmailService.get_mail_log(lines=lines) result = PostfixService.get_logs(lines)
return jsonify(result), 200 return jsonify(result), 200
+140
View File
@@ -0,0 +1,140 @@
"""Invitation API endpoints for team invitations."""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import get_jwt_identity
from app import db
from app.models import AuditLog
from app.middleware.rbac import admin_required
from app.services.invitation_service import InvitationService
from app.services.audit_service import AuditService
invitations_bp = Blueprint('invitations', __name__)
@invitations_bp.route('/', methods=['GET'])
@admin_required
def list_invitations():
"""List all invitations, optionally filtered by status."""
status = request.args.get('status')
invitations = InvitationService.list_invitations(status=status)
return jsonify({
'invitations': [inv.to_dict() for inv in invitations]
}), 200
@invitations_bp.route('/', methods=['POST'])
@admin_required
def create_invitation():
"""Create a new invitation."""
data = request.get_json()
current_user_id = get_jwt_identity()
if not data:
return jsonify({'error': 'No data provided'}), 400
email = data.get('email') # Optional
role = data.get('role', 'developer')
permissions = data.get('permissions')
expires_in_days = data.get('expires_in_days', 7)
result = InvitationService.create_invitation(
email=email,
role=role,
permissions=permissions,
invited_by=current_user_id,
expires_in_days=expires_in_days
)
if not result['success']:
return jsonify({'error': result['error']}), 400
invitation = result['invitation']
# Build invite URL
base_url = request.host_url.rstrip('/')
invite_url = f"{base_url}/register?invite={invitation.token}"
# Try to send email if email provided
email_sent = False
email_error = None
if email:
email_result = InvitationService.send_invitation_email(invitation, base_url)
email_sent = email_result['success']
if not email_sent:
email_error = email_result.get('error')
# Audit log
AuditService.log(
action=AuditLog.ACTION_INVITATION_CREATE,
user_id=current_user_id,
target_type='invitation',
target_id=invitation.id,
details={'email': email, 'role': role, 'email_sent': email_sent}
)
db.session.commit()
return jsonify({
'message': 'Invitation created successfully',
'invitation': invitation.to_dict(),
'invite_url': invite_url,
'email_sent': email_sent,
'email_error': email_error,
}), 201
@invitations_bp.route('/<int:invitation_id>', methods=['DELETE'])
@admin_required
def revoke_invitation(invitation_id):
"""Revoke a pending invitation."""
current_user_id = get_jwt_identity()
result = InvitationService.revoke_invitation(invitation_id)
if not result['success']:
return jsonify({'error': result['error']}), 400
AuditService.log(
action=AuditLog.ACTION_INVITATION_REVOKE,
user_id=current_user_id,
target_type='invitation',
target_id=invitation_id,
)
db.session.commit()
return jsonify({'message': 'Invitation revoked'}), 200
@invitations_bp.route('/resend/<int:invitation_id>', methods=['POST'])
@admin_required
def resend_invitation(invitation_id):
"""Resend invitation email."""
from app.models import Invitation
invitation = Invitation.query.get(invitation_id)
if not invitation:
return jsonify({'error': 'Invitation not found'}), 404
if invitation.status != Invitation.STATUS_PENDING:
return jsonify({'error': 'Can only resend pending invitations'}), 400
if not invitation.email:
return jsonify({'error': 'Invitation has no email address'}), 400
base_url = request.host_url.rstrip('/')
result = InvitationService.send_invitation_email(invitation, base_url)
if not result['success']:
return jsonify({'error': result.get('error', 'Failed to send email')}), 500
return jsonify({'message': 'Invitation email resent'}), 200
@invitations_bp.route('/validate/<token>', methods=['GET'])
def validate_invitation(token):
"""Validate an invite token (public endpoint for registration page)."""
invitation = InvitationService.validate_token(token)
if not invitation:
return jsonify({'error': 'Invalid or expired invitation'}), 404
return jsonify({
'valid': True,
'email': invitation.email,
'role': invitation.role,
}), 200
+27
View File
@@ -108,6 +108,33 @@ def developer_required(fn):
return wrapper return wrapper
def permission_required(feature, level='read'):
"""
Decorator that checks per-feature permissions.
Usage:
@permission_required('applications', 'write')
def create_app():
...
"""
def decorator(fn):
@wraps(fn)
@auth_required()
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return jsonify({'error': 'User not found'}), 404
if not user.is_active:
return jsonify({'error': 'Account is deactivated'}), 403
if not user.has_permission(feature, level):
return jsonify({
'error': f'Permission denied: {level} access to {feature} required'
}), 403
return fn(*args, **kwargs)
return wrapper
return decorator
def viewer_required(fn): def viewer_required(fn):
""" """
Decorator that requires viewer role or higher (any valid role). Decorator that requires viewer role or higher (any valid role).
+5 -3
View File
@@ -15,11 +15,12 @@ from app.models.wordpress_site import WordPressSite, DatabaseSnapshot, SyncJob
from app.models.environment_activity import EnvironmentActivity from app.models.environment_activity import EnvironmentActivity
from app.models.promotion_job import PromotionJob from app.models.promotion_job import PromotionJob
from app.models.sanitization_profile import SanitizationProfile from app.models.sanitization_profile import SanitizationProfile
from app.models.email_account import EmailAccount from app.models.email import EmailDomain, EmailAccount, EmailAlias, EmailForwardingRule, DNSProviderConfig
from app.models.oauth_identity import OAuthIdentity from app.models.oauth_identity import OAuthIdentity
from app.models.api_key import ApiKey from app.models.api_key import ApiKey
from app.models.api_usage import ApiUsageLog, ApiUsageSummary from app.models.api_usage import ApiUsageLog, ApiUsageSummary
from app.models.event_subscription import EventSubscription, EventDelivery from app.models.event_subscription import EventSubscription, EventDelivery
from app.models.invitation import Invitation
__all__ = [ __all__ = [
'User', 'Application', 'Domain', 'EnvironmentVariable', 'EnvironmentVariableHistory', 'User', 'Application', 'Domain', 'EnvironmentVariable', 'EnvironmentVariableHistory',
@@ -27,7 +28,8 @@ __all__ = [
'MetricsHistory', 'Workflow', 'GitWebhook', 'WebhookLog', 'GitDeployment', 'MetricsHistory', 'Workflow', 'GitWebhook', 'WebhookLog', 'GitDeployment',
'Server', 'ServerGroup', 'ServerMetrics', 'ServerCommand', 'AgentSession', 'SecurityAlert', 'Server', 'ServerGroup', 'ServerMetrics', 'ServerCommand', 'AgentSession', 'SecurityAlert',
'WordPressSite', 'DatabaseSnapshot', 'SyncJob', 'WordPressSite', 'DatabaseSnapshot', 'SyncJob',
'EnvironmentActivity', 'PromotionJob', 'SanitizationProfile', 'EmailAccount', 'EnvironmentActivity', 'PromotionJob', 'SanitizationProfile',
'EmailDomain', 'EmailAccount', 'EmailAlias', 'EmailForwardingRule', 'DNSProviderConfig',
'OAuthIdentity', 'ApiKey', 'ApiUsageLog', 'ApiUsageSummary', 'OAuthIdentity', 'ApiKey', 'ApiUsageLog', 'ApiUsageSummary',
'EventSubscription', 'EventDelivery' 'EventSubscription', 'EventDelivery', 'Invitation'
] ]
+5
View File
@@ -47,6 +47,11 @@ class AuditLog(db.Model):
ACTION_API_KEY_CREATE = 'api_key.create' ACTION_API_KEY_CREATE = 'api_key.create'
ACTION_API_KEY_REVOKE = 'api_key.revoke' ACTION_API_KEY_REVOKE = 'api_key.revoke'
ACTION_API_KEY_ROTATE = 'api_key.rotate' ACTION_API_KEY_ROTATE = 'api_key.rotate'
ACTION_INVITATION_CREATE = 'invitation.create'
ACTION_INVITATION_REVOKE = 'invitation.revoke'
ACTION_INVITATION_ACCEPT = 'invitation.accept'
ACTION_USER_PERMISSIONS_UPDATE = 'user.permissions_update'
ACTION_USER_PERMISSIONS_RESET = 'user.permissions_reset'
def get_details(self): def get_details(self):
"""Return parsed details JSON.""" """Return parsed details JSON."""
+171
View File
@@ -0,0 +1,171 @@
from datetime import datetime
from app import db
class EmailDomain(db.Model):
__tablename__ = 'email_domains'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), unique=True, nullable=False, index=True)
is_active = db.Column(db.Boolean, default=True)
# DKIM
dkim_selector = db.Column(db.String(63), default='default')
dkim_private_key_path = db.Column(db.String(500))
dkim_public_key = db.Column(db.Text)
# SPF / DMARC
spf_record = db.Column(db.String(500))
dmarc_record = db.Column(db.String(500))
# DNS provider linkage
dns_provider_id = db.Column(db.Integer, db.ForeignKey('dns_provider_configs.id'), nullable=True)
dns_zone_id = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
accounts = db.relationship('EmailAccount', backref='domain', lazy=True, cascade='all, delete-orphan')
aliases = db.relationship('EmailAlias', backref='domain', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'name': self.name,
'is_active': self.is_active,
'dkim_selector': self.dkim_selector,
'dkim_public_key': self.dkim_public_key,
'spf_record': self.spf_record,
'dmarc_record': self.dmarc_record,
'dns_provider_id': self.dns_provider_id,
'dns_zone_id': self.dns_zone_id,
'accounts_count': len(self.accounts) if self.accounts else 0,
'aliases_count': len(self.aliases) if self.aliases else 0,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<EmailDomain {self.name}>'
class EmailAccount(db.Model):
__tablename__ = 'email_accounts'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
username = db.Column(db.String(255), nullable=False)
password_hash = db.Column(db.String(500), nullable=False)
domain_id = db.Column(db.Integer, db.ForeignKey('email_domains.id'), nullable=False)
quota_mb = db.Column(db.Integer, default=1024)
quota_used_mb = db.Column(db.Integer, default=0)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
forwarding_rules = db.relationship('EmailForwardingRule', backref='account', lazy=True, cascade='all, delete-orphan')
def to_dict(self):
return {
'id': self.id,
'email': self.email,
'username': self.username,
'domain_id': self.domain_id,
'domain_name': self.domain.name if self.domain else None,
'quota_mb': self.quota_mb,
'quota_used_mb': self.quota_used_mb,
'is_active': self.is_active,
'forwarding_count': len(self.forwarding_rules) if self.forwarding_rules else 0,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<EmailAccount {self.email}>'
class EmailAlias(db.Model):
__tablename__ = 'email_aliases'
id = db.Column(db.Integer, primary_key=True)
source = db.Column(db.String(255), nullable=False, index=True)
destination = db.Column(db.String(255), nullable=False)
domain_id = db.Column(db.Integer, db.ForeignKey('email_domains.id'), nullable=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'source': self.source,
'destination': self.destination,
'domain_id': self.domain_id,
'domain_name': self.domain.name if self.domain else None,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<EmailAlias {self.source} -> {self.destination}>'
class EmailForwardingRule(db.Model):
__tablename__ = 'email_forwarding_rules'
id = db.Column(db.Integer, primary_key=True)
account_id = db.Column(db.Integer, db.ForeignKey('email_accounts.id'), nullable=False)
destination = db.Column(db.String(255), nullable=False)
keep_copy = db.Column(db.Boolean, default=True)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def to_dict(self):
return {
'id': self.id,
'account_id': self.account_id,
'account_email': self.account.email if self.account else None,
'destination': self.destination,
'keep_copy': self.keep_copy,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<EmailForwardingRule {self.account_id} -> {self.destination}>'
class DNSProviderConfig(db.Model):
__tablename__ = 'dns_provider_configs'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
provider = db.Column(db.String(50), nullable=False) # 'cloudflare' | 'route53'
api_key = db.Column(db.String(500))
api_secret = db.Column(db.String(500))
api_email = db.Column(db.String(255))
is_default = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
domains = db.relationship('EmailDomain', backref='dns_provider', lazy=True)
def to_dict(self, mask_secrets=True):
result = {
'id': self.id,
'name': self.name,
'provider': self.provider,
'api_email': self.api_email,
'is_default': self.is_default,
'domains_count': len(self.domains) if self.domains else 0,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
if mask_secrets:
result['api_key'] = '****' + (self.api_key[-4:] if self.api_key and len(self.api_key) > 4 else '')
result['api_secret'] = '****' if self.api_secret else None
else:
result['api_key'] = self.api_key
result['api_secret'] = self.api_secret
return result
def __repr__(self):
return f'<DNSProviderConfig {self.name} ({self.provider})>'
-36
View File
@@ -1,36 +0,0 @@
from datetime import datetime
from app import db
class EmailAccount(db.Model):
__tablename__ = 'email_accounts'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), unique=True, nullable=False)
domain = db.Column(db.String(255), nullable=False)
username = db.Column(db.String(100), nullable=False)
quota_mb = db.Column(db.Integer, default=1024)
enabled = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Forwarding
forward_to = db.Column(db.Text, nullable=True) # comma-separated addresses
forward_keep_copy = db.Column(db.Boolean, default=True)
def to_dict(self):
return {
'id': self.id,
'email': self.email,
'domain': self.domain,
'username': self.username,
'quota_mb': self.quota_mb,
'enabled': self.enabled,
'forward_to': self.forward_to,
'forward_keep_copy': self.forward_keep_copy,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
}
def __repr__(self):
return f'<EmailAccount {self.email}>'
+72
View File
@@ -0,0 +1,72 @@
from datetime import datetime
from uuid import uuid4
from app import db
import json
class Invitation(db.Model):
"""Team invitation model for invite-based registration."""
__tablename__ = 'invitations'
STATUS_PENDING = 'pending'
STATUS_ACCEPTED = 'accepted'
STATUS_EXPIRED = 'expired'
STATUS_REVOKED = 'revoked'
id = db.Column(db.Integer, primary_key=True)
email = db.Column(db.String(255), nullable=True) # Nullable for link-only invites
token = db.Column(db.String(64), unique=True, nullable=False, index=True,
default=lambda: uuid4().hex)
role = db.Column(db.String(20), nullable=False, default='developer')
permissions = db.Column(db.Text, nullable=True) # JSON custom permissions
invited_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
expires_at = db.Column(db.DateTime, nullable=True)
accepted_at = db.Column(db.DateTime, nullable=True)
accepted_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
status = db.Column(db.String(20), default=STATUS_PENDING, nullable=False, index=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Relationships
inviter = db.relationship('User', foreign_keys=[invited_by])
accepter = db.relationship('User', foreign_keys=[accepted_by])
@property
def is_expired(self):
if self.expires_at is None:
return False
return datetime.utcnow() > self.expires_at
@property
def is_valid(self):
return self.status == self.STATUS_PENDING and not self.is_expired
def get_permissions(self):
if self.permissions:
try:
return json.loads(self.permissions)
except (json.JSONDecodeError, TypeError):
return None
return None
def set_permissions(self, perms_dict):
self.permissions = json.dumps(perms_dict) if perms_dict else None
def to_dict(self):
return {
'id': self.id,
'email': self.email,
'token': self.token,
'role': self.role,
'permissions': self.get_permissions(),
'invited_by': self.invited_by,
'inviter_username': self.inviter.username if self.inviter else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'accepted_at': self.accepted_at.isoformat() if self.accepted_at else None,
'accepted_by': self.accepted_by,
'status': self.status,
'is_expired': self.is_expired,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
def __repr__(self):
return f'<Invitation {self.token[:8]}... role={self.role} status={self.status}>'
+70
View File
@@ -19,6 +19,7 @@ class User(db.Model):
password_hash = db.Column(db.String(256), nullable=True) password_hash = db.Column(db.String(256), nullable=True)
auth_provider = db.Column(db.String(50), default='local') # local, google, github, oidc, saml auth_provider = db.Column(db.String(50), default='local') # local, google, github, oidc, saml
role = db.Column(db.String(20), default='developer') # 'admin', 'developer', 'viewer' role = db.Column(db.String(20), default='developer') # 'admin', 'developer', 'viewer'
permissions = db.Column(db.Text, nullable=True) # JSON per-feature read/write flags
is_active = db.Column(db.Boolean, default=True) is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow) created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
@@ -42,6 +43,51 @@ class User(db.Model):
LOCKOUT_DURATIONS = [5, 15, 60] LOCKOUT_DURATIONS = [5, 15, 60]
MAX_FAILED_ATTEMPTS = 5 MAX_FAILED_ATTEMPTS = 5
# Per-feature permission system
PERMISSION_FEATURES = [
'applications', 'databases', 'docker', 'domains', 'files',
'monitoring', 'backups', 'security', 'email', 'git', 'cron',
'terminal', 'users', 'settings', 'servers'
]
ROLE_PERMISSION_TEMPLATES = {
'admin': {f: {'read': True, 'write': True} for f in PERMISSION_FEATURES},
'developer': {
'applications': {'read': True, 'write': True},
'databases': {'read': True, 'write': True},
'docker': {'read': True, 'write': True},
'domains': {'read': True, 'write': True},
'files': {'read': True, 'write': True},
'email': {'read': True, 'write': True},
'git': {'read': True, 'write': True},
'cron': {'read': True, 'write': True},
'monitoring': {'read': True, 'write': False},
'backups': {'read': True, 'write': False},
'security': {'read': True, 'write': False},
'terminal': {'read': True, 'write': False},
'servers': {'read': True, 'write': False},
'users': {'read': False, 'write': False},
'settings': {'read': False, 'write': False},
},
'viewer': {
'applications': {'read': True, 'write': False},
'databases': {'read': True, 'write': False},
'docker': {'read': True, 'write': False},
'domains': {'read': True, 'write': False},
'files': {'read': True, 'write': False},
'email': {'read': True, 'write': False},
'git': {'read': True, 'write': False},
'cron': {'read': True, 'write': False},
'monitoring': {'read': True, 'write': False},
'backups': {'read': True, 'write': False},
'security': {'read': True, 'write': False},
'terminal': {'read': False, 'write': False},
'users': {'read': False, 'write': False},
'settings': {'read': False, 'write': False},
'servers': {'read': True, 'write': False},
},
}
@property @property
def is_locked(self): def is_locked(self):
"""Check if account is currently locked.""" """Check if account is currently locked."""
@@ -97,12 +143,36 @@ class User(db.Model):
def has_password(self): def has_password(self):
return self.password_hash is not None return self.password_hash is not None
def get_permissions(self):
"""Return resolved permissions: custom if set, otherwise role template."""
if self.role == self.ROLE_ADMIN:
return self.ROLE_PERMISSION_TEMPLATES['admin']
if self.permissions:
try:
return json.loads(self.permissions)
except (json.JSONDecodeError, TypeError):
pass
return self.ROLE_PERMISSION_TEMPLATES.get(self.role, {})
def set_permissions(self, perms_dict):
"""Store custom permissions as JSON."""
self.permissions = json.dumps(perms_dict) if perms_dict else None
def has_permission(self, feature, level='read'):
"""Check if user has a specific permission. Admin always returns True."""
if self.role == self.ROLE_ADMIN:
return True
perms = self.get_permissions()
feature_perms = perms.get(feature, {})
return feature_perms.get(level, False)
def to_dict(self): def to_dict(self):
return { return {
'id': self.id, 'id': self.id,
'email': self.email, 'email': self.email,
'username': self.username, 'username': self.username,
'role': self.role, 'role': self.role,
'permissions': self.get_permissions(),
'is_active': self.is_active, 'is_active': self.is_active,
'totp_enabled': self.totp_enabled, 'totp_enabled': self.totp_enabled,
'auth_provider': self.auth_provider or 'local', 'auth_provider': self.auth_provider or 'local',
+6
View File
@@ -17,3 +17,9 @@ BUILD_CACHE_DIR = os.path.join(SERVERKIT_CACHE_DIR, 'builds')
DB_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'databases') DB_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'databases')
WP_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'wordpress') WP_BACKUP_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'wordpress')
SNAPSHOT_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'snapshots') SNAPSHOT_DIR = os.path.join(SERVERKIT_BACKUP_DIR, 'snapshots')
# Email / Mail server paths
VMAIL_DIR = os.environ.get('VMAIL_DIR', '/var/vmail')
VMAIL_UID = 5000
VMAIL_GID = 5000
EMAIL_CONFIG_DIR = os.path.join(SERVERKIT_CONFIG_DIR, 'email')
+270
View File
@@ -0,0 +1,270 @@
"""OpenDKIM management service for DKIM signing."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import PackageManager, ServiceControl, run_privileged
class DKIMService:
"""Service for managing OpenDKIM (DKIM email signing)."""
OPENDKIM_CONF = '/etc/opendkim.conf'
OPENDKIM_DIR = '/etc/opendkim'
OPENDKIM_KEYS_DIR = '/etc/opendkim/keys'
KEY_TABLE = '/etc/opendkim/KeyTable'
SIGNING_TABLE = '/etc/opendkim/SigningTable'
TRUSTED_HOSTS = '/etc/opendkim/TrustedHosts'
OPENDKIM_CONF_CONTENT = """# OpenDKIM configuration - Managed by ServerKit
Syslog yes
SyslogSuccess yes
LogWhy yes
Canonicalization relaxed/simple
Mode sv
SubDomains no
AutoRestart yes
AutoRestartRate 10/1M
Background yes
DNSTimeout 5
SignatureAlgorithm rsa-sha256
KeyTable refile:/etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList /etc/opendkim/TrustedHosts
InternalHosts /etc/opendkim/TrustedHosts
Socket inet:8891@localhost
PidFile /run/opendkim/opendkim.pid
OversignHeaders From
UserID opendkim
UMask 007
"""
TRUSTED_HOSTS_DEFAULT = """# Trusted hosts - Managed by ServerKit
127.0.0.1
::1
localhost
"""
@classmethod
def get_status(cls) -> Dict:
"""Get OpenDKIM installation and running status."""
installed = False
running = False
enabled = False
version = None
try:
result = subprocess.run(['which', 'opendkim'], capture_output=True, text=True)
installed = result.returncode == 0
if not installed:
installed = PackageManager.is_installed('opendkim')
if installed:
running = ServiceControl.is_active('opendkim')
enabled = ServiceControl.is_enabled('opendkim')
result = subprocess.run(['opendkim', '-V'], capture_output=True, text=True, stderr=subprocess.STDOUT)
match = re.search(r'OpenDKIM\s+Filter\s+v(\S+)', result.stdout)
if match:
version = match.group(1)
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install OpenDKIM."""
try:
result = PackageManager.install(['opendkim', 'opendkim-tools'], timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install OpenDKIM'}
# Create directories
run_privileged(['mkdir', '-p', cls.OPENDKIM_KEYS_DIR])
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
# Create config files
run_privileged(['tee', cls.OPENDKIM_CONF], input=cls.OPENDKIM_CONF_CONTENT)
run_privileged(['tee', cls.TRUSTED_HOSTS], input=cls.TRUSTED_HOSTS_DEFAULT)
run_privileged(['touch', cls.KEY_TABLE])
run_privileged(['touch', cls.SIGNING_TABLE])
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', cls.OPENDKIM_DIR])
run_privileged(['chmod', '700', cls.OPENDKIM_KEYS_DIR])
# Create PID directory
run_privileged(['mkdir', '-p', '/run/opendkim'])
run_privileged(['chown', 'opendkim:opendkim', '/run/opendkim'])
# Add postfix to opendkim group
run_privileged(['usermod', '-aG', 'opendkim', 'postfix'])
ServiceControl.enable('opendkim')
ServiceControl.start('opendkim', timeout=30)
return {'success': True, 'message': 'OpenDKIM installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def generate_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Generate DKIM key pair for a domain."""
try:
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['mkdir', '-p', key_dir])
# Generate key
result = run_privileged([
'opendkim-genkey',
'-s', selector,
'-d', domain,
'-D', key_dir,
'-b', '2048',
])
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Key generation failed'}
# Set permissions
run_privileged(['chown', '-R', 'opendkim:opendkim', key_dir])
run_privileged(['chmod', '600', os.path.join(key_dir, f'{selector}.private')])
# Read the public key TXT record
txt_file = os.path.join(key_dir, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
public_key_record = result.stdout.strip() if result.returncode == 0 else ''
# Extract just the key value from the TXT record
key_match = re.search(r'p=([A-Za-z0-9+/=\s]+)', public_key_record)
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '') if key_match else ''
return {
'success': True,
'domain': domain,
'selector': selector,
'private_key_path': os.path.join(key_dir, f'{selector}.private'),
'public_key': public_key,
'dns_record': public_key_record,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_domain(cls, domain: str, selector: str = 'default') -> Dict:
"""Add domain to KeyTable and SigningTable."""
try:
key_path = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.private')
# Add to KeyTable
key_entry = f'{selector}._domainkey.{domain} {domain}:{selector}:{key_path}\n'
result = run_privileged(['cat', cls.KEY_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.KEY_TABLE], input=key_entry)
# Add to SigningTable
signing_entry = f'*@{domain} {selector}._domainkey.{domain}\n'
result = run_privileged(['cat', cls.SIGNING_TABLE])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.SIGNING_TABLE], input=signing_entry)
# Add to TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
if domain not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.TRUSTED_HOSTS], input=f'*.{domain}\n')
# Reload OpenDKIM
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} added to DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_domain(cls, domain: str) -> Dict:
"""Remove domain from DKIM configuration."""
try:
# Remove from KeyTable
result = run_privileged(['cat', cls.KEY_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.KEY_TABLE], input='\n'.join(lines) + '\n')
# Remove from SigningTable
result = run_privileged(['cat', cls.SIGNING_TABLE])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.SIGNING_TABLE], input='\n'.join(lines) + '\n')
# Remove from TrustedHosts
result = run_privileged(['cat', cls.TRUSTED_HOSTS])
lines = [l for l in (result.stdout or '').splitlines() if domain not in l]
run_privileged(['tee', cls.TRUSTED_HOSTS], input='\n'.join(lines) + '\n')
# Remove key files
key_dir = os.path.join(cls.OPENDKIM_KEYS_DIR, domain)
run_privileged(['rm', '-rf', key_dir])
ServiceControl.restart('opendkim', timeout=30)
return {'success': True, 'message': f'Domain {domain} removed from DKIM'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_dns_record(cls, domain: str, selector: str = 'default') -> Dict:
"""Get the DKIM DNS TXT record content for a domain."""
try:
txt_file = os.path.join(cls.OPENDKIM_KEYS_DIR, domain, f'{selector}.txt')
result = run_privileged(['cat', txt_file])
if result.returncode != 0:
return {'success': False, 'error': 'DKIM key not found'}
record = result.stdout.strip()
key_match = re.search(r'p=([A-Za-z0-9+/=\s"]+)', record)
public_key = ''
if key_match:
public_key = key_match.group(1).replace(' ', '').replace('\n', '').replace('\t', '').replace('"', '')
return {
'success': True,
'dns_name': f'{selector}._domainkey.{domain}',
'dns_value': f'v=DKIM1; k=rsa; p={public_key}',
'raw_record': record,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def verify_key(cls, domain: str, selector: str = 'default') -> Dict:
"""Verify DKIM key configuration."""
try:
result = run_privileged([
'opendkim-testkey',
'-d', domain,
'-s', selector,
'-vvv',
])
success = result.returncode == 0
output = (result.stdout or '') + (result.stderr or '')
return {
'success': success,
'verified': success and 'key OK' in output,
'output': output,
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload OpenDKIM."""
try:
result = ServiceControl.restart('opendkim', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'OpenDKIM restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@@ -0,0 +1,376 @@
"""DNS Provider service for managing DKIM/SPF/DMARC records via Cloudflare and Route53."""
import logging
from typing import Dict, List, Optional
import requests
from app import db
from app.models.email import DNSProviderConfig
logger = logging.getLogger(__name__)
class DNSProviderService:
"""Service for managing DNS records via Cloudflare and Route53 APIs."""
@classmethod
def list_providers(cls) -> List[Dict]:
"""List all configured DNS providers (secrets masked)."""
providers = DNSProviderConfig.query.all()
return [p.to_dict(mask_secrets=True) for p in providers]
@classmethod
def get_provider(cls, provider_id: int) -> Optional[DNSProviderConfig]:
"""Get a DNS provider config by ID."""
return DNSProviderConfig.query.get(provider_id)
@classmethod
def add_provider(cls, name: str, provider: str, api_key: str,
api_secret: str = None, api_email: str = None,
is_default: bool = False) -> Dict:
"""Add a new DNS provider configuration."""
if provider not in ('cloudflare', 'route53'):
return {'success': False, 'error': 'Provider must be cloudflare or route53'}
try:
if is_default:
# Unset other defaults
DNSProviderConfig.query.filter_by(is_default=True).update({'is_default': False})
config = DNSProviderConfig(
name=name,
provider=provider,
api_key=api_key,
api_secret=api_secret,
api_email=api_email,
is_default=is_default,
)
db.session.add(config)
db.session.commit()
return {'success': True, 'provider': config.to_dict(), 'message': 'DNS provider added'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def remove_provider(cls, provider_id: int) -> Dict:
"""Remove a DNS provider configuration."""
try:
config = DNSProviderConfig.query.get(provider_id)
if not config:
return {'success': False, 'error': 'Provider not found'}
db.session.delete(config)
db.session.commit()
return {'success': True, 'message': 'DNS provider removed'}
except Exception as e:
db.session.rollback()
return {'success': False, 'error': str(e)}
@classmethod
def test_connection(cls, provider_id: int) -> Dict:
"""Test DNS provider API connection."""
config = DNSProviderConfig.query.get(provider_id)
if not config:
return {'success': False, 'error': 'Provider not found'}
if config.provider == 'cloudflare':
return cls._test_cloudflare(config)
elif config.provider == 'route53':
return cls._test_route53(config)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
def list_zones(cls, provider_id: int) -> Dict:
"""List DNS zones from the provider."""
config = DNSProviderConfig.query.get(provider_id)
if not config:
return {'success': False, 'error': 'Provider not found'}
if config.provider == 'cloudflare':
return cls._cloudflare_list_zones(config)
elif config.provider == 'route53':
return cls._route53_list_zones(config)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
def set_record(cls, provider_id: int, zone_id: str, record_type: str,
name: str, value: str, ttl: int = 3600) -> Dict:
"""Create or update a DNS record."""
config = DNSProviderConfig.query.get(provider_id)
if not config:
return {'success': False, 'error': 'Provider not found'}
if config.provider == 'cloudflare':
return cls._cloudflare_set_record(config, zone_id, record_type, name, value, ttl)
elif config.provider == 'route53':
return cls._route53_set_record(config, zone_id, record_type, name, value, ttl)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
def delete_record(cls, provider_id: int, zone_id: str, record_type: str, name: str) -> Dict:
"""Delete a DNS record."""
config = DNSProviderConfig.query.get(provider_id)
if not config:
return {'success': False, 'error': 'Provider not found'}
if config.provider == 'cloudflare':
return cls._cloudflare_delete_record(config, zone_id, record_type, name)
elif config.provider == 'route53':
return cls._route53_delete_record(config, zone_id, record_type, name)
return {'success': False, 'error': 'Unknown provider'}
@classmethod
def deploy_email_records(cls, provider_id: int, zone_id: str, domain: str,
selector: str, dkim_public_key: str,
server_ip: str = None) -> Dict:
"""Deploy DKIM, SPF, and DMARC records for an email domain."""
results = {}
# Deploy DKIM record
dkim_name = f'{selector}._domainkey.{domain}'
dkim_value = f'v=DKIM1; k=rsa; p={dkim_public_key}'
results['dkim'] = cls.set_record(provider_id, zone_id, 'TXT', dkim_name, dkim_value)
# Deploy SPF record
spf_value = 'v=spf1 mx a ~all'
if server_ip:
spf_value = f'v=spf1 mx a ip4:{server_ip} ~all'
results['spf'] = cls.set_record(provider_id, zone_id, 'TXT', domain, spf_value)
# Deploy DMARC record
dmarc_name = f'_dmarc.{domain}'
dmarc_value = f'v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain}; pct=100'
results['dmarc'] = cls.set_record(provider_id, zone_id, 'TXT', dmarc_name, dmarc_value)
# Deploy MX record
results['mx'] = cls.set_record(provider_id, zone_id, 'MX', domain, f'10 mail.{domain}')
all_ok = all(r.get('success') for r in results.values())
return {
'success': all_ok,
'results': results,
'message': 'All DNS records deployed' if all_ok else 'Some records failed',
}
# ── Cloudflare Implementation ──
@classmethod
def _cloudflare_headers(cls, config: DNSProviderConfig) -> Dict:
"""Build Cloudflare API headers."""
if config.api_email:
return {
'X-Auth-Email': config.api_email,
'X-Auth-Key': config.api_key,
'Content-Type': 'application/json',
}
return {
'Authorization': f'Bearer {config.api_key}',
'Content-Type': 'application/json',
}
@classmethod
def _test_cloudflare(cls, config: DNSProviderConfig) -> Dict:
"""Test Cloudflare API connection."""
try:
resp = requests.get(
'https://api.cloudflare.com/client/v4/user/tokens/verify',
headers=cls._cloudflare_headers(config),
timeout=15,
)
data = resp.json()
if data.get('success'):
return {'success': True, 'message': 'Cloudflare connection successful'}
return {'success': False, 'error': data.get('errors', [{}])[0].get('message', 'Unknown error')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _cloudflare_list_zones(cls, config: DNSProviderConfig) -> Dict:
"""List Cloudflare zones."""
try:
resp = requests.get(
'https://api.cloudflare.com/client/v4/zones?per_page=50',
headers=cls._cloudflare_headers(config),
timeout=15,
)
data = resp.json()
if not data.get('success'):
return {'success': False, 'error': 'Failed to list zones'}
zones = [{'id': z['id'], 'name': z['name'], 'status': z['status']}
for z in data.get('result', [])]
return {'success': True, 'zones': zones}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _cloudflare_set_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str, value: str, ttl: int) -> Dict:
"""Create or update a Cloudflare DNS record."""
try:
headers = cls._cloudflare_headers(config)
base = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records'
# Check if record exists
resp = requests.get(
f'{base}?type={record_type}&name={name}',
headers=headers, timeout=15,
)
data = resp.json()
existing = data.get('result', [])
payload = {'type': record_type, 'name': name, 'content': value, 'ttl': ttl}
if existing:
# Update existing record
record_id = existing[0]['id']
resp = requests.put(
f'{base}/{record_id}',
headers=headers, json=payload, timeout=15,
)
else:
# Create new record
resp = requests.post(base, headers=headers, json=payload, timeout=15)
data = resp.json()
if data.get('success'):
return {'success': True, 'message': f'{record_type} record set for {name}'}
return {'success': False, 'error': data.get('errors', [{}])[0].get('message', 'Unknown error')}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _cloudflare_delete_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str) -> Dict:
"""Delete a Cloudflare DNS record."""
try:
headers = cls._cloudflare_headers(config)
base = f'https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records'
resp = requests.get(
f'{base}?type={record_type}&name={name}',
headers=headers, timeout=15,
)
data = resp.json()
existing = data.get('result', [])
if not existing:
return {'success': True, 'message': 'Record not found (already deleted)'}
for record in existing:
requests.delete(f'{base}/{record["id"]}', headers=headers, timeout=15)
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
except Exception as e:
return {'success': False, 'error': str(e)}
# ── Route53 Implementation ──
@classmethod
def _get_route53_client(cls, config: DNSProviderConfig):
"""Get a boto3 Route53 client."""
try:
import boto3
except ImportError:
raise RuntimeError('boto3 is required for Route53 integration. Install with: pip install boto3')
return boto3.client(
'route53',
aws_access_key_id=config.api_key,
aws_secret_access_key=config.api_secret,
)
@classmethod
def _test_route53(cls, config: DNSProviderConfig) -> Dict:
"""Test Route53 API connection."""
try:
client = cls._get_route53_client(config)
client.list_hosted_zones(MaxItems='1')
return {'success': True, 'message': 'Route53 connection successful'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _route53_list_zones(cls, config: DNSProviderConfig) -> Dict:
"""List Route53 hosted zones."""
try:
client = cls._get_route53_client(config)
resp = client.list_hosted_zones()
zones = [
{
'id': z['Id'].replace('/hostedzone/', ''),
'name': z['Name'].rstrip('.'),
'status': 'active',
}
for z in resp.get('HostedZones', [])
]
return {'success': True, 'zones': zones}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _route53_set_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str, value: str, ttl: int) -> Dict:
"""Create or update a Route53 DNS record."""
try:
client = cls._get_route53_client(config)
# Ensure name ends with a dot for Route53
fqdn = name if name.endswith('.') else f'{name}.'
resource_record = {'Value': value}
if record_type == 'TXT':
# TXT records need to be quoted
resource_record = {'Value': f'"{value}"'}
elif record_type == 'MX':
resource_record = {'Value': value}
client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
'Changes': [{
'Action': 'UPSERT',
'ResourceRecordSet': {
'Name': fqdn,
'Type': record_type,
'TTL': ttl,
'ResourceRecords': [resource_record],
}
}]
}
)
return {'success': True, 'message': f'{record_type} record set for {name}'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def _route53_delete_record(cls, config: DNSProviderConfig, zone_id: str,
record_type: str, name: str) -> Dict:
"""Delete a Route53 DNS record."""
try:
client = cls._get_route53_client(config)
fqdn = name if name.endswith('.') else f'{name}.'
# Get current record to know its value (required for DELETE)
resp = client.list_resource_record_sets(
HostedZoneId=zone_id,
StartRecordName=fqdn,
StartRecordType=record_type,
MaxItems='1',
)
records = resp.get('ResourceRecordSets', [])
matching = [r for r in records if r['Name'] == fqdn and r['Type'] == record_type]
if not matching:
return {'success': True, 'message': 'Record not found (already deleted)'}
record = matching[0]
client.change_resource_record_sets(
HostedZoneId=zone_id,
ChangeBatch={
'Changes': [{
'Action': 'DELETE',
'ResourceRecordSet': record,
}]
}
)
return {'success': True, 'message': f'{record_type} record deleted for {name}'}
except Exception as e:
return {'success': False, 'error': str(e)}
+351
View File
@@ -0,0 +1,351 @@
"""Dovecot IMAP/POP3 server management service."""
import os
import re
import subprocess
from typing import Dict, Optional
from app.utils.system import PackageManager, ServiceControl, run_privileged
from app import paths
class DovecotService:
"""Service for managing Dovecot (IMAP/POP3 server)."""
DOVECOT_CONF_DIR = '/etc/dovecot'
DOVECOT_CONF = '/etc/dovecot/dovecot.conf'
DOVECOT_AUTH_CONF = '/etc/dovecot/conf.d/10-auth.conf'
DOVECOT_MAIL_CONF = '/etc/dovecot/conf.d/10-mail.conf'
DOVECOT_MASTER_CONF = '/etc/dovecot/conf.d/10-master.conf'
DOVECOT_SSL_CONF = '/etc/dovecot/conf.d/10-ssl.conf'
DOVECOT_PASSWD_FILE = '/etc/dovecot/users'
AUTH_PASSWDFILE_CONF = '/etc/dovecot/conf.d/auth-passwdfile.conf.ext'
MAIL_CONF_CONTENT = """# Dovecot mail configuration - Managed by ServerKit
mail_location = maildir:{vmail_dir}/%d/%n/Maildir
namespace inbox {{
inbox = yes
separator = /
}}
mail_uid = {vmail_uid}
mail_gid = {vmail_gid}
mail_privileged_group = vmail
first_valid_uid = {vmail_uid}
last_valid_uid = {vmail_uid}
"""
AUTH_CONF_CONTENT = """# Dovecot auth configuration - Managed by ServerKit
disable_plaintext_auth = yes
auth_mechanisms = plain login
!include auth-passwdfile.conf.ext
"""
AUTH_PASSWDFILE_CONTENT = """# Password file auth - Managed by ServerKit
passdb {{
driver = passwd-file
args = scheme=SHA512-CRYPT /etc/dovecot/users
}}
userdb {{
driver = static
args = uid={vmail_uid} gid={vmail_gid} home={vmail_dir}/%d/%n
}}
"""
MASTER_CONF_CONTENT = """# Dovecot master configuration - Managed by ServerKit
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
}
service pop3-login {
inet_listener pop3 {
port = 0
}
inet_listener pop3s {
port = 995
ssl = yes
}
}
service lmtp {
unix_listener /var/spool/postfix/private/dovecot-lmtp {
mode = 0600
user = postfix
group = postfix
}
}
service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0666
user = postfix
group = postfix
}
unix_listener auth-userdb {
mode = 0600
user = vmail
group = vmail
}
user = dovecot
}
service auth-worker {
user = vmail
}
"""
SSL_CONF_CONTENT = """# Dovecot SSL configuration - Managed by ServerKit
ssl = required
ssl_cert = <{tls_cert}
ssl_key = <{tls_key}
ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes
"""
@classmethod
def get_status(cls) -> Dict:
"""Get Dovecot installation and running status."""
installed = False
running = False
enabled = False
version = None
try:
result = subprocess.run(['which', 'dovecot'], capture_output=True, text=True)
installed = result.returncode == 0
if not installed:
installed = PackageManager.is_installed('dovecot-core') or PackageManager.is_installed('dovecot')
if installed:
running = ServiceControl.is_active('dovecot')
enabled = ServiceControl.is_enabled('dovecot')
result = subprocess.run(['dovecot', '--version'], capture_output=True, text=True)
version_match = re.search(r'(\d+\.\d+\.\d+)', result.stdout)
if version_match:
version = version_match.group(1)
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
}
@classmethod
def install(cls) -> Dict:
"""Install Dovecot packages."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'dovecot-lmtpd', 'dovecot-sieve']
else:
packages = ['dovecot']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install Dovecot'}
# Create empty passwd file
run_privileged(['touch', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chown', 'vmail:dovecot', cls.DOVECOT_PASSWD_FILE])
run_privileged(['chmod', '640', cls.DOVECOT_PASSWD_FILE])
ServiceControl.enable('dovecot')
return {'success': True, 'message': 'Dovecot installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, tls_cert: str = None, tls_key: str = None) -> Dict:
"""Write Dovecot configuration files."""
try:
cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem'
key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key'
# 10-mail.conf
mail_conf = cls.MAIL_CONF_CONTENT.format(
vmail_dir=paths.VMAIL_DIR,
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
)
run_privileged(['tee', cls.DOVECOT_MAIL_CONF], input=mail_conf)
# 10-auth.conf
run_privileged(['tee', cls.DOVECOT_AUTH_CONF], input=cls.AUTH_CONF_CONTENT)
# auth-passwdfile.conf.ext
auth_passwd = cls.AUTH_PASSWDFILE_CONTENT.format(
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
vmail_dir=paths.VMAIL_DIR,
)
run_privileged(['tee', cls.AUTH_PASSWDFILE_CONF], input=auth_passwd)
# 10-master.conf
run_privileged(['tee', cls.DOVECOT_MASTER_CONF], input=cls.MASTER_CONF_CONTENT)
# 10-ssl.conf
ssl_conf = cls.SSL_CONF_CONTENT.format(tls_cert=cert, tls_key=key)
run_privileged(['tee', cls.DOVECOT_SSL_CONF], input=ssl_conf)
# Restart to apply
ServiceControl.restart('dovecot', timeout=30)
return {'success': True, 'message': 'Dovecot configured successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def create_mailbox(cls, email: str, password: str, domain: str, username: str, quota_mb: int = 1024) -> Dict:
"""Create a virtual mailbox with password entry."""
try:
# Generate password hash using doveadm
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
password_hash = result.stdout.strip()
# Build passwd-file entry
# Format: user@domain:{scheme}hash:uid:gid:home::userdb_quota_rule=*:storage=NM
entry = f'{email}:{password_hash}:{paths.VMAIL_UID}:{paths.VMAIL_GID}:{paths.VMAIL_DIR}/{domain}/{username}::userdb_quota_rule=*:storage={quota_mb}M'
# Append to passwd file
run_privileged(['tee', '-a', cls.DOVECOT_PASSWD_FILE], input=entry + '\n')
# Create Maildir
maildir = os.path.join(paths.VMAIL_DIR, domain, username, 'Maildir')
run_privileged(['mkdir', '-p', f'{maildir}/cur', f'{maildir}/new', f'{maildir}/tmp'])
run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}',
os.path.join(paths.VMAIL_DIR, domain, username)])
return {'success': True, 'message': f'Mailbox {email} created'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def delete_mailbox(cls, email: str, domain: str, username: str, remove_files: bool = False) -> Dict:
"""Delete a virtual mailbox."""
try:
# Remove from passwd file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
new_lines = [l for l in lines if not l.startswith(f'{email}:')]
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
# Optionally remove Maildir
if remove_files:
mailbox_path = os.path.join(paths.VMAIL_DIR, domain, username)
run_privileged(['rm', '-rf', mailbox_path])
return {'success': True, 'message': f'Mailbox {email} deleted'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def change_password(cls, email: str, new_password: str) -> Dict:
"""Change a mailbox password."""
try:
# Generate new hash
result = run_privileged(['doveadm', 'pw', '-s', 'SHA512-CRYPT', '-p', new_password])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to hash password'}
new_hash = result.stdout.strip()
# Read current file
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
parts = line.split(':')
parts[1] = new_hash
new_lines.append(':'.join(parts))
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': 'Password changed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def set_quota(cls, email: str, quota_mb: int) -> Dict:
"""Update mailbox quota."""
try:
result = run_privileged(['cat', cls.DOVECOT_PASSWD_FILE])
lines = (result.stdout or '').splitlines()
updated = False
new_lines = []
for line in lines:
if line.startswith(f'{email}:'):
# Replace quota in the userdb_quota_rule field
line = re.sub(r'userdb_quota_rule=\*:storage=\d+M',
f'userdb_quota_rule=*:storage={quota_mb}M', line)
new_lines.append(line)
updated = True
else:
new_lines.append(line)
if not updated:
return {'success': False, 'error': f'Account {email} not found'}
run_privileged(['tee', cls.DOVECOT_PASSWD_FILE], input='\n'.join(new_lines) + '\n')
return {'success': True, 'message': f'Quota set to {quota_mb}MB'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_quota_usage(cls, email: str) -> Dict:
"""Get mailbox quota usage."""
try:
result = run_privileged(['doveadm', 'quota', 'get', '-u', email])
if result.returncode != 0:
return {'success': False, 'error': 'Failed to get quota'}
# Parse output
usage = {'storage_used': 0, 'storage_limit': 0, 'message_count': 0}
for line in result.stdout.splitlines():
parts = line.split()
if len(parts) >= 4 and parts[0] == 'STORAGE':
usage['storage_used'] = int(parts[1]) // 1024 # KB to MB
usage['storage_limit'] = int(parts[2]) // 1024 if parts[2] != '-' else 0
elif len(parts) >= 4 and parts[0] == 'MESSAGE':
usage['message_count'] = int(parts[1])
return {'success': True, **usage}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload Dovecot configuration."""
try:
result = ServiceControl.reload('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot reloaded'}
return {'success': False, 'error': result.stderr or 'Reload failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Dovecot."""
try:
result = ServiceControl.restart('dovecot', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Dovecot restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
File diff suppressed because it is too large. Load diff
+180
View File
@@ -0,0 +1,180 @@
"""Service for managing team invitations."""
import smtplib
from datetime import datetime, timedelta
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app import db
from app.models import User, Invitation
class InvitationService:
"""Stateless service for invitation operations."""
@staticmethod
def create_invitation(email=None, role='developer', permissions=None,
invited_by=None, expires_in_days=7):
"""Create a new invitation."""
if role not in User.VALID_ROLES:
return {'success': False, 'error': f'Invalid role: {role}'}
# Check for duplicate pending invite with same email
if email:
existing = Invitation.query.filter_by(
email=email, status=Invitation.STATUS_PENDING
).first()
if existing and not existing.is_expired:
return {'success': False, 'error': 'A pending invitation already exists for this email'}
expires_at = None
if expires_in_days and expires_in_days > 0:
expires_at = datetime.utcnow() + timedelta(days=expires_in_days)
invitation = Invitation(
email=email,
role=role,
invited_by=invited_by,
expires_at=expires_at,
)
if permissions:
invitation.set_permissions(permissions)
db.session.add(invitation)
db.session.commit()
return {'success': True, 'invitation': invitation}
@staticmethod
def validate_token(token):
"""Validate an invite token. Returns Invitation if valid, else None."""
invitation = Invitation.query.filter_by(token=token).first()
if not invitation:
return None
# Auto-expire if past date
if invitation.status == Invitation.STATUS_PENDING and invitation.is_expired:
invitation.status = Invitation.STATUS_EXPIRED
db.session.commit()
return None
if invitation.status != Invitation.STATUS_PENDING:
return None
return invitation
@staticmethod
def accept_invitation(token, user_id):
"""Mark an invitation as accepted."""
invitation = InvitationService.validate_token(token)
if not invitation:
return {'success': False, 'error': 'Invalid or expired invitation'}
invitation.status = Invitation.STATUS_ACCEPTED
invitation.accepted_at = datetime.utcnow()
invitation.accepted_by = user_id
db.session.commit()
return {'success': True, 'invitation': invitation}
@staticmethod
def revoke_invitation(invitation_id):
"""Revoke a pending invitation."""
invitation = Invitation.query.get(invitation_id)
if not invitation:
return {'success': False, 'error': 'Invitation not found'}
if invitation.status != Invitation.STATUS_PENDING:
return {'success': False, 'error': 'Only pending invitations can be revoked'}
invitation.status = Invitation.STATUS_REVOKED
db.session.commit()
return {'success': True}
@staticmethod
def list_invitations(status=None):
"""List invitations, optionally filtered by status."""
query = Invitation.query.order_by(Invitation.created_at.desc())
if status:
query = query.filter_by(status=status)
return query.all()
@staticmethod
def send_invitation_email(invitation, base_url):
"""Send invitation email via SMTP. Returns {success, error}."""
if not invitation.email:
return {'success': False, 'error': 'No email address on invitation'}
try:
from app.services.notification_service import NotificationService
config = NotificationService.get_config()
email_config = config.get('email', {})
except Exception:
return {'success': False, 'error': 'Could not load email configuration'}
if not email_config.get('smtp_host') or not email_config.get('from_email'):
return {'success': False, 'error': 'SMTP not configured'}
invite_url = f"{base_url}/register?invite={invitation.token}"
msg = MIMEMultipart('alternative')
msg['Subject'] = 'You have been invited to ServerKit'
msg['From'] = email_config['from_email']
msg['To'] = invitation.email
text = (
f"You have been invited to join ServerKit as a {invitation.role}.\n\n"
f"Click the link below to create your account:\n{invite_url}\n\n"
)
if invitation.expires_at:
text += f"This invitation expires on {invitation.expires_at.strftime('%Y-%m-%d %H:%M UTC')}.\n"
html = (
f"<h2>You're invited to ServerKit</h2>"
f"<p>You have been invited to join as a <strong>{invitation.role}</strong>.</p>"
f"<p><a href=\"{invite_url}\" style=\"display:inline-block;padding:10px 20px;"
f"background:#6366f1;color:#fff;text-decoration:none;border-radius:6px;\">"
f"Accept Invitation</a></p>"
)
if invitation.expires_at:
html += f"<p style=\"color:#888;\">Expires {invitation.expires_at.strftime('%Y-%m-%d %H:%M UTC')}</p>"
msg.attach(MIMEText(text, 'plain'))
msg.attach(MIMEText(html, 'html'))
try:
smtp_port = int(email_config.get('smtp_port', 587))
use_tls = email_config.get('smtp_tls', True)
if use_tls:
server = smtplib.SMTP(email_config['smtp_host'], smtp_port)
server.starttls()
else:
server = smtplib.SMTP_SSL(email_config['smtp_host'], smtp_port)
if email_config.get('smtp_user') and email_config.get('smtp_password'):
server.login(email_config['smtp_user'], email_config['smtp_password'])
server.sendmail(email_config['from_email'], [invitation.email], msg.as_string())
server.quit()
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def cleanup_expired():
"""Batch-mark expired invitations."""
now = datetime.utcnow()
expired = Invitation.query.filter(
Invitation.status == Invitation.STATUS_PENDING,
Invitation.expires_at.isnot(None),
Invitation.expires_at < now
).all()
count = 0
for inv in expired:
inv.status = Invitation.STATUS_EXPIRED
count += 1
if count:
db.session.commit()
return count
+7 -4
View File
@@ -112,12 +112,15 @@ class MigrationService:
'down_revision': rev.down_revision, 'down_revision': rev.down_revision,
}) })
cls._pending_migrations.reverse() cls._pending_migrations.reverse()
cls._needs_migration = len(cls._pending_migrations) > 0 if cls._pending_migrations:
if cls._needs_migration: logger.info(
logger.warning(
f'Database migration needed: {len(cls._pending_migrations)} pending ' f'Database migration needed: {len(cls._pending_migrations)} pending '
f'(current={cls._current_revision}, head={head})' f'(current={cls._current_revision}, head={head}) — running upgrade'
) )
with app.app_context():
command.upgrade(cfg, 'head')
cls._current_revision = head
cls._needs_migration = False
else: else:
cls._needs_migration = False cls._needs_migration = False
cls._pending_migrations = [] cls._pending_migrations = []
@@ -0,0 +1,71 @@
"""Service for managing per-feature user permissions."""
from app import db
from app.models import User
class PermissionService:
"""Stateless service for user permission operations."""
@staticmethod
def get_role_template(role):
"""Return default permissions for a role."""
return User.ROLE_PERMISSION_TEMPLATES.get(role, {})
@staticmethod
def get_user_permissions(user_id):
"""Get resolved permissions for a user."""
user = User.query.get(user_id)
if not user:
return None
return user.get_permissions()
@staticmethod
def update_user_permissions(user_id, permissions):
"""Validate and store custom permissions for a user."""
user = User.query.get(user_id)
if not user:
return {'success': False, 'error': 'User not found'}
if user.role == User.ROLE_ADMIN:
return {'success': False, 'error': 'Admin permissions cannot be customized'}
error = PermissionService.validate_permissions(permissions)
if error:
return {'success': False, 'error': error}
user.set_permissions(permissions)
db.session.commit()
return {'success': True, 'permissions': user.get_permissions()}
@staticmethod
def reset_to_role_defaults(user_id):
"""Clear custom permissions so user falls back to role template."""
user = User.query.get(user_id)
if not user:
return {'success': False, 'error': 'User not found'}
user.permissions = None
db.session.commit()
return {'success': True, 'permissions': user.get_permissions()}
@staticmethod
def validate_permissions(permissions):
"""Validate permission structure. Returns error string or None."""
if not isinstance(permissions, dict):
return 'Permissions must be an object'
for feature, access in permissions.items():
if feature not in User.PERMISSION_FEATURES:
return f'Unknown feature: {feature}'
if not isinstance(access, dict):
return f'Feature "{feature}" must have read/write object'
for key in access:
if key not in ('read', 'write'):
return f'Invalid permission key "{key}" for feature "{feature}"'
if not isinstance(access[key], bool):
return f'Permission values must be boolean for "{feature}.{key}"'
# Write without read is invalid
if access.get('write') and not access.get('read'):
return f'Cannot have write without read for "{feature}"'
return None
+390
View File
@@ -0,0 +1,390 @@
"""Postfix SMTP server management service."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import PackageManager, ServiceControl, run_privileged
from app import paths
class PostfixService:
"""Service for managing Postfix (SMTP server)."""
POSTFIX_MAIN_CF = '/etc/postfix/main.cf'
POSTFIX_MASTER_CF = '/etc/postfix/master.cf'
VIRTUAL_DOMAINS_FILE = '/etc/postfix/virtual_domains'
VIRTUAL_MAILBOXES_FILE = '/etc/postfix/virtual_mailboxes'
VIRTUAL_ALIASES_FILE = '/etc/postfix/virtual_aliases'
MAIN_CF_ADDITIONS = """
# ServerKit mail configuration
smtpd_banner = $myhostname ESMTP
biff = no
append_dot_mydomain = no
readme_directory = no
compatibility_level = 3.6
# TLS parameters
smtpd_tls_cert_file = {tls_cert}
smtpd_tls_key_file = {tls_key}
smtpd_tls_security_level = may
smtpd_tls_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtpd_tls_mandatory_protocols = !SSLv2, !SSLv3, !TLSv1, !TLSv1.1
smtp_tls_security_level = may
# Virtual mailbox configuration
virtual_mailbox_domains = hash:/etc/postfix/virtual_domains
virtual_mailbox_maps = hash:/etc/postfix/virtual_mailboxes
virtual_alias_maps = hash:/etc/postfix/virtual_aliases
virtual_mailbox_base = {vmail_dir}
virtual_minimum_uid = {vmail_uid}
virtual_uid_maps = static:{vmail_uid}
virtual_gid_maps = static:{vmail_gid}
virtual_transport = lmtp:unix:private/dovecot-lmtp
# SASL authentication
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
broken_sasl_auth_clients = yes
# Restrictions
smtpd_recipient_restrictions =
permit_sasl_authenticated,
permit_mynetworks,
reject_unauth_destination,
reject_invalid_hostname,
reject_non_fqdn_hostname,
reject_non_fqdn_sender,
reject_non_fqdn_recipient,
reject_unknown_sender_domain,
reject_unknown_recipient_domain,
reject_rbl_client zen.spamhaus.org
# DKIM milter
smtpd_milters = inet:localhost:8891
non_smtpd_milters = $smtpd_milters
milter_default_action = accept
milter_protocol = 6
# SpamAssassin milter
smtpd_milters = inet:localhost:8891, inet:localhost:8893
# Message size limit (25MB)
message_size_limit = 26214400
mailbox_size_limit = 0
"""
SUBMISSION_CONF = """submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_reject_unlisted_recipient=no
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
"""
@classmethod
def get_status(cls) -> Dict:
"""Get Postfix installation and running status."""
installed = False
running = False
enabled = False
version = None
hostname = None
try:
result = subprocess.run(['which', 'postfix'], capture_output=True, text=True)
installed = result.returncode == 0
if not installed:
installed = PackageManager.is_installed('postfix')
if installed:
running = ServiceControl.is_active('postfix')
enabled = ServiceControl.is_enabled('postfix')
result = subprocess.run(['postconf', 'mail_version'], capture_output=True, text=True)
match = re.search(r'mail_version\s*=\s*(\S+)', result.stdout)
if match:
version = match.group(1)
result = subprocess.run(['postconf', 'myhostname'], capture_output=True, text=True)
match = re.search(r'myhostname\s*=\s*(\S+)', result.stdout)
if match:
hostname = match.group(1)
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
'hostname': hostname,
}
@classmethod
def install(cls, hostname: str = None) -> Dict:
"""Install Postfix."""
try:
# Pre-seed debconf to avoid interactive prompts
if PackageManager.detect() == 'apt':
run_privileged(['bash', '-c',
'echo "postfix postfix/mailname string ' + (hostname or 'localhost') + '" | debconf-set-selections'])
run_privileged(['bash', '-c',
'echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections'])
result = PackageManager.install(['postfix'], timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install Postfix'}
# Create virtual map files
for path in [cls.VIRTUAL_DOMAINS_FILE, cls.VIRTUAL_MAILBOXES_FILE, cls.VIRTUAL_ALIASES_FILE]:
run_privileged(['touch', path])
run_privileged(['postmap', path])
# Create vmail user/group
run_privileged(['groupadd', '-g', str(paths.VMAIL_GID), 'vmail'], check=False)
run_privileged(['useradd', '-u', str(paths.VMAIL_UID), '-g', 'vmail',
'-d', paths.VMAIL_DIR, '-s', '/usr/sbin/nologin', 'vmail'], check=False)
run_privileged(['mkdir', '-p', paths.VMAIL_DIR])
run_privileged(['chown', '-R', f'{paths.VMAIL_UID}:{paths.VMAIL_GID}', paths.VMAIL_DIR])
ServiceControl.enable('postfix')
ServiceControl.start('postfix', timeout=30)
return {'success': True, 'message': 'Postfix installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, hostname: str = None, tls_cert: str = None, tls_key: str = None) -> Dict:
"""Configure Postfix for virtual mailbox hosting."""
try:
cert = tls_cert or '/etc/ssl/certs/ssl-cert-snakeoil.pem'
key = tls_key or '/etc/ssl/private/ssl-cert-snakeoil.key'
if hostname:
run_privileged(['postconf', '-e', f'myhostname={hostname}'])
additions = cls.MAIN_CF_ADDITIONS.format(
tls_cert=cert,
tls_key=key,
vmail_dir=paths.VMAIL_DIR,
vmail_uid=paths.VMAIL_UID,
vmail_gid=paths.VMAIL_GID,
)
# Append to main.cf
run_privileged(['tee', '-a', cls.POSTFIX_MAIN_CF], input=additions)
# Enable submission port in master.cf
result = run_privileged(['cat', cls.POSTFIX_MASTER_CF])
if 'submission' not in (result.stdout or ''):
run_privileged(['tee', '-a', cls.POSTFIX_MASTER_CF], input=cls.SUBMISSION_CONF)
# Restart
ServiceControl.restart('postfix', timeout=30)
return {'success': True, 'message': 'Postfix configured successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_domain(cls, domain: str) -> Dict:
"""Add a domain to the virtual domains map."""
try:
result = run_privileged(['cat', cls.VIRTUAL_DOMAINS_FILE])
content = result.stdout or ''
if domain not in content:
run_privileged(['tee', '-a', cls.VIRTUAL_DOMAINS_FILE], input=f'{domain} OK\n')
run_privileged(['postmap', cls.VIRTUAL_DOMAINS_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_domain(cls, domain: str) -> Dict:
"""Remove a domain from the virtual domains map."""
try:
result = run_privileged(['cat', cls.VIRTUAL_DOMAINS_FILE])
lines = [l for l in (result.stdout or '').splitlines() if not l.startswith(f'{domain} ')]
run_privileged(['tee', cls.VIRTUAL_DOMAINS_FILE], input='\n'.join(lines) + '\n')
run_privileged(['postmap', cls.VIRTUAL_DOMAINS_FILE])
# Also remove mailboxes for this domain
result = run_privileged(['cat', cls.VIRTUAL_MAILBOXES_FILE])
lines = [l for l in (result.stdout or '').splitlines() if not l.endswith(f'@{domain}') and f'@{domain} ' not in l]
run_privileged(['tee', cls.VIRTUAL_MAILBOXES_FILE], input='\n'.join(lines) + '\n')
run_privileged(['postmap', cls.VIRTUAL_MAILBOXES_FILE])
# Remove aliases for this domain
result = run_privileged(['cat', cls.VIRTUAL_ALIASES_FILE])
lines = [l for l in (result.stdout or '').splitlines() if f'@{domain}' not in l]
run_privileged(['tee', cls.VIRTUAL_ALIASES_FILE], input='\n'.join(lines) + '\n')
run_privileged(['postmap', cls.VIRTUAL_ALIASES_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_mailbox(cls, email: str, domain: str, username: str) -> Dict:
"""Add a mailbox to the virtual mailbox map."""
try:
mailbox_path = f'{domain}/{username}/Maildir/'
run_privileged(['tee', '-a', cls.VIRTUAL_MAILBOXES_FILE], input=f'{email} {mailbox_path}\n')
run_privileged(['postmap', cls.VIRTUAL_MAILBOXES_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_mailbox(cls, email: str) -> Dict:
"""Remove a mailbox from the virtual mailbox map."""
try:
result = run_privileged(['cat', cls.VIRTUAL_MAILBOXES_FILE])
lines = [l for l in (result.stdout or '').splitlines() if not l.startswith(f'{email} ')]
run_privileged(['tee', cls.VIRTUAL_MAILBOXES_FILE], input='\n'.join(lines) + '\n')
run_privileged(['postmap', cls.VIRTUAL_MAILBOXES_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def add_alias(cls, source: str, destination: str) -> Dict:
"""Add a virtual alias."""
try:
run_privileged(['tee', '-a', cls.VIRTUAL_ALIASES_FILE], input=f'{source} {destination}\n')
run_privileged(['postmap', cls.VIRTUAL_ALIASES_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def remove_alias(cls, source: str) -> Dict:
"""Remove a virtual alias."""
try:
result = run_privileged(['cat', cls.VIRTUAL_ALIASES_FILE])
lines = [l for l in (result.stdout or '').splitlines() if not l.startswith(f'{source} ')]
run_privileged(['tee', cls.VIRTUAL_ALIASES_FILE], input='\n'.join(lines) + '\n')
run_privileged(['postmap', cls.VIRTUAL_ALIASES_FILE])
run_privileged(['postfix', 'reload'])
return {'success': True}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_queue(cls) -> Dict:
"""Get the Postfix mail queue."""
try:
result = subprocess.run(['mailq'], capture_output=True, text=True)
output = result.stdout or ''
if 'Mail queue is empty' in output:
return {'success': True, 'queue': [], 'total': 0}
queue = []
current = None
for line in output.splitlines():
# Queue ID line: starts with hex ID
id_match = re.match(r'^([A-F0-9]+)\s+(\d+)\s+(\S+\s+\S+\s+\S+)\s+(.+)', line)
if id_match:
if current:
queue.append(current)
current = {
'queue_id': id_match.group(1),
'size': int(id_match.group(2)),
'arrival_time': id_match.group(3),
'sender': id_match.group(4),
'recipients': [],
'error': None,
}
elif current and line.strip().startswith('('):
# Error message
current['error'] = line.strip().strip('()')
elif current and line.strip() and not line.startswith('-'):
# Recipient line
current['recipients'].append(line.strip())
if current:
queue.append(current)
return {'success': True, 'queue': queue, 'total': len(queue)}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def flush_queue(cls) -> Dict:
"""Flush the Postfix mail queue."""
try:
result = run_privileged(['postfix', 'flush'])
if result.returncode == 0:
return {'success': True, 'message': 'Mail queue flushed'}
return {'success': False, 'error': result.stderr or 'Flush failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def delete_from_queue(cls, queue_id: str) -> Dict:
"""Delete a message from the mail queue."""
try:
result = run_privileged(['postsuper', '-d', queue_id])
if result.returncode == 0:
return {'success': True, 'message': f'Message {queue_id} deleted from queue'}
return {'success': False, 'error': result.stderr or 'Delete failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_logs(cls, lines: int = 100) -> Dict:
"""Get recent mail log entries."""
try:
log_files = ['/var/log/mail.log', '/var/log/maillog']
log_file = None
for f in log_files:
if os.path.exists(f):
log_file = f
break
if not log_file:
return {'success': True, 'logs': [], 'message': 'No mail log file found'}
result = run_privileged(['tail', '-n', str(lines), log_file])
log_lines = (result.stdout or '').splitlines()
return {'success': True, 'logs': log_lines, 'total': len(log_lines)}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload Postfix configuration."""
try:
result = run_privileged(['postfix', 'reload'])
if result.returncode == 0:
return {'success': True, 'message': 'Postfix reloaded'}
return {'success': False, 'error': result.stderr or 'Reload failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Postfix."""
try:
result = ServiceControl.restart('postfix', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'Postfix restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
+183
View File
@@ -0,0 +1,183 @@
"""Roundcube webmail management service (Docker-based)."""
import subprocess
from typing import Dict
from app.utils.system import run_privileged, is_command_available
class RoundcubeService:
"""Service for managing Roundcube webmail via Docker."""
CONTAINER_NAME = 'serverkit-roundcube'
VOLUME_NAME = 'roundcube_data'
IMAGE = 'roundcube/roundcubemail:latest'
HOST_PORT = 9000
@classmethod
def get_status(cls) -> Dict:
"""Get Roundcube container status."""
if not is_command_available('docker'):
return {'installed': False, 'running': False, 'error': 'Docker not available'}
try:
result = subprocess.run(
['docker', 'inspect', '--format', '{{.State.Status}}', cls.CONTAINER_NAME],
capture_output=True, text=True,
)
if result.returncode != 0:
return {'installed': False, 'running': False}
status = result.stdout.strip()
return {
'installed': True,
'running': status == 'running',
'status': status,
'port': cls.HOST_PORT,
}
except Exception as e:
return {'installed': False, 'running': False, 'error': str(e)}
@classmethod
def install(cls, imap_host: str = 'host.docker.internal',
smtp_host: str = 'host.docker.internal') -> Dict:
"""Install Roundcube via Docker container."""
if not is_command_available('docker'):
return {'success': False, 'error': 'Docker is not installed'}
try:
# Remove existing container if any
subprocess.run(
['docker', 'rm', '-f', cls.CONTAINER_NAME],
capture_output=True, text=True,
)
# Create volume
subprocess.run(
['docker', 'volume', 'create', cls.VOLUME_NAME],
capture_output=True, text=True,
)
# Run container
result = subprocess.run([
'docker', 'run', '-d',
'--name', cls.CONTAINER_NAME,
'--restart', 'unless-stopped',
'--add-host', 'host.docker.internal:host-gateway',
'-p', f'{cls.HOST_PORT}:80',
'-e', f'ROUNDCUBEMAIL_DEFAULT_HOST=ssl://{imap_host}',
'-e', f'ROUNDCUBEMAIL_SMTP_SERVER=tls://{smtp_host}',
'-e', 'ROUNDCUBEMAIL_DEFAULT_PORT=993',
'-e', 'ROUNDCUBEMAIL_SMTP_PORT=587',
'-e', 'ROUNDCUBEMAIL_UPLOAD_MAX_FILESIZE=25M',
'-e', 'ROUNDCUBEMAIL_SKIN=elastic',
'-v', f'{cls.VOLUME_NAME}:/var/roundcube/db',
cls.IMAGE,
], capture_output=True, text=True)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to start container'}
return {
'success': True,
'message': 'Roundcube installed successfully',
'port': cls.HOST_PORT,
'url': f'http://localhost:{cls.HOST_PORT}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def uninstall(cls) -> Dict:
"""Stop and remove Roundcube container."""
try:
subprocess.run(['docker', 'rm', '-f', cls.CONTAINER_NAME], capture_output=True, text=True)
return {'success': True, 'message': 'Roundcube uninstalled'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def start(cls) -> Dict:
"""Start Roundcube container."""
try:
result = subprocess.run(
['docker', 'start', cls.CONTAINER_NAME],
capture_output=True, text=True,
)
if result.returncode == 0:
return {'success': True, 'message': 'Roundcube started'}
return {'success': False, 'error': result.stderr or 'Start failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def stop(cls) -> Dict:
"""Stop Roundcube container."""
try:
result = subprocess.run(
['docker', 'stop', cls.CONTAINER_NAME],
capture_output=True, text=True,
)
if result.returncode == 0:
return {'success': True, 'message': 'Roundcube stopped'}
return {'success': False, 'error': result.stderr or 'Stop failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def restart(cls) -> Dict:
"""Restart Roundcube container."""
try:
result = subprocess.run(
['docker', 'restart', cls.CONTAINER_NAME],
capture_output=True, text=True,
)
if result.returncode == 0:
return {'success': True, 'message': 'Roundcube restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure_nginx_proxy(cls, domain: str) -> Dict:
"""Create Nginx reverse proxy config for Roundcube."""
try:
from app.services.nginx_service import NginxService
config = f"""# Roundcube Webmail - Managed by ServerKit
server {{
listen 80;
server_name {domain};
location / {{
proxy_pass http://127.0.0.1:{cls.HOST_PORT};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 25m;
}}
}}
"""
site_name = f'roundcube-{domain.replace(".", "-")}'
config_path = f'/etc/nginx/sites-available/{site_name}'
enabled_path = f'/etc/nginx/sites-enabled/{site_name}'
run_privileged(['tee', config_path], input=config)
run_privileged(['ln', '-sf', config_path, enabled_path])
# Test and reload
test = run_privileged(['nginx', '-t'])
if test.returncode != 0:
# Rollback
run_privileged(['rm', '-f', enabled_path])
return {'success': False, 'error': f'Nginx config test failed: {test.stderr}'}
run_privileged(['systemctl', 'reload', 'nginx'])
return {
'success': True,
'message': f'Nginx proxy configured for {domain}',
'url': f'http://{domain}',
}
except Exception as e:
return {'success': False, 'error': str(e)}
+12 -8
View File
@@ -152,14 +152,18 @@ class SettingsService:
@staticmethod @staticmethod
def migrate_legacy_roles(): def migrate_legacy_roles():
"""Migrate users with 'user' role to 'developer' role.""" """Migrate users with 'user' role to 'developer' role."""
users_to_migrate = User.query.filter_by(role='user').all() try:
count = 0 users_to_migrate = User.query.filter_by(role='user').all()
for user in users_to_migrate: count = 0
user.role = User.ROLE_DEVELOPER for user in users_to_migrate:
count += 1 user.role = User.ROLE_DEVELOPER
if count > 0: count += 1
db.session.commit() if count > 0:
return count db.session.commit()
return count
except Exception:
db.session.rollback()
return 0
@staticmethod @staticmethod
def ensure_admin_exists(): def ensure_admin_exists():
@@ -0,0 +1,253 @@
"""SpamAssassin management service."""
import os
import re
import subprocess
from typing import Dict
from app.utils.system import PackageManager, ServiceControl, run_privileged
class SpamAssassinService:
"""Service for managing SpamAssassin spam filtering."""
SPAMASSASSIN_CONF = '/etc/spamassassin/local.cf'
SPAMASSASSIN_DEFAULT = '/etc/default/spamassassin'
SPAMASS_MILTER_DEFAULT = '/etc/default/spamass-milter'
LOCAL_CF_TEMPLATE = """# SpamAssassin local configuration - Managed by ServerKit
required_score {required_score}
report_safe {report_safe}
rewrite_header Subject {rewrite_subject}
use_bayes {use_bayes}
bayes_auto_learn {bayes_auto_learn}
bayes_auto_learn_threshold_nonspam 0.1
bayes_auto_learn_threshold_spam 12.0
skip_rbl_checks {skip_rbl_checks}
use_razor2 0
use_pyzor 0
# Network checks
dns_available yes
# Trusted networks
trusted_networks 127.0.0.0/8
internal_networks 127.0.0.0/8
"""
DEFAULT_CONFIG = {
'required_score': 5.0,
'report_safe': 0,
'rewrite_subject': '[SPAM]',
'use_bayes': 1,
'bayes_auto_learn': 1,
'skip_rbl_checks': 0,
}
@classmethod
def get_status(cls) -> Dict:
"""Get SpamAssassin installation and running status."""
installed = False
running = False
enabled = False
version = None
milter_installed = False
milter_running = False
try:
installed = PackageManager.is_installed('spamassassin')
if installed:
running = ServiceControl.is_active('spamassassin')
enabled = ServiceControl.is_enabled('spamassassin')
result = subprocess.run(['spamassassin', '--version'], capture_output=True, text=True)
match = re.search(r'version\s+(\S+)', result.stdout)
if match:
version = match.group(1)
# Check milter
milter_installed = PackageManager.is_installed('spamass-milter')
if milter_installed:
milter_running = ServiceControl.is_active('spamass-milter')
except (subprocess.SubprocessError, FileNotFoundError):
pass
return {
'installed': installed,
'running': running,
'enabled': enabled,
'version': version,
'milter_installed': milter_installed,
'milter_running': milter_running,
}
@classmethod
def install(cls) -> Dict:
"""Install SpamAssassin and spamass-milter."""
try:
manager = PackageManager.detect()
if manager == 'apt':
packages = ['spamassassin', 'spamass-milter', 'spamc']
else:
packages = ['spamassassin', 'spamass-milter-postfix']
result = PackageManager.install(packages, timeout=300)
if result.returncode != 0:
return {'success': False, 'error': result.stderr or 'Failed to install SpamAssassin'}
# Enable spamd on Debian/Ubuntu
if manager == 'apt' and os.path.exists(cls.SPAMASSASSIN_DEFAULT):
result = run_privileged(['cat', cls.SPAMASSASSIN_DEFAULT])
content = result.stdout or ''
content = re.sub(r'ENABLED=0', 'ENABLED=1', content)
content = re.sub(r'CRON=0', 'CRON=1', content)
run_privileged(['tee', cls.SPAMASSASSIN_DEFAULT], input=content)
# Configure milter to listen on port 8893
milter_config = 'OPTIONS="-u spamass-milter -i 127.0.0.1 -p inet:8893@localhost -- --socket=/var/run/spamassassin/spamd.sock"\n'
if os.path.exists(cls.SPAMASS_MILTER_DEFAULT):
run_privileged(['tee', cls.SPAMASS_MILTER_DEFAULT], input=milter_config)
# Write default config
cls.configure(cls.DEFAULT_CONFIG)
# Update rules
run_privileged(['sa-update'], timeout=120)
ServiceControl.enable('spamassassin')
ServiceControl.start('spamassassin', timeout=30)
if PackageManager.is_installed('spamass-milter'):
ServiceControl.enable('spamass-milter')
ServiceControl.start('spamass-milter', timeout=30)
return {'success': True, 'message': 'SpamAssassin installed successfully'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def configure(cls, settings: Dict = None) -> Dict:
"""Write SpamAssassin configuration."""
try:
config = dict(cls.DEFAULT_CONFIG)
if settings:
config.update(settings)
content = cls.LOCAL_CF_TEMPLATE.format(**config)
run_privileged(['tee', cls.SPAMASSASSIN_CONF], input=content)
return {'success': True, 'message': 'SpamAssassin configured'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_config(cls) -> Dict:
"""Get current SpamAssassin configuration."""
try:
if not os.path.exists(cls.SPAMASSASSIN_CONF):
return {'success': True, 'config': dict(cls.DEFAULT_CONFIG)}
result = run_privileged(['cat', cls.SPAMASSASSIN_CONF])
content = result.stdout or ''
config = dict(cls.DEFAULT_CONFIG)
# Parse values
score_match = re.search(r'required_score\s+(\S+)', content)
if score_match:
config['required_score'] = float(score_match.group(1))
report_match = re.search(r'report_safe\s+(\d+)', content)
if report_match:
config['report_safe'] = int(report_match.group(1))
subject_match = re.search(r'rewrite_header Subject\s+(.+)', content)
if subject_match:
config['rewrite_subject'] = subject_match.group(1).strip()
bayes_match = re.search(r'use_bayes\s+(\d+)', content)
if bayes_match:
config['use_bayes'] = int(bayes_match.group(1))
auto_learn_match = re.search(r'bayes_auto_learn\s+(\d+)', content)
if auto_learn_match:
config['bayes_auto_learn'] = int(auto_learn_match.group(1))
rbl_match = re.search(r'skip_rbl_checks\s+(\d+)', content)
if rbl_match:
config['skip_rbl_checks'] = int(rbl_match.group(1))
return {'success': True, 'config': config}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def update_rules(cls) -> Dict:
"""Update SpamAssassin rules."""
try:
result = run_privileged(['sa-update'], timeout=120)
# sa-update returns 0 for updates, 1 for no updates, 2+ for errors
if result.returncode <= 1:
ServiceControl.restart('spamassassin', timeout=30)
return {
'success': True,
'message': 'Rules updated' if result.returncode == 0 else 'Rules already up to date',
'updated': result.returncode == 0,
}
return {'success': False, 'error': result.stderr or 'Update failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_spam(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a spam message."""
try:
result = run_privileged(['sa-learn', '--spam', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as spam'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def train_ham(cls, message_path: str) -> Dict:
"""Train SpamAssassin with a ham (non-spam) message."""
try:
result = run_privileged(['sa-learn', '--ham', message_path])
if result.returncode == 0:
return {'success': True, 'message': 'Message learned as ham'}
return {'success': False, 'error': result.stderr or 'Training failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def get_stats(cls) -> Dict:
"""Get SpamAssassin Bayes statistics."""
try:
result = run_privileged(['sa-learn', '--dump', 'magic'])
output = result.stdout or ''
stats = {
'nspam': 0,
'nham': 0,
'ntokens': 0,
}
for line in output.splitlines():
parts = line.split()
if len(parts) >= 4:
if parts[2] == 'nspam':
stats['nspam'] = int(parts[1])
elif parts[2] == 'nham':
stats['nham'] = int(parts[1])
elif parts[2] == 'ntokens':
stats['ntokens'] = int(parts[1])
return {'success': True, 'stats': stats}
except Exception as e:
return {'success': False, 'error': str(e)}
@classmethod
def reload(cls) -> Dict:
"""Reload SpamAssassin."""
try:
result = ServiceControl.restart('spamassassin', timeout=30)
if result.returncode == 0:
return {'success': True, 'message': 'SpamAssassin restarted'}
return {'success': False, 'error': result.stderr or 'Restart failed'}
except Exception as e:
return {'success': False, 'error': str(e)}
+11 -1
View File
@@ -31,6 +31,15 @@ class DevelopmentConfig(Config):
DEBUG = True DEBUG = True
class TestingConfig(Config):
"""Config for pytest and other automated tests."""
TESTING = True
DEBUG = True
SQLALCHEMY_DATABASE_URI = os.environ.get('TEST_DATABASE_URL', 'sqlite:///:memory:')
# Reduce noise during tests
JWT_ACCESS_TOKEN_EXPIRES = timedelta(minutes=5)
class ProductionConfig(Config): class ProductionConfig(Config):
DEBUG = False DEBUG = False
@@ -49,6 +58,7 @@ class ProductionConfig(Config):
config = { config = {
'development': DevelopmentConfig, 'development': DevelopmentConfig,
'testing': TestingConfig,
'production': ProductionConfig, 'production': ProductionConfig,
'default': DevelopmentConfig 'default': DevelopmentConfig,
} }
@@ -0,0 +1,57 @@
"""Add permissions column to users and create invitations table.
Revision ID: 002_permissions_invitations
Revises: 001_baseline
Create Date: 2026-03-04
"""
from alembic import op
import sqlalchemy as sa
revision = '002_permissions_invitations'
down_revision = '001_baseline'
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
# Add permissions column to users if missing
if 'users' in existing_tables:
existing_cols = {c['name'] for c in inspector.get_columns('users')}
if 'permissions' not in existing_cols:
with op.batch_alter_table('users') as batch_op:
batch_op.add_column(sa.Column('permissions', sa.Text(), nullable=True))
# Create invitations table
if 'invitations' not in existing_tables:
op.create_table('invitations',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('email', sa.String(255), nullable=True),
sa.Column('token', sa.String(64), unique=True, nullable=False, index=True),
sa.Column('role', sa.String(20), nullable=False, server_default='developer'),
sa.Column('permissions', sa.Text(), nullable=True),
sa.Column('invited_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('accepted_at', sa.DateTime(), nullable=True),
sa.Column('accepted_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=True),
sa.Column('status', sa.String(20), nullable=False, server_default='pending', index=True),
sa.Column('created_at', sa.DateTime(), server_default=sa.func.now()),
)
def downgrade():
conn = op.get_bind()
inspector = sa.inspect(conn)
existing_tables = inspector.get_table_names()
if 'invitations' in existing_tables:
op.drop_table('invitations')
if 'users' in existing_tables:
existing_cols = {c['name'] for c in inspector.get_columns('users')}
if 'permissions' in existing_cols:
with op.batch_alter_table('users') as batch_op:
batch_op.drop_column('permissions')
+62
View File
@@ -0,0 +1,62 @@
"""Pytest fixtures for backend tests (Flask app, DB, client)."""
import os
import sys
import pytest
# Ensure backend root is on path
_backend = os.path.dirname(os.path.abspath(__file__))
if _backend not in sys.path:
sys.path.insert(0, _backend)
os.environ.setdefault('FLASK_ENV', 'testing')
@pytest.fixture(scope='function')
def app():
"""Create Flask app with testing config and in-memory DB."""
from app import create_app
from app import db as _db
app = create_app('testing')
with app.app_context():
_db.create_all()
yield app
_db.session.remove()
_db.drop_all()
@pytest.fixture
def client(app):
"""Flask test client."""
return app.test_client()
@pytest.fixture
def db_session(app):
"""Database session for the current test (same as app's db)."""
from app import db
return db
@pytest.fixture
def auth_headers(app):
"""Create an admin user and return headers with valid JWT for API tests."""
from app import db
from app.models import User
from flask_jwt_extended import create_access_token
from werkzeug.security import generate_password_hash
with app.app_context():
user = User(
email='testadmin@test.local',
username='testadmin',
password_hash=generate_password_hash('testpass'),
role=User.ROLE_ADMIN,
is_active=True,
)
db.session.add(user)
db.session.commit()
token = create_access_token(identity=user.id)
return {'Authorization': f'Bearer {token}'}
+5 -9
View File
@@ -149,8 +149,6 @@ function SetupRoute({ children }) {
} }
function AppRoutes() { function AppRoutes() {
const { registrationEnabled } = useAuth();
return ( return (
<Routes> <Routes>
<Route path="/migrate" element={<DatabaseMigration />} /> <Route path="/migrate" element={<DatabaseMigration />} />
@@ -169,13 +167,11 @@ function AppRoutes() {
<SSOCallback /> <SSOCallback />
</PublicRoute> </PublicRoute>
} /> } />
{registrationEnabled && ( <Route path="/register" element={
<Route path="/register" element={ <PublicRoute>
<PublicRoute> <Register />
<Register /> </PublicRoute>
</PublicRoute> } />
} />
)}
<Route path="/" element={ <Route path="/" element={
<PrivateRoute> <PrivateRoute>
<DashboardLayout /> <DashboardLayout />
@@ -0,0 +1,211 @@
import React, { useState, useEffect } from 'react';
import api from '../../services/api';
const ActivityTab = () => {
const [summary, setSummary] = useState(null);
const [feed, setFeed] = useState([]);
const [feedPagination, setFeedPagination] = useState(null);
const [loading, setLoading] = useState(true);
const [feedLoading, setFeedLoading] = useState(false);
const [users, setUsers] = useState([]);
const [filters, setFilters] = useState({ user_id: '', action: '', page: 1 });
const [actions, setActions] = useState([]);
useEffect(() => {
loadSummary();
loadUsers();
loadActions();
}, []);
useEffect(() => {
loadFeed();
}, [filters]);
async function loadSummary() {
try {
const data = await api.getActivitySummary();
setSummary(data);
} catch {
// Silently handle
} finally {
setLoading(false);
}
}
async function loadFeed() {
try {
setFeedLoading(true);
const params = { per_page: 20 };
if (filters.page) params.page = filters.page;
if (filters.user_id) params.user_id = filters.user_id;
if (filters.action) params.action = filters.action;
const data = await api.getActivityFeed(params);
setFeed(data.logs || []);
setFeedPagination(data.pagination);
} catch {
// Silently handle
} finally {
setFeedLoading(false);
}
}
async function loadUsers() {
try {
const data = await api.getUsers();
setUsers(data.users || []);
} catch {
// Silently handle
}
}
async function loadActions() {
try {
const data = await api.getAuditLogActions();
setActions(data.actions || []);
} catch {
// Silently handle
}
}
function formatDate(dateString) {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
function getActionClass(action) {
if (action?.includes('delete') || action?.includes('revoke')) return 'action-danger';
if (action?.includes('failed') || action?.includes('disable')) return 'action-warning';
if (action?.includes('create') || action?.includes('login') || action?.includes('accept')) return 'action-success';
return 'action-info';
}
if (loading) {
return <div className="activity-tab"><div className="loading-state">Loading activity...</div></div>;
}
const maxCount = summary?.top_users?.length
? Math.max(...summary.top_users.map(u => u.action_count))
: 1;
return (
<div className="activity-tab">
<div className="tab-header">
<div className="tab-header-content">
<h3>Activity Dashboard</h3>
<p>Monitor team activity and recent actions</p>
</div>
</div>
{summary && (
<>
<div className="activity-summary">
<div className="activity-stat-card">
<span className="stat-number">{summary.active_users_today}</span>
<span className="stat-label">Active Users Today</span>
</div>
<div className="activity-stat-card">
<span className="stat-number">{summary.actions_this_week}</span>
<span className="stat-label">Actions This Week</span>
</div>
<div className="activity-stat-card">
<span className="stat-number">{summary.total_users}</span>
<span className="stat-label">Total Users</span>
</div>
</div>
{summary.top_users && summary.top_users.length > 0 && (
<div className="most-active-users">
<h4>Most Active Users (This Week)</h4>
{summary.top_users.map((u, i) => (
<div key={u.user_id} className="active-user-item">
<span className="active-user-rank">{i + 1}</span>
<span className="active-user-name">{u.username}</span>
<div className="active-user-bar-wrapper">
<div
className="active-user-bar"
style={{ width: `${(u.action_count / maxCount) * 100}%` }}
/>
</div>
<span className="active-user-count">{u.action_count}</span>
</div>
))}
</div>
)}
</>
)}
<div className="activity-feed-section">
<h4>Activity Feed</h4>
<div className="filters-bar">
<div className="filter-group">
<label>User</label>
<select value={filters.user_id} onChange={e => setFilters(f => ({ ...f, user_id: e.target.value, page: 1 }))}>
<option value="">All users</option>
{users.map(u => (
<option key={u.id} value={u.id}>{u.username}</option>
))}
</select>
</div>
<div className="filter-group">
<label>Action</label>
<select value={filters.action} onChange={e => setFilters(f => ({ ...f, action: e.target.value, page: 1 }))}>
<option value="">All actions</option>
{actions.map(a => (
<option key={a} value={a}>{a}</option>
))}
</select>
</div>
</div>
{feedLoading ? (
<div className="loading-state">Loading...</div>
) : feed.length === 0 ? (
<div className="empty-state">No activity found</div>
) : (
<div className="audit-log-list">
{feed.map(entry => (
<div key={entry.id} className={`log-entry ${getActionClass(entry.action)}`}>
<div className="log-content">
<div className="log-header">
<span className="log-action">{entry.action}</span>
{entry.username && <span className="log-user">by {entry.username}</span>}
</div>
<div className="log-meta">
<span>{formatDate(entry.created_at)}</span>
{entry.ip_address && <span className="log-ip">{entry.ip_address}</span>}
</div>
</div>
</div>
))}
</div>
)}
{feedPagination && feedPagination.pages > 1 && (
<div className="pagination">
<button
className="btn btn-ghost btn-sm"
disabled={!feedPagination.has_prev}
onClick={() => setFilters(f => ({ ...f, page: f.page - 1 }))}
>
Previous
</button>
<span className="pagination-info">
Page {feedPagination.page} of {feedPagination.pages}
</span>
<button
className="btn btn-ghost btn-sm"
disabled={!feedPagination.has_next}
onClick={() => setFilters(f => ({ ...f, page: f.page + 1 }))}
>
Next
</button>
</div>
)}
</div>
</div>
);
};
export default ActivityTab;
@@ -0,0 +1,166 @@
import React, { useState, useEffect } from 'react';
import api from '../../services/api';
import InviteModal from './InviteModal';
const InvitationsTab = () => {
const [invitations, setInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const [showInviteModal, setShowInviteModal] = useState(false);
const [copied, setCopied] = useState(null);
useEffect(() => {
loadInvitations();
}, []);
async function loadInvitations() {
try {
setLoading(true);
const data = await api.getInvitations();
setInvitations(data.invitations || []);
} catch {
// Silently handle
} finally {
setLoading(false);
}
}
async function handleRevoke(id) {
try {
await api.revokeInvitation(id);
await loadInvitations();
} catch {
// Silently handle
}
}
async function handleResend(id) {
try {
await api.resendInvitation(id);
} catch {
// Silently handle
}
}
function copyLink(token) {
const url = `${window.location.origin}/register?invite=${token}`;
navigator.clipboard.writeText(url);
setCopied(token);
setTimeout(() => setCopied(null), 2000);
}
function formatDate(dateString) {
if (!dateString) return 'Never';
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric', month: 'short', day: 'numeric'
});
}
function getStatusBadgeClass(status, isExpired) {
if (isExpired && status === 'pending') return 'invitation-status expired';
switch (status) {
case 'pending': return 'invitation-status pending';
case 'accepted': return 'invitation-status accepted';
case 'expired': return 'invitation-status expired';
case 'revoked': return 'invitation-status revoked';
default: return 'invitation-status';
}
}
return (
<div className="invitations-section">
<div className="tab-header">
<div className="tab-header-content">
<h4>Invitations</h4>
<p>Manage team invitations</p>
</div>
<button className="btn btn-primary btn-sm" onClick={() => setShowInviteModal(true)}>
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" strokeWidth="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Invite User
</button>
</div>
{loading ? (
<div className="loading-state">Loading invitations...</div>
) : invitations.length === 0 ? (
<div className="empty-state">No invitations yet</div>
) : (
<div className="users-table-container">
<table className="users-table">
<thead>
<tr>
<th>Recipient</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{invitations.map(inv => (
<tr key={inv.id}>
<td>{inv.email || <span className="text-muted">Link only</span>}</td>
<td>
<span className={`badge ${inv.role === 'admin' ? 'badge-danger' : inv.role === 'developer' ? 'badge-primary' : 'badge-secondary'}`}>
{inv.role}
</span>
</td>
<td>
<span className={getStatusBadgeClass(inv.status, inv.is_expired)}>
{inv.is_expired && inv.status === 'pending' ? 'expired' : inv.status}
</span>
</td>
<td className="date-cell">{formatDate(inv.created_at)}</td>
<td className="date-cell">{formatDate(inv.expires_at)}</td>
<td className="actions-cell">
{inv.status === 'pending' && !inv.is_expired && (
<>
<button
className="btn btn-sm btn-ghost"
onClick={() => copyLink(inv.token)}
title="Copy invite link"
>
{copied === inv.token ? 'Copied!' : 'Copy Link'}
</button>
{inv.email && (
<button
className="btn btn-sm btn-ghost"
onClick={() => handleResend(inv.id)}
title="Resend email"
>
Resend
</button>
)}
<button
className="btn btn-sm btn-ghost text-danger"
onClick={() => handleRevoke(inv.id)}
title="Revoke invitation"
>
Revoke
</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{showInviteModal && (
<InviteModal
onClose={() => setShowInviteModal(false)}
onCreated={loadInvitations}
/>
)}
</div>
);
};
export default InvitationsTab;
@@ -0,0 +1,203 @@
import React, { useState, useEffect } from 'react';
import api from '../../services/api';
import PermissionEditor from './PermissionEditor';
const InviteModal = ({ onClose, onCreated }) => {
const [email, setEmail] = useState('');
const [role, setRole] = useState('developer');
const [expiryDays, setExpiryDays] = useState(7);
const [showPermissions, setShowPermissions] = useState(false);
const [permissions, setPermissions] = useState({});
const [templates, setTemplates] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null);
const [copied, setCopied] = useState(false);
useEffect(() => {
api.getPermissionTemplates().then(data => {
setTemplates(data.templates || {});
if (data.templates?.developer) {
setPermissions(data.templates.developer);
}
}).catch(() => {});
}, []);
function handleRoleChange(e) {
const newRole = e.target.value;
setRole(newRole);
if (templates[newRole]) {
setPermissions(templates[newRole]);
}
}
async function handleSubmit(e) {
e.preventDefault();
setError('');
setLoading(true);
try {
const data = {
role,
expires_in_days: expiryDays === 0 ? null : expiryDays,
};
if (email.trim()) data.email = email.trim();
if (showPermissions && role !== 'admin') {
data.permissions = permissions;
}
const response = await api.createInvitation(data);
setResult(response);
if (onCreated) onCreated();
} catch (err) {
setError(err.message || 'Failed to create invitation');
} finally {
setLoading(false);
}
}
function copyLink() {
if (result?.invite_url) {
navigator.clipboard.writeText(result.invite_url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}
// Show result screen after creation
if (result) {
return (
<div className="modal-overlay">
<div className="modal modal-md">
<div className="modal-header">
<h3>Invitation Created</h3>
<button className="modal-close" onClick={onClose}>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" fill="none" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div className="modal-body">
<p>Share this invitation link:</p>
<div className="invite-link-display">
<code>{result.invite_url}</code>
<button className="btn btn-sm btn-ghost" onClick={copyLink}>
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
{result.email_sent && (
<p className="text-success" style={{ marginTop: 12 }}>
Invitation email sent to {result.invitation.email}
</p>
)}
{result.email_error && (
<p className="text-warning" style={{ marginTop: 12 }}>
Email could not be sent: {result.email_error}
</p>
)}
</div>
<div className="modal-footer">
<button className="btn btn-primary" onClick={onClose}>Done</button>
</div>
</div>
</div>
);
}
return (
<div className="modal-overlay">
<div className="modal modal-md">
<div className="modal-header">
<h3>Invite User</h3>
<button className="modal-close" onClick={onClose}>
<svg viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" fill="none" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{error && <div className="error-message">{error}</div>}
<div className="form-group">
<label htmlFor="invite-email">Email (optional)</label>
<input
type="email"
id="invite-email"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="user@example.com (leave blank for link-only)"
/>
<span className="form-help">
If provided, an invitation email will be sent. Otherwise, share the link manually.
</span>
</div>
<div className="form-row">
<div className="form-group">
<label htmlFor="invite-role">Role</label>
<select id="invite-role" value={role} onChange={handleRoleChange}>
<option value="admin">Admin</option>
<option value="developer">Developer</option>
<option value="viewer">Viewer</option>
</select>
</div>
<div className="form-group">
<label htmlFor="invite-expiry">Expires</label>
<select id="invite-expiry" value={expiryDays} onChange={e => setExpiryDays(Number(e.target.value))}>
<option value={1}>1 day</option>
<option value={3}>3 days</option>
<option value={7}>7 days</option>
<option value={30}>30 days</option>
<option value={0}>Never</option>
</select>
</div>
</div>
{role !== 'admin' && (
<div className="customize-permissions-section">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
if (!showPermissions && templates[role]) {
setPermissions(templates[role]);
}
setShowPermissions(!showPermissions);
}}
>
{showPermissions ? 'Hide' : 'Customize'} Permissions
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" strokeWidth="2" style={{ marginLeft: 4 }}>
{showPermissions
? <polyline points="18 15 12 9 6 15"/>
: <polyline points="6 9 12 15 18 9"/>
}
</svg>
</button>
{showPermissions && (
<PermissionEditor
permissions={permissions}
onChange={setPermissions}
/>
)}
</div>
)}
</div>
<div className="modal-footer">
<button type="button" className="btn btn-ghost" onClick={onClose}>Cancel</button>
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Creating...' : 'Create Invitation'}
</button>
</div>
</form>
</div>
</div>
);
};
export default InviteModal;
@@ -0,0 +1,100 @@
import React from 'react';
const PERMISSION_GROUPS = [
{
label: 'Infrastructure',
features: ['applications', 'databases', 'docker', 'domains', 'servers']
},
{
label: 'Operations',
features: ['files', 'monitoring', 'backups', 'cron', 'security', 'email', 'git']
},
{
label: 'System',
features: ['terminal', 'users', 'settings']
}
];
const FEATURE_LABELS = {
applications: 'Applications',
databases: 'Databases',
docker: 'Docker',
domains: 'Domains',
servers: 'Servers',
files: 'File Manager',
monitoring: 'Monitoring',
backups: 'Backups',
cron: 'Cron Jobs',
security: 'Security',
email: 'Email',
git: 'Git',
terminal: 'Terminal',
users: 'Users',
settings: 'Settings'
};
const PermissionEditor = ({ permissions = {}, onChange, disabled = false }) => {
function handleToggle(feature, level) {
const current = permissions[feature] || { read: false, write: false };
const updated = { ...permissions };
if (level === 'read') {
const newRead = !current.read;
updated[feature] = {
read: newRead,
write: newRead ? current.write : false // Disable write if read is off
};
} else {
const newWrite = !current.write;
updated[feature] = {
read: newWrite ? true : current.read, // Enable read if write is on
write: newWrite
};
}
onChange(updated);
}
return (
<div className="permission-editor">
<div className="permission-header-row">
<span className="permission-feature-label">Feature</span>
<span className="permission-level-label">Read</span>
<span className="permission-level-label">Write</span>
</div>
{PERMISSION_GROUPS.map(group => (
<div key={group.label} className="permission-group">
<div className="permission-group-label">{group.label}</div>
{group.features.map(feature => {
const featurePerms = permissions[feature] || { read: false, write: false };
return (
<div key={feature} className="permission-row">
<span className="permission-feature-name">
{FEATURE_LABELS[feature]}
</span>
<label className="permission-checkbox">
<input
type="checkbox"
checked={!!featurePerms.read}
onChange={() => handleToggle(feature, 'read')}
disabled={disabled}
/>
</label>
<label className="permission-checkbox">
<input
type="checkbox"
checked={!!featurePerms.write}
onChange={() => handleToggle(feature, 'write')}
disabled={disabled}
/>
</label>
</div>
);
})}
</div>
))}
</div>
);
};
export default PermissionEditor;
@@ -1,5 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import api from '../../services/api';
import PermissionEditor from './PermissionEditor';
const UserModal = ({ user, onSave, onClose }) => { const UserModal = ({ user, onSave, onClose }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -10,6 +12,9 @@ const UserModal = ({ user, onSave, onClose }) => {
role: 'developer', role: 'developer',
is_active: true is_active: true
}); });
const [permissions, setPermissions] = useState({});
const [showPermissions, setShowPermissions] = useState(false);
const [templates, setTemplates] = useState({});
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
@@ -27,15 +32,31 @@ const UserModal = ({ user, onSave, onClose }) => {
role: user.role || 'developer', role: user.role || 'developer',
is_active: user.is_active !== false is_active: user.is_active !== false
}); });
if (user.permissions) {
setPermissions(user.permissions);
// Show permissions section if user has custom permissions set
const hasCustom = user.permissions && Object.keys(user.permissions).length > 0;
setShowPermissions(hasCustom);
}
} }
}, [user]); }, [user]);
useEffect(() => {
api.getPermissionTemplates().then(data => {
setTemplates(data.templates || {});
}).catch(() => {});
}, []);
function handleChange(e) { function handleChange(e) {
const { name, value, type, checked } = e.target; const { name, value, type, checked } = e.target;
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[name]: type === 'checkbox' ? checked : value [name]: type === 'checkbox' ? checked : value
})); }));
// When role changes, load template defaults for the permissions editor
if (name === 'role' && templates[value]) {
setPermissions(templates[value]);
}
} }
async function handleSubmit(e) { async function handleSubmit(e) {
@@ -78,6 +99,11 @@ const UserModal = ({ user, onSave, onClose }) => {
userData.password = formData.password; userData.password = formData.password;
} }
// Include custom permissions if editor is open and role isn't admin
if (showPermissions && formData.role !== 'admin') {
userData.permissions = permissions;
}
await onSave(userData); await onSave(userData);
} catch (err) { } catch (err) {
setError(err.message || 'Failed to save user'); setError(err.message || 'Failed to save user');
@@ -208,6 +234,35 @@ const UserModal = ({ user, onSave, onClose }) => {
<span className="role-desc">Read-only access to dashboards and logs</span> <span className="role-desc">Read-only access to dashboards and logs</span>
</div> </div>
</div> </div>
{formData.role !== 'admin' && (
<div className="customize-permissions-section">
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => {
if (!showPermissions && templates[formData.role]) {
setPermissions(templates[formData.role]);
}
setShowPermissions(!showPermissions);
}}
>
{showPermissions ? 'Hide' : 'Customize'} Permissions
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" fill="none" strokeWidth="2" style={{ marginLeft: 4 }}>
{showPermissions
? <polyline points="18 15 12 9 6 15"/>
: <polyline points="6 9 12 15 18 9"/>
}
</svg>
</button>
{showPermissions && (
<PermissionEditor
permissions={permissions}
onChange={setPermissions}
/>
)}
</div>
)}
</div> </div>
<div className="modal-footer"> <div className="modal-footer">
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import api from '../../services/api'; import api from '../../services/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import UserModal from './UserModal'; import UserModal from './UserModal';
import InvitationsTab from './InvitationsTab';
const UsersTab = () => { const UsersTab = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
@@ -252,6 +253,8 @@ const UsersTab = () => {
</div> </div>
</div> </div>
)} )}
<InvitationsTab />
</div> </div>
); );
}; };
+11 -2
View File
@@ -79,8 +79,8 @@ export function AuthProvider({ children }) {
return data; return data;
} }
async function register(email, username, password) { async function register(email, username, password, inviteToken) {
const data = await api.register(email, username, password); const data = await api.register(email, username, password, inviteToken);
setUser(data.user); setUser(data.user);
return data; return data;
} }
@@ -112,6 +112,14 @@ export function AuthProvider({ children }) {
return data.user; return data.user;
} }
function hasPermission(feature, level = 'read') {
if (!user) return false;
if (user.role === 'admin') return true;
const perms = user.permissions || {};
const featurePerms = perms[feature] || {};
return !!featurePerms[level];
}
const value = { const value = {
user, user,
setUser, setUser,
@@ -123,6 +131,7 @@ export function AuthProvider({ children }) {
updateUser, updateUser,
refreshUser, refreshUser,
refreshSetupStatus, refreshSetupStatus,
hasPermission,
isAuthenticated: !!user, isAuthenticated: !!user,
isAdmin: user?.role === 'admin', isAdmin: user?.role === 'admin',
isDeveloper: user?.role === 'admin' || user?.role === 'developer', isDeveloper: user?.role === 'admin' || user?.role === 'developer',
+901 -1113
View File
File diff suppressed because it is too large. Load diff
+82 -5
View File
@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import api from '../services/api';
import ServerKitLogo from '../components/ServerKitLogo'; import ServerKitLogo from '../components/ServerKitLogo';
const Register = () => { const Register = () => {
@@ -10,8 +11,83 @@ const Register = () => {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { register } = useAuth(); const [inviteInfo, setInviteInfo] = useState(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [inviteInvalid, setInviteInvalid] = useState(false);
const { register, registrationEnabled } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
const inviteToken = searchParams.get('invite');
useEffect(() => {
if (inviteToken) {
setInviteLoading(true);
api.validateInvitation(inviteToken)
.then(data => {
setInviteInfo(data);
if (data.email) setEmail(data.email);
})
.catch(() => {
setInviteInvalid(true);
})
.finally(() => setInviteLoading(false));
}
}, [inviteToken]);
// If no invite token and registration disabled, show message
if (!inviteToken && !registrationEnabled) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<div className="brand-logo">
<ServerKitLogo width={40} height={40} />
</div>
<h1>ServerKit</h1>
<p>Registration is currently disabled</p>
</div>
<p className="auth-footer">
Already have an account? <Link to="/login">Sign in</Link>
</p>
</div>
</div>
);
}
if (inviteLoading) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<div className="brand-logo">
<ServerKitLogo width={40} height={40} />
</div>
<h1>ServerKit</h1>
<p>Validating invitation...</p>
</div>
</div>
</div>
);
}
if (inviteInvalid) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-header">
<div className="brand-logo">
<ServerKitLogo width={40} height={40} />
</div>
<h1>ServerKit</h1>
<p>This invitation is invalid or has expired</p>
</div>
<p className="auth-footer">
Already have an account? <Link to="/login">Sign in</Link>
</p>
</div>
</div>
);
}
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
@@ -30,7 +106,7 @@ const Register = () => {
setLoading(true); setLoading(true);
try { try {
await register(email, username, password); await register(email, username, password, inviteToken || undefined);
navigate('/'); navigate('/');
} catch (err) { } catch (err) {
setError(err.message || 'Failed to register'); setError(err.message || 'Failed to register');
@@ -47,7 +123,7 @@ const Register = () => {
<ServerKitLogo width={40} height={40} /> <ServerKitLogo width={40} height={40} />
</div> </div>
<h1>ServerKit</h1> <h1>ServerKit</h1>
<p>Create your account</p> <p>{inviteInfo ? `You've been invited as ${inviteInfo.role}` : 'Create your account'}</p>
</div> </div>
{error && <div className="error-message">{error}</div>} {error && <div className="error-message">{error}</div>}
@@ -62,6 +138,7 @@ const Register = () => {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com" placeholder="you@example.com"
required required
readOnly={!!inviteInfo?.email}
/> />
</div> </div>
+10 -1
View File
@@ -6,6 +6,7 @@ import useDashboardLayout from '../hooks/useDashboardLayout';
import api from '../services/api'; import api from '../services/api';
import UsersTab from '../components/settings/UsersTab'; import UsersTab from '../components/settings/UsersTab';
import AuditLogTab from '../components/settings/AuditLogTab'; import AuditLogTab from '../components/settings/AuditLogTab';
import ActivityTab from '../components/settings/ActivityTab';
import SSOConfigTab from '../components/settings/SSOConfigTab'; import SSOConfigTab from '../components/settings/SSOConfigTab';
import MigrationHistoryTab from '../components/settings/MigrationHistoryTab'; import MigrationHistoryTab from '../components/settings/MigrationHistoryTab';
import ApiSettingsTab from '../components/settings/ApiSettingsTab'; import ApiSettingsTab from '../components/settings/ApiSettingsTab';
@@ -24,7 +25,7 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import ServerKitLogo from '../components/ServerKitLogo'; import ServerKitLogo from '../components/ServerKitLogo';
const VALID_TABS = ['profile', 'security', 'appearance', 'notifications', 'system', 'users', 'audit', 'site', 'sso', 'api', 'migrations', 'developer', 'about']; const VALID_TABS = ['profile', 'security', 'appearance', 'notifications', 'system', 'users', 'audit', 'activity', 'site', 'sso', 'api', 'migrations', 'developer', 'about'];
const Settings = () => { const Settings = () => {
const [activeTab, setActiveTab] = useTabParam('/settings', VALID_TABS); const [activeTab, setActiveTab] = useTabParam('/settings', VALID_TABS);
@@ -136,6 +137,13 @@ const Settings = () => {
</svg> </svg>
Audit Log Audit Log
</button> </button>
<button
className={`settings-nav-item ${activeTab === 'activity' ? 'active' : ''}`}
onClick={() => setActiveTab('activity')}
>
<Activity size={18} />
Activity
</button>
<button <button
className={`settings-nav-item ${activeTab === 'site' ? 'active' : ''}`} className={`settings-nav-item ${activeTab === 'site' ? 'active' : ''}`}
onClick={() => setActiveTab('site')} onClick={() => setActiveTab('site')}
@@ -206,6 +214,7 @@ const Settings = () => {
{activeTab === 'system' && <SystemInfo />} {activeTab === 'system' && <SystemInfo />}
{activeTab === 'users' && isAdmin && <UsersTab />} {activeTab === 'users' && isAdmin && <UsersTab />}
{activeTab === 'audit' && isAdmin && <AuditLogTab />} {activeTab === 'audit' && isAdmin && <AuditLogTab />}
{activeTab === 'activity' && isAdmin && <ActivityTab />}
{activeTab === 'site' && isAdmin && <SiteSettings onDevModeChange={setDevMode} />} {activeTab === 'site' && isAdmin && <SiteSettings onDevModeChange={setDevMode} />}
{activeTab === 'sso' && isAdmin && <SSOConfigTab />} {activeTab === 'sso' && isAdmin && <SSOConfigTab />}
{activeTab === 'api' && isAdmin && <ApiSettingsTab />} {activeTab === 'api' && isAdmin && <ApiSettingsTab />}
+123 -124
View File
@@ -106,10 +106,12 @@ class ApiService {
return data; return data;
} }
async register(email, username, password) { async register(email, username, password, inviteToken) {
const body = { email, username, password };
if (inviteToken) body.invite_token = inviteToken;
const data = await this.request('/auth/register', { const data = await this.request('/auth/register', {
method: 'POST', method: 'POST',
body: { email, username, password }, body,
}); });
this.setTokens(data.access_token, data.refresh_token); this.setTokens(data.access_token, data.refresh_token);
return data; return data;
@@ -2913,130 +2915,53 @@ class ApiService {
return `${baseUrl}/api/servers/agent/download/${os}/${arch}`; return `${baseUrl}/api/servers/agent/download/${os}/${arch}`;
} }
// ========================================== // ── Email Server ──
// Email Server async getEmailStatus() { return this.request('/email/status'); }
// ========================================== async installEmailServer(data = {}) { return this.request('/email/install', { method: 'POST', body: JSON.stringify(data) }); }
async controlEmailService(component, action) { return this.request(`/email/service/${component}/${action}`, { method: 'POST' }); }
async getEmailStatus() { // Email Domains
return this.request('/email/status'); async getEmailDomains() { return this.request('/email/domains'); }
} async addEmailDomain(data) { return this.request('/email/domains', { method: 'POST', body: JSON.stringify(data) }); }
async getEmailDomain(domainId) { return this.request(`/email/domains/${domainId}`); }
async getEmailConfig() { async deleteEmailDomain(domainId) { return this.request(`/email/domains/${domainId}`, { method: 'DELETE' }); }
return this.request('/email/config'); async verifyEmailDNS(domainId) { return this.request(`/email/domains/${domainId}/verify-dns`, { method: 'POST' }); }
} async deployEmailDNS(domainId) { return this.request(`/email/domains/${domainId}/deploy-dns`, { method: 'POST' }); }
async updateEmailConfig(data) {
return this.request('/email/config', { method: 'PUT', body: data });
}
// Postfix
async installPostfix() {
return this.request('/email/postfix/install', { method: 'POST' });
}
async getPostfixConfig() {
return this.request('/email/postfix/config');
}
async updatePostfixConfig(data) {
return this.request('/email/postfix/config', { method: 'PUT', body: data });
}
// Mail Queue
async getMailQueue() {
return this.request('/email/queue');
}
async flushMailQueue() {
return this.request('/email/queue/flush', { method: 'POST' });
}
async deleteQueuedMessage(queueId) {
return this.request(`/email/queue/${queueId}`, { method: 'DELETE' });
}
// Dovecot
async installDovecot() {
return this.request('/email/dovecot/install', { method: 'POST' });
}
async getDovecotConfig() {
return this.request('/email/dovecot/config');
}
// Email Accounts // Email Accounts
async getEmailAccounts() { async getEmailAccounts(domainId) { return this.request(`/email/domains/${domainId}/accounts`); }
return this.request('/email/accounts'); async createEmailAccount(domainId, data) { return this.request(`/email/domains/${domainId}/accounts`, { method: 'POST', body: JSON.stringify(data) }); }
} async getEmailAccount(accountId) { return this.request(`/email/accounts/${accountId}`); }
async updateEmailAccount(accountId, data) { return this.request(`/email/accounts/${accountId}`, { method: 'PUT', body: JSON.stringify(data) }); }
async createEmailAccount(data) { async deleteEmailAccount(accountId) { return this.request(`/email/accounts/${accountId}`, { method: 'DELETE' }); }
return this.request('/email/accounts', { method: 'POST', body: data }); async changeEmailPassword(accountId, password) { return this.request(`/email/accounts/${accountId}/password`, { method: 'POST', body: JSON.stringify({ password }) }); }
} // Email Aliases
async getEmailAliases(domainId) { return this.request(`/email/domains/${domainId}/aliases`); }
async updateEmailAccount(accountId, data) { async createEmailAlias(domainId, data) { return this.request(`/email/domains/${domainId}/aliases`, { method: 'POST', body: JSON.stringify(data) }); }
return this.request(`/email/accounts/${accountId}`, { method: 'PUT', body: data }); async deleteEmailAlias(aliasId) { return this.request(`/email/aliases/${aliasId}`, { method: 'DELETE' }); }
} // Email Forwarding
async getEmailForwarding(accountId) { return this.request(`/email/accounts/${accountId}/forwarding`); }
async deleteEmailAccount(accountId) { async createEmailForwarding(accountId, data) { return this.request(`/email/accounts/${accountId}/forwarding`, { method: 'POST', body: JSON.stringify(data) }); }
return this.request(`/email/accounts/${accountId}`, { method: 'DELETE' }); async updateEmailForwarding(ruleId, data) { return this.request(`/email/forwarding/${ruleId}`, { method: 'PUT', body: JSON.stringify(data) }); }
} async deleteEmailForwarding(ruleId) { return this.request(`/email/forwarding/${ruleId}`, { method: 'DELETE' }); }
// DNS Providers
async setEmailForwarding(accountId, data) { async getEmailDNSProviders() { return this.request('/email/dns-providers'); }
return this.request(`/email/accounts/${accountId}/forwarding`, { method: 'PUT', body: data }); async addEmailDNSProvider(data) { return this.request('/email/dns-providers', { method: 'POST', body: JSON.stringify(data) }); }
} async deleteEmailDNSProvider(providerId) { return this.request(`/email/dns-providers/${providerId}`, { method: 'DELETE' }); }
async testEmailDNSProvider(providerId) { return this.request(`/email/dns-providers/${providerId}/test`, { method: 'POST' }); }
async getEmailDNSZones(providerId) { return this.request(`/email/dns-providers/${providerId}/zones`); }
// SpamAssassin // SpamAssassin
async installSpamAssassin() { async getSpamConfig() { return this.request('/email/spam/config'); }
return this.request('/email/spamassassin/install', { method: 'POST' }); async updateSpamConfig(data) { return this.request('/email/spam/config', { method: 'PUT', body: JSON.stringify(data) }); }
} async updateSpamRules() { return this.request('/email/spam/update-rules', { method: 'POST' }); }
// Roundcube Webmail
async getSpamAssassinConfig() { async getWebmailStatus() { return this.request('/email/webmail/status'); }
return this.request('/email/spamassassin/config'); async installWebmail(data = {}) { return this.request('/email/webmail/install', { method: 'POST', body: JSON.stringify(data) }); }
} async controlWebmail(action) { return this.request(`/email/webmail/service/${action}`, { method: 'POST' }); }
async configureWebmailProxy(domain) { return this.request('/email/webmail/configure-proxy', { method: 'POST', body: JSON.stringify({ domain }) }); }
async updateSpamAssassinConfig(data) { // Mail Queue & Logs
return this.request('/email/spamassassin/config', { method: 'PUT', body: data }); async getMailQueue() { return this.request('/email/queue'); }
} async flushMailQueue() { return this.request('/email/queue/flush', { method: 'POST' }); }
async deleteMailQueueItem(queueId) { return this.request(`/email/queue/${queueId}`, { method: 'DELETE' }); }
// DKIM async getMailLogs(lines = 100) { return this.request(`/email/logs?lines=${lines}`); }
async installDkim() {
return this.request('/email/dkim/install', { method: 'POST' });
}
async generateDkimKey(data) {
return this.request('/email/dkim/generate', { method: 'POST', body: data });
}
async getEmailDnsRecords(domain) {
return this.request(`/email/dns/${domain}`);
}
// Service Control
async startEmailService(service) {
return this.request(`/email/services/${service}/start`, { method: 'POST' });
}
async stopEmailService(service) {
return this.request(`/email/services/${service}/stop`, { method: 'POST' });
}
async restartEmailService(service) {
return this.request(`/email/services/${service}/restart`, { method: 'POST' });
}
// Webmail
async getWebmailStatus() {
return this.request('/email/webmail/status');
}
async installWebmail() {
return this.request('/email/webmail/install', { method: 'POST' });
}
// Mail Logs
async getMailLogs(lines = 100) {
return this.request(`/email/logs?lines=${lines}`);
}
// ==================== SSO / OAuth ==================== // ==================== SSO / OAuth ====================
@@ -3196,6 +3121,80 @@ class ApiService {
async getApiKeyUsage(keyId, period = '24h') { async getApiKeyUsage(keyId, period = '24h') {
return this.request(`/api-analytics/keys/${keyId}/usage?period=${period}`); return this.request(`/api-analytics/keys/${keyId}/usage?period=${period}`);
} }
// ========================================
// Admin - Permissions endpoints
// ========================================
async getUserPermissions(userId) {
return this.request(`/admin/users/${userId}/permissions`);
}
async updateUserPermissions(userId, permissions) {
return this.request(`/admin/users/${userId}/permissions`, {
method: 'PUT',
body: { permissions }
});
}
async resetUserPermissions(userId) {
return this.request(`/admin/users/${userId}/permissions/reset`, {
method: 'POST'
});
}
async getPermissionTemplates() {
return this.request('/admin/permissions/templates');
}
// ========================================
// Admin - Invitations endpoints
// ========================================
async getInvitations(status) {
const query = status ? `?status=${status}` : '';
return this.request(`/admin/invitations/${query}`);
}
async createInvitation(data) {
return this.request('/admin/invitations/', {
method: 'POST',
body: data
});
}
async revokeInvitation(id) {
return this.request(`/admin/invitations/${id}`, {
method: 'DELETE'
});
}
async resendInvitation(id) {
return this.request(`/admin/invitations/resend/${id}`, {
method: 'POST'
});
}
async validateInvitation(token) {
return this.request(`/admin/invitations/validate/${token}`);
}
// ========================================
// Admin - Activity endpoints
// ========================================
async getActivitySummary() {
return this.request('/admin/activity/summary');
}
async getActivityFeed(params = {}) {
const searchParams = new URLSearchParams();
if (params.page) searchParams.append('page', params.page);
if (params.per_page) searchParams.append('per_page', params.per_page);
if (params.user_id) searchParams.append('user_id', params.user_id);
if (params.action) searchParams.append('action', params.action);
if (params.start_date) searchParams.append('start_date', params.start_date);
if (params.end_date) searchParams.append('end_date', params.end_date);
const query = searchParams.toString();
return this.request(`/admin/activity/feed${query ? '?' + query : ''}`);
}
} }
export const api = new ApiService(); export const api = new ApiService();
+287
View File
@@ -484,3 +484,290 @@
background: fade(@danger, 10%); background: fade(@danger, 10%);
} }
} }
// --------------------------------------------
// Permission Editor
// --------------------------------------------
.permission-editor {
margin-top: @space-4;
border: 1px solid @border-default;
border-radius: @radius-md;
max-height: 400px;
overflow-y: auto;
}
.permission-header-row {
display: grid;
grid-template-columns: 1fr 60px 60px;
gap: @space-2;
padding: @space-2 @space-4;
background: @bg-hover;
font-size: @font-size-xs;
font-weight: 600;
color: @text-secondary;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid @border-default;
position: sticky;
top: 0;
z-index: 1;
}
.permission-level-label {
text-align: center;
}
.permission-group {
&:not(:last-child) {
border-bottom: 1px solid @border-default;
}
}
.permission-group-label {
padding: @space-2 @space-4;
font-size: @font-size-xs;
font-weight: 600;
color: @text-tertiary;
text-transform: uppercase;
letter-spacing: 0.1em;
background: fade(@bg-hover-raw, 50%);
}
.permission-row {
display: grid;
grid-template-columns: 1fr 60px 60px;
gap: @space-2;
align-items: center;
padding: @space-2 @space-4;
&:hover {
background: @bg-hover;
}
}
.permission-feature-name {
font-size: @font-size-sm;
}
.permission-checkbox {
display: flex;
justify-content: center;
input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
}
.customize-permissions-section {
margin-top: @space-4;
> .btn {
margin-bottom: @space-2;
}
}
// --------------------------------------------
// Invitations Section
// --------------------------------------------
.invitations-section {
margin-top: @space-8;
padding-top: @space-8;
border-top: 1px solid @border-default;
.tab-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: @space-4;
.tab-header-content {
h4 {
margin: 0 0 @space-1 0;
font-size: @font-size-md;
font-weight: 600;
}
p {
margin: 0;
color: @text-secondary;
font-size: @font-size-sm;
}
}
}
}
.invite-link-display {
display: flex;
align-items: center;
gap: @space-2;
padding: @space-3;
background: @bg-hover;
border: 1px solid @border-default;
border-radius: @radius-md;
margin-top: @space-2;
code {
flex: 1;
font-family: @font-mono;
font-size: @font-size-sm;
word-break: break-all;
color: @text-primary;
}
}
.invitation-status {
display: inline-flex;
align-items: center;
padding: 3px 8px;
border-radius: @radius-full;
font-size: @font-size-xs;
font-weight: 500;
text-transform: capitalize;
&.pending {
background: fade(@accent-primary-raw, 15%);
color: @accent-primary;
}
&.accepted {
background: fade(@success, 15%);
color: @success;
}
&.expired {
background: fade(@text-tertiary-raw, 15%);
color: @text-tertiary;
}
&.revoked {
background: fade(@danger, 15%);
color: @danger;
}
}
// --------------------------------------------
// Activity Tab
// --------------------------------------------
.activity-tab {
.tab-header {
margin-bottom: @space-6;
h3 {
margin: 0 0 @space-1 0;
font-size: @font-size-lg;
font-weight: 600;
}
p {
margin: 0;
color: @text-secondary;
font-size: @font-size-sm;
}
}
}
.activity-summary {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: @space-4;
margin-bottom: @space-6;
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
}
.activity-stat-card {
display: flex;
flex-direction: column;
align-items: center;
padding: @space-6;
background: @bg-card;
border: 1px solid @border-default;
border-radius: @radius-md;
.stat-number {
font-size: 2rem;
font-weight: 700;
color: @accent-primary;
line-height: 1;
}
.stat-label {
margin-top: @space-2;
font-size: @font-size-sm;
color: @text-secondary;
}
}
.most-active-users {
margin-bottom: @space-6;
padding: @space-4;
background: @bg-card;
border: 1px solid @border-default;
border-radius: @radius-md;
h4 {
margin: 0 0 @space-4 0;
font-size: @font-size-md;
font-weight: 600;
}
}
.active-user-item {
display: flex;
align-items: center;
gap: @space-3;
padding: @space-2 0;
&:not(:last-child) {
border-bottom: 1px solid @border-default;
}
}
.active-user-rank {
width: 24px;
text-align: center;
font-weight: 600;
font-size: @font-size-sm;
color: @text-tertiary;
}
.active-user-name {
min-width: 100px;
font-size: @font-size-sm;
font-weight: 500;
}
.active-user-bar-wrapper {
flex: 1;
height: 8px;
background: @bg-hover;
border-radius: @radius-full;
overflow: hidden;
}
.active-user-bar {
height: 100%;
background: @accent-primary;
border-radius: @radius-full;
transition: width 0.3s ease;
}
.active-user-count {
min-width: 40px;
text-align: right;
font-size: @font-size-sm;
font-weight: 600;
color: @text-secondary;
}
.activity-feed-section {
h4 {
margin: 0 0 @space-4 0;
font-size: @font-size-md;
font-weight: 600;
}
}
+508 -133
View File
@@ -3,7 +3,41 @@
// ============================================ // ============================================
.email-page { .email-page {
.tabs-nav { .page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: @space-6;
flex-wrap: wrap;
gap: @space-4;
.page-header-content {
h1 {
font-size: 24px;
font-weight: @font-weight-semibold;
margin: 0 0 4px 0;
}
.page-description {
color: @text-secondary;
margin: 0;
}
}
.page-header-actions {
display: flex;
gap: @space-2;
}
}
.page-loading {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
}
.tab-navigation {
display: flex; display: flex;
gap: @space-2; gap: @space-2;
margin-bottom: @space-6; margin-bottom: @space-6;
@@ -11,7 +45,7 @@
padding-bottom: @space-2; padding-bottom: @space-2;
overflow-x: auto; overflow-x: auto;
.tab-btn { .tab-button {
padding: @space-2 @space-4; padding: @space-2 @space-4;
border: none; border: none;
background: none; background: none;
@@ -19,6 +53,7 @@
cursor: pointer; cursor: pointer;
border-radius: @radius-md; border-radius: @radius-md;
white-space: nowrap; white-space: nowrap;
font-size: @font-size-sm;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
@@ -32,21 +67,79 @@
} }
} }
} }
// Not-installed state
.not-installed {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80px @space-6;
background: @bg-card;
border-radius: @radius-lg;
text-align: center;
.icon {
font-size: 64px;
color: @text-tertiary;
margin-bottom: @space-4;
}
h2 {
margin: 0 0 @space-2 0;
}
p {
color: @text-secondary;
margin: 0 0 @space-6 0;
max-width: 480px;
}
.install-form {
display: flex;
flex-direction: column;
gap: @space-4;
align-items: center;
width: 100%;
max-width: 400px;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: @space-4;
h2 {
margin: 0;
font-size: @font-size-lg;
font-weight: @font-weight-semibold;
}
.section-actions {
display: flex;
gap: @space-2;
}
}
} }
// Service overview cards // ── Status Tab ──
.email-overview {
.services-grid { .email-status {
.status-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: @space-4; gap: @space-4;
margin-bottom: @space-6;
} }
} }
.email-service-card { .email-service-card {
.card-body { background: @bg-card;
padding: @space-6; border-radius: @radius-lg;
} border: 1px solid @border-subtle;
padding: @space-5;
} }
.email-service-header { .email-service-header {
@@ -61,15 +154,22 @@
font-weight: @font-weight-semibold; font-weight: @font-weight-semibold;
} }
p { .version {
margin: 0; font-size: @font-size-xs;
font-size: @font-size-sm; color: @text-tertiary;
} }
} }
.email-service-meta { .email-service-meta {
margin-top: @space-3; margin-top: @space-3;
font-size: @font-size-sm; font-size: @font-size-sm;
color: @text-secondary;
.meta-item {
display: flex;
justify-content: space-between;
padding: @space-1 0;
}
} }
.email-service-actions { .email-service-actions {
@@ -80,160 +180,394 @@
border-top: 1px solid @border-subtle; border-top: 1px solid @border-subtle;
} }
// Accounts section // ── Domains Tab ──
.email-accounts {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: @space-4;
h2 { .email-domains {
margin: 0; .domain-list {
} display: flex;
flex-direction: column;
gap: @space-4;
} }
} }
.email-create-form { .domain-card {
background: @bg-card;
border-radius: @radius-lg;
border: 1px solid @border-subtle;
padding: @space-5;
.domain-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: @space-3;
h3 {
margin: 0;
font-size: @font-size-md;
}
}
.domain-stats {
display: flex;
gap: @space-6;
margin-bottom: @space-3;
font-size: @font-size-sm;
color: @text-secondary;
span {
display: flex;
align-items: center;
gap: @space-1;
}
}
.domain-dns {
display: flex;
gap: @space-2;
flex-wrap: wrap;
margin-bottom: @space-3;
.dns-badge {
font-size: @font-size-xs;
padding: 2px @space-2;
border-radius: @radius-sm;
background: @bg-elevated;
border: 1px solid @border-subtle;
color: @text-secondary;
&.verified {
background: @bg-elevated;
border-color: @success;
color: @success;
}
&.missing {
background: @bg-elevated;
border-color: @danger;
color: @danger;
}
}
}
.domain-actions {
display: flex;
gap: @space-2;
padding-top: @space-3;
border-top: 1px solid @border-subtle;
}
}
// ── Accounts Tab ──
.email-accounts {
.domain-selector {
margin-bottom: @space-4;
}
.accounts-list {
display: flex;
flex-direction: column;
gap: @space-3;
}
}
.account-card {
background: @bg-card;
border-radius: @radius-md;
border: 1px solid @border-subtle;
padding: @space-4;
display: flex;
justify-content: space-between;
align-items: center;
gap: @space-4;
flex-wrap: wrap;
.account-info {
.account-email {
font-weight: @font-weight-medium;
margin-bottom: @space-1;
}
.account-meta {
font-size: @font-size-sm;
color: @text-secondary;
display: flex;
gap: @space-4;
}
}
.account-actions {
display: flex;
gap: @space-2;
}
}
// ── Form styles ──
.email-form {
background: @bg-card;
border-radius: @radius-lg;
border: 1px solid @border-subtle;
padding: @space-5;
margin-bottom: @space-4; margin-bottom: @space-4;
.form-title {
font-size: @font-size-md;
font-weight: @font-weight-semibold;
margin: 0 0 @space-4 0;
}
.form-grid { .form-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: @space-4; gap: @space-4;
margin-bottom: @space-4;
}
.form-actions {
display: flex;
gap: @space-2;
justify-content: flex-end;
} }
} }
// Postfix / Dovecot config // ── Aliases & Forwarding ──
.email-postfix,
.email-dovecot, .email-aliases,
.email-spam { .email-forwarding {
.form-grid { .items-list {
display: grid; display: flex;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); flex-direction: column;
gap: @space-3;
}
}
.alias-card,
.forwarding-card {
background: @bg-card;
border-radius: @radius-md;
border: 1px solid @border-subtle;
padding: @space-4;
display: flex;
justify-content: space-between;
align-items: center;
gap: @space-4;
flex-wrap: wrap;
.item-info {
.item-mapping {
font-weight: @font-weight-medium;
margin-bottom: @space-1;
display: flex;
align-items: center;
gap: @space-2;
.arrow {
color: @text-tertiary;
}
}
.item-meta {
font-size: @font-size-sm;
color: @text-secondary;
}
}
.item-actions {
display: flex;
gap: @space-2;
}
}
// ── DNS Providers Tab ──
.email-dns-providers {
.provider-list {
display: flex;
flex-direction: column;
gap: @space-4; gap: @space-4;
} }
} }
// Config list (read-only) .provider-card {
.config-list { background: @bg-card;
display: flex; border-radius: @radius-lg;
flex-direction: column; border: 1px solid @border-subtle;
gap: @space-2; padding: @space-5;
}
.config-item { .provider-header {
display: flex; display: flex;
gap: @space-4; justify-content: space-between;
padding: @space-2 0; align-items: flex-start;
border-bottom: 1px solid @border-subtle; margin-bottom: @space-3;
font-size: @font-size-sm;
&:last-child { h3 {
border-bottom: none; margin: 0;
font-size: @font-size-md;
}
.provider-type {
font-size: @font-size-xs;
padding: 2px @space-2;
border-radius: @radius-sm;
background: @bg-elevated;
text-transform: uppercase;
letter-spacing: 0.5px;
}
} }
.config-key { .provider-meta {
font-size: @font-size-sm;
color: @text-secondary; color: @text-secondary;
min-width: 200px; margin-bottom: @space-3;
font-family: @font-mono;
font-size: @font-size-xs; .meta-row {
display: flex;
gap: @space-4;
padding: @space-1 0;
}
} }
.config-value { .provider-actions {
color: @text-primary; display: flex;
word-break: break-all; gap: @space-2;
padding-top: @space-3;
border-top: 1px solid @border-subtle;
}
.zones-list {
margin-top: @space-3;
padding: @space-3;
background: @bg-elevated;
border-radius: @radius-md;
font-size: @font-size-sm;
.zone-item {
display: flex;
justify-content: space-between;
padding: @space-1 0;
&:not(:last-child) {
border-bottom: 1px solid @border-subtle;
}
}
} }
} }
// DNS records // ── SpamAssassin Config ──
.dns-records-list {
display: flex; .email-spam {
flex-direction: column; .spam-config {
gap: @space-4; background: @bg-card;
border-radius: @radius-lg;
border: 1px solid @border-subtle;
padding: @space-5;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: @space-4;
margin-bottom: @space-4;
}
.checkbox-field {
display: flex;
align-items: center;
gap: @space-2;
label {
font-size: @font-size-sm;
}
}
} }
.dns-record-item { // ── Webmail ──
padding: @space-4;
background: @bg-elevated; .email-webmail {
.webmail-card {
background: @bg-card;
border-radius: @radius-lg;
border: 1px solid @border-subtle;
padding: @space-5;
.webmail-status-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: @space-4;
}
.webmail-info {
font-size: @font-size-sm;
color: @text-secondary;
margin-bottom: @space-4;
}
.webmail-actions {
display: flex;
gap: @space-2;
flex-wrap: wrap;
}
}
.proxy-form {
margin-top: @space-4;
padding-top: @space-4;
border-top: 1px solid @border-subtle;
}
}
// ── Mail Queue ──
.email-queue {
.queue-list {
display: flex;
flex-direction: column;
gap: @space-3;
}
}
.queue-item {
background: @bg-card;
border-radius: @radius-md; border-radius: @radius-md;
border: 1px solid @border-subtle; border: 1px solid @border-subtle;
} padding: @space-4;
.dns-record-header {
display: flex;
align-items: center;
gap: @space-3;
margin-bottom: @space-3;
}
.dns-record-purpose {
font-size: @font-size-sm;
color: @text-secondary;
}
.dns-record-details {
display: flex;
flex-direction: column;
gap: @space-2;
}
.dns-field {
display: flex; display: flex;
justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: @space-2; gap: @space-4;
.dns-label { .queue-info {
font-size: @font-size-sm; flex: 1;
color: @text-tertiary;
min-width: 50px;
}
code { .queue-id {
font-family: @font-mono; font-family: @font-mono;
font-size: @font-size-xs; font-size: @font-size-sm;
color: @text-primary; font-weight: @font-weight-medium;
background: @bg-secondary; margin-bottom: @space-1;
padding: @space-1 @space-2; }
border-radius: @radius-sm;
word-break: break-all;
}
}
// Mail queue .queue-meta {
.email-queue { font-size: @font-size-sm;
.section-header { color: @text-secondary;
display: flex; display: flex;
justify-content: space-between; flex-wrap: wrap;
align-items: center; gap: @space-3;
margin-bottom: @space-4; }
h2 { .queue-error {
margin: 0; font-size: @font-size-sm;
color: @danger;
margin-top: @space-1;
} }
} }
} }
// Webmail // ── Logs ──
.email-webmail {
.webmail-status {
padding: @space-2;
}
}
// Logs
.email-logs { .email-logs {
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: @space-4;
h2 {
margin: 0;
}
}
.log-output { .log-output {
font-family: @font-mono; font-family: @font-mono;
font-size: @font-size-xs; font-size: @font-size-xs;
@@ -249,17 +583,58 @@
word-break: break-all; word-break: break-all;
margin: 0; margin: 0;
} }
}
// Authentication tab .log-controls {
.email-authentication { display: flex;
display: flex; gap: @space-2;
flex-direction: column; margin-bottom: @space-3;
gap: @space-4; align-items: center;
}
.form-grid { }
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); // ── Shared ──
gap: @space-4;
.status-badge {
display: inline-flex;
align-items: center;
gap: @space-1;
padding: 2px @space-2;
border-radius: @radius-sm;
font-size: @font-size-xs;
font-weight: @font-weight-medium;
&::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
}
&.online {
color: @success;
background: @bg-elevated;
&::before {
background: @success;
}
}
&.offline {
color: @danger;
background: @bg-elevated;
&::before {
background: @danger;
}
}
}
.empty-state {
text-align: center;
padding: @space-8;
color: @text-secondary;
p {
margin: 0;
} }
} }