477 lines
21 KiB
HTML
477 lines
21 KiB
HTML
{% extends "base.html" %}
|
||
{% 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 and branding.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||
{% for cat, msg in messages %}
|
||
<div class="alert alert-{{ cat }} alert-dismissible fade show" role="alert">
|
||
{{ msg }}
|
||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||
</div>
|
||
{% endfor %}
|
||
{% endwith %}
|
||
|
||
<!-- Timezone -->
|
||
<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-clock me-2"></i>Date & Time
|
||
</h5>
|
||
</div>
|
||
<div class="card-body" style="padding:20px;">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
|
||
All timestamps are stored in UTC internally. This setting controls how they are
|
||
displayed throughout the application — tickets, comments, history, and logs.
|
||
</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="timezone">
|
||
<div class="mb-3">
|
||
<label class="form-label">Display Timezone</label>
|
||
<select class="form-select" name="app_timezone" style="max-width:400px;">
|
||
{% for tz_value, tz_label in common_timezones %}
|
||
<option value="{{ tz_value }}" {% if tz_value == current_tz %}selected{% endif %}>
|
||
{{ tz_label }}
|
||
</option>
|
||
{% endfor %}
|
||
{% if current_tz not in common_timezones|map(attribute=0)|list %}
|
||
<option value="{{ current_tz }}" selected>{{ current_tz }}</option>
|
||
{% endif %}
|
||
</select>
|
||
<div class="form-text">
|
||
Current setting: <strong>{{ current_tz }}</strong> —
|
||
local time is <strong>{{ now_local.strftime('%b %d, %Y %H:%M %Z') }}</strong>
|
||
</div>
|
||
</div>
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-check2 me-2"></i>Save Timezone
|
||
</button>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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 & 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>
|
||
|
||
|
||
<!-- Email Ingestion -->
|
||
<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-envelope-arrow-down me-2"></i>Email-to-Ticket Ingestion
|
||
</h5>
|
||
</div>
|
||
<div class="card-body" style="padding:20px;">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
|
||
When enabled, TechDesk polls a dedicated IMAP mailbox and automatically converts
|
||
inbound emails into tickets. Use a dedicated support inbox with an app-specific password.
|
||
</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="email_ingestion">
|
||
|
||
<!-- Enable toggle -->
|
||
<div class="d-flex align-items-center gap-3 mb-4 p-3"
|
||
style="border:1px solid var(--border);border-radius:10px;background:var(--surface);">
|
||
<div style="flex:1;">
|
||
<div style="font-weight:600;font-size:14px;">
|
||
<i class="bi bi-power me-2"></i>Enable Email Ingestion
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
|
||
Start polling the mailbox below for new tickets.
|
||
</div>
|
||
</div>
|
||
<div class="form-check form-switch mb-0">
|
||
<input class="form-check-input" type="checkbox" role="switch"
|
||
id="email-ingestion-toggle" name="email_ingestion_enabled" value="1"
|
||
{% if email_settings.enabled %}checked{% endif %}
|
||
style="width:40px;height:22px;cursor:pointer;"/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row g-3">
|
||
<div class="col-md-8">
|
||
<label class="form-label">IMAP Host</label>
|
||
<input type="text" class="form-control" name="email_ingestion_host"
|
||
value="{{ email_settings.host }}" placeholder="e.g. imap.gmail.com"/>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Port</label>
|
||
<input type="number" class="form-control" name="email_ingestion_port"
|
||
value="{{ email_settings.port }}" placeholder="993"/>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Mailbox Username / Email</label>
|
||
<input type="text" class="form-control" name="email_ingestion_user"
|
||
value="{{ email_settings.user }}" placeholder="support@yourcompany.com"/>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Password
|
||
<span style="font-size:11px;color:var(--muted);font-weight:400;">
|
||
— use an app-specific password
|
||
</span>
|
||
</label>
|
||
<input type="password" class="form-control" name="email_ingestion_password"
|
||
value="{{ email_settings.password }}" placeholder="Leave blank to keep current"/>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Watch Folder</label>
|
||
<input type="text" class="form-control" name="email_ingestion_folder"
|
||
value="{{ email_settings.folder }}" placeholder="INBOX"/>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Processed Folder</label>
|
||
<input type="text" class="form-control" name="email_ingestion_move_to"
|
||
value="{{ email_settings.move_to }}" placeholder="Processed"/>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<label class="form-label">Poll Interval (minutes)</label>
|
||
<input type="number" class="form-control" name="email_ingestion_interval"
|
||
value="{{ email_settings.interval }}" min="1" max="60" placeholder="5"/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="alert alert-info mt-3 mb-0" style="font-size:12px;">
|
||
<i class="bi bi-info-circle me-2"></i>
|
||
<strong>Note:</strong> For Gmail, enable IMAP in Settings → Forwarding and POP/IMAP,
|
||
and use an App Password (Google Account → Security → 2-Step Verification → App passwords).
|
||
For other providers, ensure IMAP is enabled and check their specific settings.
|
||
</div>
|
||
|
||
<div class="d-flex align-items-center gap-2 flex-wrap mt-3">
|
||
<button type="submit" class="btn btn-primary">
|
||
<i class="bi bi-check2 me-2"></i>Save Email Settings
|
||
</button>
|
||
<button type="button" class="btn btn-outline-secondary" id="btn-test-ingest"
|
||
onclick="emailIngestTest()">
|
||
<i class="bi bi-plug me-2"></i>Test Connection
|
||
</button>
|
||
<button type="button" class="btn btn-outline-primary" id="btn-run-ingest"
|
||
onclick="emailIngestRunNow()">
|
||
<i class="bi bi-play-circle me-2"></i>Run Now
|
||
</button>
|
||
</div>
|
||
<div id="ingest-result" class="mt-3" style="display:none;"></div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
var csrfToken = document.querySelector('meta[name="csrf-token"]').content;
|
||
|
||
function setResult(ok, message) {
|
||
var el = document.getElementById('ingest-result');
|
||
el.style.display = '';
|
||
el.className = ok
|
||
? 'alert alert-success py-2 px-3'
|
||
: 'alert alert-danger py-2 px-3';
|
||
el.style.fontSize = '13px';
|
||
el.innerHTML = '<i class="bi bi-' + (ok ? 'check-circle' : 'x-circle') + ' me-2"></i>' +
|
||
message.replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
function collectFields() {
|
||
return {
|
||
host : document.querySelector('[name="email_ingestion_host"]').value.trim(),
|
||
port : document.querySelector('[name="email_ingestion_port"]').value.trim(),
|
||
user : document.querySelector('[name="email_ingestion_user"]').value.trim(),
|
||
password: document.querySelector('[name="email_ingestion_password"]').value.trim(),
|
||
folder : document.querySelector('[name="email_ingestion_folder"]').value.trim(),
|
||
};
|
||
}
|
||
|
||
window.emailIngestTest = function () {
|
||
var btn = document.getElementById('btn-test-ingest');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Testing…';
|
||
|
||
var fields = collectFields();
|
||
var body = new URLSearchParams(fields);
|
||
body.append('csrf_token', csrfToken);
|
||
|
||
fetch('{{ url_for("admin.email_ingestion_test") }}', {
|
||
method : 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body : body.toString(),
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
setResult(data.ok, data.message);
|
||
})
|
||
.catch(function (err) {
|
||
setResult(false, 'Request failed: ' + err);
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-plug me-2"></i>Test Connection';
|
||
});
|
||
};
|
||
|
||
window.emailIngestRunNow = function () {
|
||
var btn = document.getElementById('btn-run-ingest');
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Running…';
|
||
|
||
var body = new URLSearchParams({ csrf_token: csrfToken });
|
||
|
||
fetch('{{ url_for("admin.email_ingestion_run_now") }}', {
|
||
method : 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/x-www-form-urlencoded' },
|
||
body : body.toString(),
|
||
})
|
||
.then(function (r) { return r.json(); })
|
||
.then(function (data) {
|
||
setResult(data.ok, data.message);
|
||
})
|
||
.catch(function (err) {
|
||
setResult(false, 'Request failed: ' + err);
|
||
})
|
||
.finally(function () {
|
||
btn.disabled = false;
|
||
btn.innerHTML = '<i class="bi bi-play-circle me-2"></i>Run Now';
|
||
});
|
||
};
|
||
})();
|
||
</script>
|
||
|
||
<!-- Satisfaction Survey -->
|
||
<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-star-half me-2"></i>Satisfaction Survey
|
||
</h5>
|
||
</div>
|
||
<div class="card-body" style="padding:20px;">
|
||
<p style="font-size:13px;color:var(--muted);margin-bottom:20px;">
|
||
When enabled, employees automatically receive a one-click satisfaction survey email
|
||
whenever their ticket is marked as Resolved. Responses appear in the
|
||
<a href="{{ url_for('admin.satisfaction_report') }}" style="color:var(--accent3);">Satisfaction Report</a>.
|
||
</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="survey">
|
||
<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>
|
||
<div style="font-weight:600;font-size:14px;">
|
||
<i class="bi bi-envelope-heart me-2"></i>Send Survey on Resolve
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
|
||
Automatically email employees a 1–5 star rating prompt when their ticket is resolved.
|
||
</div>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-3" style="flex-shrink:0;margin-left:16px;">
|
||
<span class="badge {% if survey_enabled %}bg-success{% else %}bg-secondary{% endif %}"
|
||
style="font-size:11px;padding:5px 10px;">
|
||
{{ 'Enabled' if survey_enabled else 'Disabled' }}
|
||
</span>
|
||
{% if survey_enabled %}
|
||
<button type="submit" name="survey_enabled" value="0"
|
||
class="btn btn-sm btn-outline-danger"
|
||
data-confirm="Disable satisfaction surveys? No survey emails will be sent when tickets are resolved."
|
||
data-confirm-title="Disable Satisfaction Surveys" data-confirm-ok="Disable">
|
||
<i class="bi bi-toggle-on me-1"></i>Disable
|
||
</button>
|
||
{% else %}
|
||
<button type="submit" name="survey_enabled" value="1"
|
||
class="btn btn-sm btn-outline-success">
|
||
<i class="bi bi-toggle-off me-1"></i>Enable
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</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
|
||
</h5>
|
||
</div>
|
||
<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. 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>
|
||
<div style="font-weight:600;font-size:14px;">
|
||
<i class="bi bi-person-plus me-2"></i>Allow Self-Registration
|
||
</div>
|
||
<div style="font-size:12px;color:var(--muted);margin-top:3px;">
|
||
Permits new employees to create their own accounts via the Register page.
|
||
</div>
|
||
</div>
|
||
<div class="d-flex align-items-center gap-3" style="flex-shrink:0;margin-left:16px;">
|
||
<span class="badge {% if registration_enabled %}bg-success{% else %}bg-secondary{% endif %}"
|
||
style="font-size:11px;padding:5px 10px;">
|
||
{{ 'Enabled' if registration_enabled else 'Disabled' }}
|
||
</span>
|
||
{% if registration_enabled %}
|
||
<button type="submit" name="registration_enabled" value="0"
|
||
class="btn btn-sm btn-outline-danger"
|
||
data-confirm="Disable self-registration? New users will not be able to sign up independently."
|
||
data-confirm-title="Disable Self-Registration" data-confirm-ok="Disable">
|
||
<i class="bi bi-toggle-on me-1"></i>Disable
|
||
</button>
|
||
{% else %}
|
||
<button type="submit" name="registration_enabled" value="1"
|
||
class="btn btn-sm btn-outline-success">
|
||
<i class="bi bi-toggle-off me-1"></i>Enable
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</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() {
|
||
confirmModal('Remove Logo', 'Remove the current logo? The initials fallback will be displayed instead.', 'Remove', 'btn-danger', () => {
|
||
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 %}
|