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)"""