260 lines
12 KiB
HTML
260 lines
12 KiB
HTML
|
|
{% extends "base.html" %}
|
||
|
|
{% block title %} — Settings{% endblock %}
|
||
|
|
|
||
|
|
{% block content %}
|
||
|
|
<div class="page-header">
|
||
|
|
<h4><i class="bi bi-gear me-2 text-primary"></i>Settings</h4>
|
||
|
|
<button form="settingsForm" type="submit" class="btn btn-primary px-4">
|
||
|
|
<i class="bi bi-floppy me-2"></i>Save Settings
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<form method="POST" id="settingsForm">
|
||
|
|
<div class="row g-4">
|
||
|
|
|
||
|
|
<!-- Azure AD -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card h-100">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-microsoft me-2 text-primary"></i>Azure AD Application
|
||
|
|
</h6>
|
||
|
|
<p class="text-muted small mb-3">
|
||
|
|
Create an App Registration in Azure AD with <strong>API.ReadWrite.All</strong>
|
||
|
|
permission on Dynamics 365 Business Central and admin consent granted.
|
||
|
|
</p>
|
||
|
|
<div class="row g-3">
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Tenant ID (Directory ID)</label>
|
||
|
|
<input type="text" name="azure_tenant_id" class="form-control font-monospace"
|
||
|
|
value="{{ cfg.get('azure_tenant_id', '') }}"
|
||
|
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Client ID (Application ID)</label>
|
||
|
|
<input type="text" name="azure_client_id" class="form-control font-monospace"
|
||
|
|
value="{{ cfg.get('azure_client_id', '') }}"
|
||
|
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Client Secret</label>
|
||
|
|
<div class="input-group">
|
||
|
|
<input type="password" name="azure_client_secret" id="azureSecret" class="form-control"
|
||
|
|
value="{{ cfg.get('azure_client_secret', '') }}"
|
||
|
|
placeholder="Azure AD client secret value" required>
|
||
|
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('azureSecret', this)">
|
||
|
|
<i class="bi bi-eye"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Business Central -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card h-100">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-building me-2 text-success"></i>Business Central
|
||
|
|
</h6>
|
||
|
|
<div class="row g-3">
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Environment Name</label>
|
||
|
|
<input type="text" name="bc_environment_name" class="form-control"
|
||
|
|
value="{{ cfg.get('bc_environment_name', 'Production') }}"
|
||
|
|
placeholder="Production" required>
|
||
|
|
<div class="form-text">Found in BC Admin Center — typically "Production" or "Sandbox".</div>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">
|
||
|
|
Company Name <span class="fw-normal text-muted">(optional)</span>
|
||
|
|
</label>
|
||
|
|
<input type="text" name="bc_company_name" class="form-control"
|
||
|
|
value="{{ cfg.get('bc_company_name', '') }}"
|
||
|
|
placeholder="Leave blank to back up all companies">
|
||
|
|
<div class="form-text">Filter backup to a specific company. Leave blank for all.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- S3 / MinIO -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card h-100">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-hdd-stack-fill me-2 text-warning"></i>S3 / MinIO Storage
|
||
|
|
</h6>
|
||
|
|
<div class="row g-3">
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Endpoint URL</label>
|
||
|
|
<input type="url" name="s3_endpoint" class="form-control"
|
||
|
|
value="{{ cfg.get('s3_endpoint', '') }}"
|
||
|
|
placeholder="https://minio.your-domain.com:9000" required>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Access Key</label>
|
||
|
|
<input type="text" name="s3_access_key" class="form-control"
|
||
|
|
value="{{ cfg.get('s3_access_key', '') }}"
|
||
|
|
placeholder="Access key / username" required>
|
||
|
|
</div>
|
||
|
|
<div class="col-12">
|
||
|
|
<label class="form-label small fw-semibold">Secret Key</label>
|
||
|
|
<div class="input-group">
|
||
|
|
<input type="password" name="s3_secret_key" id="s3Secret" class="form-control"
|
||
|
|
value="{{ cfg.get('s3_secret_key', '') }}"
|
||
|
|
placeholder="Secret key / password" required>
|
||
|
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('s3Secret', this)">
|
||
|
|
<i class="bi bi-eye"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="col-8">
|
||
|
|
<label class="form-label small fw-semibold">Bucket Name</label>
|
||
|
|
<input type="text" name="s3_bucket" class="form-control"
|
||
|
|
value="{{ cfg.get('s3_bucket', 'bcbak') }}" required>
|
||
|
|
</div>
|
||
|
|
<div class="col-4">
|
||
|
|
<label class="form-label small fw-semibold">Region</label>
|
||
|
|
<input type="text" name="s3_region" class="form-control"
|
||
|
|
value="{{ cfg.get('s3_region', 'us-east-1') }}">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Encryption -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-shield-lock-fill me-2 text-success"></i>Encryption
|
||
|
|
</h6>
|
||
|
|
<div class="alert alert-warning border-0 small py-2 mb-3">
|
||
|
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||
|
|
Store this passphrase securely and separately from your backups — losing it means
|
||
|
|
losing access to all encrypted archives. Backups use GPG symmetric AES256.
|
||
|
|
</div>
|
||
|
|
<label class="form-label small fw-semibold">GPG Passphrase</label>
|
||
|
|
<div class="input-group">
|
||
|
|
<input type="password" name="encryption_passphrase" id="encPassphrase" class="form-control"
|
||
|
|
value="{{ cfg.get('encryption_passphrase', '') }}"
|
||
|
|
placeholder="Strong random passphrase" required>
|
||
|
|
<button type="button" class="btn btn-outline-secondary" onclick="togglePassword('encPassphrase', this)">
|
||
|
|
<i class="bi bi-eye"></i>
|
||
|
|
</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary" onclick="generatePassphrase()">
|
||
|
|
<i class="bi bi-shuffle"></i>
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
<div class="form-text">GPG symmetric AES256 · compatible with <code>gpg --decrypt</code></div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Incremental Schedule -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-arrow-repeat me-2 text-success"></i>Incremental Backup Schedule
|
||
|
|
</h6>
|
||
|
|
<p class="text-muted small mb-3">
|
||
|
|
Exports only records modified since the last successful run
|
||
|
|
(<code>lastModifiedDateTime</code> filter). Run frequently to minimize data loss.
|
||
|
|
</p>
|
||
|
|
<div class="form-check form-switch mb-3">
|
||
|
|
<input class="form-check-input" type="checkbox" name="schedule_incr_enabled" id="incrToggle"
|
||
|
|
{% if cfg.get('schedule_incr_enabled', 'false') == 'true' %}checked{% endif %}>
|
||
|
|
<label class="form-check-label fw-semibold" for="incrToggle">Enable incremental schedule</label>
|
||
|
|
</div>
|
||
|
|
<label class="form-label small fw-semibold">Cron Expression</label>
|
||
|
|
<input type="text" name="schedule_incr_cron" id="incrCron" class="form-control font-monospace"
|
||
|
|
value="{{ cfg.get('schedule_incr_cron', '0 * * * *') }}"
|
||
|
|
placeholder="0 * * * *">
|
||
|
|
<div class="form-text">
|
||
|
|
<code>0 * * * *</code> — every hour <code>0 */4 * * *</code> — every 4 h<br>
|
||
|
|
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||
|
|
</div>
|
||
|
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 * * * *')">Every hour</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */2 * * *')">Every 2 h</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 */4 * * *')">Every 4 h</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('incrCron','0 8-20 * * 1-5')">Business hours</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<!-- Full Backup Schedule -->
|
||
|
|
<div class="col-12 col-xl-6">
|
||
|
|
<div class="card stat-card">
|
||
|
|
<div class="card-body">
|
||
|
|
<h6 class="fw-bold mb-3 pb-2 border-bottom">
|
||
|
|
<i class="bi bi-database-fill-up me-2 text-primary"></i>Full Backup Schedule
|
||
|
|
</h6>
|
||
|
|
<p class="text-muted small mb-3">
|
||
|
|
Complete export of all entities and all companies — no date filter applied.
|
||
|
|
Run weekly to provide a chain restore baseline.
|
||
|
|
</p>
|
||
|
|
<div class="form-check form-switch mb-3">
|
||
|
|
<input class="form-check-input" type="checkbox" name="schedule_full_enabled" id="fullToggle"
|
||
|
|
{% if cfg.get('schedule_full_enabled', 'false') == 'true' %}checked{% endif %}>
|
||
|
|
<label class="form-check-label fw-semibold" for="fullToggle">Enable full backup schedule</label>
|
||
|
|
</div>
|
||
|
|
<label class="form-label small fw-semibold">Cron Expression</label>
|
||
|
|
<input type="text" name="schedule_full_cron" id="fullCron" class="form-control font-monospace"
|
||
|
|
value="{{ cfg.get('schedule_full_cron', '0 1 * * 0') }}"
|
||
|
|
placeholder="0 1 * * 0">
|
||
|
|
<div class="form-text">
|
||
|
|
<code>0 1 * * 0</code> — Sunday 01:00 <code>0 2 1 * *</code> — 1st of month 02:00<br>
|
||
|
|
<a href="https://crontab.guru/" target="_blank" rel="noopener">crontab.guru ↗</a>
|
||
|
|
</div>
|
||
|
|
<div class="mt-2 d-flex flex-wrap gap-2">
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 0')">Weekly Sun 01:00</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 1 * * 1')">Weekly Mon 01:00</button>
|
||
|
|
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="setCron('fullCron','0 2 1 * *')">Monthly 1st 02:00</button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="d-flex justify-content-end mt-3 gap-2">
|
||
|
|
<button form="settingsForm" type="submit" class="btn btn-primary px-5">
|
||
|
|
<i class="bi bi-floppy me-2"></i>Save Settings
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
{% endblock %}
|
||
|
|
|
||
|
|
{% block scripts %}
|
||
|
|
<script>
|
||
|
|
function togglePassword(id, btn) {
|
||
|
|
const field = document.getElementById(id);
|
||
|
|
const isPass = field.type === 'password';
|
||
|
|
field.type = isPass ? 'text' : 'password';
|
||
|
|
btn.innerHTML = isPass ? '<i class="bi bi-eye-slash"></i>' : '<i class="bi bi-eye"></i>';
|
||
|
|
}
|
||
|
|
|
||
|
|
function generatePassphrase() {
|
||
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
|
||
|
|
const arr = new Uint8Array(32);
|
||
|
|
crypto.getRandomValues(arr);
|
||
|
|
const pass = Array.from(arr, b => chars[b % chars.length]).join('');
|
||
|
|
const field = document.getElementById('encPassphrase');
|
||
|
|
field.value = pass;
|
||
|
|
field.type = 'text';
|
||
|
|
}
|
||
|
|
|
||
|
|
function setCron(fieldId, expr) {
|
||
|
|
document.getElementById(fieldId).value = expr;
|
||
|
|
}
|
||
|
|
</script>
|
||
|
|
{% endblock %}
|