mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-17 10:29:49 -05:00
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:
+7
-1
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user