From eb2f5c6afa4a3245cffb12f0f74a8e5f751e6cb8 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Thu, 16 Apr 2026 19:59:20 +0200 Subject: [PATCH] fix(apps): harden desktop and mobile server connectivity Desktop (Electron): - Add two-step first-run wizard: test TimeTracker via GET /api/v1/info, then log in with API token - Replace bogus token check with validateSession (users/me, fallback to timer/status for narrow scopes) - Normalize base URLs; classify TLS/DNS/timeout errors; periodic 401 forces re-login - Settings save/test use public + authenticated checks; prebuild/prestart and npm test Server: - Exempt /api/v1/info, /api/v1/health, and POST /api/v1/auth/login from HTML setup redirect - Include setup_required on GET /api/v1/info for unfinished installs Mobile (Flutter): - Validate saved token against new server URL before persisting settings change - Remove unused lib/core/config.dart; point BUILD_CONFIGURATION at app_config.dart Docs: DESKTOP_SETTINGS, desktop README, mobile-desktop-apps README, REST_API /info --- app/__init__.py | 6 + app/routes/api_v1.py | 6 + desktop/README.md | 39 +- desktop/package.json | 3 + desktop/src/renderer/css/styles.css | 19 + desktop/src/renderer/index.html | 34 +- desktop/src/renderer/js/api/client.js | 300 +++++++++-- desktop/src/renderer/js/app.js | 328 +++++++++--- desktop/src/renderer/js/bundle.js | 482 ++++++++++++++++-- desktop/src/renderer/js/state.js | 4 + desktop/src/renderer/js/utils/helpers.js | 10 + desktop/test/api-client.test.js | 63 ++- docs/BUILD_CONFIGURATION.md | 2 +- docs/DESKTOP_SETTINGS.md | 53 +- docs/api/REST_API.md | 3 + docs/mobile-desktop-apps/README.md | 11 +- mobile/lib/core/config.dart | 71 --- .../presentation/screens/settings_screen.dart | 42 +- tests/test_api_v1.py | 2 + 19 files changed, 1178 insertions(+), 300 deletions(-) delete mode 100644 mobile/lib/core/config.dart diff --git a/app/__init__.py b/app/__init__.py index 5443b2d8..136bddfc 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -551,6 +551,12 @@ def create_app(config=None): if request.path.startswith("/static/") or request.path.startswith("/_"): return + # API discovery and mobile login must stay JSON (not HTML redirect) during install + if request.path.startswith("/api/v1/info") or request.path.startswith("/api/v1/health"): + return + if request.path == "/api/v1/auth/login" and request.method == "POST": + return + # Check if setup is complete from app.utils.installation import get_installation_config diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py index 6e0bbc1e..b6c157d1 100644 --- a/app/routes/api_v1.py +++ b/app/routes/api_v1.py @@ -114,10 +114,16 @@ def api_info(): # Fallback to config or default app_version = current_app.config.get("APP_VERSION", "1.0.0") + from app.utils.installation import get_installation_config + + installation_config = get_installation_config() + setup_required = not installation_config.is_setup_complete() + return jsonify( { "api_version": "v1", "app_version": app_version, + "setup_required": setup_required, "documentation_url": "/api/docs", "authentication": "API Token (Bearer or X-API-Key header)", "endpoints": { diff --git a/desktop/README.md b/desktop/README.md index 70e42b50..e2b20ac1 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -77,7 +77,7 @@ The UI is bundled from [`src/renderer/js/app.js`](src/renderer/js/app.js) into [ npm start ``` -(`npm start` runs `build:renderer` first, then launches Electron.) +(`npm start` runs `build:renderer` first via **prestart**, then launches Electron. `npm run build` uses **prebuild** the same way so installers do not ship a stale `bundle.js`.) ### Run with DevTools @@ -97,11 +97,12 @@ Before connecting the desktop app, you need to create an API token: 4. Fill in the required information: - **Name**: A descriptive name (e.g., "Desktop App - Windows") - **User**: Select the user this token will authenticate as - - **Scopes**: Select the following permissions: + - **Scopes**: Select at least the following permissions: - `read:projects` - View projects - `read:tasks` - View tasks - - `read:time_entries` - View time entries + - `read:time_entries` - View time entries (required for timer status; the app uses this if `read:users` is not granted) - `write:time_entries` - Create and update time entries + - `read:users` - Recommended: lets the app verify your session with `GET /api/v1/users/me` (otherwise it falls back to timer status) - **Expires In**: Optional expiration period (leave empty for no expiration) 5. Click **"Create Token"** 6. **Important**: Copy the generated token immediately - you won't be able to see it again! @@ -115,13 +116,11 @@ The desktop app can be configured in multiple ways: #### Method 1: In-App Login (Recommended) 1. **Launch the desktop app** -2. On the login screen, enter: - - **Server URL**: Your TimeTracker server URL (e.g., `https://your-server.com`) - - Do not include a trailing slash - - Use `http://` for local development or `https://` for production - - **API Token**: Paste the token you copied from the web app -3. Click **"Login"** -4. The app will validate your connection and show the main screen if successful +2. **Step 1 — Server**: Enter your TimeTracker **base URL** (e.g. `https://your-server.com` or `http://192.168.1.10:5000`). Trailing slashes are normalized away. You may omit the scheme for convenience; the app assumes `https://` when checking. +3. Click **Test server** and confirm you see a success message (the app calls `GET /api/v1/info` and checks for a TimeTracker response). +4. Click **Continue to token**. +5. **Step 2 — API token**: Paste the `tt_…` token from the web app, then **Log in**. The app verifies the token with the server (user profile or timer status). +6. If successful, the main window opens. If initial server setup is not finished in the browser, `setup_required` in the API response is surfaced so you can complete setup first. #### Method 2: Command Line @@ -159,13 +158,23 @@ The app shows a connection status indicator in the header: The connection is automatically checked every 30 seconds. +### Automated tests (renderer client) + +From the `desktop/` directory: + +```bash +npm test +``` + +Runs Node’s test runner on `test/api-client.test.js` (URL normalization, TimeTracker JSON shape checks, and error classification). + ### Troubleshooting -**"Invalid API token" error:** -- Verify the token starts with `tt_` -- Check that the token hasn't expired -- Ensure the token has the required scopes -- Try creating a new token in the web app +**Login or “Test server” shows a TLS or certificate message:** +- Use a certificate trusted by the OS, or for lab use only, try `http://` on a trusted network if your server supports it. + +**Token rejected after “server OK”:** +- Verify the token starts with `tt_`, is not expired, and includes at least `read:time_entries` (and ideally `read:users`). **"Connection failed" error:** - Verify the server URL is correct and accessible diff --git a/desktop/package.json b/desktop/package.json index 5c6152f0..1ef59799 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -4,6 +4,9 @@ "description": "TimeTracker desktop app for Windows, Linux, and macOS", "main": "src/main/main.js", "scripts": { + "prestart": "npm run build:renderer", + "prebuild": "npm run build:renderer", + "test": "node --test test/", "start": "npm run build:renderer && electron .", "dev": "electron . --dev", "build:renderer": "esbuild src/renderer/js/app.js --bundle --outfile=src/renderer/js/bundle.js --platform=browser --format=iife", diff --git a/desktop/src/renderer/css/styles.css b/desktop/src/renderer/css/styles.css index a1a140eb..345124b8 100644 --- a/desktop/src/renderer/css/styles.css +++ b/desktop/src/renderer/css/styles.css @@ -146,6 +146,25 @@ button, input, select, textarea { color: var(--text-secondary); } +.wizard-step-label { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 16px; +} + +.wizard-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; +} + +.wizard-actions .btn { + flex: 1; + min-width: 120px; +} + .form-group { margin-bottom: 20px; } diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html index a53bc180..e0f15322 100644 --- a/desktop/src/renderer/index.html +++ b/desktop/src/renderer/index.html @@ -23,26 +23,40 @@ - +
diff --git a/desktop/src/renderer/js/api/client.js b/desktop/src/renderer/js/api/client.js index f07c27ea..a63cae23 100644 --- a/desktop/src/renderer/js/api/client.js +++ b/desktop/src/renderer/js/api/client.js @@ -4,40 +4,143 @@ const cfg = (typeof window !== 'undefined' && window.config) ? window.config : ( const storeGet = cfg.storeGet || (async (k) => null); const storeSet = cfg.storeSet || (async (k, v) => {}); +/** @typedef {{ ok: true }} OkResult */ +/** @typedef {{ ok: false, code: string, message: string }} ErrResult */ +/** @typedef {OkResult | ErrResult} ValidationResult */ + +function isTlsRelatedError(error) { + const code = error && error.code; + const msg = (error && error.message) || ''; + const tlsCodes = new Set([ + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'CERT_NOT_YET_VALID', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + ]); + if (code && tlsCodes.has(code)) return true; + if (/certificate|ssl|tls|UNABLE_TO_VERIFY/i.test(msg)) return true; + return false; +} + +/** + * Map axios/network errors to a stable code + user-facing message. + * @param {import('axios').AxiosError} error + * @returns {{ code: string, message: string }} + */ +function classifyAxiosError(error) { + if (isTlsRelatedError(error)) { + return { + code: 'TLS', + message: + 'SSL/TLS certificate could not be verified. If the server uses a self-signed certificate, install a trusted CA or use http:// only on trusted networks.', + }; + } + if (error.response) { + const status = error.response.status; + const data = error.response.data; + if (status === 401) { + return { + code: 'UNAUTHORIZED', + message: 'Authentication failed. Check your API token.', + }; + } + if (status === 403) { + return { + code: 'FORBIDDEN', + message: 'Access denied. Your token may not have the required permissions (e.g. read:users).', + }; + } + if (status === 404) { + return { + code: 'NOT_FOUND', + message: data?.error || 'Resource not found. Is the base URL correct (no extra path)?', + }; + } + if (status >= 500) { + return { code: 'SERVER_ERROR', message: 'Server error. Please try again later.' }; + } + if (data && typeof data === 'object' && data.error) { + return { code: 'HTTP_' + status, message: String(data.error) }; + } + return { code: 'HTTP_' + status, message: `Server returned HTTP ${status}.` }; + } + if (error.code === 'ECONNABORTED') { + return { + code: 'TIMEOUT', + message: 'Request timed out. Check the server URL, firewall, and network.', + }; + } + if (error.code === 'ENOTFOUND') { + return { + code: 'DNS', + message: 'Host not found (DNS). Check the hostname in your server URL.', + }; + } + if (error.code === 'ECONNREFUSED') { + return { + code: 'REFUSED', + message: 'Connection refused. Check the host, port, and that the TimeTracker server is running.', + }; + } + if (error.code === 'ENETUNREACH' || error.code === 'EHOSTUNREACH') { + return { + code: 'UNREACHABLE', + message: 'Network unreachable. Check your connection and server address.', + }; + } + const msg = error.message || 'Unknown error'; + return { code: 'UNKNOWN', message: msg }; +} + +/** + * @param {unknown} data + * @returns {boolean} + */ +function isTimeTrackerInfoPayload(data) { + return ( + data !== null && + typeof data === 'object' && + !Array.isArray(data) && + data.api_version === 'v1' && + typeof data.endpoints === 'object' + ); +} + class ApiClient { constructor(baseUrl) { - this.baseUrl = baseUrl; + const normalized = ApiClient.normalizeBaseUrl(baseUrl); + this.baseUrl = normalized; this.client = axios.create({ - baseURL: baseUrl, + baseURL: normalized, timeout: 10000, headers: { 'Content-Type': 'application/json', - 'Accept': 'application/json', + Accept: 'application/json', }, }); - + this.setupInterceptors(); } - + setupInterceptors() { - // Add auth token to requests this.client.interceptors.request.use(async (config) => { const token = await storeGet('api_token'); if (token) { - config.headers['Authorization'] = `Bearer ${token}`; + config.headers.Authorization = `Bearer ${token}`; } return config; }); - - // Handle errors + this.client.interceptors.response.use( (response) => response, (error) => { - // Enhance error messages if (error.response) { const status = error.response.status; const data = error.response.data; - + if (status === 401) { error.message = 'Authentication failed. Please check your API token.'; } else if (status === 403) { @@ -52,37 +155,155 @@ class ApiClient { } else if (error.code === 'ECONNABORTED') { error.message = 'Request timeout. Please check your internet connection.'; } else if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') { - error.message = 'Unable to connect to server. Please check the server URL and your internet connection.'; + error.message = + 'Unable to connect to server. Please check the server URL and your internet connection.'; + } else if (isTlsRelatedError(error)) { + error.message = + 'SSL/TLS error: certificate could not be verified. Use a trusted certificate or verify the server URL.'; } - + return Promise.reject(error); } ); } - + + static normalizeBaseUrl(url) { + let u = String(url || '').trim(); + if (!u) return u; + u = u.replace(/\/+$/, ''); + return u; + } + + /** + * Unauthenticated check: reachable TimeTracker JSON at GET /api/v1/info. + * @param {string} baseUrl + * @returns {Promise} + */ + static async testPublicServerInfo(baseUrl) { + const normalized = ApiClient.normalizeBaseUrl(baseUrl); + if (!normalized) { + return { ok: false, code: 'NO_URL', message: 'Please enter a server URL.' }; + } + let parsed; + try { + parsed = new URL(normalized); + } catch (_) { + return { ok: false, code: 'BAD_URL', message: 'Server URL is not valid.' }; + } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return { ok: false, code: 'BAD_URL', message: 'Server URL must start with http:// or https://.' }; + } + + const plain = axios.create({ + baseURL: normalized, + timeout: 10000, + headers: { Accept: 'application/json' }, + }); + + try { + const response = await plain.get('/api/v1/info'); + if (response.status !== 200) { + return { + ok: false, + code: 'HTTP_' + response.status, + message: `Server returned HTTP ${response.status}. Check the URL and port.`, + }; + } + const data = response.data; + if (!isTimeTrackerInfoPayload(data)) { + return { + ok: false, + code: 'NOT_TIMETRACKER', + message: + 'This address did not return a TimeTracker API response. Check the URL (base URL only, no path) and port.', + }; + } + if (data.setup_required === true) { + return { + ok: false, + code: 'SETUP_REQUIRED', + message: + 'TimeTracker is not fully set up yet. Open this server URL in a browser, complete initial setup, then try again.', + }; + } + return { ok: true }; + } catch (error) { + const { code, message } = classifyAxiosError(error); + return { ok: false, code, message }; + } + } + async setAuthToken(token) { await storeSet('api_token', token); } - - async validateToken() { + + /** + * Authenticated session check: prefers GET /api/v1/users/me (read:users). + * Falls back to GET /api/v1/timer/status (read:time_entries) for narrower tokens. + * @returns {Promise} + */ + async validateSession() { try { - const response = await this.client.get('/api/v1/info'); - return response.status === 200; + const response = await this.client.get('/api/v1/users/me'); + if (response.status !== 200) { + return { + ok: false, + code: 'HTTP_' + response.status, + message: `Unexpected HTTP ${response.status} from the server.`, + }; + } + const data = response.data; + if (!data || typeof data !== 'object' || !data.user) { + return { + ok: false, + code: 'INVALID_RESPONSE', + message: 'Server response was not a valid TimeTracker user payload.', + }; + } + return { ok: true }; } catch (error) { - return false; + const status = error.response && error.response.status; + if (status === 401) { + const { code, message } = classifyAxiosError(error); + return { ok: false, code, message }; + } + if (status === 403) { + try { + const res2 = await this.client.get('/api/v1/timer/status'); + if (res2.status === 200 && res2.data && typeof res2.data.active === 'boolean') { + return { ok: true }; + } + } catch (e2) { + const { code, message } = classifyAxiosError(e2); + return { ok: false, code, message }; + } + return { + ok: false, + code: 'FORBIDDEN', + message: + 'This API token cannot access your profile or timer. Use a token with read:users or read:time_entries.', + }; + } + const { code, message } = classifyAxiosError(error); + return { ok: false, code, message }; } } + /** @deprecated Prefer validateSession() for correct auth + error detail */ + async validateToken() { + const r = await this.validateSession(); + return r.ok; + } + async getUsersMe() { const response = await this.client.get('/api/v1/users/me'); return response.data; } - - // Timer endpoints + async getTimerStatus() { return await this.client.get('/api/v1/timer/status'); } - + async startTimer({ projectId, taskId, notes }) { return await this.client.post('/api/v1/timer/start', { project_id: projectId, @@ -90,12 +311,11 @@ class ApiClient { notes: notes, }); } - + async stopTimer() { return await this.client.post('/api/v1/timer/stop'); } - - // Time entries endpoints + async getTimeEntries({ projectId, startDate, endDate, billable, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; @@ -104,33 +324,32 @@ class ApiClient { if (billable !== undefined) params.billable = billable; if (page) params.page = page; if (perPage) params.per_page = perPage; - + return await this.client.get('/api/v1/time-entries', { params }); } - + async createTimeEntry(data) { return await this.client.post('/api/v1/time-entries', data); } - + async updateTimeEntry(id, data) { return await this.client.put(`/api/v1/time-entries/${id}`, data); } - + async deleteTimeEntry(id) { return await this.client.delete(`/api/v1/time-entries/${id}`); } - - // Projects endpoints + async getProjects({ status, clientId, page, perPage }) { const params = {}; if (status) params.status = status; if (clientId) params.client_id = clientId; if (page) params.page = page; if (perPage) params.per_page = perPage; - + return await this.client.get('/api/v1/projects', { params }); } - + async getProject(id) { return await this.client.get(`/api/v1/projects/${id}`); } @@ -142,28 +361,25 @@ class ApiClient { if (perPage) params.per_page = perPage; return await this.client.get('/api/v1/clients', { params }); } - - // Tasks endpoints + async getTasks({ projectId, status, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; if (status) params.status = status; if (page) params.page = page; if (perPage) params.per_page = perPage; - + return await this.client.get('/api/v1/tasks', { params }); } - + async getTask(id) { return await this.client.get(`/api/v1/tasks/${id}`); } - - // Get time entry by ID + async getTimeEntry(id) { return await this.client.get(`/api/v1/time-entries/${id}`); } - // Invoices endpoints async getInvoices({ status, clientId, projectId, page, perPage }) { const params = {}; if (status) params.status = status; @@ -187,7 +403,6 @@ class ApiClient { return await this.client.put(`/api/v1/invoices/${id}`, data); } - // Expenses endpoints async getExpenses({ projectId, category, startDate, endDate, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; @@ -285,7 +500,8 @@ class ApiClient { } } -// Export for use in other files if (typeof module !== 'undefined' && module.exports) { module.exports = ApiClient; + module.exports.classifyAxiosError = classifyAxiosError; + module.exports.isTimeTrackerInfoPayload = isTimeTrackerInfoPayload; } diff --git a/desktop/src/renderer/js/app.js b/desktop/src/renderer/js/app.js index 9a1000a0..371b803b 100644 --- a/desktop/src/renderer/js/app.js +++ b/desktop/src/renderer/js/app.js @@ -8,28 +8,32 @@ const state = require('./state'); // Initialize app async function initApp() { - // Check if already logged in - const serverUrl = await storeGet('server_url'); + const serverUrlRaw = await storeGet('server_url'); const apiToken = await storeGet('api_token'); - + const serverUrl = serverUrlRaw ? ApiClient.normalizeBaseUrl(String(serverUrlRaw)) : null; + if (serverUrl && serverUrl !== serverUrlRaw) { + await storeSet('server_url', serverUrl); + } + if (serverUrl && apiToken) { - // Initialize API client state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(apiToken); - - // Validate token - const isValid = await state.apiClient.validateToken(); - if (isValid) { + + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; await loadCurrentUserProfile(); + updateConnectionStatus('connected'); showMainScreen(); loadDashboard(); } else { - showLoginScreen(); + state.apiClient = null; + showLoginScreen({ prefillServerUrl: serverUrl, sessionError: session }); } } else { - showLoginScreen(); + showLoginScreen({ prefillServerUrl: serverUrl || '' }); } - + setupEventListeners(); startConnectionCheck(); setupTrayListeners(); @@ -65,12 +69,22 @@ async function checkConnection() { updateConnectionStatus('disconnected'); return; } - - try { - const isValid = await state.apiClient.validateToken(); - updateConnectionStatus(isValid ? 'connected' : 'error'); - } catch (error) { - updateConnectionStatus('error'); + + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; + updateConnectionStatus('connected'); + return; + } + + updateConnectionStatus('error'); + if (session.code === 'UNAUTHORIZED') { + state.authFailureStreak = (state.authFailureStreak || 0) + 1; + if (state.authFailureStreak >= 2 && document.getElementById('main-screen')?.classList.contains('active')) { + await forceRelogin(session.message || 'Your session is no longer valid. Please sign in again.'); + } + } else { + state.authFailureStreak = 0; } } @@ -86,8 +100,10 @@ async function loadCurrentUserProfile() { is_admin: Boolean(user.is_admin), can_approve: Boolean(user.is_admin) || roleCanApprove, }; - } catch (_) { + } catch (err) { + console.warn('loadCurrentUserProfile failed:', err && err.message ? err.message : err); state.currentUserProfile = { id: null, is_admin: false, can_approve: false }; + showError('Could not load your user profile. Some actions may be unavailable until the connection improves.'); } } @@ -119,12 +135,59 @@ function updateConnectionStatus(status) { statusEl.setAttribute('aria-label', label); } +async function forceRelogin(message) { + state.authFailureStreak = 0; + const url = await storeGet('server_url'); + await storeDelete('api_token'); + state.apiClient = null; + if (state.isTimerRunning) { + state.isTimerRunning = false; + stopTimerPolling(); + } + showLoginScreen({ + prefillServerUrl: url || '', + openTokenStep: true, + bannerMessage: message, + }); +} + +function showWizardServerStep() { + const s1 = document.getElementById('wizard-step-server'); + const s2 = document.getElementById('wizard-step-token'); + if (s1) s1.style.display = ''; + if (s2) s2.style.display = 'none'; +} + +function showWizardTokenStep() { + const s1 = document.getElementById('wizard-step-server'); + const s2 = document.getElementById('wizard-step-token'); + if (s1) s1.style.display = 'none'; + if (s2) s2.style.display = ''; +} + +function resetLoginWizard() { + showWizardServerStep(); + const cont = document.getElementById('login-wizard-continue'); + if (cont) cont.disabled = true; + clearLoginError(); +} + +function clearLoginError() { + showLoginError(''); +} + function setupEventListeners() { // Login form const loginForm = document.getElementById('login-form'); if (loginForm) { loginForm.addEventListener('submit', handleLogin); } + const loginTestServerBtn = document.getElementById('login-test-server-btn'); + const loginWizardContinue = document.getElementById('login-wizard-continue'); + const loginWizardBack = document.getElementById('login-wizard-back'); + if (loginTestServerBtn) loginTestServerBtn.addEventListener('click', handleLoginTestServer); + if (loginWizardContinue) loginWizardContinue.addEventListener('click', handleLoginWizardContinue); + if (loginWizardBack) loginWizardBack.addEventListener('click', handleLoginWizardBack); // Navigation document.querySelectorAll('.nav-btn').forEach(btn => { @@ -213,58 +276,148 @@ function setupEventListeners() { if (expenseNextPageBtn) expenseNextPageBtn.addEventListener('click', () => changeExpensePage(1)); } +async function handleLoginTestServer() { + clearLoginError(); + const raw = document.getElementById('server-url')?.value.trim() || ''; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { + showLoginError('Enter a valid server URL (e.g. https://your-server.com or http://192.168.1.10:5000)'); + return; + } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + return; + } + showSuccess('TimeTracker server detected. Continue to enter your API token.'); + const cont = document.getElementById('login-wizard-continue'); + if (cont) cont.disabled = false; +} + +async function handleLoginWizardContinue() { + clearLoginError(); + const raw = document.getElementById('server-url')?.value.trim() || ''; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { + showLoginError('Enter a valid server URL'); + return; + } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + return; + } + showWizardTokenStep(); +} + +function handleLoginWizardBack() { + clearLoginError(); + showWizardServerStep(); +} + async function handleLogin(e) { e.preventDefault(); - - const serverUrl = document.getElementById('server-url').value.trim(); - const apiToken = document.getElementById('api-token').value.trim(); - const errorDiv = document.getElementById('login-error'); - - // Validate - if (!serverUrl || !isValidUrl(serverUrl)) { + + const raw = document.getElementById('server-url')?.value.trim() || ''; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { showLoginError('Please enter a valid server URL'); return; } - + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + + const apiToken = document.getElementById('api-token')?.value.trim() || ''; if (!apiToken || !apiToken.startsWith('tt_')) { - showError('Please enter a valid API token (must start with tt_)'); + showLoginError('Please enter a valid API token (must start with tt_)'); return; } - - // Store credentials + + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + showWizardServerStep(); + return; + } + await storeSet('server_url', serverUrl); await storeSet('api_token', apiToken); - - // Initialize API client + state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(apiToken); - - // Validate token - const isValid = await state.apiClient.validateToken(); - if (isValid) { - await loadCurrentUserProfile(); - updateConnectionStatus('connected'); - showMainScreen(); - loadDashboard(); + + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; + await loadCurrentUserProfile(); + updateConnectionStatus('connected'); + showMainScreen(); + loadDashboard(); + } else { + updateConnectionStatus('error'); + await storeDelete('api_token'); + state.apiClient = null; + showLoginError(session.message || 'Login failed'); + if (session.code === 'UNAUTHORIZED' || session.code === 'FORBIDDEN') { + const cont = document.getElementById('login-wizard-continue'); + if (cont) cont.disabled = false; + showWizardTokenStep(); } else { - updateConnectionStatus('error'); - showLoginError('Invalid API token. Please check your token.'); - await storeDelete('api_token'); + showWizardServerStep(); } + } } function showLoginError(message) { const errorDiv = document.getElementById('login-error'); - if (errorDiv) { - errorDiv.textContent = message; + if (!errorDiv) return; + errorDiv.textContent = message || ''; + if (message) { errorDiv.classList.add('show'); + } else { + errorDiv.classList.remove('show'); } } -function showLoginScreen() { +function showLoginScreen(options = {}) { document.getElementById('loading-screen').classList.remove('active'); document.getElementById('login-screen').classList.add('active'); document.getElementById('main-screen').classList.remove('active'); + state.authFailureStreak = 0; + + const su = document.getElementById('server-url'); + if (su && options.prefillServerUrl !== undefined && options.prefillServerUrl !== null) { + su.value = String(options.prefillServerUrl || ''); + } + + if (options.openTokenStep) { + const cont = document.getElementById('login-wizard-continue'); + if (cont) cont.disabled = false; + showWizardTokenStep(); + if (options.bannerMessage) { + showLoginError(options.bannerMessage); + } else { + clearLoginError(); + } + return; + } + + if (options.sessionError) { + const se = options.sessionError; + if (se.code === 'UNAUTHORIZED' || se.code === 'FORBIDDEN') { + const cont = document.getElementById('login-wizard-continue'); + if (cont) cont.disabled = false; + showWizardTokenStep(); + showLoginError(se.message || 'Authentication failed'); + return; + } + resetLoginWizard(); + showLoginError(se.message || 'Could not reach the server'); + return; + } + + resetLoginWizard(); } function showMainScreen() { @@ -654,7 +807,15 @@ function startTimerPolling() { stopTimerPolling(); } } catch (error) { - console.error('Error polling timer:', error); + console.warn('Error polling timer:', error && error.message ? error.message : error); + updateConnectionStatus('error'); + const now = Date.now(); + if (!state.lastTimerPollUserMessageAt || now - state.lastTimerPollUserMessageAt > 60000) { + state.lastTimerPollUserMessageAt = now; + showError( + 'Lost connection while syncing the active timer. Check the connection indicator; polling will retry.', + ); + } } }, 5000); // Poll every 5 seconds } @@ -1393,7 +1554,7 @@ async function loadSettings() { const syncIntervalInput = document.getElementById('sync-interval'); if (serverUrlInput) { - serverUrlInput.value = serverUrl; + serverUrlInput.value = serverUrl ? ApiClient.normalizeBaseUrl(String(serverUrl)) : ''; } if (apiTokenInput) { // Only show if token exists, otherwise leave empty @@ -1425,16 +1586,17 @@ async function handleSaveSettings() { if (!serverUrlInput || !apiTokenInput || !autoSyncInput || !syncIntervalInput) return; - const serverUrl = serverUrlInput.value.trim(); + const rawServer = serverUrlInput.value.trim(); + const normalizedInput = normalizeServerUrlInput(rawServer); const apiToken = apiTokenInput.value.trim(); const autoSync = autoSyncInput.checked; const syncInterval = parseInt(syncIntervalInput.value, 10); - - // Validate server URL - if (!serverUrl || !isValidUrl(serverUrl)) { + + if (!normalizedInput || !isValidUrl(normalizedInput)) { showSettingsMessage('Please enter a valid server URL', 'error'); return; } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); // Check if API token was changed (if it's not the masked value) const hasExistingToken = apiTokenInput.dataset.hasToken === 'true'; @@ -1459,23 +1621,29 @@ async function handleSaveSettings() { await storeSet('api_token', finalApiToken); await storeSet('auto_sync', autoSync); await storeSet('sync_interval', syncInterval); - - // Reinitialize API client with new settings + + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + updateConnectionStatus('error'); + showSettingsMessage(pub.message, 'error'); + return; + } + state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(finalApiToken); - - // Validate connection - const isValid = await state.apiClient.validateToken(); - if (isValid) { + + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; await loadCurrentUserProfile(); updateConnectionStatus('connected'); showSettingsMessage('Settings saved successfully!', 'success'); - // Update token input to show masked value apiTokenInput.value = '••••••••'; apiTokenInput.dataset.hasToken = 'true'; + serverUrlInput.value = serverUrl; } else { updateConnectionStatus('error'); - showSettingsMessage('Settings saved, but connection test failed. Please check your API token.', 'warning'); + showSettingsMessage(session.message || 'Session check failed after save.', 'warning'); } } catch (error) { console.error('Error saving settings:', error); @@ -1490,39 +1658,45 @@ async function handleTestConnection() { if (!serverUrlInput || !apiTokenInput) return; - const serverUrl = serverUrlInput.value.trim(); + const rawServer = serverUrlInput.value.trim(); + const normalizedInput = normalizeServerUrlInput(rawServer); let apiToken = apiTokenInput.value.trim(); - - // Validate server URL - if (!serverUrl || !isValidUrl(serverUrl)) { + + if (!normalizedInput || !isValidUrl(normalizedInput)) { showSettingsMessage('Please enter a valid server URL', 'error'); return; } - - // Get actual token if masked + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const hasExistingToken = apiTokenInput.dataset.hasToken === 'true'; if (hasExistingToken && apiToken === '••••••••') { apiToken = await storeGet('api_token'); } - + if (!apiToken || !apiToken.startsWith('tt_')) { showSettingsMessage('Please enter a valid API token (must start with tt_)', 'error'); return; } - - // Test connection + try { showSettingsMessage('Testing connection...', 'info'); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + updateConnectionStatus('error'); + showSettingsMessage(pub.message, 'error'); + return; + } + const testClient = new ApiClient(serverUrl); await testClient.setAuthToken(apiToken); - const isValid = await testClient.validateToken(); - - if (isValid) { + const session = await testClient.validateSession(); + + if (session.ok) { updateConnectionStatus('connected'); - showSettingsMessage('Connection successful!', 'success'); + showSettingsMessage('Connection successful: server and API token are valid.', 'success'); } else { updateConnectionStatus('error'); - showSettingsMessage('Connection failed. Please check your server URL and API token.', 'error'); + showSettingsMessage(session.message || 'Token validation failed.', 'error'); } } catch (error) { console.error('Error testing connection:', error); @@ -1564,7 +1738,13 @@ if (document.readyState === 'loading') { } // Use helper functions from helpers.js -const { formatDuration, formatDurationLong, formatDateTime, isValidUrl } = window.Helpers || {}; +const { + formatDuration, + formatDurationLong, + formatDateTime, + isValidUrl, + normalizeServerUrlInput, +} = window.Helpers || {}; // Filter functions function toggleFilters() { diff --git a/desktop/src/renderer/js/bundle.js b/desktop/src/renderer/js/bundle.js index 7994da7b..5c65e30f 100644 --- a/desktop/src/renderer/js/bundle.js +++ b/desktop/src/renderer/js/bundle.js @@ -64,6 +64,12 @@ return false; } } + function normalizeServerUrlInput2(input) { + const trimmed = String(input || "").trim(); + if (!trimmed) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return "https://" + trimmed; + } function debounce(func, wait) { let timeout; return function executedFunction(...args) { @@ -83,6 +89,7 @@ formatDateTime: formatDateTime2, parseISODate, isValidUrl: isValidUrl2, + normalizeServerUrlInput: normalizeServerUrlInput2, debounce }; } @@ -94,6 +101,7 @@ formatDateTime: formatDateTime2, parseISODate, isValidUrl: isValidUrl2, + normalizeServerUrlInput: normalizeServerUrlInput2, debounce }; } @@ -2639,15 +2647,98 @@ var storeGet2 = cfg.storeGet || (async (k) => null); var storeSet2 = cfg.storeSet || (async (k, v) => { }); - var ApiClient2 = class { + function isTlsRelatedError(error) { + const code = error && error.code; + const msg = error && error.message || ""; + const tlsCodes = /* @__PURE__ */ new Set([ + "DEPTH_ZERO_SELF_SIGNED_CERT", + "CERT_HAS_EXPIRED", + "CERT_NOT_YET_VALID", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "ERR_TLS_CERT_ALTNAME_INVALID", + "SELF_SIGNED_CERT_IN_CHAIN", + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" + ]); + if (code && tlsCodes.has(code)) return true; + if (/certificate|ssl|tls|UNABLE_TO_VERIFY/i.test(msg)) return true; + return false; + } + function classifyAxiosError(error) { + if (isTlsRelatedError(error)) { + return { + code: "TLS", + message: "SSL/TLS certificate could not be verified. If the server uses a self-signed certificate, install a trusted CA or use http:// only on trusted networks." + }; + } + if (error.response) { + const status = error.response.status; + const data = error.response.data; + if (status === 401) { + return { + code: "UNAUTHORIZED", + message: "Authentication failed. Check your API token." + }; + } + if (status === 403) { + return { + code: "FORBIDDEN", + message: "Access denied. Your token may not have the required permissions (e.g. read:users)." + }; + } + if (status === 404) { + return { + code: "NOT_FOUND", + message: data?.error || "Resource not found. Is the base URL correct (no extra path)?" + }; + } + if (status >= 500) { + return { code: "SERVER_ERROR", message: "Server error. Please try again later." }; + } + if (data && typeof data === "object" && data.error) { + return { code: "HTTP_" + status, message: String(data.error) }; + } + return { code: "HTTP_" + status, message: `Server returned HTTP ${status}.` }; + } + if (error.code === "ECONNABORTED") { + return { + code: "TIMEOUT", + message: "Request timed out. Check the server URL, firewall, and network." + }; + } + if (error.code === "ENOTFOUND") { + return { + code: "DNS", + message: "Host not found (DNS). Check the hostname in your server URL." + }; + } + if (error.code === "ECONNREFUSED") { + return { + code: "REFUSED", + message: "Connection refused. Check the host, port, and that the TimeTracker server is running." + }; + } + if (error.code === "ENETUNREACH" || error.code === "EHOSTUNREACH") { + return { + code: "UNREACHABLE", + message: "Network unreachable. Check your connection and server address." + }; + } + const msg = error.message || "Unknown error"; + return { code: "UNKNOWN", message: msg }; + } + function isTimeTrackerInfoPayload(data) { + return data !== null && typeof data === "object" && !Array.isArray(data) && data.api_version === "v1" && typeof data.endpoints === "object"; + } + var ApiClient2 = class _ApiClient { constructor(baseUrl) { - this.baseUrl = baseUrl; + const normalized = _ApiClient.normalizeBaseUrl(baseUrl); + this.baseUrl = normalized; this.client = axios.create({ - baseURL: baseUrl, + baseURL: normalized, timeout: 1e4, headers: { "Content-Type": "application/json", - "Accept": "application/json" + Accept: "application/json" } }); this.setupInterceptors(); @@ -2656,7 +2747,7 @@ this.client.interceptors.request.use(async (config) => { const token = await storeGet2("api_token"); if (token) { - config.headers["Authorization"] = `Bearer ${token}`; + config.headers.Authorization = `Bearer ${token}`; } return config; }); @@ -2681,27 +2772,135 @@ error.message = "Request timeout. Please check your internet connection."; } else if (error.code === "ENOTFOUND" || error.code === "ECONNREFUSED") { error.message = "Unable to connect to server. Please check the server URL and your internet connection."; + } else if (isTlsRelatedError(error)) { + error.message = "SSL/TLS error: certificate could not be verified. Use a trusted certificate or verify the server URL."; } return Promise.reject(error); } ); } + static normalizeBaseUrl(url) { + let u = String(url || "").trim(); + if (!u) return u; + u = u.replace(/\/+$/, ""); + return u; + } + /** + * Unauthenticated check: reachable TimeTracker JSON at GET /api/v1/info. + * @param {string} baseUrl + * @returns {Promise} + */ + static async testPublicServerInfo(baseUrl) { + const normalized = _ApiClient.normalizeBaseUrl(baseUrl); + if (!normalized) { + return { ok: false, code: "NO_URL", message: "Please enter a server URL." }; + } + let parsed; + try { + parsed = new URL(normalized); + } catch (_) { + return { ok: false, code: "BAD_URL", message: "Server URL is not valid." }; + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return { ok: false, code: "BAD_URL", message: "Server URL must start with http:// or https://." }; + } + const plain = axios.create({ + baseURL: normalized, + timeout: 1e4, + headers: { Accept: "application/json" } + }); + try { + const response = await plain.get("/api/v1/info"); + if (response.status !== 200) { + return { + ok: false, + code: "HTTP_" + response.status, + message: `Server returned HTTP ${response.status}. Check the URL and port.` + }; + } + const data = response.data; + if (!isTimeTrackerInfoPayload(data)) { + return { + ok: false, + code: "NOT_TIMETRACKER", + message: "This address did not return a TimeTracker API response. Check the URL (base URL only, no path) and port." + }; + } + if (data.setup_required === true) { + return { + ok: false, + code: "SETUP_REQUIRED", + message: "TimeTracker is not fully set up yet. Open this server URL in a browser, complete initial setup, then try again." + }; + } + return { ok: true }; + } catch (error) { + const { code, message } = classifyAxiosError(error); + return { ok: false, code, message }; + } + } async setAuthToken(token) { await storeSet2("api_token", token); } - async validateToken() { + /** + * Authenticated session check: prefers GET /api/v1/users/me (read:users). + * Falls back to GET /api/v1/timer/status (read:time_entries) for narrower tokens. + * @returns {Promise} + */ + async validateSession() { try { - const response = await this.client.get("/api/v1/info"); - return response.status === 200; + const response = await this.client.get("/api/v1/users/me"); + if (response.status !== 200) { + return { + ok: false, + code: "HTTP_" + response.status, + message: `Unexpected HTTP ${response.status} from the server.` + }; + } + const data = response.data; + if (!data || typeof data !== "object" || !data.user) { + return { + ok: false, + code: "INVALID_RESPONSE", + message: "Server response was not a valid TimeTracker user payload." + }; + } + return { ok: true }; } catch (error) { - return false; + const status = error.response && error.response.status; + if (status === 401) { + const { code: code2, message: message2 } = classifyAxiosError(error); + return { ok: false, code: code2, message: message2 }; + } + if (status === 403) { + try { + const res2 = await this.client.get("/api/v1/timer/status"); + if (res2.status === 200 && res2.data && typeof res2.data.active === "boolean") { + return { ok: true }; + } + } catch (e2) { + const { code: code2, message: message2 } = classifyAxiosError(e2); + return { ok: false, code: code2, message: message2 }; + } + return { + ok: false, + code: "FORBIDDEN", + message: "This API token cannot access your profile or timer. Use a token with read:users or read:time_entries." + }; + } + const { code, message } = classifyAxiosError(error); + return { ok: false, code, message }; } } + /** @deprecated Prefer validateSession() for correct auth + error detail */ + async validateToken() { + const r = await this.validateSession(); + return r.ok; + } async getUsersMe() { const response = await this.client.get("/api/v1/users/me"); return response.data; } - // Timer endpoints async getTimerStatus() { return await this.client.get("/api/v1/timer/status"); } @@ -2715,7 +2914,6 @@ async stopTimer() { return await this.client.post("/api/v1/timer/stop"); } - // Time entries endpoints async getTimeEntries({ projectId, startDate, endDate, billable, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; @@ -2735,7 +2933,6 @@ async deleteTimeEntry(id) { return await this.client.delete(`/api/v1/time-entries/${id}`); } - // Projects endpoints async getProjects({ status, clientId, page, perPage }) { const params = {}; if (status) params.status = status; @@ -2754,7 +2951,6 @@ if (perPage) params.per_page = perPage; return await this.client.get("/api/v1/clients", { params }); } - // Tasks endpoints async getTasks({ projectId, status, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; @@ -2766,11 +2962,9 @@ async getTask(id) { return await this.client.get(`/api/v1/tasks/${id}`); } - // Get time entry by ID async getTimeEntry(id) { return await this.client.get(`/api/v1/time-entries/${id}`); } - // Invoices endpoints async getInvoices({ status, clientId, projectId, page, perPage }) { const params = {}; if (status) params.status = status; @@ -2789,7 +2983,6 @@ async updateInvoice(id, data) { return await this.client.put(`/api/v1/invoices/${id}`, data); } - // Expenses endpoints async getExpenses({ projectId, category, startDate, endDate, page, perPage }) { const params = {}; if (projectId) params.project_id = projectId; @@ -2873,6 +3066,8 @@ }; if (typeof module !== "undefined" && module.exports) { module.exports = ApiClient2; + module.exports.classifyAxiosError = classifyAxiosError; + module.exports.isTimeTrackerInfoPayload = isTimeTrackerInfoPayload; } } }); @@ -7794,6 +7989,10 @@ "src/renderer/js/state.js"(exports, module) { module.exports = { apiClient: null, + /** Count consecutive background checks that failed with auth (401) while on main UI */ + authFailureStreak: 0, + /** Last timer poll error shown to user (avoid spam) */ + lastTimerPollUserMessageAt: 0, currentView: "dashboard", timerInterval: null, isTimerRunning: false, @@ -7820,21 +8019,28 @@ var { showError, showSuccess } = require_notifications(); var state = require_state(); async function initApp() { - const serverUrl = await storeGet("server_url"); + const serverUrlRaw = await storeGet("server_url"); const apiToken = await storeGet("api_token"); + const serverUrl = serverUrlRaw ? ApiClient.normalizeBaseUrl(String(serverUrlRaw)) : null; + if (serverUrl && serverUrl !== serverUrlRaw) { + await storeSet("server_url", serverUrl); + } if (serverUrl && apiToken) { state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(apiToken); - const isValid = await state.apiClient.validateToken(); - if (isValid) { + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; await loadCurrentUserProfile(); + updateConnectionStatus("connected"); showMainScreen(); loadDashboard(); } else { - showLoginScreen(); + state.apiClient = null; + showLoginScreen({ prefillServerUrl: serverUrl, sessionError: session }); } } else { - showLoginScreen(); + showLoginScreen({ prefillServerUrl: serverUrl || "" }); } setupEventListeners(); startConnectionCheck(); @@ -7862,11 +8068,20 @@ updateConnectionStatus("disconnected"); return; } - try { - const isValid = await state.apiClient.validateToken(); - updateConnectionStatus(isValid ? "connected" : "error"); - } catch (error) { - updateConnectionStatus("error"); + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; + updateConnectionStatus("connected"); + return; + } + updateConnectionStatus("error"); + if (session.code === "UNAUTHORIZED") { + state.authFailureStreak = (state.authFailureStreak || 0) + 1; + if (state.authFailureStreak >= 2 && document.getElementById("main-screen")?.classList.contains("active")) { + await forceRelogin(session.message || "Your session is no longer valid. Please sign in again."); + } + } else { + state.authFailureStreak = 0; } } async function loadCurrentUserProfile() { @@ -7881,8 +8096,10 @@ is_admin: Boolean(user.is_admin), can_approve: Boolean(user.is_admin) || roleCanApprove }; - } catch (_) { + } catch (err) { + console.warn("loadCurrentUserProfile failed:", err && err.message ? err.message : err); state.currentUserProfile = { id: null, is_admin: false, can_approve: false }; + showError("Could not load your user profile. Some actions may be unavailable until the connection improves."); } } function updateConnectionStatus(status) { @@ -7911,11 +8128,53 @@ } statusEl.setAttribute("aria-label", label); } + async function forceRelogin(message) { + state.authFailureStreak = 0; + const url = await storeGet("server_url"); + await storeDelete("api_token"); + state.apiClient = null; + if (state.isTimerRunning) { + state.isTimerRunning = false; + stopTimerPolling(); + } + showLoginScreen({ + prefillServerUrl: url || "", + openTokenStep: true, + bannerMessage: message + }); + } + function showWizardServerStep() { + const s1 = document.getElementById("wizard-step-server"); + const s2 = document.getElementById("wizard-step-token"); + if (s1) s1.style.display = ""; + if (s2) s2.style.display = "none"; + } + function showWizardTokenStep() { + const s1 = document.getElementById("wizard-step-server"); + const s2 = document.getElementById("wizard-step-token"); + if (s1) s1.style.display = "none"; + if (s2) s2.style.display = ""; + } + function resetLoginWizard() { + showWizardServerStep(); + const cont = document.getElementById("login-wizard-continue"); + if (cont) cont.disabled = true; + clearLoginError(); + } + function clearLoginError() { + showLoginError(""); + } function setupEventListeners() { const loginForm = document.getElementById("login-form"); if (loginForm) { loginForm.addEventListener("submit", handleLogin); } + const loginTestServerBtn = document.getElementById("login-test-server-btn"); + const loginWizardContinue = document.getElementById("login-wizard-continue"); + const loginWizardBack = document.getElementById("login-wizard-back"); + if (loginTestServerBtn) loginTestServerBtn.addEventListener("click", handleLoginTestServer); + if (loginWizardContinue) loginWizardContinue.addEventListener("click", handleLoginWizardContinue); + if (loginWizardBack) loginWizardBack.addEventListener("click", handleLoginWizardBack); document.querySelectorAll(".nav-btn").forEach((btn) => { btn.addEventListener("click", (e) => { const view = e.target.dataset.view; @@ -7988,46 +8247,133 @@ if (expensePrevPageBtn) expensePrevPageBtn.addEventListener("click", () => changeExpensePage(-1)); if (expenseNextPageBtn) expenseNextPageBtn.addEventListener("click", () => changeExpensePage(1)); } + async function handleLoginTestServer() { + clearLoginError(); + const raw = document.getElementById("server-url")?.value.trim() || ""; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { + showLoginError("Enter a valid server URL (e.g. https://your-server.com or http://192.168.1.10:5000)"); + return; + } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + return; + } + showSuccess("TimeTracker server detected. Continue to enter your API token."); + const cont = document.getElementById("login-wizard-continue"); + if (cont) cont.disabled = false; + } + async function handleLoginWizardContinue() { + clearLoginError(); + const raw = document.getElementById("server-url")?.value.trim() || ""; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { + showLoginError("Enter a valid server URL"); + return; + } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + return; + } + showWizardTokenStep(); + } + function handleLoginWizardBack() { + clearLoginError(); + showWizardServerStep(); + } async function handleLogin(e) { e.preventDefault(); - const serverUrl = document.getElementById("server-url").value.trim(); - const apiToken = document.getElementById("api-token").value.trim(); - const errorDiv = document.getElementById("login-error"); - if (!serverUrl || !isValidUrl(serverUrl)) { + const raw = document.getElementById("server-url")?.value.trim() || ""; + const normalizedInput = normalizeServerUrlInput(raw); + if (!normalizedInput || !isValidUrl(normalizedInput)) { showLoginError("Please enter a valid server URL"); return; } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); + const apiToken = document.getElementById("api-token")?.value.trim() || ""; if (!apiToken || !apiToken.startsWith("tt_")) { - showError("Please enter a valid API token (must start with tt_)"); + showLoginError("Please enter a valid API token (must start with tt_)"); + return; + } + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + showLoginError(pub.message); + showWizardServerStep(); return; } await storeSet("server_url", serverUrl); await storeSet("api_token", apiToken); state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(apiToken); - const isValid = await state.apiClient.validateToken(); - if (isValid) { + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; await loadCurrentUserProfile(); updateConnectionStatus("connected"); showMainScreen(); loadDashboard(); } else { updateConnectionStatus("error"); - showLoginError("Invalid API token. Please check your token."); await storeDelete("api_token"); + state.apiClient = null; + showLoginError(session.message || "Login failed"); + if (session.code === "UNAUTHORIZED" || session.code === "FORBIDDEN") { + const cont = document.getElementById("login-wizard-continue"); + if (cont) cont.disabled = false; + showWizardTokenStep(); + } else { + showWizardServerStep(); + } } } function showLoginError(message) { const errorDiv = document.getElementById("login-error"); - if (errorDiv) { - errorDiv.textContent = message; + if (!errorDiv) return; + errorDiv.textContent = message || ""; + if (message) { errorDiv.classList.add("show"); + } else { + errorDiv.classList.remove("show"); } } - function showLoginScreen() { + function showLoginScreen(options = {}) { document.getElementById("loading-screen").classList.remove("active"); document.getElementById("login-screen").classList.add("active"); document.getElementById("main-screen").classList.remove("active"); + state.authFailureStreak = 0; + const su = document.getElementById("server-url"); + if (su && options.prefillServerUrl !== void 0 && options.prefillServerUrl !== null) { + su.value = String(options.prefillServerUrl || ""); + } + if (options.openTokenStep) { + const cont = document.getElementById("login-wizard-continue"); + if (cont) cont.disabled = false; + showWizardTokenStep(); + if (options.bannerMessage) { + showLoginError(options.bannerMessage); + } else { + clearLoginError(); + } + return; + } + if (options.sessionError) { + const se = options.sessionError; + if (se.code === "UNAUTHORIZED" || se.code === "FORBIDDEN") { + const cont = document.getElementById("login-wizard-continue"); + if (cont) cont.disabled = false; + showWizardTokenStep(); + showLoginError(se.message || "Authentication failed"); + return; + } + resetLoginWizard(); + showLoginError(se.message || "Could not reach the server"); + return; + } + resetLoginWizard(); } function showMainScreen() { document.getElementById("loading-screen").classList.remove("active"); @@ -8330,7 +8676,15 @@ stopTimerPolling(); } } catch (error) { - console.error("Error polling timer:", error); + console.warn("Error polling timer:", error && error.message ? error.message : error); + updateConnectionStatus("error"); + const now = Date.now(); + if (!state.lastTimerPollUserMessageAt || now - state.lastTimerPollUserMessageAt > 6e4) { + state.lastTimerPollUserMessageAt = now; + showError( + "Lost connection while syncing the active timer. Check the connection indicator; polling will retry." + ); + } } }, 5e3); } @@ -8894,7 +9248,7 @@ const autoSyncInput = document.getElementById("auto-sync"); const syncIntervalInput = document.getElementById("sync-interval"); if (serverUrlInput) { - serverUrlInput.value = serverUrl; + serverUrlInput.value = serverUrl ? ApiClient.normalizeBaseUrl(String(serverUrl)) : ""; } if (apiTokenInput) { apiTokenInput.value = apiToken ? "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022" : ""; @@ -8921,14 +9275,16 @@ const syncIntervalInput = document.getElementById("sync-interval"); const messageDiv = document.getElementById("settings-message"); if (!serverUrlInput || !apiTokenInput || !autoSyncInput || !syncIntervalInput) return; - const serverUrl = serverUrlInput.value.trim(); + const rawServer = serverUrlInput.value.trim(); + const normalizedInput = normalizeServerUrlInput(rawServer); const apiToken = apiTokenInput.value.trim(); const autoSync = autoSyncInput.checked; const syncInterval = parseInt(syncIntervalInput.value, 10); - if (!serverUrl || !isValidUrl(serverUrl)) { + if (!normalizedInput || !isValidUrl(normalizedInput)) { showSettingsMessage("Please enter a valid server URL", "error"); return; } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); const hasExistingToken = apiTokenInput.dataset.hasToken === "true"; let finalApiToken = apiToken; if (hasExistingToken && apiToken === "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022") { @@ -8946,18 +9302,26 @@ await storeSet("api_token", finalApiToken); await storeSet("auto_sync", autoSync); await storeSet("sync_interval", syncInterval); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + updateConnectionStatus("error"); + showSettingsMessage(pub.message, "error"); + return; + } state.apiClient = new ApiClient(serverUrl); await state.apiClient.setAuthToken(finalApiToken); - const isValid = await state.apiClient.validateToken(); - if (isValid) { + const session = await state.apiClient.validateSession(); + if (session.ok) { + state.authFailureStreak = 0; await loadCurrentUserProfile(); updateConnectionStatus("connected"); showSettingsMessage("Settings saved successfully!", "success"); apiTokenInput.value = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"; apiTokenInput.dataset.hasToken = "true"; + serverUrlInput.value = serverUrl; } else { updateConnectionStatus("error"); - showSettingsMessage("Settings saved, but connection test failed. Please check your API token.", "warning"); + showSettingsMessage(session.message || "Session check failed after save.", "warning"); } } catch (error) { console.error("Error saving settings:", error); @@ -8969,12 +9333,14 @@ const apiTokenInput = document.getElementById("settings-api-token"); const messageDiv = document.getElementById("settings-message"); if (!serverUrlInput || !apiTokenInput) return; - const serverUrl = serverUrlInput.value.trim(); + const rawServer = serverUrlInput.value.trim(); + const normalizedInput = normalizeServerUrlInput(rawServer); let apiToken = apiTokenInput.value.trim(); - if (!serverUrl || !isValidUrl(serverUrl)) { + if (!normalizedInput || !isValidUrl(normalizedInput)) { showSettingsMessage("Please enter a valid server URL", "error"); return; } + const serverUrl = ApiClient.normalizeBaseUrl(normalizedInput); const hasExistingToken = apiTokenInput.dataset.hasToken === "true"; if (hasExistingToken && apiToken === "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022") { apiToken = await storeGet("api_token"); @@ -8985,15 +9351,21 @@ } try { showSettingsMessage("Testing connection...", "info"); + const pub = await ApiClient.testPublicServerInfo(serverUrl); + if (!pub.ok) { + updateConnectionStatus("error"); + showSettingsMessage(pub.message, "error"); + return; + } const testClient = new ApiClient(serverUrl); await testClient.setAuthToken(apiToken); - const isValid = await testClient.validateToken(); - if (isValid) { + const session = await testClient.validateSession(); + if (session.ok) { updateConnectionStatus("connected"); - showSettingsMessage("Connection successful!", "success"); + showSettingsMessage("Connection successful: server and API token are valid.", "success"); } else { updateConnectionStatus("error"); - showSettingsMessage("Connection failed. Please check your server URL and API token.", "error"); + showSettingsMessage(session.message || "Token validation failed.", "error"); } } catch (error) { console.error("Error testing connection:", error); @@ -9026,7 +9398,13 @@ } else { initApp(); } - var { formatDuration, formatDurationLong, formatDateTime, isValidUrl } = window.Helpers || {}; + var { + formatDuration, + formatDurationLong, + formatDateTime, + isValidUrl, + normalizeServerUrlInput + } = window.Helpers || {}; function toggleFilters() { const filtersEl = document.getElementById("entries-filters"); if (filtersEl) { diff --git a/desktop/src/renderer/js/state.js b/desktop/src/renderer/js/state.js index 63daf2bd..0bf32521 100644 --- a/desktop/src/renderer/js/state.js +++ b/desktop/src/renderer/js/state.js @@ -4,6 +4,10 @@ */ module.exports = { apiClient: null, + /** Count consecutive background checks that failed with auth (401) while on main UI */ + authFailureStreak: 0, + /** Last timer poll error shown to user (avoid spam) */ + lastTimerPollUserMessageAt: 0, currentView: 'dashboard', timerInterval: null, isTimerRunning: false, diff --git a/desktop/src/renderer/js/utils/helpers.js b/desktop/src/renderer/js/utils/helpers.js index 2507d0ee..534d7094 100644 --- a/desktop/src/renderer/js/utils/helpers.js +++ b/desktop/src/renderer/js/utils/helpers.js @@ -46,6 +46,14 @@ function isValidUrl(string) { } } +/** Add https:// when user entered host:port or hostname only */ +function normalizeServerUrlInput(input) { + const trimmed = String(input || '').trim(); + if (!trimmed) return trimmed; + if (/^https?:\/\//i.test(trimmed)) return trimmed; + return 'https://' + trimmed; +} + function debounce(func, wait) { let timeout; return function executedFunction(...args) { @@ -67,6 +75,7 @@ if (typeof module !== 'undefined' && module.exports) { formatDateTime, parseISODate, isValidUrl, + normalizeServerUrlInput, debounce, }; } @@ -79,6 +88,7 @@ if (typeof window !== 'undefined') { formatDateTime, parseISODate, isValidUrl, + normalizeServerUrlInput, debounce, }; } diff --git a/desktop/test/api-client.test.js b/desktop/test/api-client.test.js index e1d42c77..5daf71cb 100644 --- a/desktop/test/api-client.test.js +++ b/desktop/test/api-client.test.js @@ -1,15 +1,54 @@ -// Basic API client tests -// Note: These would require mocking axios and electron APIs -// For now, this is a placeholder structure +const test = require('node:test'); +const assert = require('node:assert'); -describe('ApiClient', () => { - test('should initialize with base URL', () => { - // Test implementation would go here - expect(true).toBe(true); // Placeholder - }); +const ApiClient = require('../src/renderer/js/api/client'); - test('should handle token authentication', () => { - // Test implementation would go here - expect(true).toBe(true); // Placeholder - }); +test('normalizeBaseUrl trims trailing slashes', () => { + assert.strictEqual(ApiClient.normalizeBaseUrl('https://example.com/'), 'https://example.com'); + assert.strictEqual(ApiClient.normalizeBaseUrl('http://10.0.0.1:5000///'), 'http://10.0.0.1:5000'); +}); + +test('normalizeBaseUrl leaves empty string', () => { + assert.strictEqual(ApiClient.normalizeBaseUrl(''), ''); + assert.strictEqual(ApiClient.normalizeBaseUrl(' '), ''); +}); + +test('isTimeTrackerInfoPayload accepts v1 info shape', () => { + const ok = { + api_version: 'v1', + app_version: '1.0.0', + endpoints: { projects: '/api/v1/projects' }, + }; + assert.strictEqual(ApiClient.isTimeTrackerInfoPayload(ok), true); +}); + +test('isTimeTrackerInfoPayload rejects wrong api_version', () => { + assert.strictEqual( + ApiClient.isTimeTrackerInfoPayload({ api_version: 'v2', endpoints: {} }), + false, + ); +}); + +test('isTimeTrackerInfoPayload rejects missing endpoints object', () => { + assert.strictEqual(ApiClient.isTimeTrackerInfoPayload({ api_version: 'v1' }), false); +}); + +test('classifyAxiosError maps 401', () => { + const err = { response: { status: 401, data: {} } }; + const r = ApiClient.classifyAxiosError(err); + assert.strictEqual(r.code, 'UNAUTHORIZED'); + assert.ok(r.message.includes('token')); +}); + +test('classifyAxiosError maps TLS-ish code', () => { + const err = { code: 'DEPTH_ZERO_SELF_SIGNED_CERT', message: 'self signed certificate' }; + const r = ApiClient.classifyAxiosError(err); + assert.strictEqual(r.code, 'TLS'); + assert.ok(r.message.toLowerCase().includes('cert')); +}); + +test('classifyAxiosError maps ENOTFOUND', () => { + const err = { code: 'ENOTFOUND', message: 'getaddrinfo' }; + const r = ApiClient.classifyAxiosError(err); + assert.strictEqual(r.code, 'DNS'); }); diff --git a/docs/BUILD_CONFIGURATION.md b/docs/BUILD_CONFIGURATION.md index 64f06940..455285ff 100644 --- a/docs/BUILD_CONFIGURATION.md +++ b/docs/BUILD_CONFIGURATION.md @@ -80,7 +80,7 @@ The mobile app supports server URL configuration through: 3. **Runtime Configuration:** - Users can configure the server URL in the app settings - Stored in SharedPreferences - - Configuration class: `mobile/lib/core/config.dart` + - Configuration class: `mobile/lib/core/config/app_config.dart` ## Icon and Favicon Configuration diff --git a/docs/DESKTOP_SETTINGS.md b/docs/DESKTOP_SETTINGS.md index 35790aee..451f02b5 100644 --- a/docs/DESKTOP_SETTINGS.md +++ b/docs/DESKTOP_SETTINGS.md @@ -2,6 +2,18 @@ The TimeTracker desktop app includes a comprehensive settings system that allows users to configure the server URL and API token. +## First sign-in (connection wizard) + +On first launch (or whenever credentials are missing), the app shows a **two-step** flow: + +1. **Step 1 — Server** + Enter the base URL of your TimeTracker server (protocol and port as needed, e.g. `https://timetracker.example.com` or `http://192.168.1.50:5000`). If you omit the scheme, `https://` is assumed when validating. Use **Test server** to confirm the host speaks the TimeTracker API (`GET /api/v1/info` must return JSON with `api_version: "v1"`). **Continue to token** is enabled only after a successful test. + +2. **Step 2 — API token** + Paste an API token from the web app (**Admin → Security & Access → API tokens**). **Log in** verifies the token against the server (see **Connection testing** below). + +Command-line `--server-url` / `TIMETRACKER_SERVER_URL` can pre-fill the stored server URL and skip typing it in step 1; you still complete token entry unless the token is already saved. + ## Settings Location Settings are stored using Electron's secure storage (`electron-store`), which saves data in a JSON file in the user's application data directory: @@ -54,7 +66,7 @@ export TIMETRACKER_SERVER_URL=https://your-server.com ### Server URL Configuration -- **Validation**: The app validates that the URL is properly formatted (must start with `http://` or `https://`) +- **Validation**: URLs are normalized (trailing slashes removed). If you type a host without a scheme (e.g. `internal.company.com:8443`), `https://` is prepended for validation. - **Persistence**: Server URL is saved to secure storage and persists across app restarts - **Change Detection**: The app automatically reinitializes the API client when the server URL changes @@ -67,11 +79,16 @@ export TIMETRACKER_SERVER_URL=https://your-server.com ### Connection Testing -The settings screen includes a "Test Connection" button that: -- Validates the server URL format -- Tests the API token against the server -- Provides immediate feedback on connection status -- Shows success/error messages +The settings screen includes a **Test Connection** button (and **Save Settings** runs the same checks). The flow is: + +1. **Public check** — `GET /api/v1/info` without credentials. The response must be JSON with `api_version: "v1"` and an `endpoints` object. If the server returns `setup_required: true`, finish initial web setup in a browser first. +2. **Authenticated check** — With your token, the app calls `GET /api/v1/users/me`. If the token does not include the `read:users` scope, it falls back to `GET /api/v1/timer/status` (requires `read:time_entries`). One of these must succeed for the session to be considered valid. + +Errors are shown with specific causes when possible (DNS, connection refused, timeout, TLS/certificate issues, HTTP status, wrong app). + +### Session loss and background checks + +While you are signed in, the app re-validates the session about every **30 seconds**. If the server repeatedly rejects the token (**401**), the app signs you out to the login wizard (step 2) and shows a short message so you can fix the token or server URL. ## Settings File Structure @@ -97,18 +114,18 @@ When the settings view is opened: ### Settings Saving When "Save Settings" is clicked: -1. Server URL is validated +1. Server URL is validated and normalized 2. API token is validated (if changed) -3. Settings are saved to secure storage -4. API client is reinitialized with new settings -5. Connection is automatically tested -6. Success/error message is displayed +3. Values are written to secure storage (URL, token, sync options) +4. API client is reinitialized with the new URL and token +5. The same **public + authenticated** checks as **Test Connection** are run +6. On full success, a success message is shown. If the **public** check fails, an error message is shown (values were already saved—correct them and save again). If only the **session** check fails, a **warning** is shown with the server message. ### Settings Validation -- **Server URL**: Must be a valid HTTP/HTTPS URL +- **Server URL**: Must resolve to a valid HTTP/HTTPS URL after normalization - **API Token**: Must start with `tt_` and be non-empty -- **Connection**: Server must be reachable and token must be valid +- **Connection**: Server must expose TimeTracker `GET /api/v1/info`, and the token must pass the authenticated check described above ## Security Considerations @@ -153,7 +170,11 @@ To manually edit or backup settings: ## Code References -- Settings UI: `desktop/src/renderer/index.html` (settings view) -- Settings Logic: `desktop/src/renderer/js/app.js` (loadSettings, handleSaveSettings, handleTestConnection) +- Login wizard and settings UI: `desktop/src/renderer/index.html` +- Connection and settings logic: `desktop/src/renderer/js/app.js` (initApp, wizard handlers, loadSettings, handleSaveSettings, handleTestConnection, checkConnection) +- HTTP client: `desktop/src/renderer/js/api/client.js` (`testPublicServerInfo`, `validateSession`, URL normalization, error classification) +- Unit tests: `desktop/test/api-client.test.js` (run `npm test` from `desktop/`) - Storage: `desktop/src/shared/config.js` (storeGet, storeSet, storeDelete, storeClear) -- Main Process: `desktop/src/main/main.js` (command line argument parsing) +- Main process: `desktop/src/main/main.js` (command line argument parsing) + +`npm run build` and `npm start` run **`prebuild` / `prestart`**, which rebuild the renderer bundle (`bundle.js`) via esbuild so packaged builds do not ship a stale UI. diff --git a/docs/api/REST_API.md b/docs/api/REST_API.md index 0ed625d2..e782afdb 100644 --- a/docs/api/REST_API.md +++ b/docs/api/REST_API.md @@ -185,11 +185,14 @@ GET /api/v1/info Returns API version and available endpoints. No authentication required. +`setup_required` is a boolean: when `true`, the installation’s initial web setup is not complete; finish setup in the browser. Desktop and mobile apps use this (and JSON shape) to avoid treating arbitrary HTTP 200 pages as TimeTracker. During that phase, `GET /api/v1/info`, `GET /api/v1/health`, and `POST /api/v1/auth/login` are not redirected to the HTML setup wizard so clients still receive JSON. + **Response:** ```json { "api_version": "v1", "app_version": "1.0.0", + "setup_required": false, "documentation_url": "/api/docs", "endpoints": { "projects": "/api/v1/projects", diff --git a/docs/mobile-desktop-apps/README.md b/docs/mobile-desktop-apps/README.md index a00c834b..82032857 100644 --- a/docs/mobile-desktop-apps/README.md +++ b/docs/mobile-desktop-apps/README.md @@ -57,20 +57,21 @@ Both apps use the TimeTracker REST API v1 (`/api/v1/`). See [API Documentation]( ### Authentication -1. **Mobile app**: Sign in with your web username and password; the app obtains an API token in the background for the same basics access as the web app. -2. **Desktop app**: Enter the server URL and either sign in with username/password (if supported) or use an API token from Admin > Security & Access > Api-tokens. -3. The app will validate credentials and store the token securely. +1. **Mobile app**: Sign in with your web username and password; the server returns an API token (`tt_…`) which is stored securely. Changing the **Server URL** in Settings probes the new host with your saved token before persisting the change. +2. **Desktop app**: Use the two-step sign-in wizard (test server, then API token) or **Settings** with an API token from **Admin → Security & Access → API tokens** (no username/password flow in the Electron app). +3. Clients validate the server with `GET /api/v1/info` (and respect `setup_required` when the installation is not finished) and validate the token with authenticated API calls. ### Required API Scopes -- `read:time_entries` - View time entries +- `read:time_entries` - View time entries and timer status (desktop session check fallback; mobile login token includes this) - `write:time_entries` - Create/update time entries and control timer - `read:projects` - View projects - `read:tasks` - View tasks +- `read:users` - Optional on desktop tokens; preferred so `GET /api/v1/users/me` can be used for session verification ### API Endpoints Used -- `GET /api/v1/info` - API version and health check +- `GET /api/v1/info` - API metadata (includes `setup_required` when the server install is not complete); used for discovery without auth - `GET /api/v1/timer/status` - Get active timer status - `POST /api/v1/timer/start` - Start timer - `POST /api/v1/timer/stop` - Stop timer diff --git a/mobile/lib/core/config.dart b/mobile/lib/core/config.dart deleted file mode 100644 index 47392362..00000000 --- a/mobile/lib/core/config.dart +++ /dev/null @@ -1,71 +0,0 @@ -/// Core configuration for TimeTracker Mobile App -/// -/// This file handles server URL configuration and other app-wide settings. -/// The server URL can be: -/// 1. Set via environment variable: TIMETRACKER_SERVER_URL -/// 2. Configured in the app settings -/// 3. Defaults to empty string (must be configured by user) - -import 'package:shared_preferences/shared_preferences.dart'; - -class AppConfig { - static const String _serverUrlKey = 'server_url'; - static const String _apiTokenKey = 'api_token'; - - /// Get server URL from preferences or environment - static Future getServerUrl() async { - // First check environment variable - const envUrl = String.fromEnvironment('TIMETRACKER_SERVER_URL'); - if (envUrl.isNotEmpty) { - return envUrl; - } - - // Then check shared preferences - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_serverUrlKey); - } - - /// Set server URL in preferences - static Future setServerUrl(String url) async { - final prefs = await SharedPreferences.getInstance(); - return await prefs.setString(_serverUrlKey, url); - } - - /// Get API token from preferences - static Future getApiToken() async { - final prefs = await SharedPreferences.getInstance(); - return prefs.getString(_apiTokenKey); - } - - /// Set API token in preferences - static Future setApiToken(String token) async { - final prefs = await SharedPreferences.getInstance(); - return await prefs.setString(_apiTokenKey, token); - } - - /// Clear all stored configuration - static Future clearConfig() async { - final prefs = await SharedPreferences.getInstance(); - return await prefs.remove(_serverUrlKey) && - await prefs.remove(_apiTokenKey); - } - - /// Validate server URL format - static bool isValidServerUrl(String url) { - try { - final uri = Uri.parse(url); - return uri.hasScheme && - (uri.scheme == 'http' || uri.scheme == 'https') && - uri.hasAuthority; - } catch (e) { - return false; - } - } - - /// Get default server URL (can be overridden) - static String? getDefaultServerUrl() { - // Can be set at compile time via --dart-define - const defaultUrl = String.fromEnvironment('DEFAULT_SERVER_URL'); - return defaultUrl.isEmpty ? null : defaultUrl; - } -} diff --git a/mobile/lib/presentation/screens/settings_screen.dart b/mobile/lib/presentation/screens/settings_screen.dart index b7337af4..af0026f5 100644 --- a/mobile/lib/presentation/screens/settings_screen.dart +++ b/mobile/lib/presentation/screens/settings_screen.dart @@ -6,6 +6,7 @@ import '../../domain/usecases/sync_usecase.dart'; import '../providers/api_provider.dart'; import '../../utils/auth/auth_service.dart'; import '../providers/theme_mode_provider.dart'; +import 'package:timetracker_mobile/data/api/api_client.dart'; import 'login_screen.dart'; class SettingsScreen extends ConsumerStatefulWidget { @@ -104,6 +105,20 @@ class _SettingsScreenState extends ConsumerState { ref.read(themeModeProvider.notifier).setMode(value); } + /// Match login flow: optional scheme, trim trailing slashes for stored base URL. + String _normalizeServerUrlForSettings(String input) { + var t = input.trim(); + if (t.isEmpty) return t; + final lower = t.toLowerCase(); + if (!lower.startsWith('http://') && !lower.startsWith('https://')) { + t = 'https://$t'; + } + while (t.endsWith('/')) { + t = t.substring(0, t.length - 1); + } + return t; + } + Future _showEditServerUrlDialog() async { final controller = TextEditingController(text: _serverUrl ?? ''); final result = await showDialog( @@ -140,10 +155,33 @@ class _SettingsScreenState extends ConsumerState { return; } - await AppConfig.setServerUrl(result); + final normalized = _normalizeServerUrlForSettings(result); + final token = await AuthService.getToken(); + if (token != null && token.isNotEmpty) { + final trustedHosts = await AppConfig.getTrustedInsecureHosts(); + final probe = ApiClient(baseUrl: normalized, trustedInsecureHosts: trustedHosts); + await probe.setAuthToken(token); + final validation = await probe.validateTokenRaw(); + final status = validation.statusCode ?? 0; + if (status != 200) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + status == 401 + ? 'This server did not accept your saved token (401). Sign out, then sign in again on the new server.' + : 'Could not verify your token on this server (HTTP $status). Server URL was not changed.', + ), + ), + ); + return; + } + } + + await AppConfig.setServerUrl(normalized); ref.invalidate(apiClientProvider); if (mounted) { - setState(() => _serverUrl = result); + setState(() => _serverUrl = normalized); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Server URL updated')), ); diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index dd34b206..d2be33cd 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -503,6 +503,8 @@ class TestSystemEndpoints: data = json.loads(response.data) assert "api_version" in data assert "endpoints" in data + assert "setup_required" in data + assert isinstance(data["setup_required"], bool) def test_health_check(self, client): """Test health check endpoint (no auth required)"""