feat(support): system-wide support visibility (admin-only)

- Add Settings.donate_ui_hidden and migration 122; admin verify in Admin → Settings
- Support visibility section in admin settings: System ID, code input, Verify and hide for everyone
- New route POST /admin/settings/verify-donate-hide-code (Ed25519 or HMAC) sets donate_ui_hidden
- Templates (base, dashboard, about, help) hide donate UI when settings.donate_ui_hidden
- User settings: remove per-user code/System ID block; add note that admins configure in Admin → Settings
- Config: DONATE_HIDE_PUBLIC_KEY_PEM / _FILE and HMAC fallback; refuse private key PEM
- Dockerfile: set DONATE_HIDE_PUBLIC_KEY_FILE=/app/donate_hide_public.pem
- .gitignore: docs/internal/, scripts/generate_donate_hide_code.py, donate_hide_private.pem
- Update SUPPORT_VISIBILITY.md for system-wide flow and admin-only setup

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dries Peeters
2026-02-08 14:00:30 +01:00
parent 2814661cf1
commit a7f2fec930
14 changed files with 242 additions and 135 deletions
+7 -1
View File
@@ -242,4 +242,10 @@ mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock
mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock
mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock
mobile/.flutter-plugins-dependencies.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock.lock
mobile/.android/
mobile/.android/
## License Files
donate_hide_private.pem
+3 -1
View File
@@ -28,6 +28,8 @@ ENV FLASK_APP=app
ENV FLASK_ENV=production
ENV APP_VERSION=${APP_VERSION}
ENV TZ=Europe/Rome
# Support visibility: if donate_hide_public.pem is in project root it is copied to /app; set path so app finds it (override in compose if needed)
ENV DONATE_HIDE_PUBLIC_KEY_FILE=/app/donate_hide_public.pem
# Install all system dependencies in a single layer
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
@@ -86,7 +88,7 @@ RUN mkdir -p \
/app/static/uploads/logos \
/app/app/static/dist
# Copy project files with correct ownership
# Copy project files with correct ownership (includes optional donate_hide_public.pem from root when present)
COPY --chown=timetracker:timetracker . .
# Also install certificate generation script to a stable path used by docs/compose
+11 -1
View File
@@ -85,14 +85,24 @@ class Config:
#
# Option A - Ed25519 (recommended): Server only has the PUBLIC key. You keep the private key
# and sign the system_id to generate codes. Set DONATE_HIDE_PUBLIC_KEY (PEM string) or
# DONATE_HIDE_PUBLIC_KEY_FILE (path to PEM file). Safe to put in .env or a file.
# DONATE_HIDE_PUBLIC_KEY_FILE (path to PEM file). If unset, a file named donate_hide_public.pem
# in the project root is used when present (local builds and Docker when copied into image).
_donate_public_key = os.getenv("DONATE_HIDE_PUBLIC_KEY", "").strip()
if not _donate_public_key:
_pk_file = os.getenv("DONATE_HIDE_PUBLIC_KEY_FILE", "").strip()
if not _pk_file:
# Default: project root (parent of app/) for local builds and Docker (/app)
_project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_default_pk = os.path.join(_project_root, "donate_hide_public.pem")
if os.path.isfile(_default_pk):
_pk_file = _default_pk
if _pk_file and os.path.isfile(_pk_file):
try:
with open(_pk_file, "r", encoding="utf-8") as f:
_donate_public_key = f.read().strip()
# Refuse to load a private key on the server
if "PRIVATE KEY" in _donate_public_key and "PUBLIC KEY" not in _donate_public_key:
_donate_public_key = ""
except OSError:
_donate_public_key = ""
DONATE_HIDE_PUBLIC_KEY_PEM = _donate_public_key
+2
View File
@@ -88,6 +88,8 @@ class Settings(db.Model):
# Stable per-installation ID (UUID); used for donate-hide code requests.
system_instance_id = db.Column(db.String(36), nullable=True)
# When True, donate/support UI is hidden for all users (set after code verification in Admin).
donate_ui_hidden = db.Column(db.Boolean, default=False, nullable=False)
# Kiosk mode settings
kiosk_mode_enabled = db.Column(db.Boolean, default=False, nullable=False)
+43
View File
@@ -1126,12 +1126,14 @@ def settings():
ZoneInfo(timezone) # This will raise an exception if timezone is invalid
except (ZoneInfoNotFoundError, KeyError):
flash(_("Invalid timezone: %(timezone)s", timezone=timezone), "error")
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
system_instance_id=system_instance_id,
)
# Update basic settings
@@ -1244,12 +1246,14 @@ def settings():
if not safe_commit("admin_update_settings"):
flash(_("Could not update settings due to a database error. Please check server logs."), "error")
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
system_instance_id=system_instance_id,
)
# #region agent log
try:
@@ -1273,15 +1277,54 @@ def settings():
"kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"),
}
system_instance_id = Settings.get_system_instance_id()
return render_template(
"admin/settings.html",
settings=settings_obj,
timezones=timezones,
kiosk_settings=kiosk_settings,
peppol_env_enabled=peppol_env_enabled,
system_instance_id=system_instance_id,
)
@admin_bp.route("/admin/settings/verify-donate-hide-code", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def admin_verify_donate_hide_code():
"""Verify code (Ed25519 or HMAC) and set system-wide donate_ui_hidden=True."""
import hmac
from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature
settings_obj = Settings.get_settings()
if getattr(settings_obj, "donate_ui_hidden", False):
return jsonify({"success": True})
data = request.get_json() or {}
code = (data.get("code") or "").strip()
system_id = Settings.get_system_instance_id()
if not system_id:
return jsonify({"error": _("Invalid code.")}), 400
valid = False
public_key_pem = current_app.config.get("DONATE_HIDE_PUBLIC_KEY_PEM") or ""
if public_key_pem:
valid = verify_ed25519_signature(code, system_id, public_key_pem)
if not valid:
secret = current_app.config.get("DONATE_HIDE_UNLOCK_SECRET") or ""
if secret:
expected = compute_donate_hide_code(secret, system_id)
valid = bool(expected and hmac.compare_digest(code, expected))
if not valid:
return jsonify({"error": _("Invalid code.")}), 400
settings_obj.donate_ui_hidden = True
if safe_commit(db.session):
return jsonify({"success": True})
return jsonify({"error": _("Error saving settings")}), 500
@admin_bp.route("/admin/pdf-layout", methods=["GET", "POST"])
@limiter.limit("30 per minute", methods=["POST"]) # editor saves
@login_required
-3
View File
@@ -167,8 +167,6 @@ def settings():
rounding_intervals = get_available_rounding_intervals()
rounding_methods = get_available_rounding_methods()
system_instance_id = Settings.get_system_instance_id()
return render_template(
"user/settings.html",
user=current_user,
@@ -176,7 +174,6 @@ def settings():
languages=languages,
rounding_intervals=rounding_intervals,
rounding_methods=rounding_methods,
system_instance_id=system_instance_id,
)
+93
View File
@@ -109,6 +109,44 @@
</div>
</div>
<!-- Support / Donate visibility (system-wide) -->
<div class="border-b border-border-light dark:border-border-dark pb-6" id="donateVisibilitySection">
<h2 class="text-lg font-semibold mb-4"><i class="fas fa-eye-slash mr-2"></i>{{ _('Support visibility') }}</h2>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Hide donate and support UI for all users by verifying a code. The code is generated from the system ID (see internal documentation).') }}
</p>
{% if system_instance_id %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('System ID') }}</label>
<div class="flex items-center gap-2">
<input type="text" id="adminSystemInstanceId" value="{{ system_instance_id }}" readonly class="flex-1 form-input font-mono text-sm bg-gray-100 dark:bg-gray-700">
<button type="button" onclick="copyAdminSystemId(this)" class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded-md text-sm font-medium transition">
<i class="fas fa-copy mr-1"></i>{{ _('Copy') }}
</button>
</div>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Use this ID to generate the code (see internal documentation).') }}</p>
</div>
{% endif %}
{% if settings.donate_ui_hidden %}
<div class="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
<i class="fas fa-check-circle mr-2"></i>{{ _('Donate and support UI are hidden for all users.') }}
</p>
</div>
{% else %}
<div class="flex flex-wrap items-end gap-2">
<div class="flex-1 min-w-[200px]">
<label for="adminDonateHideCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Code') }}</label>
<input type="text" id="adminDonateHideCode" placeholder="{{ _('Enter code') }}" class="form-input w-full">
</div>
<button type="button" id="adminVerifyDonateHideCodeBtn" onclick="verifyAdminDonateHideCode()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium transition">
{{ _('Verify and hide for everyone') }}
</button>
</div>
<p id="adminDonateHideCodeMessage" class="mt-2 text-sm hidden"></p>
{% endif %}
</div>
<!-- Company Branding -->
<div class="border-b border-border-light dark:border-border-dark pb-6">
<h2 class="text-lg font-semibold mb-4">{{ _('Company Branding') }}</h2>
@@ -450,6 +488,61 @@ async function confirmRemoveLogo() {
}
}
function copyAdminSystemId(btn) {
const input = document.getElementById('adminSystemInstanceId');
if (input) {
input.select();
document.execCommand('copy');
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check mr-1"></i>{{ _("Copied") }}';
setTimeout(function() { btn.innerHTML = orig; }, 2000);
}
}
async function verifyAdminDonateHideCode() {
const codeInput = document.getElementById('adminDonateHideCode');
const msgEl = document.getElementById('adminDonateHideCodeMessage');
const btn = document.getElementById('adminVerifyDonateHideCodeBtn');
if (!codeInput || !msgEl || !btn) return;
msgEl.classList.add('hidden');
msgEl.textContent = '';
const code = (codeInput.value || '').trim();
if (!code) {
msgEl.textContent = '{{ _("Please enter a code.") }}';
msgEl.classList.remove('hidden');
msgEl.classList.add('text-amber-600', 'dark:text-amber-400');
return;
}
btn.disabled = true;
const csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
try {
const res = await fetch('{{ url_for("admin.admin_verify_donate_hide_code") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfToken },
body: JSON.stringify({ code: code })
});
const data = await res.json().catch(function() { return {}; });
if (res.ok && data.success) {
msgEl.textContent = '{{ _("Success. Donate UI is now hidden for everyone. Reload the page to see the change.") }}';
msgEl.classList.remove('text-amber-600', 'dark:text-amber-400', 'text-red-600', 'dark:text-red-400');
msgEl.classList.add('text-green-600', 'dark:text-green-400');
msgEl.classList.remove('hidden');
setTimeout(function() { window.location.reload(); }, 1500);
} else {
msgEl.textContent = data.error || '{{ _("Invalid code.") }}';
msgEl.classList.remove('text-green-600', 'dark:text-green-400');
msgEl.classList.add('text-red-600', 'dark:text-red-400');
msgEl.classList.remove('hidden');
}
} catch (e) {
msgEl.textContent = '{{ _("Request failed.") }}';
msgEl.classList.remove('text-green-600', 'dark:text-green-400');
msgEl.classList.add('text-red-600', 'dark:text-red-400');
msgEl.classList.remove('hidden');
}
btn.disabled = false;
}
// Initialize: keep collapsed by default
document.addEventListener('DOMContentLoaded', function() {
const content = document.getElementById('integrationCredentialsContent');
+4 -4
View File
@@ -943,7 +943,7 @@
<span class="ml-3 sidebar-label">{{ _('Help') }}</span>
</a>
</li>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<li class="mt-2">
<a href="{{ url_for('main.donate') }}" class="sidebar-nav-item flex items-center p-2 rounded-lg {% if ep == 'main.donate' %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}bg-gradient-to-r from-amber-500/10 to-orange-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-400 hover:from-amber-500/20 hover:to-orange-500/20 hover:border-amber-500/30{% endif %} transition-all duration-200 group">
<i class="fas fa-mug-saucer w-6 text-center group-hover:scale-110 transition-transform"></i>
@@ -987,7 +987,7 @@
<!-- Right side controls -->
<div class="flex items-center space-x-4">
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- BuyMeACoffee Button -->
<a href="https://buymeacoffee.com/DryTrix?utm_source=timetracker&utm_medium=header&utm_campaign=support"
target="_blank"
@@ -1063,7 +1063,7 @@
</li>
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('My Profile') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('My Settings') }}</a></li>
{% if current_user.ui_show_donate %}<li><a href="{{ url_for('main.donate') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-mug-saucer w-4"></i> {{ _('Support Development') }}</a></li>{% endif %}
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}<li><a href="{{ url_for('main.donate') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-amber-600 dark:text-amber-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-mug-saucer w-4"></i> {{ _('Support Development') }}</a></li>{% endif %}
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
</ul>
</div>
@@ -1090,7 +1090,7 @@
{% endwith %}
</div>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Dismissible Support Banner -->
<div id="supportBanner" class="bg-gradient-to-r from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 border-b border-amber-200 dark:border-amber-800 px-4 py-3 opacity-0 invisible max-h-0 overflow-hidden transition-all duration-300 ease-in-out">
<div class="max-w-7xl mx-auto flex items-center justify-between gap-4">
+2 -2
View File
@@ -179,14 +179,14 @@
<a href="https://github.com/drytrix/TimeTracker" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<i class="fab fa-github mr-2"></i>{{ _('View on GitHub') }}
</a>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<a href="{{ url_for('main.donate') }}" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all">
<i class="fas fa-heart mr-2"></i>{{ _('Support Development') }}
</a>
{% endif %}
</div>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Support Section -->
<div class="bg-gradient-to-br from-amber-50 to-orange-50 dark:from-amber-900/20 dark:to-orange-900/20 p-6 rounded-lg border border-amber-200 dark:border-amber-800 mt-6">
<div class="flex items-start gap-4">
+1 -1
View File
@@ -406,7 +406,7 @@
</div>
</div>
{% if current_user.ui_show_donate %}
{% if (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<!-- Support TimeTracker Widget -->
<div class="bg-gradient-to-br from-amber-500 via-orange-500 to-amber-600 p-6 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 dashboard-widget animated-card text-white">
<div class="flex items-start justify-between mb-4">
+1 -1
View File
@@ -805,7 +805,7 @@
<a href="https://github.com/drytrix/TimeTracker/issues" target="_blank" rel="noopener" class="px-4 py-2 rounded-lg border border-amber-600 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20">
<i class="fas fa-bug mr-1"></i>{{ _('Report Issue') }}
</a>
{% if current_user.is_authenticated and current_user.ui_show_donate %}
{% if current_user.is_authenticated and (not settings or not getattr(settings, 'donate_ui_hidden', false)) and current_user.ui_show_donate %}
<a href="{{ url_for('main.donate') }}" class="px-4 py-2 rounded-lg bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white font-semibold shadow-md hover:shadow-lg transition-all">
<i class="fas fa-heart mr-1"></i>{{ _('Support Development') }}
</a>
+3 -104
View File
@@ -297,49 +297,9 @@
</div>
</div>
<!-- Support / Donate visibility -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-md p-6" id="donateVisibilitySection">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-eye-slash mr-2"></i>{{ _('Support visibility') }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
{{ _('You can hide donate and support buttons and the support banner by entering a valid code.') }}
</p>
{% if system_instance_id %}
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('System ID') }}
</label>
<div class="flex items-center gap-2">
<input type="text" id="systemInstanceId" value="{{ system_instance_id }}" readonly
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-100 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300 font-mono text-sm">
<button type="button" id="copySystemIdBtn" onclick="copySystemId(this)" class="px-3 py-2 bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500 rounded-md text-sm font-medium text-gray-700 dark:text-gray-200 transition">
<i class="fas fa-copy mr-1"></i>{{ _('Copy') }}
</button>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Use this ID when requesting a code to hide donate prompts.') }}</p>
</div>
{% endif %}
{% if not user.ui_show_donate %}
<div class="p-4 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800">
<p class="text-sm font-medium text-green-800 dark:text-green-200">
<i class="fas fa-check-circle mr-2"></i>{{ _('Donate and support prompts are hidden.') }}
</p>
</div>
{% else %}
<div class="flex flex-wrap items-end gap-2">
<div class="flex-1 min-w-[200px]">
<label for="donateHideCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Code') }}</label>
<input type="text" id="donateHideCode" placeholder="{{ _('Enter code') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:text-white">
</div>
<button type="button" id="verifyDonateHideCodeBtn" onclick="verifyDonateHideCode()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md text-sm font-medium transition">
{{ _('Verify') }}
</button>
</div>
<p id="donateHideCodeMessage" class="mt-2 text-sm hidden"></p>
{% endif %}
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Support visibility (hiding donate/support UI) is configured system-wide by administrators in Admin → Settings.') }}
</p>
<!-- Save Button -->
<div class="flex justify-end space-x-4">
@@ -458,67 +418,6 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('time_rounding_enabled').addEventListener('change', updateRoundingExample);
document.getElementById('time_rounding_minutes').addEventListener('change', updateRoundingExample);
});
// Copy System ID to clipboard
function copySystemId(btn) {
const el = document.getElementById('systemInstanceId');
if (el) {
el.select();
el.setSelectionRange(0, 99999);
try {
navigator.clipboard.writeText(el.value);
if (btn) {
const orig = btn.innerHTML;
btn.innerHTML = '<i class="fas fa-check mr-1"></i>{{ _("Copied") }}';
setTimeout(function() { btn.innerHTML = orig; }, 2000);
}
} catch (e) {}
}
}
// Verify donate-hide code
function verifyDonateHideCode() {
const codeInput = document.getElementById('donateHideCode');
const msgEl = document.getElementById('donateHideCodeMessage');
const btn = document.getElementById('verifyDonateHideCodeBtn');
if (!codeInput || !msgEl || !btn) return;
const code = (codeInput.value || '').trim();
if (!code) {
msgEl.textContent = '{{ _("Please enter a code.") }}';
msgEl.className = 'mt-2 text-sm text-amber-600 dark:text-amber-400';
msgEl.classList.remove('hidden');
return;
}
btn.disabled = true;
msgEl.classList.add('hidden');
const csrfToken = document.querySelector('meta[name="csrf-token"]') ? document.querySelector('meta[name="csrf-token"]').content : '';
fetch('{{ url_for("user.verify_donate_hide_code") }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify({ code: code })
})
.then(function(r) { return r.json().then(function(data) { return { ok: r.ok, data: data }; }); })
.then(function(result) {
if (result.ok && result.data.success) {
window.location.reload();
} else {
msgEl.textContent = result.data.error || '{{ _("Invalid code.") }}';
msgEl.className = 'mt-2 text-sm text-rose-600 dark:text-rose-400';
msgEl.classList.remove('hidden');
btn.disabled = false;
}
})
.catch(function() {
msgEl.textContent = '{{ _("Error verifying code.") }}';
msgEl.className = 'mt-2 text-sm text-rose-600 dark:text-rose-400';
msgEl.classList.remove('hidden');
btn.disabled = false;
});
}
</script>
{% endblock %}
+29 -17
View File
@@ -1,12 +1,12 @@
# Support / Donate visibility
This guide describes how to configure the optional **Support visibility** feature. When enabled, users can hide donate and support prompts (sidebar link, header button, support banner, and donate widgets) by entering a **code** that is issued per installation.
This guide describes how to configure the optional **Support visibility** feature. When enabled, an **admin** can hide donate and support prompts (sidebar link, header button, support banner, and donate widgets) **for all users** by entering a **code** that is issued per installation.
## What users see
## What admins see
- In **Settings → Support visibility**, each user sees a **System ID** (a stable UUID for your installation) and a field to enter a code.
- After a valid code is entered and verified, donate and support UI is hidden for that user and the setting is saved.
- The System ID does not change between restarts; it identifies your instance so you can issue the correct code.
- In **Admin → Settings**, the **Support visibility** section shows the **System ID** (a stable UUID for your installation) and a field to enter a code.
- After a valid code is entered and **Verify and hide for everyone** is clicked, donate and support UI is hidden **system-wide** for all users.
- The System ID does not change between restarts; it identifies your instance so you can request the correct code.
## Server configuration
@@ -14,20 +14,32 @@ You can enable verification in two ways. Only one is required.
### Option A: Public key (recommended)
The server stores **only a public key**. No secret is on the server; codes are generated offline using the matching private key. This is the most secure option.
The server stores **only a public key**. The **private key** is never used by the application; you keep it only for running the code-generation script. This is the most secure option.
| Variable | Description |
|----------|-------------|
| `DONATE_HIDE_PUBLIC_KEY_FILE` | Path to a file containing the PEM-encoded Ed25519 **public** key. |
| `DONATE_HIDE_PUBLIC_KEY` | Alternatively, the PEM string itself (e.g. for env or secrets). |
The public key is not sensitive and can be committed or stored in normal config. Code generation uses the private key and is done outside the server (see internal documentation).
**Quick setup (when you already have the private key):**
**Example (path to file):**
1. **Derive the public key** from your private key (run once, on the same machine where you have the private key):
```bash
openssl pkey -in donate_hide_private.pem -pubout -out donate_hide_public.pem
```
2. **On the server**, configure the **public** key only. For example in `.env` or your deployment config:
```bash
DONATE_HIDE_PUBLIC_KEY_FILE=/path/to/donate_hide_public.pem
```
Use a path where you deploy `donate_hide_public.pem` (not the private key). Alternatively set `DONATE_HIDE_PUBLIC_KEY` to the full PEM contents of the public key.
3. Restart the application. The app will use the public key to **verify** codes; it never needs or uses the private key.
4. When issuing codes, run the code-generation script **offline** with your **private** key (see internal documentation).
```bash
DONATE_HIDE_PUBLIC_KEY_FILE=/etc/timetracker/donate_hide_public.pem
```
**Automatic detection:** If you do not set `DONATE_HIDE_PUBLIC_KEY` or `DONATE_HIDE_PUBLIC_KEY_FILE`, the app looks for a file named **`donate_hide_public.pem`** in the project root. Place it there for local runs; for Docker, place it in the build context root and the image sets `DONATE_HIDE_PUBLIC_KEY_FILE=/app/donate_hide_public.pem` so the copied file is used.
**GitHub Actions (release and development workflows):** To bake the public key into the Docker image when building via GitHub Actions, add a repository secret **`DONATE_HIDE_PUBLIC_KEY_PEM`** (Settings → Secrets and variables → Actions) with the **full PEM contents** of your public key (including `-----BEGIN PUBLIC KEY-----` and `-----END PUBLIC KEY-----`). The workflow writes it to `donate_hide_public.pem` before the build so the image has the key at `/app/donate_hide_public.pem`. If the secret is not set, the image still builds; Support visibility verification will simply be disabled until you configure the key at runtime (e.g. via volume or env).
The public key file is not sensitive and can live in normal config. Never deploy or configure the private key on the server.
### Option B: Shared secret (HMAC)
@@ -52,20 +64,20 @@ If both Option A and Option B are configured, the app uses the public key first;
2. Set the corresponding environment variable(s) for your deployment (e.g. in `.env`, Docker Compose, or your process manager).
3. Restart the application.
If neither is set, the feature is disabled: no code will be accepted and the Support visibility section still appears in Settings, but verification will always fail until you configure one of the options.
If neither is set, the feature is disabled: no code will be accepted and the Support visibility section still appears in Admin → Settings, but verification will always fail until you configure one of the options.
## Issuing codes to users
## Issuing codes
Codes are **per installation**: each instance has its own System ID, and a valid code for one instance is not valid for another.
- Users send you the **System ID** shown in their Settings → Support visibility.
- An admin copies the **System ID** from Admin → Settings → Support visibility (or you provide it from your deployment).
- You generate the code for that System ID using the procedure and tools described in **internal documentation**. The code-generation script, key-generation steps, and private key handling are not in the public repository; maintainers use a separate, non-committed guide and script.
- You send the code to the user; they enter it in Settings and click Verify.
- You send the code to the admin; they enter it in Admin → Settings and click **Verify and hide for everyone**.
## User experience
- **Before verification:** Donate/support links and the support banner are visible (unless the user previously verified a code on this account).
- **After verification:** Donate and support UI is hidden for that user. The setting is stored per user and persists across sessions.
- **Before verification:** Donate/support links and the support banner are visible to all users.
- **After verification:** Donate and support UI is hidden **for everyone**. The setting is stored in system settings and persists across restarts.
## Security notes
@@ -0,0 +1,43 @@
"""Add donate_ui_hidden to settings (system-wide hide donate UI)
Revision ID: 122_add_settings_donate_ui_hidden
Revises: 121_add_ui_show_donate_system_id
Create Date: 2026-02-08
When True, donate/support UI is hidden for all users (verified in Admin).
"""
from alembic import op
import sqlalchemy as sa
revision = "122_add_settings_donate_ui_hidden"
down_revision = "121_add_ui_show_donate_system_id"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if "settings" not in inspector.get_table_names():
return
cols = {c["name"] for c in inspector.get_columns("settings")}
if "donate_ui_hidden" in cols:
return
dialect_name = getattr(bind.dialect, "name", "generic")
bool_false = "0" if dialect_name == "sqlite" else "false"
op.add_column(
"settings",
sa.Column("donate_ui_hidden", sa.Boolean(), nullable=False, server_default=sa.text(bool_false)),
)
def downgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if "settings" not in inspector.get_table_names():
return
cols = {c["name"] for c in inspector.get_columns("settings")}
if "donate_ui_hidden" not in cols:
return
op.drop_column("settings", "donate_ui_hidden")