fix(oxibadge): accept hostnames and FQDNs for the time clock address
Check, format and test / build (push) Successful in 11m10s
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:
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user