mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-10-23 03:27:43 +09:00
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:
1 parent
c5710e88b9
commit
b7fab1152b
42 files changed
+6472
-2530
No files matched your search
@@ -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.`
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -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'
|
||||||
]
|
]
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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})>'
|
||||||
@@ -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}>'
|
|
||||||
@@ -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}>'
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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)}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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)}
|
||||||
@@ -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
@@ -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')
|
||||||
@@ -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}'}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user