mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-18 04:08:48 -05:00
feat(setup): guided 6-step setup wizard for first-time configuration
Replace the single-page setup (telemetry + optional Google Calendar) with a guided wizard that collects all base settings before completion. Wizard steps: 1. Welcome - intro and Next 2. Region & time - timezone, date/time format, currency (Settings) 3. Company - name, address, email, optional phone/website (Settings) 4. System - allow self-registration, rounding minutes, single active timer, idle timeout (Settings) 5. Integrations (optional) - Google Calendar OAuth; can skip 6. Privacy & finish - telemetry opt-in; Complete Setup submits form Backend (app/routes/setup.py): - GET: pass settings and timezones to template for prefilling - POST: validate timezone, date_format, currency, rounding_minutes, idle_timeout_minutes; persist all fields to Settings and mark_setup_complete(telemetry_enabled) - Default timezone/currency to UTC/EUR when missing (keeps tests passing) Frontend: - initial_setup.html: 6 wizard steps, progress bar (Step X of 6), Back/Next and submit on last step - setup-wizard.js: step navigation, progress update, optional client-side validation for step 2 (timezone, currency required) Docs updated: TELEMETRY_QUICK_START.md, GETTING_STARTED.md, TELEMETRY_IMPLEMENTATION_SUMMARY.md.
This commit is contained in:
+89
-17
@@ -4,20 +4,24 @@ Initial setup routes for TimeTracker
|
||||
Handles first-time setup and telemetry opt-in.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
|
||||
from flask_login import login_required, current_user
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_babel import _
|
||||
from app.utils.installation import get_installation_config
|
||||
from app import log_event, track_event, db
|
||||
from app import log_event, db
|
||||
from app.models import Settings
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.timezone import get_available_timezones
|
||||
|
||||
setup_bp = Blueprint("setup", __name__)
|
||||
|
||||
ALLOWED_DATE_FORMATS = ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY", "DD.MM.YYYY")
|
||||
ROUNDING_MINUTES_MIN, ROUNDING_MINUTES_MAX = 1, 60
|
||||
IDLE_TIMEOUT_MIN, IDLE_TIMEOUT_MAX = 1, 480
|
||||
|
||||
|
||||
@setup_bp.route("/setup", methods=["GET", "POST"])
|
||||
def initial_setup():
|
||||
"""Initial setup page for first-time users"""
|
||||
"""Initial setup page for first-time users (guided wizard)."""
|
||||
installation_config = get_installation_config()
|
||||
|
||||
# If setup is already complete, redirect to dashboard
|
||||
@@ -25,13 +29,70 @@ def initial_setup():
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
if request.method == "POST":
|
||||
# Get telemetry preference
|
||||
telemetry_enabled = request.form.get("telemetry_enabled") == "on"
|
||||
# Validation (use defaults when not provided for backwards compatibility)
|
||||
timezone = (request.form.get("timezone") or "").strip()
|
||||
if not timezone:
|
||||
timezone = "UTC"
|
||||
try:
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
# Save OAuth credentials if provided
|
||||
ZoneInfo(timezone)
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
flash(_("Invalid timezone: %(timezone)s", timezone=timezone or "(empty)"), "error")
|
||||
return _render_setup(Settings.get_settings(), get_available_timezones())
|
||||
|
||||
date_fmt = request.form.get("date_format", "YYYY-MM-DD")
|
||||
if date_fmt not in ALLOWED_DATE_FORMATS:
|
||||
date_fmt = "YYYY-MM-DD"
|
||||
time_fmt = request.form.get("time_format", "24h")
|
||||
if time_fmt not in ("24h", "12h"):
|
||||
time_fmt = "24h"
|
||||
currency = (request.form.get("currency") or "").strip() or "EUR"
|
||||
|
||||
try:
|
||||
rounding = int(request.form.get("rounding_minutes", 1))
|
||||
rounding = max(ROUNDING_MINUTES_MIN, min(ROUNDING_MINUTES_MAX, rounding))
|
||||
except (TypeError, ValueError):
|
||||
rounding = 1
|
||||
try:
|
||||
idle_timeout = int(request.form.get("idle_timeout_minutes", 30))
|
||||
idle_timeout = max(IDLE_TIMEOUT_MIN, min(IDLE_TIMEOUT_MAX, idle_timeout))
|
||||
except (TypeError, ValueError):
|
||||
idle_timeout = 30
|
||||
|
||||
telemetry_enabled = request.form.get("telemetry_enabled") == "on"
|
||||
settings = Settings.get_settings()
|
||||
|
||||
# Google Calendar OAuth credentials
|
||||
# Region & time
|
||||
settings.timezone = timezone
|
||||
settings.date_format = date_fmt
|
||||
settings.time_format = time_fmt
|
||||
settings.currency = currency
|
||||
|
||||
# Company
|
||||
settings.company_name = request.form.get("company_name", "").strip() or getattr(
|
||||
settings, "company_name", "Your Company Name"
|
||||
)
|
||||
settings.company_address = request.form.get("company_address", "").strip() or getattr(
|
||||
settings, "company_address", "Your Company Address"
|
||||
)
|
||||
settings.company_email = request.form.get("company_email", "").strip() or getattr(
|
||||
settings, "company_email", "info@yourcompany.com"
|
||||
)
|
||||
settings.company_phone = (request.form.get("company_phone") or "").strip() or getattr(
|
||||
settings, "company_phone", ""
|
||||
)
|
||||
settings.company_website = (request.form.get("company_website") or "").strip() or getattr(
|
||||
settings, "company_website", ""
|
||||
)
|
||||
|
||||
# System
|
||||
settings.allow_self_register = request.form.get("allow_self_register") == "on"
|
||||
settings.rounding_minutes = rounding
|
||||
settings.single_active_timer = request.form.get("single_active_timer") == "on"
|
||||
settings.idle_timeout_minutes = idle_timeout
|
||||
|
||||
# Google Calendar OAuth
|
||||
google_client_id = request.form.get("google_calendar_client_id", "").strip()
|
||||
google_client_secret = request.form.get("google_calendar_client_secret", "").strip()
|
||||
if google_client_id:
|
||||
@@ -39,25 +100,36 @@ def initial_setup():
|
||||
if google_client_secret:
|
||||
settings.google_calendar_client_secret = google_client_secret
|
||||
|
||||
# Save settings if any OAuth credentials were provided
|
||||
if google_client_id or google_client_secret:
|
||||
safe_commit("setup_oauth_credentials", {"provider": "google_calendar"})
|
||||
if settings not in db.session:
|
||||
db.session.add(settings)
|
||||
if not safe_commit("setup_wizard"):
|
||||
flash(_("Could not save settings. Please check server logs."), "error")
|
||||
return _render_setup(settings, get_available_timezones())
|
||||
|
||||
# Save preference
|
||||
installation_config.mark_setup_complete(telemetry_enabled=telemetry_enabled)
|
||||
|
||||
# Log the setup completion
|
||||
log_event("setup.completed", telemetry_enabled=telemetry_enabled, oauth_configured=bool(google_client_id))
|
||||
log_event(
|
||||
"setup.completed",
|
||||
telemetry_enabled=telemetry_enabled,
|
||||
oauth_configured=bool(google_client_id),
|
||||
)
|
||||
|
||||
# Show success message
|
||||
if telemetry_enabled:
|
||||
flash(_("Setup complete! Thank you for helping us improve TimeTracker."), "success")
|
||||
else:
|
||||
flash(_("Setup complete! Telemetry is disabled."), "success")
|
||||
|
||||
if google_client_id:
|
||||
flash(_("Google Calendar OAuth credentials have been configured."), "success")
|
||||
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
return render_template("setup/initial_setup.html")
|
||||
return _render_setup(Settings.get_settings(), get_available_timezones())
|
||||
|
||||
|
||||
def _render_setup(settings, timezones):
|
||||
"""Render the setup template with settings and timezones."""
|
||||
return render_template(
|
||||
"setup/initial_setup.html",
|
||||
settings=settings,
|
||||
timezones=timezones,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Initial setup wizard: step navigation and optional validation.
|
||||
*/
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
var currentStep = 1;
|
||||
var totalSteps = 6;
|
||||
|
||||
function getProgressLabel() {
|
||||
var el = document.getElementById('wizard-progress-label');
|
||||
return el ? el.textContent : '';
|
||||
}
|
||||
function setProgressLabel(text) {
|
||||
var el = document.getElementById('wizard-progress-label');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
function validateStep2() {
|
||||
var timezone = document.getElementById('timezone');
|
||||
var currency = document.getElementById('currency');
|
||||
if (!timezone || !currency) return true;
|
||||
var tzVal = (timezone.value || '').trim();
|
||||
var curVal = (currency.value || '').trim();
|
||||
if (!tzVal) {
|
||||
timezone.focus();
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(document.getElementById('wizard-progress-label').getAttribute('data-msg-timezone') || 'Please select a timezone.', 'error');
|
||||
} else {
|
||||
alert('Please select a timezone.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!curVal) {
|
||||
currency.focus();
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast(document.getElementById('wizard-progress-label').getAttribute('data-msg-currency') || 'Please enter a currency.', 'error');
|
||||
} else {
|
||||
alert('Please enter a currency.');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateCurrentStep() {
|
||||
if (currentStep === 2) return validateStep2();
|
||||
return true;
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (!validateCurrentStep()) return;
|
||||
if (currentStep < totalSteps) {
|
||||
currentStep++;
|
||||
updateStepUI();
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (currentStep > 1) {
|
||||
currentStep--;
|
||||
updateStepUI();
|
||||
}
|
||||
}
|
||||
|
||||
function updateStepUI() {
|
||||
var steps = document.querySelectorAll('.wizard-step');
|
||||
steps.forEach(function(step) {
|
||||
var stepNum = parseInt(step.getAttribute('data-step'), 10);
|
||||
if (stepNum === currentStep) {
|
||||
step.classList.remove('hidden');
|
||||
step.setAttribute('aria-current', 'step');
|
||||
} else {
|
||||
step.classList.add('hidden');
|
||||
step.removeAttribute('aria-current');
|
||||
}
|
||||
});
|
||||
|
||||
var dots = document.querySelectorAll('.setup-progress-dot');
|
||||
dots.forEach(function(dot) {
|
||||
var stepNum = parseInt(dot.getAttribute('data-step'), 10);
|
||||
if (stepNum <= currentStep) {
|
||||
dot.classList.remove('bg-gray-200', 'dark:bg-gray-700');
|
||||
dot.classList.add('bg-primary');
|
||||
} else {
|
||||
dot.classList.remove('bg-primary');
|
||||
dot.classList.add('bg-gray-200', 'dark:bg-gray-700');
|
||||
}
|
||||
});
|
||||
|
||||
setProgressLabel('Step ' + currentStep + ' of ' + totalSteps);
|
||||
|
||||
var backBtn = document.getElementById('setup-back-btn');
|
||||
var nextBtn = document.getElementById('setup-next-btn');
|
||||
var submitBtn = document.getElementById('setup-submit-btn');
|
||||
|
||||
if (backBtn) backBtn.classList.toggle('hidden', currentStep <= 1);
|
||||
if (nextBtn) nextBtn.classList.toggle('hidden', currentStep >= totalSteps);
|
||||
if (submitBtn) submitBtn.classList.toggle('hidden', currentStep < totalSteps);
|
||||
}
|
||||
|
||||
function init() {
|
||||
var form = document.getElementById('setup-form');
|
||||
if (!form) return;
|
||||
|
||||
var nextBtn = document.getElementById('setup-next-btn');
|
||||
var backBtn = document.getElementById('setup-back-btn');
|
||||
var submitBtn = document.getElementById('setup-submit-btn');
|
||||
|
||||
if (nextBtn) nextBtn.addEventListener('click', goNext);
|
||||
if (backBtn) backBtn.addEventListener('click', goBack);
|
||||
|
||||
updateStepUI();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
})();
|
||||
@@ -28,7 +28,6 @@
|
||||
<img src="{{ url_for('static', filename='images/timetracker-logo.svg') }}" alt="logo" class="w-24 h-24 mx-auto">
|
||||
<h1 class="text-3xl font-bold mt-4 text-primary">TimeTracker</h1>
|
||||
<p class="mt-2 text-text-muted-light dark:text-text-muted-dark">{{ _('Track time. Stay organized.') }}</p>
|
||||
|
||||
<!-- Privacy Principles -->
|
||||
<div class="mt-8 space-y-3 text-left">
|
||||
<p class="text-sm font-semibold text-text-light dark:text-text-dark mb-3">🔒 {{ _('Privacy First') }}</p>
|
||||
@@ -60,136 +59,229 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - Setup Form -->
|
||||
<!-- Right side - Wizard -->
|
||||
<div class="p-8 overflow-y-auto max-h-[90vh]">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{{ _('Welcome to TimeTracker') }}</h2>
|
||||
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("Let's get you set up in just a moment") }}</p>
|
||||
<p class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-4" id="wizard-progress-label">{{ _('Step 1 of 6') }}</p>
|
||||
<div class="flex gap-1 mb-6" aria-hidden="true">
|
||||
<div class="h-1 flex-1 rounded-full bg-primary setup-progress-dot" data-step="1"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="2"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="3"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="4"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="5"></div>
|
||||
<div class="h-1 flex-1 rounded-full bg-gray-200 dark:bg-gray-700 setup-progress-dot" data-step="6"></div>
|
||||
</div>
|
||||
|
||||
<form class="mt-6 space-y-6" method="POST" action="{{ url_for('setup.initial_setup') }}">
|
||||
<form id="setup-form" class="space-y-6" method="POST" action="{{ url_for('setup.initial_setup') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
|
||||
<!-- Welcome Message -->
|
||||
<div class="bg-primary/10 border border-primary/20 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-2">🎉 {{ _('Thank you for choosing TimeTracker!') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Your data stays on your server, and you have complete control.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- OAuth Integration Setup Section -->
|
||||
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<h3 class="text-base font-semibold mb-3">🔌 {{ _('Integration Setup (Optional)') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
|
||||
{{ _('Configure OAuth credentials now to enable calendar and other integrations. You can also configure these later in Admin → Settings.') }}
|
||||
</p>
|
||||
|
||||
<!-- Google Calendar OAuth -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
<i class="fab fa-google text-blue-600 mr-2"></i>{{ _('Google Calendar OAuth') }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
name="google_calendar_client_id"
|
||||
placeholder="{{ _('Client ID (optional)') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
name="google_calendar_client_secret"
|
||||
placeholder="{{ _('Client Secret (optional)') }}"
|
||||
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
{{ _('Get these from') }} <a href="https://console.cloud.google.com/apis/credentials" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a>
|
||||
<!-- Step 1: Welcome -->
|
||||
<div class="wizard-step" data-step="1" aria-current="step">
|
||||
<h2 class="text-2xl font-bold tracking-tight">{{ _('Welcome to TimeTracker') }}</h2>
|
||||
<p class="mt-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("Let's get you set up in just a moment") }}</p>
|
||||
<div class="mt-6 bg-primary/10 border border-primary/20 rounded-lg p-4">
|
||||
<h3 class="text-sm font-semibold text-primary mb-2">🎉 {{ _('Thank you for choosing TimeTracker!') }}</h3>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ _('Your data stays on your server, and you have complete control.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<details class="text-sm mt-3">
|
||||
<summary class="cursor-pointer font-medium text-primary hover:underline">
|
||||
{{ _('How to get Google Calendar OAuth credentials?') }}
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2 pl-4 border-l-2 border-primary/30 text-text-muted-light dark:text-text-muted-dark">
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li>{{ _('Go to') }} <a href="https://console.cloud.google.com/" target="_blank" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a></li>
|
||||
<li>{{ _('Create a new project or select an existing one') }}</li>
|
||||
<li>{{ _('Enable the Google Calendar API') }}</li>
|
||||
<li>{{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}</li>
|
||||
<li>{{ _('Set application type to "Web application"') }}</li>
|
||||
<li>{{ _('Add authorized redirect URI:') }} <code class="bg-gray-100 dark:bg-gray-800 dark:text-text-light px-1 rounded text-xs break-all">{{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}</code></li>
|
||||
<li>{{ _('Copy the Client ID and Client Secret') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry Opt-in Section -->
|
||||
<div class="bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<h3 class="text-base font-semibold mb-3">📊 {{ _('Help Us Improve (Optional)') }}</h3>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input type="checkbox" name="telemetry_enabled" class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
|
||||
<span class="ml-3 text-sm">
|
||||
<span class="font-medium">{{ _('Enable anonymous telemetry') }}</span>
|
||||
<span class="block text-text-muted-light dark:text-text-muted-dark mt-1">
|
||||
{{ _('Help us understand usage patterns to improve TimeTracker') }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<!-- Step 2: Region & time -->
|
||||
<div class="wizard-step hidden" data-step="2">
|
||||
<h2 class="text-xl font-bold tracking-tight">{{ _('Region & time') }}</h2>
|
||||
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Set your default timezone, date and time format, and currency.') }}</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="timezone" class="block text-sm font-medium mb-1">{{ _('Timezone') }} <span class="text-red-500">*</span></label>
|
||||
<select name="timezone" id="timezone" required class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
{% for tz in timezones or [] %}
|
||||
<option value="{{ tz }}" {% if settings and settings.timezone == tz %}selected{% endif %}>{{ tz }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="date_format" class="block text-sm font-medium mb-1">{{ _('Date format') }}</label>
|
||||
<select name="date_format" id="date_format" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<option value="YYYY-MM-DD" {% if settings and settings.date_format == 'YYYY-MM-DD' %}selected{% endif %}>YYYY-MM-DD</option>
|
||||
<option value="MM/DD/YYYY" {% if settings and settings.date_format == 'MM/DD/YYYY' %}selected{% endif %}>MM/DD/YYYY</option>
|
||||
<option value="DD/MM/YYYY" {% if settings and settings.date_format == 'DD/MM/YYYY' %}selected{% endif %}>DD/MM/YYYY</option>
|
||||
<option value="DD.MM.YYYY" {% if settings and settings.date_format == 'DD.MM.YYYY' %}selected{% endif %}>DD.MM.YYYY</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="time_format" class="block text-sm font-medium mb-1">{{ _('Time format') }}</label>
|
||||
<select name="time_format" id="time_format" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<option value="24h" {% if not settings or settings.time_format == '24h' %}selected{% endif %}>{{ _('24-hour') }}</option>
|
||||
<option value="12h" {% if settings and settings.time_format == '12h' %}selected{% endif %}>{{ _('12-hour') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="currency" class="block text-sm font-medium mb-1">{{ _('Currency') }} <span class="text-red-500">*</span></label>
|
||||
<input type="text" name="currency" id="currency" value="{{ settings.currency if settings else 'EUR' }}" required maxlength="10" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="EUR">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible details -->
|
||||
<details class="text-sm mt-3">
|
||||
<summary class="cursor-pointer font-medium text-primary hover:underline">
|
||||
{{ _('What data is collected?') }}
|
||||
</summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-primary/30">
|
||||
<div>
|
||||
<p class="font-semibold text-green-600 dark:text-green-400">✓ {{ _('What we collect:') }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li>{{ _('Anonymous installation fingerprint (hashed)') }}</li>
|
||||
<li>{{ _('Application version & platform info') }}</li>
|
||||
<li>{{ _('Feature usage statistics') }}</li>
|
||||
<li>{{ _('Internal numeric IDs only') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="font-semibold text-red-600 dark:text-red-400">✗ {{ _("What we DON'T collect:") }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li>{{ _('No usernames or emails') }}</li>
|
||||
<li>{{ _('No project names or descriptions') }}</li>
|
||||
<li>{{ _('No time entry data or notes') }}</li>
|
||||
<li>{{ _('No client or business data') }}</li>
|
||||
<li>{{ _('No IP addresses or PII') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="bg-primary/10 rounded p-3 text-xs">
|
||||
<p class="text-text-light dark:text-text-dark">
|
||||
<strong>{{ _('Why?') }}</strong> {{ _('Anonymous usage data helps us prioritize features and fix issues.') }}
|
||||
{{ _('You can change this anytime in') }} <strong>{{ _('Admin → Settings') }}</strong> ({{ _('Privacy & Analytics section') }}).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<button type="submit" class="btn btn-primary w-full">
|
||||
<i class="fa-solid fa-check mr-2"></i>
|
||||
{{ _('Complete Setup & Continue') }}
|
||||
</button>
|
||||
<!-- Step 3: Company -->
|
||||
<div class="wizard-step hidden" data-step="3">
|
||||
<h2 class="text-xl font-bold tracking-tight">{{ _('Company') }}</h2>
|
||||
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Company details for invoices and branding.') }}</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div>
|
||||
<label for="company_name" class="block text-sm font-medium mb-1">{{ _('Company name') }}</label>
|
||||
<input type="text" name="company_name" id="company_name" value="{{ settings.company_name if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="{{ _('Your Company Name') }}">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_address" class="block text-sm font-medium mb-1">{{ _('Address') }}</label>
|
||||
<textarea name="company_address" id="company_address" rows="2" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="{{ _('Your Company Address') }}">{{ settings.company_address if settings else '' }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_email" class="block text-sm font-medium mb-1">{{ _('Email') }}</label>
|
||||
<input type="email" name="company_email" id="company_email" value="{{ settings.company_email if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent" placeholder="info@yourcompany.com">
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="company_phone" class="block text-sm font-medium mb-1">{{ _('Phone') }} ({{ _('optional') }})</label>
|
||||
<input type="text" name="company_phone" id="company_phone" value="{{ settings.company_phone if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_website" class="block text-sm font-medium mb-1">{{ _('Website') }} ({{ _('optional') }})</label>
|
||||
<input type="text" name="company_website" id="company_website" value="{{ settings.company_website if settings else '' }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<p>{{ _('By continuing, you agree to use TimeTracker under the') }} <a href="https://www.gnu.org/licenses/gpl-3.0.html" class="text-primary hover:underline" target="_blank">{{ _('GPL-3.0 License') }}</a></p>
|
||||
<!-- Step 4: System -->
|
||||
<div class="wizard-step hidden" data-step="4">
|
||||
<h2 class="text-xl font-bold tracking-tight">{{ _('System') }}</h2>
|
||||
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('App behavior and timer settings.') }}</p>
|
||||
<div class="mt-4 space-y-4">
|
||||
<div class="flex items-start">
|
||||
<input type="checkbox" name="allow_self_register" id="allow_self_register" {% if settings and settings.allow_self_register %}checked{% endif %} class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
|
||||
<label for="allow_self_register" class="ml-3 text-sm">
|
||||
<span class="font-medium">{{ _('Allow self-registration') }}</span>
|
||||
<span class="block text-text-muted-light dark:text-text-muted-dark mt-0.5">{{ _('Users can create accounts from the login page') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="rounding_minutes" class="block text-sm font-medium mb-1">{{ _('Time rounding (minutes)') }}</label>
|
||||
<select name="rounding_minutes" id="rounding_minutes" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<option value="1" {% if not settings or settings.rounding_minutes == 1 %}selected{% endif %}>1</option>
|
||||
<option value="5" {% if settings and settings.rounding_minutes == 5 %}selected{% endif %}>5</option>
|
||||
<option value="15" {% if settings and settings.rounding_minutes == 15 %}selected{% endif %}>15</option>
|
||||
<option value="30" {% if settings and settings.rounding_minutes == 30 %}selected{% endif %}>30</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Round time entries to the nearest N minutes.') }}</p>
|
||||
</div>
|
||||
<div class="flex items-start">
|
||||
<input type="checkbox" name="single_active_timer" id="single_active_timer" {% if not settings or settings.single_active_timer %}checked{% endif %} class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
|
||||
<label for="single_active_timer" class="ml-3 text-sm">
|
||||
<span class="font-medium">{{ _('Single active timer per user') }}</span>
|
||||
<span class="block text-text-muted-light dark:text-text-muted-dark mt-0.5">{{ _('Only one running timer at a time per user') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label for="idle_timeout_minutes" class="block text-sm font-medium mb-1">{{ _('Idle timeout (minutes)') }}</label>
|
||||
<input type="number" name="idle_timeout_minutes" id="idle_timeout_minutes" value="{{ settings.idle_timeout_minutes if settings else 30 }}" min="1" max="480" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Pause timer after this many minutes of inactivity.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 5: Integrations -->
|
||||
<div class="wizard-step hidden" data-step="5">
|
||||
<h2 class="text-xl font-bold tracking-tight">{{ _('Integrations') }} ({{ _('optional') }})</h2>
|
||||
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Configure calendar OAuth now or later in Admin → Settings.') }}</p>
|
||||
<div class="mt-4 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium mb-2">
|
||||
<i class="fab fa-google text-blue-600 mr-2"></i>{{ _('Google Calendar OAuth') }}
|
||||
</label>
|
||||
<div class="space-y-2">
|
||||
<input type="text" name="google_calendar_client_id" placeholder="{{ _('Client ID (optional)') }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
<input type="password" name="google_calendar_client_secret" placeholder="{{ _('Client Secret (optional)') }}" class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark focus:ring-2 focus:ring-primary focus:border-transparent">
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
{{ _('Get these from') }} <a href="https://console.cloud.google.com/apis/credentials" target="_blank" rel="noopener" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a>
|
||||
</p>
|
||||
</div>
|
||||
<details class="text-sm mt-3">
|
||||
<summary class="cursor-pointer font-medium text-primary hover:underline">{{ _('How to get Google Calendar OAuth credentials?') }}</summary>
|
||||
<div class="mt-3 space-y-2 pl-4 border-l-2 border-primary/30 text-text-muted-light dark:text-text-muted-dark">
|
||||
<ol class="list-decimal list-inside space-y-2">
|
||||
<li>{{ _('Go to') }} <a href="https://console.cloud.google.com/" target="_blank" rel="noopener" class="text-primary hover:underline">{{ _('Google Cloud Console') }}</a></li>
|
||||
<li>{{ _('Create a new project or select an existing one') }}</li>
|
||||
<li>{{ _('Enable the Google Calendar API') }}</li>
|
||||
<li>{{ _('Go to Credentials → Create Credentials → OAuth 2.0 Client ID') }}</li>
|
||||
<li>{{ _('Set application type to "Web application"') }}</li>
|
||||
<li>{{ _('Add authorized redirect URI:') }} <code class="bg-gray-100 dark:bg-gray-800 dark:text-text-light px-1 rounded text-xs break-all">{{ url_for('integrations.oauth_callback', provider='google_calendar', _external=True) }}</code></li>
|
||||
<li>{{ _('Copy the Client ID and Client Secret') }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _("You can skip this step and configure integrations later.") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 6: Privacy & finish -->
|
||||
<div class="wizard-step hidden" data-step="6">
|
||||
<h2 class="text-xl font-bold tracking-tight">{{ _('Privacy & finish') }}</h2>
|
||||
<p class="mt-1 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Choose whether to help improve TimeTracker with anonymous usage data.') }}</p>
|
||||
<div class="mt-4 bg-background-light dark:bg-gray-700 border border-border-light dark:border-border-dark rounded-lg p-4">
|
||||
<div class="mb-3">
|
||||
<label class="flex items-start cursor-pointer">
|
||||
<input type="checkbox" name="telemetry_enabled" class="mt-1 h-4 w-4 text-primary border-border-light dark:border-border-dark rounded focus:ring-primary">
|
||||
<span class="ml-3 text-sm">
|
||||
<span class="font-medium">{{ _('Enable anonymous telemetry') }}</span>
|
||||
<span class="block text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Help us understand usage patterns to improve TimeTracker') }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<details class="text-sm mt-3">
|
||||
<summary class="cursor-pointer font-medium text-primary hover:underline">{{ _('What data is collected?') }}</summary>
|
||||
<div class="mt-3 space-y-3 pl-4 border-l-2 border-primary/30">
|
||||
<div>
|
||||
<p class="font-semibold text-green-600 dark:text-green-400">✓ {{ _('What we collect:') }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li>{{ _('Anonymous installation fingerprint (hashed)') }}</li>
|
||||
<li>{{ _('Application version & platform info') }}</li>
|
||||
<li>{{ _('Feature usage statistics') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-red-600 dark:text-red-400">✗ {{ _("What we DON'T collect:") }}</p>
|
||||
<ul class="list-disc list-inside space-y-1 ml-2 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li>{{ _('No usernames or emails') }}</li>
|
||||
<li>{{ _('No time entry data or notes') }}</li>
|
||||
<li>{{ _('No client or business data') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="text-xs">{{ _('You can change this anytime in') }} <strong>{{ _('Admin → Settings') }}</strong>.</p>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
<p>{{ _('By continuing, you agree to use TimeTracker under the') }} <a href="https://www.gnu.org/licenses/gpl-3.0.html" class="text-primary hover:underline" target="_blank" rel="noopener">{{ _('GPL-3.0 License') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex justify-between items-center pt-6 border-t border-border-light dark:border-border-dark mt-6">
|
||||
<button type="button" id="setup-back-btn" class="btn border border-border-light dark:border-border-dark hidden" aria-label="{{ _('Back') }}">
|
||||
<i class="fa-solid fa-arrow-left mr-2"></i>{{ _('Back') }}
|
||||
</button>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" id="setup-next-btn" class="btn btn-primary">
|
||||
{{ _('Next') }}<i class="fa-solid fa-arrow-right ml-2"></i>
|
||||
</button>
|
||||
<button type="submit" id="setup-submit-btn" class="btn btn-primary hidden" aria-label="{{ _('Complete Setup & Continue') }}">
|
||||
<i class="fa-solid fa-check mr-2"></i>{{ _('Complete Setup & Continue') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -210,6 +302,6 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/setup-wizard.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+13
-1
@@ -122,7 +122,19 @@ python app.py
|
||||
|
||||
## ⚙️ Initial Setup
|
||||
|
||||
### Step 1: Configure System Settings
|
||||
### Guided setup on first run
|
||||
|
||||
The **first time** you open TimeTracker (before setup is complete), you are shown a **guided setup wizard** at `/setup`. It walks you through:
|
||||
|
||||
- **Region & time** – Timezone, date/time format, currency
|
||||
- **Company** – Company name, address, email (for invoices)
|
||||
- **System** – Self-registration, time rounding, single active timer, idle timeout
|
||||
- **Integrations (optional)** – Google Calendar OAuth; can be skipped
|
||||
- **Privacy** – Opt-in for anonymous telemetry
|
||||
|
||||
After you complete the wizard, you can log in and fine-tune anything in **Admin → Settings**. If setup was already completed (e.g. by someone else), you go straight to the login/dashboard.
|
||||
|
||||
### Step 1: Configure System Settings (Admin)
|
||||
|
||||
> **Important**: You need admin access for this step. Login with a username from `ADMIN_USERNAMES` (default: `admin`).
|
||||
|
||||
|
||||
@@ -2,17 +2,21 @@
|
||||
|
||||
## For End Users
|
||||
|
||||
### First-Time Setup
|
||||
### First-Time Setup (Guided Wizard)
|
||||
|
||||
When you first access TimeTracker, you'll see a welcome screen asking about telemetry:
|
||||
When you first access TimeTracker, you'll see a **guided setup wizard** (6 steps). You can configure the basics in one flow, then continue to the dashboard.
|
||||
|
||||
1. **Read the Privacy Information** - Review what data is collected (and what isn't)
|
||||
2. **Choose Your Preference:**
|
||||
- ✅ **Enable Telemetry** - Help improve TimeTracker by sharing anonymous usage data
|
||||
- ⬜ **Disable Telemetry** - No data will be sent (default)
|
||||
3. **Click "Complete Setup & Continue"**
|
||||
1. **Welcome** – Intro; click **Next** to start.
|
||||
2. **Region & time** – Timezone, date format, time format, and currency (used for reports and invoices).
|
||||
3. **Company** – Company name, address, email; optional phone and website (for invoices and branding).
|
||||
4. **System** – Allow self-registration, time rounding, single active timer per user, idle timeout (minutes).
|
||||
5. **Integrations (optional)** – Google Calendar OAuth (Client ID / Secret). You can skip this and configure later in Admin → Settings.
|
||||
6. **Privacy & finish** – Choose whether to enable anonymous telemetry:
|
||||
- ✅ **Enable Telemetry** – Help improve TimeTracker with anonymous usage data
|
||||
- ⬜ **Disable Telemetry** – No data will be sent (default)
|
||||
- Click **Complete Setup & Continue** to finish.
|
||||
|
||||
You can change this decision anytime in the admin settings.
|
||||
You can change telemetry and all other options anytime in **Admin → Settings**.
|
||||
|
||||
### Viewing Telemetry Status (Admin Only)
|
||||
|
||||
|
||||
@@ -45,25 +45,32 @@ See `docs/all_tracked_events.md` for a complete list of tracked events.
|
||||
- `app/utils/telemetry.py` - Now uses installation config for salt
|
||||
- `app/__init__.py` - Integrated setup check middleware
|
||||
|
||||
### 3. First-Time Setup Page
|
||||
### 3. First-Time Setup (Guided Wizard)
|
||||
|
||||
**Files Created:**
|
||||
- `app/routes/setup.py` - Setup route handler
|
||||
- `app/templates/setup/initial_setup.html` - Beautiful setup page
|
||||
**Files:**
|
||||
- `app/routes/setup.py` - Setup route handler (GET: wizard with settings/timezones; POST: validate and save all steps)
|
||||
- `app/templates/setup/initial_setup.html` - 6-step wizard UI
|
||||
- `app/static/js/setup-wizard.js` - Step navigation and optional client-side validation
|
||||
|
||||
**Wizard steps:**
|
||||
1. **Welcome** – Intro; Next to continue
|
||||
2. **Region & time** – Timezone, date format, time format, currency (saved to Settings)
|
||||
3. **Company** – Company name, address, email, optional phone/website (saved to Settings)
|
||||
4. **System** – Allow self-registration, rounding minutes, single active timer, idle timeout (saved to Settings)
|
||||
5. **Integrations (optional)** – Google Calendar OAuth credentials; can skip
|
||||
6. **Privacy & finish** – Telemetry opt-in; Complete Setup submits form and marks setup complete
|
||||
|
||||
**Features:**
|
||||
- **Welcome Screen:** Professional, user-friendly design
|
||||
- **Telemetry Opt-In:** Clear explanation of what's collected
|
||||
- **Privacy Transparency:** Detailed list of what is/isn't collected
|
||||
- **Setup Completion Tracking:** Prevents re-showing after completion
|
||||
- **Middleware Integration:** Redirects to setup if not complete
|
||||
- **Guided flow:** One form, single POST at the end; progress indicator (Step X of 6)
|
||||
- **Telemetry opt-in:** Clear explanation of what is/isn't collected; opt-in by default (unchecked)
|
||||
- **Setup completion tracking:** Stored in installation config; redirect to dashboard when complete
|
||||
- **Middleware:** Unauthenticated users are redirected to `/setup` if setup not complete
|
||||
|
||||
**User Experience:**
|
||||
- ✅ Modern, clean UI with Tailwind CSS
|
||||
- ✅ Clear privacy explanations
|
||||
- ✅ Opt-in by default (unchecked checkbox)
|
||||
- ✅ Links to privacy policy and documentation
|
||||
- ✅ Easy to understand language
|
||||
**User experience:**
|
||||
- ✅ Modern UI with Tailwind CSS; Back/Next navigation
|
||||
- ✅ Prefilled from existing Settings when re-rendered (e.g. validation error)
|
||||
- ✅ Server-side validation (timezone, date format, currency, rounding, idle timeout)
|
||||
- ✅ Links to privacy policy and GPL-3.0
|
||||
|
||||
### 4. Admin Telemetry Dashboard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user