Branding customization

This commit is contained in:
2026-03-30 14:35:30 -04:00
parent 53eaf1c76d
commit a4e605297a
7 changed files with 295 additions and 43 deletions
+17 -2
View File
@@ -121,13 +121,22 @@ def create_app(config_name=None):
@app.context_processor
def inject_globals():
from flask_login import current_user
from app.models import SystemSetting
unread = 0
if current_user.is_authenticated:
from app.models import Notification
unread = Notification.query.filter_by(
user_id=current_user.id, is_read=False
).count()
return dict(unread_notifications=unread)
branding = {
'app_name' : SystemSetting.get('app_name', 'TechDesk'),
'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'),
'company_name' : SystemSetting.get('company_name', ''),
'logo_stored_name' : SystemSetting.get('logo_stored_name', ''),
'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'),
}
return dict(unread_notifications=unread, branding=branding)
# ── DB initialisation (first run) ─────────────────────────────────────────
with app.app_context():
@@ -142,7 +151,13 @@ def _seed_settings():
"""Ensure all required system settings exist with safe defaults."""
from app.models import SystemSetting
defaults = [
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
('registration_enabled', 'true', 'Allow new users to self-register via /auth/register'),
('app_name', 'TechDesk', 'Application name shown in the sidebar and page titles'),
('app_subtitle', 'IT Helpdesk System', 'Subtitle shown below the app name in the sidebar'),
('company_name', '', 'Company name shown on the login and register pages'),
('logo_stored_name', '', 'Stored filename of the uploaded company logo image'),
('logo_initials', 'TD', 'Two-letter initials shown when no logo is uploaded'),
('primary_color', '#2563eb', 'Primary accent colour (hex) used across the interface'),
]
for key, value, description in defaults:
if SystemSetting.get(key) is None:
+89 -23
View File
@@ -851,29 +851,95 @@ def settings():
from app.models import SystemSetting
if request.method == 'POST':
new_value = '1' if request.form.get('registration_enabled') == '1' else '0'
old_value = SystemSetting.get('registration_enabled', 'true')
SystemSetting.set(
'registration_enabled',
new_value,
'Allow new users to self-register via /auth/register'
)
db.session.commit()
form_type = request.form.get('form_type')
state_label = 'enabled' if new_value == '1' else 'disabled'
log_action(
current_user.id,
'setting_update',
'system_setting',
None,
f'registration_enabled changed from {old_value} to {new_value} by admin_id={current_user.id}'
)
logger.info(
f'[ADMIN SETTINGS] registration_enabled={new_value} '
f'by admin_id={current_user.id} email={current_user.email}'
)
flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings'))
# ── Registration toggle ───────────────────────────────────────────────
if form_type == 'registration':
new_value = '1' if request.form.get('registration_enabled') == '1' else '0'
old_value = SystemSetting.get('registration_enabled', 'true')
SystemSetting.set('registration_enabled', new_value,
'Allow new users to self-register via /auth/register')
db.session.commit()
state_label = 'enabled' if new_value == '1' else 'disabled'
log_action(current_user.id, 'setting_update', 'system_setting', None,
f'registration_enabled changed from {old_value} to {new_value} by admin_id={current_user.id}')
logger.info(f'[ADMIN SETTINGS] registration_enabled={new_value} by admin_id={current_user.id}')
flash(f'User registration has been {state_label}.', 'success')
return redirect(url_for('admin.settings'))
# ── Branding update ───────────────────────────────────────────────────
if form_type == 'branding':
fields = {
'app_name' : ('Application name', request.form.get('app_name', '').strip()),
'app_subtitle' : ('App subtitle', request.form.get('app_subtitle', '').strip()),
'company_name' : ('Company name', request.form.get('company_name', '').strip()),
'logo_initials': ('Logo initials', request.form.get('logo_initials', '').strip()[:3]),
'primary_color': ('Primary colour', request.form.get('primary_color', '#2563eb').strip()),
}
changes = []
for key, (label, new_val) in fields.items():
old_val = SystemSetting.get(key, '')
if new_val != old_val:
SystemSetting.set(key, new_val)
changes.append(f'{key}={new_val!r}')
# ── Logo file upload ──────────────────────────────────────────────
logo_file = request.files.get('logo_file')
if logo_file and logo_file.filename:
from app.services.validation_service import validate_file
from werkzeug.utils import secure_filename
allowed = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
file_error = validate_file(logo_file, allowed)
if file_error:
flash(f'Logo not saved: {file_error}', 'danger')
else:
logo_file.stream.seek(0, 2)
logo_size = logo_file.stream.tell()
logo_file.stream.seek(0)
if logo_size > 2 * 1024 * 1024:
flash('Logo image must be under 2 MB.', 'danger')
else:
ext = secure_filename(logo_file.filename).rsplit('.', 1)[-1].lower()
stored_name = f"logo_{uuid.uuid4().hex}.{ext}"
upload_dir = current_app.config['UPLOAD_FOLDER']
# Delete old logo from disk
old_logo = SystemSetting.get('logo_stored_name', '')
if old_logo:
old_path = os.path.join(upload_dir, old_logo)
if os.path.exists(old_path):
os.remove(old_path)
logo_file.save(os.path.join(upload_dir, stored_name))
SystemSetting.set('logo_stored_name', stored_name, 'Stored filename of the uploaded company logo image')
changes.append(f'logo_stored_name={stored_name!r}')
logger.info(f'[ADMIN BRANDING] logo uploaded file={stored_name} by admin_id={current_user.id}')
# ── Logo removal ──────────────────────────────────────────────────
if request.form.get('remove_logo') == '1':
old_logo = SystemSetting.get('logo_stored_name', '')
if old_logo:
old_path = os.path.join(current_app.config['UPLOAD_FOLDER'], old_logo)
if os.path.exists(old_path):
os.remove(old_path)
SystemSetting.set('logo_stored_name', '')
changes.append('logo_stored_name removed')
db.session.commit()
if changes:
log_action(current_user.id, 'branding_update', 'system_setting', None,
f'changes: {", ".join(changes)} by admin_id={current_user.id}')
logger.info(f'[ADMIN BRANDING] {", ".join(changes)} by admin_id={current_user.id}')
flash('Branding settings saved.', 'success')
return redirect(url_for('admin.settings'))
registration_enabled = SystemSetting.get_bool('registration_enabled', default=True)
return render_template('admin/settings.html', registration_enabled=registration_enabled)
branding_settings = {
'app_name' : SystemSetting.get('app_name', 'TechDesk'),
'app_subtitle' : SystemSetting.get('app_subtitle', 'IT Helpdesk System'),
'company_name' : SystemSetting.get('company_name', ''),
'logo_stored_name': SystemSetting.get('logo_stored_name', ''),
'logo_initials' : SystemSetting.get('logo_initials', 'TD'),
'primary_color' : SystemSetting.get('primary_color', '#2563eb'),
}
return render_template('admin/settings.html',
registration_enabled=registration_enabled,
branding_settings=branding_settings)
+12
View File
@@ -17,6 +17,9 @@ logger = logging.getLogger(__name__)
AVATAR_ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
AVATAR_MAX_BYTES = 5 * 1024 * 1024 # 5 MB
LOGO_ALLOWED_EXT = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'}
LOGO_MAX_BYTES = 2 * 1024 * 1024 # 2 MB
def _is_safe_url(target):
"""Return True only when *target* points back to this same host.
@@ -195,3 +198,12 @@ def serve_avatar(filename):
abort(400)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, filename, as_attachment=False)
@auth_bp.route('/logo/<string:filename>')
def serve_logo(filename):
"""Serve the company logo — publicly accessible (shown on login page)."""
if '/' in filename or '\\' in filename or '..' in filename:
abort(400)
upload_dir = current_app.config['UPLOAD_FOLDER']
return send_from_directory(upload_dir, filename, as_attachment=False)
+141 -7
View File
@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block title %}System Settings — TechDesk{% endblock %}
{% block title %}System Settings — {{ branding.app_name }}{% endblock %}
{% block content %}
<div class="page-header" style="margin-bottom:28px;">
<div>
<h1 class="page-title">System Settings</h1>
<p class="page-subtitle">Manage application-wide configuration flags.</p>
<p class="page-subtitle">Manage application-wide configuration and branding.</p>
</div>
</div>
@@ -18,7 +18,103 @@
{% endfor %}
{% endwith %}
<div class="card" style="max-width:640px;">
<!-- Branding -->
<div class="card mb-4" style="max-width:680px;">
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
<h5 class="mb-0" style="font-size:15px;font-weight:600;">
<i class="bi bi-palette me-2"></i>Branding &amp; Appearance
</h5>
</div>
<div class="card-body" style="padding:20px;">
<form method="POST" action="{{ url_for('admin.settings') }}" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form_type" value="branding">
<input type="hidden" name="remove_logo" id="remove-logo-input" value="0">
<!-- Logo preview + upload -->
<div class="mb-4 d-flex align-items-center gap-4">
<div id="logo-preview-wrap"
style="width:64px;height:64px;border-radius:12px;background:{{ branding_settings.primary_color }};display:flex;align-items:center;justify-content:center;flex-shrink:0;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.12);">
{% if branding_settings.logo_stored_name %}
<img id="logo-preview-img"
src="{{ url_for('auth.serve_logo', filename=branding_settings.logo_stored_name) }}"
alt="Logo" style="width:64px;height:64px;object-fit:contain;padding:4px;"/>
{% else %}
<span id="logo-preview-initials"
style="font-family:'Space Mono',monospace;font-weight:700;font-size:18px;color:#fff;">
{{ branding_settings.logo_initials[:2] }}
</span>
{% endif %}
</div>
<div style="flex:1;">
<label class="form-label" style="font-size:12px;font-weight:600;">
Company Logo
<span style="color:var(--muted);font-weight:400;">(PNG, JPG, GIF, WebP — max 2 MB)</span>
</label>
<input type="file" class="form-control form-control-sm" name="logo_file"
id="logo-file-input" accept=".png,.jpg,.jpeg,.gif,.webp"/>
{% if branding_settings.logo_stored_name %}
<div class="mt-2" id="remove-logo-row">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="removeLogo()">
<i class="bi bi-trash me-1"></i>Remove current logo
</button>
</div>
{% endif %}
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label">Application Name</label>
<input type="text" class="form-control" name="app_name"
value="{{ branding_settings.app_name }}" maxlength="60" required/>
<div class="form-text">Shown in the sidebar and browser tab.</div>
</div>
<div class="col-md-6">
<label class="form-label">App Subtitle</label>
<input type="text" class="form-control" name="app_subtitle"
value="{{ branding_settings.app_subtitle }}" maxlength="80"/>
<div class="form-text">Shown below the app name in the sidebar.</div>
</div>
<div class="col-md-6">
<label class="form-label">Company Name</label>
<input type="text" class="form-control" name="company_name"
value="{{ branding_settings.company_name }}" maxlength="100"/>
<div class="form-text">Shown on the login and register pages.</div>
</div>
<div class="col-md-3">
<label class="form-label">Logo Initials</label>
<input type="text" class="form-control" name="logo_initials" id="logo-initials"
value="{{ branding_settings.logo_initials }}" maxlength="3"
oninput="updateInitialsPreview(this.value)"/>
<div class="form-text">Fallback when no logo is uploaded.</div>
</div>
<div class="col-md-3">
<label class="form-label">Primary Colour</label>
<div class="d-flex gap-2 align-items-center">
<input type="color" class="form-control form-control-color" name="primary_color"
id="primary-color-input"
value="{{ branding_settings.primary_color }}"
oninput="updateColorPreview(this.value)"
style="width:48px;height:38px;padding:2px;cursor:pointer;"/>
<input type="text" class="form-control form-control-sm" id="primary-color-text"
value="{{ branding_settings.primary_color }}" maxlength="7"
style="font-family:'Space Mono',monospace;font-size:12px;"
oninput="syncColorPicker(this.value)"/>
</div>
<div class="form-text">Accent colour across the interface.</div>
</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2 me-2"></i>Save Branding
</button>
</form>
</div>
</div>
<!-- Registration -->
<div class="card" style="max-width:680px;">
<div class="card-header" style="padding:16px 20px;border-bottom:1px solid var(--border);">
<h5 class="mb-0" style="font-size:15px;font-weight:600;">
<i class="bi bi-person-gear me-2"></i>User Registration
@@ -27,13 +123,11 @@
<div class="card-body" style="padding:20px;">
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
When disabled, the <code>/auth/register</code> endpoint redirects all visitors to the login
page with an informational message. Existing accounts and admin-created accounts are
unaffected.
page. Existing accounts and admin-created accounts are unaffected.
</p>
<form method="POST" action="{{ url_for('admin.settings') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="form_type" value="registration">
<div class="d-flex align-items-center justify-content-between p-3"
style="border:1px solid var(--border);border-radius:10px;background:var(--surface);">
<div>
@@ -66,4 +160,44 @@
</form>
</div>
</div>
<script>
document.getElementById('logo-file-input').addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
document.getElementById('logo-preview-wrap').innerHTML =
`<img src="${e.target.result}" style="width:64px;height:64px;object-fit:contain;padding:4px;"/>`;
};
reader.readAsDataURL(file);
});
function updateInitialsPreview(val) {
const el = document.getElementById('logo-preview-initials');
if (el) el.textContent = val.substring(0, 2).toUpperCase();
}
function updateColorPreview(hex) {
document.getElementById('primary-color-text').value = hex;
document.getElementById('logo-preview-wrap').style.background = hex;
}
function syncColorPicker(val) {
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
document.getElementById('primary-color-input').value = val;
document.getElementById('logo-preview-wrap').style.background = val;
}
}
function removeLogo() {
if (!confirm('Remove the current logo?')) return;
document.getElementById('remove-logo-input').value = '1';
const initials = document.getElementById('logo-initials').value.substring(0, 2).toUpperCase();
document.getElementById('logo-preview-wrap').innerHTML =
`<span id="logo-preview-initials" style="font-family:'Space Mono',monospace;font-weight:700;font-size:18px;color:#fff;">${initials}</span>`;
const row = document.getElementById('remove-logo-row');
if (row) row.style.display = 'none';
}
</script>
{% endblock %}
+13 -4
View File
@@ -3,7 +3,8 @@
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Login — TechDesk</title>
<title>Login — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
@@ -41,9 +42,17 @@
<div class="bg-glow"></div>
<div class="card">
<div class="brand">
<div class="logo">TD</div>
<h1>TechDesk</h1>
<p class="subtitle">IT Helpdesk Portal — Sign in to continue</p>
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:52px;height:52px;border-radius:12px;object-fit:contain;margin:0 auto 12px;display:block;background:var(--accent);padding:4px;box-shadow:0 2px 10px rgba(0,0,0,.15);"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>{{ branding.app_name }}</h1>
<p class="subtitle">
{% if branding.company_name %}{{ branding.company_name }} — {% endif %}Sign in to continue
</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
+12 -3
View File
@@ -2,7 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<title>Register — TechDesk</title>
<title>Register — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet"/>
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet"/>
<style>
@@ -33,9 +34,17 @@
<div class="bg-grid"></div>
<div class="card">
<div class="brand">
<div class="logo">TD</div>
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:46px;height:46px;border-radius:11px;object-fit:contain;margin:0 auto 10px;display:block;background:var(--accent);padding:3px;box-shadow:0 2px 10px rgba(0,0,0,.15);"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<h1>Create Account</h1>
<p class="subtitle">Join TechDesk to submit IT support requests</p>
<p class="subtitle">
Join {% if branding.company_name %}{{ branding.company_name }}{% else %}{{ branding.app_name }}{% endif %} to submit IT support requests
</p>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
+11 -4
View File
@@ -4,7 +4,8 @@
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1.0"/>
<meta name="csrf-token" content="{{ csrf_token() }}"/>
<title>{% block title %}IT Helpdesk{% endblock %} — TechDesk</title>
<title>{% block title %}IT Helpdesk{% endblock %} — {{ branding.app_name }}</title>
<style>:root { --accent: {{ branding.primary_color }}; --accent-h: {{ branding.primary_color }}; }</style>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin/>
<link href="https://fonts.googleapis.com/css2?family=Space+Mono:wght@400;700&family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;1,9..40,300&display=swap" rel="stylesheet"/>
@@ -282,10 +283,16 @@
<!-- ── Sidebar ── -->
<nav id="sidebar">
<div class="brand">
<div class="logo">TD</div>
{% if branding.logo_stored_name %}
<img src="{{ url_for('auth.serve_logo', filename=branding.logo_stored_name) }}"
alt="{{ branding.app_name }}"
style="width:36px;height:36px;border-radius:8px;object-fit:contain;flex-shrink:0;background:#fff;padding:2px;"/>
{% else %}
<div class="logo">{{ branding.logo_initials[:2] }}</div>
{% endif %}
<div>
<h1>TechDesk</h1>
<small>IT Helpdesk System</small>
<h1>{{ branding.app_name }}</h1>
<small>{{ branding.app_subtitle }}</small>
</div>
</div>