fix(oxibadge): accept hostnames and FQDNs for the time clock address
Check, format and test / build (push) Successful in 11m10s

The "Edit address" field in Settings only accepted an IPv4 address
(IPV4.test), rejecting a bare hostname (e.g. "timbratore") or FQDN
(e.g. "timbratore.lan") even though the underlying connection (Rust's
ToSocketAddrs) already resolves those fine. Onboarding had no such
restriction. Replaced the IPv4-only check with isValidHost (IPv4 OR a
standard DNS hostname), dropped the numeric-only keyboard hint on that
field, and reworded the labels/placeholder error to not imply IP-only.

Also makes "Edit address" test the connection before accepting it,
instead of saving unconditionally: ActionButton gained a `disabled`
prop so the confirm/cancel buttons can be locked (with a spinner on
confirm) while the test is in flight, and the edit form now only
closes on a successful test, staying open with the raw error shown
otherwise.

Assisted-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:05:12 +02:00
parent 0c320c39b0
commit da5daf5bca
5 changed files with 50 additions and 21 deletions
@@ -3,18 +3,20 @@
let { let {
primary = false, primary = false,
disabled = false,
onclick, onclick,
icon, icon,
children children
}: { }: {
primary?: boolean; primary?: boolean;
disabled?: boolean;
onclick: () => void; onclick: () => void;
icon?: Snippet; icon?: Snippet;
children: Snippet; children: Snippet;
} = $props(); } = $props();
</script> </script>
<button type="button" class="btn" class:primary {onclick}> <button type="button" class="btn" class:primary {disabled} {onclick}>
{#if icon}{@render icon()}{/if} {#if icon}{@render icon()}{/if}
<span>{@render children()}</span> <span>{@render children()}</span>
</button> </button>
@@ -37,7 +39,7 @@
transition: background 0.15s; transition: background 0.15s;
} }
.btn:hover { .btn:hover:not(:disabled) {
background: #1e1e1e; background: #1e1e1e;
color: #ffffff; color: #ffffff;
} }
@@ -47,6 +49,11 @@
outline-offset: 2px; outline-offset: 2px;
} }
.btn:disabled {
opacity: 0.5;
cursor: progress;
}
.btn.primary { .btn.primary {
padding: 0 18px; padding: 0 18px;
border: none; border: none;
@@ -58,12 +65,12 @@
transform 0.1s; transform 0.1s;
} }
.btn.primary:hover { .btn.primary:hover:not(:disabled) {
background: var(--ox-accent); background: var(--ox-accent);
filter: brightness(1.08); filter: brightness(1.08);
} }
.btn.primary:active { .btn.primary:active:not(:disabled) {
transform: translateY(1px); transform: translateY(1px);
} }
@@ -10,7 +10,7 @@
import ActionButton from '../ActionButton.svelte'; import ActionButton from '../ActionButton.svelte';
import PillButton from '../PillButton.svelte'; import PillButton from '../PillButton.svelte';
import EmployeesDrawer from './EmployeesDrawer.svelte'; import EmployeesDrawer from './EmployeesDrawer.svelte';
import { IPV4 } from './logic'; import { isValidHost } from './logic';
let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } = let { host, port, onsave }: { host: string; port: number; onsave: (host: string, port: number) => void } =
$props(); $props();
@@ -19,6 +19,7 @@
let draftHost = $state(''); let draftHost = $state('');
let draftPort = $state(0); let draftPort = $state(0);
let ipError = $state(false); let ipError = $state(false);
let confirming = $state(false);
let status = $state<'' | 'testing' | 'ok'>(''); let status = $state<'' | 'testing' | 'ok'>('');
@@ -79,20 +80,25 @@
async function confirmEdit() { async function confirmEdit() {
const v = draftHost.trim(); const v = draftHost.trim();
if (!IPV4.test(v)) { if (!isValidHost(v)) {
ipError = true; ipError = true;
return; return;
} }
const nextPort = draftPort || 5005; const nextPort = draftPort || 5005;
editing = false; confirming = true;
status = '';
clearError(); clearError();
try { try {
await invoke('test_connection', { host: v, port: nextPort });
const settings = await invoke<Settings>('load_settings'); const settings = await invoke<Settings>('load_settings');
await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } }); await invoke('save_settings', { settings: { ...settings, readers: [{ host: v, port: nextPort }] } });
editing = false;
status = 'ok';
onsave(v, nextPort); onsave(v, nextPort);
} catch (err) { } catch (err) {
status = '';
setError(err); setError(err);
} finally {
confirming = false;
} }
} }
@@ -116,7 +122,7 @@
} }
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && editing) cancelEdit(); if (e.key === 'Escape' && editing && !confirming) cancelEdit();
} }
</script> </script>
@@ -194,7 +200,6 @@
id="dev-ip" id="dev-ip"
label={$_('settings.ip_label')} label={$_('settings.ip_label')}
bind:value={draftHost} bind:value={draftHost}
inputmode="decimal"
placeholder="192.168.1.100" placeholder="192.168.1.100"
err={ipError} err={ipError}
oninput={() => (ipError = false)} oninput={() => (ipError = false)}
@@ -212,8 +217,17 @@
/> />
</div> </div>
<div class="row-end"> <div class="row-end">
<ActionButton onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton> <ActionButton disabled={confirming} onclick={cancelEdit}>{$_('settings.cancel')}</ActionButton>
<ActionButton primary onclick={confirmEdit}>{$_('settings.confirm')}</ActionButton> <ActionButton primary disabled={confirming} onclick={confirmEdit}>
{#snippet icon()}
{#if confirming}
<svg class="spin" width="16" height="16" viewBox="0 0 24 24" aria-hidden="true" style="stroke-width: 2.4"
><path d="M12 3a9 9 0 1 0 9 9" /></svg
>
{/if}
{/snippet}
{confirming ? $_('settings.status_testing') : $_('settings.confirm')}
</ActionButton>
</div> </div>
</form> </form>
{/if} {/if}
@@ -3,7 +3,15 @@ import type { Shifts } from '$lib/shifts';
export type { Shifts }; export type { Shifts };
export const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/; const IPV4 = /^((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
// A DNS label: alphanumeric, hyphens allowed in the middle, 1-63 chars.
const LABEL = '[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?';
const HOSTNAME = new RegExp(`^${LABEL}(?:\\.${LABEL})*$`);
/** Whether `v` is a usable time clock address: an IPv4 address, a bare
* hostname (e.g. "timbratore") or a FQDN (e.g. "timbratore.lan"). */
export const isValidHost = (v: string): boolean => IPV4.test(v) || HOSTNAME.test(v);
/** The 4 time fields of `Shifts` — everything but `round_step_min`, which isn't a time. */ /** The 4 time fields of `Shifts` — everything but `round_step_min`, which isn't a time. */
export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2'; export type ShiftTimeKey = 'in1' | 'out1' | 'in2' | 'out2';
+4 -4
View File
@@ -2,8 +2,8 @@
"loading": "Loading...", "loading": "Loading...",
"onboarding": { "onboarding": {
"title": "Welcome!", "title": "Welcome!",
"subtitle": "Connect Oxibadge to your time clock by entering its IP address and port.", "subtitle": "Connect Oxibadge to your time clock by entering its address and port.",
"host_label": "IP address", "host_label": "Address",
"port_label": "Port", "port_label": "Port",
"hint": "You can find the address in the time clock's network settings.", "hint": "You can find the address in the time clock's network settings.",
"button": { "button": {
@@ -136,9 +136,9 @@
"status_idle": "Not verified", "status_idle": "Not verified",
"edit_address": "Edit address", "edit_address": "Edit address",
"test_connection": "Test connection", "test_connection": "Test connection",
"ip_label": "IP address", "ip_label": "Address",
"port_label": "Port", "port_label": "Port",
"ip_invalid": "Invalid format, e.g. 192.168.1.100", "ip_invalid": "Invalid format, e.g. 192.168.1.100 or timbratore.lan",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Confirm", "confirm": "Confirm",
"shifts_title": "Expected shifts", "shifts_title": "Expected shifts",
+4 -4
View File
@@ -2,8 +2,8 @@
"loading": "Caricamento...", "loading": "Caricamento...",
"onboarding": { "onboarding": {
"title": "Benvenuto!", "title": "Benvenuto!",
"subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo IP e la porta.", "subtitle": "Collega Oxibadge al tuo timbratore inserendo il suo indirizzo e la porta.",
"host_label": "Indirizzo IP", "host_label": "Indirizzo",
"port_label": "Porta", "port_label": "Porta",
"hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.", "hint": "Trovi l'indirizzo nelle impostazioni di rete del timbratore.",
"button": { "button": {
@@ -136,9 +136,9 @@
"status_idle": "Non verificato", "status_idle": "Non verificato",
"edit_address": "Modifica indirizzo", "edit_address": "Modifica indirizzo",
"test_connection": "Testa connessione", "test_connection": "Testa connessione",
"ip_label": "Indirizzo IP", "ip_label": "Indirizzo",
"port_label": "Porta", "port_label": "Porta",
"ip_invalid": "Formato non valido, es. 192.168.1.100", "ip_invalid": "Formato non valido, es. 192.168.1.100 o timbratore.lan",
"cancel": "Annulla", "cancel": "Annulla",
"confirm": "Conferma", "confirm": "Conferma",
"shifts_title": "Turni previsti", "shifts_title": "Turni previsti",