Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -5,7 +5,9 @@ Runs in one Docker container, stores everything locally in SQLite, and mirrors e
|
|||||||
and sign out to a Google Sheet so someone outside the building can see who is on site during
|
and sign out to a Google Sheet so someone outside the building can see who is on site during
|
||||||
an evacuation.
|
an evacuation.
|
||||||
|
|
||||||
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out.
|
- **Kiosk** at `/` — a stepped sign in, a PIN sign in for regulars, and sign out. No link to
|
||||||
|
the admin console: a kiosk is a public terminal and administration does not belong on it.
|
||||||
|
Reach the console from a staff machine at `/admin`.
|
||||||
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
|
- **Admin** at `/admin` — who's on site now, the visit log, recurring visitors, the people a
|
||||||
visitor can ask for, sites, admin accounts, and system status.
|
visitor can ask for, sites, admin accounts, and system status.
|
||||||
|
|
||||||
@@ -19,7 +21,7 @@ two factor.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| First and last name | typed each visit | on file |
|
| First and last name | typed each visit | on file |
|
||||||
| Person being visited | picked from the list | picked each visit |
|
| Person being visited | picked from the list | picked each visit |
|
||||||
| Photo | taken at the kiosk | taken at the kiosk |
|
| Photo | taken at the kiosk | on file if saved, otherwise taken at the kiosk |
|
||||||
| WWCC / VIT / none | typed each visit | on file |
|
| WWCC / VIT / none | typed each visit | on file |
|
||||||
| Mobile and/or email | at least one required | on file |
|
| Mobile and/or email | at least one required | on file |
|
||||||
|
|
||||||
@@ -103,25 +105,47 @@ staff list and log, and cannot touch the others.
|
|||||||
|
|
||||||
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
|
Turn it on per site under **Sites → Edit**. After a visitor signs in, the kiosk loads the badge
|
||||||
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
|
into a hidden frame and prints it — one label, no dialog on most kiosk setups. The badge shows
|
||||||
the site, name, who they are visiting, time in, WWCC/VIT status (or a boxed **No WWCC / VIT**),
|
the site, the visitor's name, who they are visiting, the time in, their WWCC/VIT number or a
|
||||||
the photo if you want it, and an optional line of your own text.
|
boxed **No WWCC / VIT**, the photo if you want it, and an optional line of your own text.
|
||||||
|
|
||||||
Set the label size in millimetres to match your stock. Type scales with the smaller dimension,
|
Pick your stock from the **Label stock** list and the dimensions fill themselves in. A label
|
||||||
so small labels stay legible. Common sizes:
|
noticeably taller than it is wide gets a stacked layout — photo on top, name beneath — which is
|
||||||
|
what you want on a roll printer. Wider stock gets the photo alongside the text instead. Type
|
||||||
| Stock | mm |
|
scales with the constraining dimension, so small labels stay readable.
|
||||||
|---|---|
|
|
||||||
| Card size | 86 × 54 |
|
|
||||||
| Brother DK-11202 shipping | 100 × 62 |
|
|
||||||
| Brother DK-11209 small address | 62 × 29 |
|
|
||||||
| Dymo 99014 shipping | 101 × 54 |
|
|
||||||
|
|
||||||
Use **Preview badge** to check the layout in a browser before committing a roll to it. The kiosk
|
|
||||||
browser needs the label printer set as its default, with margins off and scaling at 100%.
|
|
||||||
|
|
||||||
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
|
Admins can reprint from the **On site** list, and the visitor gets a "Print the badge again"
|
||||||
button on the confirmation screen if the first one jams.
|
button on the confirmation screen if the first one jams.
|
||||||
|
|
||||||
|
### Brother QL-820NWB
|
||||||
|
|
||||||
|
The default for a new site is 62 × 100 mm, which matches the DK-11202 die-cut label. For a
|
||||||
|
continuous roll, 62 × 90 mm is a good visitor badge.
|
||||||
|
|
||||||
|
It takes media 12 to 62 mm wide and prints up to 60.96 mm across at 300 × 300 dpi, so 62 mm is
|
||||||
|
the widest roll it will accept — the console warns you if you enter anything wider. Useful rolls:
|
||||||
|
|
||||||
|
| Roll | Size | Good for |
|
||||||
|
|---|---|---|
|
||||||
|
| DK-22205 | 62 mm continuous | The default. Cut to any length; 90 mm suits a visitor badge |
|
||||||
|
| DK-11202 | 62 × 100 mm die-cut | Pre-cut, no length to choose |
|
||||||
|
| DK-22251 | 62 mm continuous, black/red | Same as DK-22205 but supports the red option below |
|
||||||
|
| DK-11208 | 38 × 90 mm die-cut | Narrower; turn the photo off |
|
||||||
|
| DK-11209 | 29 × 62 mm die-cut | Name and host only |
|
||||||
|
|
||||||
|
**The red option.** Tick *Print the heading and the no-check warning in red* and the site name
|
||||||
|
and the **No WWCC / VIT** box print red instead of black, which makes a visitor without a check
|
||||||
|
obvious across a room. It only works on a DK-22251 roll — on any other roll the printer renders
|
||||||
|
it as grey. Two-colour printing is also far slower than black alone (Brother rate it at roughly
|
||||||
|
15 labels a minute against 110), which is irrelevant for one badge at a time but worth knowing.
|
||||||
|
|
||||||
|
**Driver setup on the kiosk.** Set the QL-820NWB as the default printer for the browser running
|
||||||
|
the kiosk, choose the matching paper size in the driver, and set margins to none and scaling to
|
||||||
|
100%. Then use **Preview badge** in the console and print one before committing a roll — the
|
||||||
|
preview renders at the exact millimetre size the printer will receive.
|
||||||
|
|
||||||
|
The QL-820NWB has Ethernet and Wi-Fi, so it does not need to hang off the kiosk tablet. Install
|
||||||
|
it as a network printer on whichever device drives the kiosk browser.
|
||||||
|
|
||||||
## WWCC and VIT expiry warnings
|
## WWCC and VIT expiry warnings
|
||||||
|
|
||||||
Give a recurring visitor an expiry date and the console watches it. Inside
|
Give a recurring visitor an expiry date and the console watches it. Inside
|
||||||
@@ -275,12 +299,34 @@ spreadsheet covers every site.
|
|||||||
|
|
||||||
## Recurring visitors and PINs
|
## Recurring visitors and PINs
|
||||||
|
|
||||||
|
Each saved person is one record: **mobile number, email address and PIN are all unique**, checked
|
||||||
|
when a record is added or edited and enforced by the database. If an existing database already
|
||||||
|
contains duplicates, the startup log names who collides and the checks stay at the application
|
||||||
|
level until you fix them.
|
||||||
|
|
||||||
|
### A photo on file
|
||||||
|
|
||||||
|
Give a recurring visitor a photo in the admin console — from the machine's camera or an uploaded
|
||||||
|
file — and the kiosk stops asking them to pose. They enter their PIN, pick who they are visiting,
|
||||||
|
and the sign in completes with their pass printing immediately.
|
||||||
|
|
||||||
|
The stored photo is *copied* onto each visit rather than referenced, so the visit log stays a
|
||||||
|
true snapshot: replacing someone's photo later does not change what past visits show, and photo
|
||||||
|
retention cleaning up old visits can never delete a live profile photo.
|
||||||
|
|
||||||
|
Leave the photo blank and they are asked at the kiosk as before.
|
||||||
|
|
||||||
|
### PINs
|
||||||
|
|
||||||
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
|
The mobile number is the username, and the PIN is four digits. PINs are stored encrypted with
|
||||||
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
|
`APP_SECRET` rather than hashed, so an admin can reprint a lost card without resetting it. Four
|
||||||
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
|
digits is only 10,000 combinations, so hashing would add nothing against anyone holding a copy
|
||||||
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
|
of the database — the real protection is the lockout: five wrong PINs on a number locks it for
|
||||||
15 minutes.
|
15 minutes.
|
||||||
|
|
||||||
|
Four digits gives 10,000 combinations and each must be unique, so that is the ceiling on
|
||||||
|
simultaneous recurring visitors. Deactivating someone frees theirs.
|
||||||
|
|
||||||
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
|
**Changing `APP_SECRET` makes every stored PIN unreadable.** If you have to change it, reissue
|
||||||
PINs from the admin console afterwards.
|
PINs from the admin console afterwards.
|
||||||
|
|
||||||
|
|||||||
@@ -294,3 +294,48 @@ tr.row-bad td { background: #fdf0f2; }
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
#system-body h3 { margin-bottom: 14px; }
|
#system-body h3 { margin-bottom: 14px; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------- visitor photo editor */
|
||||||
|
|
||||||
|
.photo-editor { margin-bottom: 6px; }
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
position: relative;
|
||||||
|
width: 148px;
|
||||||
|
aspect-ratio: 4 / 3;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: #eef1f4;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
.photo-frame img,
|
||||||
|
.photo-frame video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
transform: scaleX(-1);
|
||||||
|
}
|
||||||
|
.photo-empty { margin: 0; padding: 0 10px; color: var(--muted); font-size: 13px; text-align: center; }
|
||||||
|
|
||||||
|
.photo-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.photo-actions button, .photo-upload { padding: 8px 13px; font-size: 14px; }
|
||||||
|
.photo-upload { cursor: pointer; }
|
||||||
|
|
||||||
|
.thumb-empty {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: repeating-linear-gradient(45deg, #eef1f4, #eef1f4 5px, #e3e8ed 5px, #e3e8ed 10px);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.warn {
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-left: 4px solid #d9a441;
|
||||||
|
background: #fdf3d8;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|||||||
+1
-4
@@ -224,10 +224,7 @@
|
|||||||
|
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
|
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
|
||||||
<span>
|
<button class="foot-link" id="change-site" hidden></button>
|
||||||
<button class="foot-link" id="change-site" hidden></button>
|
|
||||||
<a href="/admin">Admin</a>
|
|
||||||
</span>
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
|
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
|
||||||
|
|||||||
+195
-10
@@ -254,7 +254,7 @@ async function loadRecurring() {
|
|||||||
: '';
|
: '';
|
||||||
|
|
||||||
$('#recurring-table').innerHTML = table(
|
$('#recurring-table').innerHTML = table(
|
||||||
['Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
|
['', 'Visitor', 'Mobile', 'Site', 'Check', 'Expiry', 'Status', ''],
|
||||||
rows.map(
|
rows.map(
|
||||||
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
|
(p) => `<tr class="${p.expiry.status === 'expired' ? 'row-bad' : p.expiry.status === 'expiring' ? 'row-warn' : ''}">
|
||||||
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
|
<td><strong>${esc(p.firstName)} ${esc(p.lastName)}</strong>
|
||||||
@@ -316,7 +316,7 @@ function siteOptions(selectedId, { anyLabel = 'Any site' } = {}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false } = {}) {
|
function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = false, onOpen, onClose } = {}) {
|
||||||
$('#modal-title').textContent = title;
|
$('#modal-title').textContent = title;
|
||||||
$('#modal-body').innerHTML = bodyHtml;
|
$('#modal-body').innerHTML = bodyHtml;
|
||||||
$('#modal-save').textContent = saveLabel;
|
$('#modal-save').textContent = saveLabel;
|
||||||
@@ -324,13 +324,124 @@ function openModal(title, bodyHtml, onSave, { saveLabel = 'Save', hideCancel = f
|
|||||||
const modal = $('#modal');
|
const modal = $('#modal');
|
||||||
modal.returnValue = '';
|
modal.returnValue = '';
|
||||||
modal.showModal();
|
modal.showModal();
|
||||||
|
onOpen?.();
|
||||||
modal.onclose = () => {
|
modal.onclose = () => {
|
||||||
$('#modal-cancel').hidden = false;
|
$('#modal-cancel').hidden = false;
|
||||||
$('#modal-save').textContent = 'Save';
|
$('#modal-save').textContent = 'Save';
|
||||||
|
onClose?.();
|
||||||
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
if (modal.returnValue === 'save') onSave?.(new FormData($('#modal-form')));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------- visitor photo editor */
|
||||||
|
// A recurring visitor can have a photo kept on file, so the kiosk never asks them
|
||||||
|
// to pose again. It can come from this machine's camera or from a file.
|
||||||
|
|
||||||
|
const photoEditor = { dataUrl: null, remove: false, stream: null };
|
||||||
|
|
||||||
|
function photoEditorMarkup(person) {
|
||||||
|
const existing = person?.hasPhoto ? `/admin/api/frequent/${person.id}/photo?t=${Date.now()}` : null;
|
||||||
|
return `
|
||||||
|
<div class="photo-editor">
|
||||||
|
<div class="photo-frame">
|
||||||
|
<img id="photo-preview" alt="Photo on file"${existing ? ` src="${existing}"` : ' hidden'}>
|
||||||
|
<video id="photo-video" playsinline muted autoplay hidden></video>
|
||||||
|
<p class="photo-empty" id="photo-empty"${existing ? ' hidden' : ''}>No photo on file</p>
|
||||||
|
</div>
|
||||||
|
<div class="photo-actions">
|
||||||
|
<button type="button" class="ghost" id="photo-camera">Use the camera</button>
|
||||||
|
<button type="button" class="ghost" id="photo-shoot" hidden>Take it</button>
|
||||||
|
<label class="ghost photo-upload">Upload a file
|
||||||
|
<input type="file" id="photo-file" accept="image/*" hidden>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="ghost danger" id="photo-clear"${existing ? '' : ' hidden'}>Remove</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">With a photo saved here, this visitor signs in with their PIN and their
|
||||||
|
pass prints straight away — the kiosk does not ask them to pose.</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wirePhotoEditor() {
|
||||||
|
photoEditor.dataUrl = null;
|
||||||
|
photoEditor.remove = false;
|
||||||
|
|
||||||
|
const preview = $('#photo-preview');
|
||||||
|
const video = $('#photo-video');
|
||||||
|
const empty = $('#photo-empty');
|
||||||
|
|
||||||
|
const showImage = (src) => {
|
||||||
|
preview.src = src;
|
||||||
|
preview.hidden = false;
|
||||||
|
video.hidden = true;
|
||||||
|
empty.hidden = true;
|
||||||
|
$('#photo-shoot').hidden = true;
|
||||||
|
$('#photo-camera').textContent = 'Retake';
|
||||||
|
$('#photo-clear').hidden = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#photo-camera').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
photoEditor.stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'user', width: { ideal: 960 } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
video.srcObject = photoEditor.stream;
|
||||||
|
video.hidden = false;
|
||||||
|
preview.hidden = true;
|
||||||
|
empty.hidden = true;
|
||||||
|
$('#photo-shoot').hidden = false;
|
||||||
|
} catch {
|
||||||
|
toast('No camera available on this machine. Upload a file instead.', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#photo-shoot').addEventListener('click', () => {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
const width = 720;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = Math.round((video.videoHeight / video.videoWidth) * width) || 540;
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
photoEditor.dataUrl = canvas.toDataURL('image/jpeg', 0.72);
|
||||||
|
photoEditor.remove = false;
|
||||||
|
stopPhotoCamera();
|
||||||
|
showImage(photoEditor.dataUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#photo-file').addEventListener('change', async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
if (file.size > 4 * 1024 * 1024) return toast('That image is over 4 MB. Use a smaller one.', true);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => {
|
||||||
|
photoEditor.dataUrl = reader.result;
|
||||||
|
photoEditor.remove = false;
|
||||||
|
stopPhotoCamera();
|
||||||
|
showImage(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#photo-clear').addEventListener('click', () => {
|
||||||
|
photoEditor.dataUrl = null;
|
||||||
|
photoEditor.remove = true;
|
||||||
|
stopPhotoCamera();
|
||||||
|
preview.hidden = true;
|
||||||
|
video.hidden = true;
|
||||||
|
empty.hidden = false;
|
||||||
|
$('#photo-shoot').hidden = true;
|
||||||
|
$('#photo-clear').hidden = true;
|
||||||
|
$('#photo-camera').textContent = 'Use the camera';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPhotoCamera() {
|
||||||
|
if (!photoEditor.stream) return;
|
||||||
|
photoEditor.stream.getTracks().forEach((t) => t.stop());
|
||||||
|
photoEditor.stream = null;
|
||||||
|
const video = $('#photo-video');
|
||||||
|
if (video) video.srcObject = null;
|
||||||
|
}
|
||||||
|
|
||||||
function openRecurringModal(person = null) {
|
function openRecurringModal(person = null) {
|
||||||
const editing = Boolean(person);
|
const editing = Boolean(person);
|
||||||
openModal(
|
openModal(
|
||||||
@@ -354,12 +465,16 @@ function openRecurringModal(person = null) {
|
|||||||
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
|
<select name="defaultHostId">${hostOptions(person?.defaultHostId)}</select></label>
|
||||||
${field('PIN (leave blank to generate one)', 'pin', '')}
|
${field('PIN (leave blank to generate one)', 'pin', '')}
|
||||||
${field('Notes', 'notes', person?.notes)}
|
${field('Notes', 'notes', person?.notes)}
|
||||||
|
<h4 class="modal-section">Photo on file</h4>
|
||||||
|
${photoEditorMarkup(person)}
|
||||||
${editing ? `<label class="inline"><input type="checkbox" name="active" ${person.active ? 'checked' : ''}> Active</label>` : ''}
|
${editing ? `<label class="inline"><input type="checkbox" name="active" ${person.active ? 'checked' : ''}> Active</label>` : ''}
|
||||||
`,
|
`,
|
||||||
async (form) => {
|
async (form) => {
|
||||||
const payload = Object.fromEntries(form.entries());
|
const payload = Object.fromEntries(form.entries());
|
||||||
payload.active = editing ? form.has('active') : true;
|
payload.active = editing ? form.has('active') : true;
|
||||||
if (!payload.pin) delete payload.pin;
|
if (!payload.pin) delete payload.pin;
|
||||||
|
if (photoEditor.dataUrl) payload.photo = photoEditor.dataUrl;
|
||||||
|
if (photoEditor.remove) payload.removePhoto = true;
|
||||||
try {
|
try {
|
||||||
const saved = editing
|
const saved = editing
|
||||||
? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload })
|
? await api(`/frequent/${person.id}`, { method: 'PATCH', body: payload })
|
||||||
@@ -370,7 +485,8 @@ function openRecurringModal(person = null) {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ onOpen: wirePhotoEditor, onClose: stopPhotoCamera }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,7 +617,7 @@ async function loadSites() {
|
|||||||
<dt>Badge printing</dt>
|
<dt>Badge printing</dt>
|
||||||
<dd>${
|
<dd>${
|
||||||
s.badge.enabled
|
s.badge.enabled
|
||||||
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}`
|
? `On — ${s.badge.widthMm} × ${s.badge.heightMm} mm${s.badge.showPhoto ? ', with photo' : ''}${s.badge.accent ? ', two-colour' : ''}`
|
||||||
: 'Off'
|
: 'Off'
|
||||||
}</dd>
|
}</dd>
|
||||||
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
${s.badge.note ? `<dt>Badge note</dt><dd>${esc(s.badge.note)}</dd>` : ''}
|
||||||
@@ -522,6 +638,22 @@ async function loadSites() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Label stock, so nobody has to measure a roll. The Brother QL-820NWB takes
|
||||||
|
* 12–62 mm wide media and prints up to 60.96 mm across, so anything wider than
|
||||||
|
* 62 mm is for a different printer.
|
||||||
|
*/
|
||||||
|
const LABEL_PRESETS = [
|
||||||
|
{ id: 'dk22205-90', label: 'Brother DK-22205 continuous, cut at 90 mm', w: 62, h: 90, photo: true },
|
||||||
|
{ id: 'dk11202', label: 'Brother DK-11202 die-cut 62 × 100 mm', w: 62, h: 100, photo: true },
|
||||||
|
{ id: 'dk22251-90', label: 'Brother DK-22251 black/red continuous, cut at 90 mm', w: 62, h: 90, photo: true, accent: true },
|
||||||
|
{ id: 'dk11208', label: 'Brother DK-11208 die-cut 38 × 90 mm', w: 38, h: 90, photo: false },
|
||||||
|
{ id: 'dk11209', label: 'Brother DK-11209 die-cut 29 × 62 mm', w: 29, h: 62, photo: false },
|
||||||
|
{ id: 'dk11201', label: 'Brother DK-11201 die-cut 29 × 90 mm', w: 29, h: 90, photo: false },
|
||||||
|
{ id: 'card', label: 'Card size 86 × 54 mm (not a QL-820NWB size)', w: 86, h: 54, photo: true },
|
||||||
|
{ id: 'dymo99014', label: 'Dymo 99014 101 × 54 mm', w: 101, h: 54, photo: true },
|
||||||
|
];
|
||||||
|
|
||||||
function openSiteModal(site) {
|
function openSiteModal(site) {
|
||||||
openModal(
|
openModal(
|
||||||
`Edit ${site.name}`,
|
`Edit ${site.name}`,
|
||||||
@@ -530,14 +662,26 @@ function openSiteModal(site) {
|
|||||||
<label class="inline"><input type="checkbox" name="active" ${site.active ? 'checked' : ''}> Active</label>
|
<label class="inline"><input type="checkbox" name="active" ${site.active ? 'checked' : ''}> Active</label>
|
||||||
<h4 class="modal-section">Badge printing</h4>
|
<h4 class="modal-section">Badge printing</h4>
|
||||||
<label class="inline"><input type="checkbox" name="badgeEnabled" ${site.badge.enabled ? 'checked' : ''}> Print a badge after each sign in</label>
|
<label class="inline"><input type="checkbox" name="badgeEnabled" ${site.badge.enabled ? 'checked' : ''}> Print a badge after each sign in</label>
|
||||||
|
<label class="modal-field"><span>Label stock</span>
|
||||||
|
<select name="preset" id="badge-preset">
|
||||||
|
<option value="">Custom size</option>
|
||||||
|
${LABEL_PRESETS.map(
|
||||||
|
(p) =>
|
||||||
|
`<option value="${p.id}" ${
|
||||||
|
Number(site.badge.widthMm) === p.w && Number(site.badge.heightMm) === p.h ? 'selected' : ''
|
||||||
|
}>${esc(p.label)}</option>`
|
||||||
|
).join('')}
|
||||||
|
</select></label>
|
||||||
<div class="modal-row">
|
<div class="modal-row">
|
||||||
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
${field('Width (mm)', 'widthMm', site.badge.widthMm, 'number')}
|
||||||
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
${field('Height (mm)', 'heightMm', site.badge.heightMm, 'number')}
|
||||||
</div>
|
</div>
|
||||||
<label class="inline"><input type="checkbox" name="showPhoto" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
<p class="hint" id="badge-warning" hidden></p>
|
||||||
${field('Line printed at the bottom', 'note', site.badge.note)}
|
<label class="inline"><input type="checkbox" name="showPhoto" id="badge-photo" ${site.badge.showPhoto ? 'checked' : ''}> Include the visitor's photo</label>
|
||||||
<p class="hint">Common label sizes: 86 × 54 mm (card), 100 × 62 mm and 62 × 29 mm (Brother),
|
<label class="inline"><input type="checkbox" name="accent" id="badge-accent" ${site.badge.accent ? 'checked' : ''}> Print the heading and the no-check warning in red</label>
|
||||||
101 × 54 mm (Dymo). Preview before you commit a roll to it.</p>`,
|
<p class="hint">Red needs a two-colour roll such as the Brother DK-22251. On any other
|
||||||
|
roll it prints as grey. Two-colour printing is also much slower than black alone.</p>
|
||||||
|
${field('Line printed at the bottom', 'note', site.badge.note)}`,
|
||||||
async (form) => {
|
async (form) => {
|
||||||
const data = Object.fromEntries(form.entries());
|
const data = Object.fromEntries(form.entries());
|
||||||
try {
|
try {
|
||||||
@@ -552,19 +696,60 @@ function openSiteModal(site) {
|
|||||||
widthMm: Number(data.widthMm),
|
widthMm: Number(data.widthMm),
|
||||||
heightMm: Number(data.heightMm),
|
heightMm: Number(data.heightMm),
|
||||||
showPhoto: form.has('showPhoto'),
|
showPhoto: form.has('showPhoto'),
|
||||||
|
accent: form.has('accent'),
|
||||||
note: data.note,
|
note: data.note,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast('Site saved.');
|
toast('Site saved. Preview the badge before committing a roll to it.');
|
||||||
loadSites();
|
loadSites();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err.message, true);
|
toast(err.message, true);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
{ onOpen: wireBadgePreset }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wireBadgePreset() {
|
||||||
|
const width = $('#modal-form [name="widthMm"]');
|
||||||
|
const height = $('#modal-form [name="heightMm"]');
|
||||||
|
const warning = $('#badge-warning');
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const w = Number(width.value);
|
||||||
|
const h = Number(height.value);
|
||||||
|
if (w > 62) {
|
||||||
|
warning.hidden = false;
|
||||||
|
warning.className = 'hint warn';
|
||||||
|
warning.textContent = `${w} mm is wider than a QL-820NWB can take — it handles 12 to 62 mm media, printing up to 60.96 mm across. Fine for a different printer.`;
|
||||||
|
} else if (h < w * 1.2 && $('#badge-photo').checked && w <= 40) {
|
||||||
|
warning.hidden = false;
|
||||||
|
warning.className = 'hint warn';
|
||||||
|
warning.textContent = 'A photo on a label this narrow leaves very little room for the name. Consider turning the photo off.';
|
||||||
|
} else {
|
||||||
|
warning.hidden = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$('#badge-preset').addEventListener('change', (event) => {
|
||||||
|
const preset = LABEL_PRESETS.find((p) => p.id === event.target.value);
|
||||||
|
if (!preset) return;
|
||||||
|
width.value = preset.w;
|
||||||
|
height.value = preset.h;
|
||||||
|
$('#badge-photo').checked = preset.photo;
|
||||||
|
$('#badge-accent').checked = Boolean(preset.accent);
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
|
||||||
|
[width, height].forEach((el) => el.addEventListener('input', () => {
|
||||||
|
$('#badge-preset').value = '';
|
||||||
|
check();
|
||||||
|
}));
|
||||||
|
$('#badge-photo').addEventListener('change', check);
|
||||||
|
check();
|
||||||
|
}
|
||||||
|
|
||||||
$('#new-site').addEventListener('click', () => {
|
$('#new-site').addEventListener('click', () => {
|
||||||
openModal('Add a site', field('Site name', 'name', ''), async (form) => {
|
openModal('Add a site', field('Site name', 'name', ''), async (form) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
+19
-5
@@ -18,6 +18,7 @@ const state = {
|
|||||||
checkNumber: '',
|
checkNumber: '',
|
||||||
photo: null,
|
photo: null,
|
||||||
frequentVisitorId: null,
|
frequentVisitorId: null,
|
||||||
|
hasStoredPhoto: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let hosts = [];
|
let hosts = [];
|
||||||
@@ -143,6 +144,7 @@ function resetState() {
|
|||||||
checkNumber: '',
|
checkNumber: '',
|
||||||
photo: null,
|
photo: null,
|
||||||
frequentVisitorId: null,
|
frequentVisitorId: null,
|
||||||
|
hasStoredPhoto: false,
|
||||||
});
|
});
|
||||||
history = [];
|
history = [];
|
||||||
$$('#app input').forEach((i) => {
|
$$('#app input').forEach((i) => {
|
||||||
@@ -312,8 +314,13 @@ function buildReview() {
|
|||||||
/* ------------------------------------------------------------ submits */
|
/* ------------------------------------------------------------ submits */
|
||||||
|
|
||||||
async function submitSignIn() {
|
async function submitSignIn() {
|
||||||
const button = state.mode === 'frequent' ? $('#cam-use') : $('#do-signin');
|
// Called from the review screen, the camera screen, or straight from the host
|
||||||
button.disabled = true;
|
// list when a recurring visitor already has a photo on file.
|
||||||
|
const button =
|
||||||
|
(current === 'photo' && $('#cam-use')) ||
|
||||||
|
(current === 'review' && $('#do-signin')) ||
|
||||||
|
null;
|
||||||
|
if (button) button.disabled = true;
|
||||||
try {
|
try {
|
||||||
const result = await api('/api/signin', {
|
const result = await api('/api/signin', {
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
@@ -342,7 +349,7 @@ async function submitSignIn() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
say(err.message);
|
say(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
button.disabled = false;
|
if (button) button.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -501,9 +508,14 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
|||||||
state.frequentVisitorId = person.id;
|
state.frequentVisitorId = person.id;
|
||||||
state.firstName = person.firstName;
|
state.firstName = person.firstName;
|
||||||
state.lastName = person.lastName;
|
state.lastName = person.lastName;
|
||||||
|
// With a photo already on file there is nothing to pose for: picking a host
|
||||||
|
// completes the sign in and the pass prints straight away.
|
||||||
|
state.hasStoredPhoto = Boolean(person.hasPhoto);
|
||||||
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
|
$('#freq-greeting').textContent = `Hi ${person.firstName}. Who are you here to see?`;
|
||||||
$('#in-freq-host-search').value = '';
|
$('#in-freq-host-search').value = '';
|
||||||
renderHosts($('#freq-host-list'), '', () => show('photo'));
|
renderHosts($('#freq-host-list'), '', () =>
|
||||||
|
state.hasStoredPhoto ? submitSignIn() : show('photo')
|
||||||
|
);
|
||||||
show('freq-host');
|
show('freq-host');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
say(err.message);
|
say(err.message);
|
||||||
@@ -514,7 +526,9 @@ $('#do-freq-auth').addEventListener('click', async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
$('#in-freq-host-search').addEventListener('input', (e) =>
|
$('#in-freq-host-search').addEventListener('input', (e) =>
|
||||||
renderHosts($('#freq-host-list'), e.target.value, () => show('photo'))
|
renderHosts($('#freq-host-list'), e.target.value, () =>
|
||||||
|
state.hasStoredPhoto ? submitSignIn() : show('photo')
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
/* sign out */
|
/* sign out */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Database from 'better-sqlite3';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import config from './config.js';
|
import config from './config.js';
|
||||||
|
import { decryptPin, pinLookup } from './pins.js';
|
||||||
|
|
||||||
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
fs.mkdirSync(path.dirname(config.dbPath), { recursive: true });
|
||||||
fs.mkdirSync(config.photoDir, { recursive: true });
|
fs.mkdirSync(config.photoDir, { recursive: true });
|
||||||
@@ -16,9 +17,10 @@ CREATE TABLE IF NOT EXISTS sites (
|
|||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
slug TEXT NOT NULL UNIQUE,
|
slug TEXT NOT NULL UNIQUE,
|
||||||
badge_enabled INTEGER NOT NULL DEFAULT 0,
|
badge_enabled INTEGER NOT NULL DEFAULT 0,
|
||||||
badge_width_mm REAL NOT NULL DEFAULT 86,
|
badge_width_mm REAL NOT NULL DEFAULT 62,
|
||||||
badge_height_mm REAL NOT NULL DEFAULT 54,
|
badge_height_mm REAL NOT NULL DEFAULT 100,
|
||||||
badge_show_photo INTEGER NOT NULL DEFAULT 1,
|
badge_show_photo INTEGER NOT NULL DEFAULT 1,
|
||||||
|
badge_accent INTEGER NOT NULL DEFAULT 0,
|
||||||
badge_note TEXT,
|
badge_note TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
@@ -46,6 +48,8 @@ CREATE TABLE IF NOT EXISTS frequent_visitors (
|
|||||||
check_expiry TEXT,
|
check_expiry TEXT,
|
||||||
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
|
default_host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
|
||||||
pin_enc TEXT NOT NULL,
|
pin_enc TEXT NOT NULL,
|
||||||
|
pin_lookup TEXT,
|
||||||
|
photo_path TEXT,
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
active INTEGER NOT NULL DEFAULT 1,
|
active INTEGER NOT NULL DEFAULT 1,
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
@@ -138,8 +142,17 @@ addColumn('hosts', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE CASCADE');
|
|||||||
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
|
addColumn('visits', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
|
||||||
addColumn('visits', 'site_name', 'TEXT');
|
addColumn('visits', 'site_name', 'TEXT');
|
||||||
addColumn('visits', 'check_expiry', 'TEXT');
|
addColumn('visits', 'check_expiry', 'TEXT');
|
||||||
|
addColumn('visits', 'photo_path', 'TEXT');
|
||||||
// NULL site_id on a recurring visitor means they are welcome at every site.
|
// NULL site_id on a recurring visitor means they are welcome at every site.
|
||||||
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
|
addColumn('frequent_visitors', 'site_id', 'INTEGER REFERENCES sites(id) ON DELETE SET NULL');
|
||||||
|
// A photo kept on file, so a regular visitor is not asked to pose every visit.
|
||||||
|
addColumn('frequent_visitors', 'photo_path', 'TEXT');
|
||||||
|
// PINs are stored encrypted with a random IV, so the same PIN encrypts differently
|
||||||
|
// every time and cannot be compared. This deterministic digest makes the uniqueness
|
||||||
|
// check and the index possible.
|
||||||
|
addColumn('frequent_visitors', 'pin_lookup', 'TEXT');
|
||||||
|
// Two-colour printing, for rolls like the Brother DK-22251.
|
||||||
|
addColumn('sites', 'badge_accent', 'INTEGER NOT NULL DEFAULT 0');
|
||||||
|
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_visits_site ON visits(site_id, signed_out_at)');
|
||||||
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
db.exec('CREATE INDEX IF NOT EXISTS idx_hosts_site ON hosts(site_id, active)');
|
||||||
@@ -156,6 +169,86 @@ db.prepare('UPDATE hosts SET site_id = ? WHERE site_id IS NULL').run(firstSite.i
|
|||||||
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
|
db.prepare('UPDATE visits SET site_id = ? WHERE site_id IS NULL').run(firstSite.id);
|
||||||
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
|
db.prepare('UPDATE visits SET site_name = ? WHERE site_name IS NULL').run(firstSite.name);
|
||||||
|
|
||||||
|
/* -------------------------------------------------- one record per person */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Existing records predate the PIN digest, so fill it in once. Without this,
|
||||||
|
* a legacy visitor's PIN would be invisible to the uniqueness check and could be
|
||||||
|
* handed out to somebody else.
|
||||||
|
*/
|
||||||
|
const needingLookup = db
|
||||||
|
.prepare('SELECT id, pin_enc FROM frequent_visitors WHERE pin_lookup IS NULL')
|
||||||
|
.all();
|
||||||
|
if (needingLookup.length) {
|
||||||
|
const setLookup = db.prepare('UPDATE frequent_visitors SET pin_lookup = ? WHERE id = ?');
|
||||||
|
let filled = 0;
|
||||||
|
for (const row of needingLookup) {
|
||||||
|
const pin = decryptPin(row.pin_enc);
|
||||||
|
if (pin) {
|
||||||
|
setLookup.run(pinLookup(pin), row.id);
|
||||||
|
filled += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[db] indexed ${filled} existing PIN(s) for the uniqueness check`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names any records that already collide, so an admin knows who to fix. */
|
||||||
|
function reportDuplicates(column, label) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT ${column} AS value, GROUP_CONCAT(first_name || ' ' || last_name, ', ') AS people
|
||||||
|
FROM frequent_visitors
|
||||||
|
WHERE ${column} IS NOT NULL AND ${column} <> ''
|
||||||
|
GROUP BY ${column} HAVING COUNT(*) > 1`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
for (const row of rows) {
|
||||||
|
console.warn(`[db] duplicate ${label} shared by: ${row.people}`);
|
||||||
|
}
|
||||||
|
return rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicates =
|
||||||
|
reportDuplicates('lower(email)', 'email address') + reportDuplicates('pin_lookup', 'PIN');
|
||||||
|
if (duplicates) {
|
||||||
|
console.warn(
|
||||||
|
'[db] Fix the records above in Admin -> Recurring visitors. Until then the database ' +
|
||||||
|
'cannot enforce uniqueness, though new and edited records are still checked.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saved people must be unique on mobile number, email address and PIN. The phone
|
||||||
|
* column has carried a UNIQUE constraint from the start; these add the other two.
|
||||||
|
* Existing data may already contain duplicates, so a failure here is reported
|
||||||
|
* rather than thrown — the application-level checks still refuse new collisions.
|
||||||
|
*/
|
||||||
|
function addUniqueIndex(name, sql, what) {
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(
|
||||||
|
`[db] could not enforce unique ${what}: ${err.message}\n` +
|
||||||
|
` Existing records collide. Fix them in Admin -> Recurring visitors; ` +
|
||||||
|
`new and edited records are still checked.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addUniqueIndex(
|
||||||
|
'idx_freq_email',
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_email
|
||||||
|
ON frequent_visitors(lower(email)) WHERE email IS NOT NULL AND email <> ''`,
|
||||||
|
'email addresses'
|
||||||
|
);
|
||||||
|
|
||||||
|
addUniqueIndex(
|
||||||
|
'idx_freq_pin',
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_freq_pin
|
||||||
|
ON frequent_visitors(pin_lookup) WHERE pin_lookup IS NOT NULL`,
|
||||||
|
'PINs'
|
||||||
|
);
|
||||||
|
|
||||||
export function getSetting(key, fallback = null) {
|
export function getSetting(key, fallback = null) {
|
||||||
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
|
||||||
return row ? row.value : fallback;
|
return row ? row.value : fallback;
|
||||||
|
|||||||
@@ -31,6 +31,29 @@ export function savePhoto(dataUrl) {
|
|||||||
return path.join(folder, name);
|
return path.join(folder, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copies a recurring visitor's stored photo into a new file for one visit.
|
||||||
|
*
|
||||||
|
* A copy rather than a shared reference on purpose: the visit record is a snapshot
|
||||||
|
* of who was in the building that day, so replacing someone's profile photo later
|
||||||
|
* must not retroactively change what every past visit shows. It also keeps photo
|
||||||
|
* retention simple — purging old visits can never delete a live profile photo.
|
||||||
|
*/
|
||||||
|
export function copyStoredPhoto(relative) {
|
||||||
|
const source = photoAbsolutePath(relative);
|
||||||
|
if (!source) return null;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const folder = path.join(String(now.getFullYear()), String(now.getMonth() + 1).padStart(2, '0'));
|
||||||
|
const dir = path.join(config.photoDir, folder);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
|
||||||
|
const ext = path.extname(source) || '.jpeg';
|
||||||
|
const name = `${now.toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(4).toString('hex')}${ext}`;
|
||||||
|
fs.copyFileSync(source, path.join(dir, name));
|
||||||
|
return path.join(folder, name);
|
||||||
|
}
|
||||||
|
|
||||||
export function photoAbsolutePath(relative) {
|
export function photoAbsolutePath(relative) {
|
||||||
if (!relative) return null;
|
if (!relative) return null;
|
||||||
const resolved = path.resolve(config.photoDir, relative);
|
const resolved = path.resolve(config.photoDir, relative);
|
||||||
|
|||||||
+24
-8
@@ -43,12 +43,28 @@ export function verifyPin(stored, candidate) {
|
|||||||
return crypto.timingSafeEqual(a, b);
|
return crypto.timingSafeEqual(a, b);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generatePin() {
|
// PINs people will misread on a printed pass, or guess first.
|
||||||
// Avoids the handful of PINs people will misread on a printed pass.
|
const BANNED_PINS = new Set(['0000', '1111', '1234', '4321', '9999', '1122', '2580']);
|
||||||
const banned = new Set(['0000', '1111', '1234', '4321', '9999']);
|
|
||||||
let pin;
|
/**
|
||||||
do {
|
* A deterministic digest of a PIN, so two records can be compared without either
|
||||||
pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
|
* being decrypted. Keyed with APP_SECRET, so the database alone does not let
|
||||||
} while (banned.has(pin));
|
* anyone build a lookup table of all 10,000 possibilities.
|
||||||
return pin;
|
*/
|
||||||
|
export function pinLookup(pin) {
|
||||||
|
return crypto.createHmac('sha256', key).update(String(pin)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A PIN nobody else holds. `isTaken` is passed in by the caller so this module
|
||||||
|
* stays free of database knowledge.
|
||||||
|
*/
|
||||||
|
export function generatePin(isTaken = () => false) {
|
||||||
|
for (let attempt = 0; attempt < 200; attempt += 1) {
|
||||||
|
const pin = String(crypto.randomInt(0, 10000)).padStart(4, '0');
|
||||||
|
if (!BANNED_PINS.has(pin) && !isTaken(pin)) return pin;
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'No unused 4 digit PIN could be found. Deactivate some old recurring visitors first.'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+83
-18
@@ -4,8 +4,8 @@ import QRCode from 'qrcode';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import db from '../db.js';
|
import db from '../db.js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import { decryptPin, encryptPin, generatePin } from '../pins.js';
|
import { decryptPin, encryptPin, generatePin, pinLookup } from '../pins.js';
|
||||||
import { photoAbsolutePath, deletePhoto, purgeOldPhotos } from '../photos.js';
|
import { photoAbsolutePath, deletePhoto, purgeOldPhotos, savePhoto } from '../photos.js';
|
||||||
import * as sheets from '../sheets.js';
|
import * as sheets from '../sheets.js';
|
||||||
import * as tls from '../tls.js';
|
import * as tls from '../tls.js';
|
||||||
import * as users from '../users.js';
|
import * as users from '../users.js';
|
||||||
@@ -378,7 +378,7 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
const badge = req.body?.badge || {};
|
const badge = req.body?.badge || {};
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
`UPDATE sites SET name = ?, slug = ?, active = ?, badge_enabled = ?, badge_width_mm = ?,
|
||||||
badge_height_mm = ?, badge_show_photo = ?, badge_note = ? WHERE id = ?`
|
badge_height_mm = ?, badge_show_photo = ?, badge_accent = ?, badge_note = ? WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
clean(req.body?.name ?? site.name, 100) || site.name,
|
clean(req.body?.name ?? site.name, 100) || site.name,
|
||||||
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
req.body?.slug ? uniqueSlug(req.body.slug, site.id) : site.slug,
|
||||||
@@ -387,6 +387,7 @@ router.patch('/sites/:id', (req, res) => {
|
|||||||
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
|
Math.min(200, Math.max(20, Number(badge.widthMm ?? site.badge_width_mm) || 86)),
|
||||||
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
|
Math.min(200, Math.max(15, Number(badge.heightMm ?? site.badge_height_mm) || 54)),
|
||||||
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
|
badge.showPhoto !== undefined ? (badge.showPhoto ? 1 : 0) : site.badge_show_photo,
|
||||||
|
badge.accent !== undefined ? (badge.accent ? 1 : 0) : site.badge_accent,
|
||||||
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
|
badge.note !== undefined ? clean(badge.note, 120) || null : site.badge_note
|
||||||
, site.id);
|
, site.id);
|
||||||
|
|
||||||
@@ -549,6 +550,7 @@ function shapeFrequent(row, includePin = false) {
|
|||||||
checkExpiry: row.check_expiry,
|
checkExpiry: row.check_expiry,
|
||||||
defaultHostId: row.default_host_id,
|
defaultHostId: row.default_host_id,
|
||||||
siteId: row.site_id,
|
siteId: row.site_id,
|
||||||
|
hasPhoto: Boolean(row.photo_path),
|
||||||
notes: row.notes,
|
notes: row.notes,
|
||||||
active: Boolean(row.active),
|
active: Boolean(row.active),
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
@@ -607,7 +609,25 @@ router.get('/alerts', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function validateFrequent(body, { existingPhone = null } = {}) {
|
/** True when another recurring visitor already holds this PIN. */
|
||||||
|
function pinTaken(pin, excludeId = null) {
|
||||||
|
const row = db
|
||||||
|
.prepare('SELECT id FROM frequent_visitors WHERE pin_lookup = ?')
|
||||||
|
.get(pinLookup(pin));
|
||||||
|
return Boolean(row) && row.id !== excludeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function allocatePin(requested, excludeId = null) {
|
||||||
|
if (requested && /^\d{4}$/.test(String(requested))) {
|
||||||
|
if (pinTaken(String(requested), excludeId)) {
|
||||||
|
throw new Error('Another recurring visitor already uses that PIN. Choose a different one.');
|
||||||
|
}
|
||||||
|
return String(requested);
|
||||||
|
}
|
||||||
|
return generatePin((candidate) => pinTaken(candidate, excludeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateFrequent(body, { existingPhone = null, existingId = null } = {}) {
|
||||||
const firstName = titleCase(body?.firstName, 60);
|
const firstName = titleCase(body?.firstName, 60);
|
||||||
const lastName = titleCase(body?.lastName, 60);
|
const lastName = titleCase(body?.lastName, 60);
|
||||||
const phone = normalisePhone(body?.phone);
|
const phone = normalisePhone(body?.phone);
|
||||||
@@ -621,9 +641,24 @@ function validateFrequent(body, { existingPhone = null } = {}) {
|
|||||||
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
|
if (checkType !== 'NONE' && !clean(body?.checkNumber)) {
|
||||||
throw new Error(`A ${checkType} number is required.`);
|
throw new Error(`A ${checkType} number is required.`);
|
||||||
}
|
}
|
||||||
if (phone !== existingPhone) {
|
// One record per person: mobile number, email and PIN must each be unique.
|
||||||
const clash = db.prepare('SELECT id FROM frequent_visitors WHERE phone = ?').get(phone);
|
const phoneClash = db
|
||||||
if (clash) throw new Error('Another recurring visitor already uses that mobile number.');
|
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE phone = ?')
|
||||||
|
.get(phone);
|
||||||
|
if (phoneClash && phoneClash.id !== existingId) {
|
||||||
|
throw new Error(
|
||||||
|
`${phoneClash.first_name} ${phoneClash.last_name} already uses that mobile number.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (email) {
|
||||||
|
const emailClash = db
|
||||||
|
.prepare('SELECT id, first_name, last_name FROM frequent_visitors WHERE lower(email) = ?')
|
||||||
|
.get(email);
|
||||||
|
if (emailClash && emailClash.id !== existingId) {
|
||||||
|
throw new Error(
|
||||||
|
`${emailClash.first_name} ${emailClash.last_name} already uses that email address.`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
firstName,
|
firstName,
|
||||||
@@ -644,17 +679,19 @@ router.post('/frequent', (req, res) => {
|
|||||||
const v = validateFrequent(req.body);
|
const v = validateFrequent(req.body);
|
||||||
const scope = scopedSiteId(req);
|
const scope = scopedSiteId(req);
|
||||||
const siteId = scope || v.siteId;
|
const siteId = scope || v.siteId;
|
||||||
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
|
const pin = allocatePin(req.body?.pin);
|
||||||
|
const photoPath = req.body?.photo ? savePhoto(req.body.photo) : null;
|
||||||
|
|
||||||
const info = db
|
const info = db
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO frequent_visitors
|
`INSERT INTO frequent_visitors
|
||||||
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
|
(first_name, last_name, phone, email, check_type, check_number, check_expiry,
|
||||||
default_host_id, site_id, pin_enc, notes, active)
|
default_host_id, site_id, pin_enc, pin_lookup, photo_path, notes, active)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||||
v.defaultHostId, siteId, encryptPin(pin), v.notes
|
v.defaultHostId, siteId, encryptPin(pin), pinLookup(pin), photoPath, v.notes
|
||||||
);
|
);
|
||||||
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
|
res.json(shapeFrequent(db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(info.lastInsertRowid), true));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -666,16 +703,31 @@ router.patch('/frequent/:id', (req, res) => {
|
|||||||
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found.' });
|
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||||
try {
|
try {
|
||||||
const v = validateFrequent({ ...shapeFrequent(row), ...req.body }, { existingPhone: row.phone });
|
const v = validateFrequent(
|
||||||
|
{ ...shapeFrequent(row), ...req.body },
|
||||||
|
{ existingPhone: row.phone, existingId: row.id }
|
||||||
|
);
|
||||||
const scope = scopedSiteId(req);
|
const scope = scopedSiteId(req);
|
||||||
|
|
||||||
|
// A new photo replaces the old file; removePhoto clears it entirely.
|
||||||
|
let photoPath = row.photo_path;
|
||||||
|
if (req.body?.photo) {
|
||||||
|
photoPath = savePhoto(req.body.photo);
|
||||||
|
if (row.photo_path) deletePhoto(row.photo_path);
|
||||||
|
} else if (req.body?.removePhoto) {
|
||||||
|
if (row.photo_path) deletePhoto(row.photo_path);
|
||||||
|
photoPath = null;
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
|
`UPDATE frequent_visitors SET first_name = ?, last_name = ?, phone = ?, email = ?,
|
||||||
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
check_type = ?, check_number = ?, check_expiry = ?, default_host_id = ?, site_id = ?,
|
||||||
notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
photo_path = ?, notes = ?, active = ?, updated_at = datetime('now') WHERE id = ?`
|
||||||
).run(
|
).run(
|
||||||
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
v.firstName, v.lastName, v.phone, v.email, v.checkType, v.checkNumber, v.checkExpiry,
|
||||||
v.defaultHostId,
|
v.defaultHostId,
|
||||||
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
|
scope || (req.body?.siteId !== undefined ? v.siteId : row.site_id),
|
||||||
|
photoPath,
|
||||||
v.notes,
|
v.notes,
|
||||||
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
|
req.body?.active !== undefined ? (req.body.active ? 1 : 0) : row.active,
|
||||||
row.id
|
row.id
|
||||||
@@ -686,14 +738,27 @@ router.patch('/frequent/:id', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The photo kept on file for a recurring visitor. */
|
||||||
|
router.get('/frequent/:id/photo', (req, res) => {
|
||||||
|
const row = db.prepare('SELECT photo_path FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||||
|
const abs = row && photoAbsolutePath(row.photo_path);
|
||||||
|
if (!abs) return res.status(404).send('No photo on file.');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=60');
|
||||||
|
res.sendFile(abs);
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/frequent/:id/pin', (req, res) => {
|
router.post('/frequent/:id/pin', (req, res) => {
|
||||||
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
const row = db.prepare('SELECT * FROM frequent_visitors WHERE id = ?').get(req.params.id);
|
||||||
if (!row) return res.status(404).json({ error: 'Not found.' });
|
if (!row) return res.status(404).json({ error: 'Not found.' });
|
||||||
const pin = /^\d{4}$/.test(String(req.body?.pin || '')) ? String(req.body.pin) : generatePin();
|
let pin;
|
||||||
db.prepare("UPDATE frequent_visitors SET pin_enc = ?, updated_at = datetime('now') WHERE id = ?").run(
|
try {
|
||||||
encryptPin(pin),
|
pin = allocatePin(req.body?.pin, row.id);
|
||||||
row.id
|
} catch (err) {
|
||||||
);
|
return res.status(400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
db.prepare(
|
||||||
|
"UPDATE frequent_visitors SET pin_enc = ?, pin_lookup = ?, updated_at = datetime('now') WHERE id = ?"
|
||||||
|
).run(encryptPin(pin), pinLookup(pin), row.id);
|
||||||
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
|
db.prepare('DELETE FROM pin_attempts WHERE phone = ?').run(row.phone);
|
||||||
res.json({ ok: true, pin });
|
res.json({ ok: true, pin });
|
||||||
});
|
});
|
||||||
|
|||||||
+9
-2
@@ -2,7 +2,7 @@ import express from 'express';
|
|||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import db from '../db.js';
|
import db from '../db.js';
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import { savePhoto, photoAbsolutePath } from '../photos.js';
|
import { savePhoto, photoAbsolutePath, copyStoredPhoto } from '../photos.js';
|
||||||
import { mirror } from '../sheets.js';
|
import { mirror } from '../sheets.js';
|
||||||
import { verifyPin } from '../pins.js';
|
import { verifyPin } from '../pins.js';
|
||||||
import { listSites, resolveSite, badgeHtml } from '../sites.js';
|
import { listSites, resolveSite, badgeHtml } from '../sites.js';
|
||||||
@@ -150,10 +150,15 @@ router.post('/signin', signInLimiter, (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A recurring visitor with a photo on file is not asked to pose again; the
|
||||||
|
// stored photo is copied onto this visit as its own snapshot.
|
||||||
let photoPath = null;
|
let photoPath = null;
|
||||||
if (body.photo) {
|
if (body.photo) {
|
||||||
photoPath = savePhoto(body.photo);
|
photoPath = savePhoto(body.photo);
|
||||||
} else if (config.requirePhoto) {
|
} else if (isFrequent && frequent.photo_path) {
|
||||||
|
photoPath = copyStoredPhoto(frequent.photo_path);
|
||||||
|
}
|
||||||
|
if (!photoPath && config.requirePhoto) {
|
||||||
return res.status(400).json({ error: 'A photo is required to sign in.' });
|
return res.status(400).json({ error: 'A photo is required to sign in.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -336,6 +341,8 @@ router.post('/frequent/auth', pinLimiter, (req, res) => {
|
|||||||
checkType: person.check_type,
|
checkType: person.check_type,
|
||||||
checkNumber: person.check_number,
|
checkNumber: person.check_number,
|
||||||
defaultHostId: person.default_host_id,
|
defaultHostId: person.default_host_id,
|
||||||
|
// Tells the kiosk it can skip the camera step entirely.
|
||||||
|
hasPhoto: Boolean(person.photo_path),
|
||||||
openVisit: open || null,
|
openVisit: open || null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+73
-34
@@ -53,6 +53,7 @@ export function shapeSite(site) {
|
|||||||
widthMm: site.badge_width_mm,
|
widthMm: site.badge_width_mm,
|
||||||
heightMm: site.badge_height_mm,
|
heightMm: site.badge_height_mm,
|
||||||
showPhoto: Boolean(site.badge_show_photo),
|
showPhoto: Boolean(site.badge_show_photo),
|
||||||
|
accent: Boolean(site.badge_accent),
|
||||||
note: site.badge_note,
|
note: site.badge_note,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -70,14 +71,43 @@ const esc = (value) =>
|
|||||||
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
|
* on load so a kiosk can drop it into a hidden iframe and get one badge out.
|
||||||
*/
|
*/
|
||||||
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
|
export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {}) {
|
||||||
const width = Number(site.badge_width_mm) || 86;
|
const width = Number(site.badge_width_mm) || 62;
|
||||||
const height = Number(site.badge_height_mm) || 54;
|
const height = Number(site.badge_height_mm) || 100;
|
||||||
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
|
const showPhoto = Boolean(site.badge_show_photo) && Boolean(photoUrl);
|
||||||
// Scale the type with the smaller dimension so tiny labels stay legible.
|
|
||||||
const unit = Math.min(width, height);
|
// A label noticeably taller than it is wide gets a stacked layout. That is the
|
||||||
const nameSize = Math.max(3.4, unit * 0.115);
|
// normal case on a 62mm roll printer like the Brother QL-820NWB, where the roll
|
||||||
const bodySize = Math.max(2.1, unit * 0.062);
|
// fixes the width and the length runs down the badge.
|
||||||
|
const portrait = height >= width * 1.2;
|
||||||
|
|
||||||
|
// Type scales with the dimension that constrains it: the width on a portrait
|
||||||
|
// badge, the shorter side on a wide one. Keeps small stock legible.
|
||||||
|
const unit = portrait ? width : Math.min(width, height);
|
||||||
|
const pad = unit * 0.07;
|
||||||
|
const nameSize = Math.max(3.2, unit * (portrait ? 0.105 : 0.115));
|
||||||
|
const bodySize = Math.max(2.0, unit * (portrait ? 0.055 : 0.062));
|
||||||
|
const photoWidth = portrait ? unit * 0.52 : unit * 0.42;
|
||||||
|
|
||||||
|
// Red only appears on a two-colour roll (DK-22251 on the QL-820NWB). Anywhere
|
||||||
|
// else it prints as grey, so it is off unless the site opts in.
|
||||||
|
const accent = site.badge_accent ? '#d00019' : '#000';
|
||||||
const timeIn = new Date(visit.signed_in_at);
|
const timeIn = new Date(visit.signed_in_at);
|
||||||
|
const noCheck = visit.check_type === 'NONE';
|
||||||
|
|
||||||
|
const photo = showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : '';
|
||||||
|
const details = `
|
||||||
|
<div class="rows">
|
||||||
|
<div>Visiting <b>${esc(visit.host_name)}</b></div>
|
||||||
|
<div>In at <b>${esc(
|
||||||
|
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
|
||||||
|
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
|
||||||
|
<div>${
|
||||||
|
noCheck
|
||||||
|
? '<span class="flag">No WWCC / VIT</span>'
|
||||||
|
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
|
||||||
|
}</div>
|
||||||
|
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
return `<!doctype html>
|
return `<!doctype html>
|
||||||
<html lang="en-AU">
|
<html lang="en-AU">
|
||||||
@@ -91,27 +121,40 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
|||||||
.badge {
|
.badge {
|
||||||
width: ${width}mm;
|
width: ${width}mm;
|
||||||
height: ${height}mm;
|
height: ${height}mm;
|
||||||
padding: ${unit * 0.075}mm ${unit * 0.09}mm;
|
padding: ${pad}mm;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: ${unit * 0.07}mm;
|
flex-direction: ${portrait ? 'column' : 'row'};
|
||||||
align-items: stretch;
|
align-items: ${portrait ? 'center' : 'stretch'};
|
||||||
|
text-align: ${portrait ? 'center' : 'left'};
|
||||||
|
gap: ${unit * 0.05}mm;
|
||||||
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
|
font-family: "Segoe UI", Arial, Helvetica, sans-serif;
|
||||||
color: #000;
|
color: #000;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.photo {
|
.photo {
|
||||||
width: ${unit * 0.42}mm;
|
width: ${photoWidth}mm;
|
||||||
|
${portrait ? `height: ${photoWidth * 0.78}mm;` : ''}
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 0.3mm solid #000;
|
border: 0.3mm solid #000;
|
||||||
}
|
}
|
||||||
.body { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; }
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
${portrait ? 'align-items: center;' : ''}
|
||||||
|
}
|
||||||
.site {
|
.site {
|
||||||
font-size: ${bodySize * 0.85}mm;
|
width: 100%;
|
||||||
letter-spacing: 0.02em;
|
font-size: ${bodySize * 0.8}mm;
|
||||||
border-bottom: 0.35mm solid #000;
|
letter-spacing: 0.03em;
|
||||||
padding-bottom: ${unit * 0.025}mm;
|
text-transform: uppercase;
|
||||||
margin-bottom: ${unit * 0.045}mm;
|
color: ${accent};
|
||||||
|
border-bottom: 0.35mm solid ${accent};
|
||||||
|
padding-bottom: ${unit * 0.02}mm;
|
||||||
|
margin-bottom: ${unit * 0.035}mm;
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
font-size: ${nameSize}mm;
|
font-size: ${nameSize}mm;
|
||||||
@@ -120,14 +163,21 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
|||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
.rows { margin-top: auto; font-size: ${bodySize}mm; line-height: 1.35; }
|
.rows {
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: ${unit * 0.04}mm;
|
||||||
|
font-size: ${bodySize}mm;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
.rows b { font-weight: 700; }
|
.rows b { font-weight: 700; }
|
||||||
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.03}mm; }
|
.note { font-size: ${bodySize * 0.85}mm; margin-top: ${unit * 0.025}mm; }
|
||||||
.flag {
|
.flag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 0 ${unit * 0.03}mm;
|
padding: 0 ${unit * 0.03}mm;
|
||||||
border: 0.3mm solid #000;
|
border: 0.35mm solid ${accent};
|
||||||
font-size: ${bodySize * 0.85}mm;
|
color: ${accent};
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: ${bodySize * 0.9}mm;
|
||||||
}
|
}
|
||||||
@media screen {
|
@media screen {
|
||||||
body { background: #e7ecf0; padding: 12mm; }
|
body { background: #e7ecf0; padding: 12mm; }
|
||||||
@@ -137,22 +187,11 @@ export function badgeHtml(visit, site, { autoPrint = true, photoUrl = null } = {
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="badge">
|
<div class="badge">
|
||||||
${showPhoto ? `<img class="photo" src="${esc(photoUrl)}" alt="">` : ''}
|
${photo}
|
||||||
<div class="body">
|
<div class="body">
|
||||||
<div class="site">${esc(site.name)} · VISITOR</div>
|
<div class="site">${esc(site.name)} · Visitor</div>
|
||||||
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
|
<div class="name">${esc(visit.first_name)} ${esc(visit.last_name)}</div>
|
||||||
<div class="rows">
|
${details}
|
||||||
<div>Visiting <b>${esc(visit.host_name)}</b></div>
|
|
||||||
<div>In at <b>${esc(
|
|
||||||
timeIn.toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })
|
|
||||||
)}</b> on ${esc(timeIn.toLocaleDateString('en-AU', { day: '2-digit', month: 'short', year: '2-digit' }))}</div>
|
|
||||||
<div>${
|
|
||||||
visit.check_type === 'NONE'
|
|
||||||
? '<span class="flag">No WWCC / VIT</span>'
|
|
||||||
: `${esc(visit.check_type)} ${esc(visit.check_number || '')}`
|
|
||||||
}</div>
|
|
||||||
${site.badge_note ? `<div class="note">${esc(site.badge_note)}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
|
${autoPrint ? '<script>window.addEventListener("load", () => window.print());</script>' : ''}
|
||||||
|
|||||||
Reference in New Issue
Block a user