Public Access
Visitor sign in kiosk: multi-site, badge printing, WWCC expiry warnings, admin accounts with 2FA
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin — visitor sign in</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/admin.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ------------------------------------------------------------ login -->
|
||||
<!-- ---------------------------------------------------------- console -->
|
||||
<div id="console">
|
||||
<header class="topbar">
|
||||
<strong id="site-name">Visitor admin</strong>
|
||||
<nav>
|
||||
<button class="tab on" data-tab="onsite">On site</button>
|
||||
<button class="tab" data-tab="log">Visit log</button>
|
||||
<button class="tab" data-tab="recurring">Recurring visitors <span class="badge-count" id="alert-count" hidden></span></button>
|
||||
<button class="tab" data-tab="hosts">People to visit</button>
|
||||
<button class="tab" data-tab="sites">Sites</button>
|
||||
<button class="tab owner-only" data-tab="admins" hidden>Admins</button>
|
||||
<button class="tab" data-tab="system">System</button>
|
||||
</nav>
|
||||
<label class="site-switch" id="site-switch" hidden>
|
||||
<span>Site</span>
|
||||
<select id="site-filter"></select>
|
||||
</label>
|
||||
<button id="logout" class="link">Sign out</button>
|
||||
</header>
|
||||
|
||||
<p class="banner" id="expiry-banner" hidden></p>
|
||||
|
||||
<main>
|
||||
<!-- ------------------------------------------------------ on site -->
|
||||
<section class="panel on" id="panel-onsite">
|
||||
<div class="panel-head">
|
||||
<h2>Currently on site</h2>
|
||||
<button class="ghost" id="refresh-onsite">Refresh</button>
|
||||
</div>
|
||||
<p class="stat" id="onsite-count">—</p>
|
||||
<div id="onsite-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------------- log -->
|
||||
<section class="panel" id="panel-log">
|
||||
<div class="panel-head">
|
||||
<h2>Visit log</h2>
|
||||
<a class="ghost" id="csv-link" href="/admin/api/visits.csv">Download CSV</a>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<label><span>From</span><input type="date" id="log-from"></label>
|
||||
<label><span>To</span><input type="date" id="log-to"></label>
|
||||
<label class="grow"><span>Search name, company, host or contact</span><input id="log-q"></label>
|
||||
<button class="ghost" id="log-search">Apply</button>
|
||||
</div>
|
||||
<div id="log-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------- recurring -->
|
||||
<section class="panel" id="panel-recurring">
|
||||
<div class="panel-head">
|
||||
<h2>Recurring visitors</h2>
|
||||
<button class="primary" id="new-recurring">Add a recurring visitor</button>
|
||||
</div>
|
||||
<div id="expiry-summary"></div>
|
||||
<div id="recurring-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- ----------------------------------------------------- hosts -->
|
||||
<section class="panel" id="panel-hosts">
|
||||
<div class="panel-head">
|
||||
<h2>People a visitor can ask for</h2>
|
||||
<button class="primary" id="new-host">Add a person</button>
|
||||
</div>
|
||||
<p class="hint" id="hosts-scope"></p>
|
||||
<details class="import">
|
||||
<summary>Import from CSV</summary>
|
||||
<p class="hint">
|
||||
Paste the file contents below, or choose a .csv file. Recognised column headings are
|
||||
<code>name</code>, <code>email</code> and <code>area</code> (or department / team).
|
||||
A single column of names works too. The import applies to the site selected above.
|
||||
</p>
|
||||
<input type="file" id="host-file" accept=".csv,text/csv">
|
||||
<textarea id="host-csv" rows="6" placeholder="name,email,area Jess Rogerson,jess@example.com,Front office"></textarea>
|
||||
<label class="inline"><input type="checkbox" id="host-replace"> Deactivate anyone at this site who is not in the file</label>
|
||||
<button class="primary" id="do-host-import">Import</button>
|
||||
</details>
|
||||
<div id="hosts-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- ----------------------------------------------------- sites -->
|
||||
<section class="panel" id="panel-sites">
|
||||
<div class="panel-head">
|
||||
<h2>Sites</h2>
|
||||
<button class="primary owner-only" id="new-site" hidden>Add a site</button>
|
||||
</div>
|
||||
<p class="hint">Each site has its own name, its own list of people to visit, and its own
|
||||
badge settings. Point a kiosk at one with
|
||||
<code>/?site=<em>slug</em></code>, or let staff pick on first use.</p>
|
||||
<div id="sites-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------------- admins -->
|
||||
<section class="panel" id="panel-admins">
|
||||
<div class="panel-head">
|
||||
<h2>Admin accounts</h2>
|
||||
<button class="primary" id="new-admin">Invite an admin</button>
|
||||
</div>
|
||||
<p class="hint" id="admins-note"></p>
|
||||
<div id="admins-table"></div>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------------- system -->
|
||||
<section class="panel" id="panel-system">
|
||||
<h2>System</h2>
|
||||
<div id="system-body"></div>
|
||||
<h2 class="section-gap">Your account</h2>
|
||||
<div id="account-body"></div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="modal">
|
||||
<form method="dialog" id="modal-form">
|
||||
<h3 id="modal-title"></h3>
|
||||
<div id="modal-body"></div>
|
||||
<div class="modal-actions">
|
||||
<button value="cancel" class="ghost" id="modal-cancel">Cancel</button>
|
||||
<button value="save" class="primary" id="modal-save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<p class="toast" id="toast" role="status" hidden></p>
|
||||
|
||||
<footer class="foot">Created by: Jess Rogerson (yelling commands at Claude.AI)</footer>
|
||||
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,402 @@
|
||||
:root {
|
||||
--paper: #eef1f4;
|
||||
--card: #ffffff;
|
||||
--ink: #16202b;
|
||||
--muted: #5d6b7a;
|
||||
--rule: #d4dce3;
|
||||
--deep: #0b4f4a;
|
||||
--exit: #2c4a6b;
|
||||
--alert: #96162f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Author display rules beat the browser's [hidden] { display: none }, and several
|
||||
elements here are toggled with that attribute. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1, h2, h3 { letter-spacing: -0.015em; font-weight: 620; }
|
||||
h2 { font-size: 20px; margin: 0; }
|
||||
h3 { font-size: 18px; margin: 0 0 14px; }
|
||||
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
|
||||
.primary {
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--deep);
|
||||
border-radius: 3px;
|
||||
background: var(--deep);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.ghost {
|
||||
display: inline-block;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
}
|
||||
.danger { color: var(--alert); border-color: #e3bcc4; }
|
||||
.link {
|
||||
border: none;
|
||||
background: none;
|
||||
color: #d7e4e1;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; }
|
||||
|
||||
/* -------------------------------------------------------------- chrome */
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 22px;
|
||||
background: var(--deep);
|
||||
color: #eef5f3;
|
||||
}
|
||||
.topbar nav { display: flex; gap: 4px; flex-wrap: wrap; margin-right: auto; }
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: #cfe0dd;
|
||||
}
|
||||
.tab.on { background: rgba(255, 255, 255, 0.14); color: #fff; font-weight: 600; }
|
||||
|
||||
main { max-width: 1100px; margin: 0 auto; padding: 26px 22px 60px; }
|
||||
|
||||
.panel { display: none; }
|
||||
.panel.on { display: block; }
|
||||
|
||||
.panel-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 40px;
|
||||
font-weight: 650;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 0 0 6px;
|
||||
color: var(--deep);
|
||||
}
|
||||
|
||||
.hint { color: var(--muted); font-size: 14px; max-width: 70ch; }
|
||||
|
||||
/* -------------------------------------------------------------- tables */
|
||||
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card); font-size: 14.5px; }
|
||||
th, td { text-align: left; padding: 11px 12px; border-bottom: 1px solid var(--rule); vertical-align: middle; }
|
||||
th { font-weight: 600; color: var(--muted); font-size: 13.5px; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
td.actions { text-align: right; white-space: nowrap; }
|
||||
td.actions button { margin-left: 6px; padding: 6px 11px; font-size: 13.5px; }
|
||||
.mono { font-variant-numeric: tabular-nums; }
|
||||
.thumb { width: 42px; height: 42px; object-fit: cover; border-radius: 3px; display: block; }
|
||||
.empty { padding: 26px; background: var(--card); color: var(--muted); }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
padding: 2px 9px;
|
||||
border-radius: 2px;
|
||||
font-size: 12.5px;
|
||||
background: #e6efed;
|
||||
color: var(--deep);
|
||||
}
|
||||
.pill.off { background: #eceff2; color: var(--muted); }
|
||||
.pill.out { background: #e7edf4; color: var(--exit); }
|
||||
|
||||
/* ------------------------------------------------------------- filters */
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filters label { display: block; }
|
||||
.filters .grow { flex: 1 1 240px; }
|
||||
.filters span { display: block; font-size: 13px; color: var(--muted); margin-bottom: 4px; }
|
||||
.filters input { width: 100%; padding: 9px 11px; border: 1px solid var(--rule); border-radius: 3px; }
|
||||
|
||||
/* -------------------------------------------------------------- import */
|
||||
|
||||
.import {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.import summary { cursor: pointer; font-weight: 600; }
|
||||
.import textarea {
|
||||
width: 100%;
|
||||
margin: 12px 0;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.import input[type="file"] { margin-top: 12px; }
|
||||
.inline { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; font-size: 14px; }
|
||||
|
||||
/* --------------------------------------------------------------- modal */
|
||||
|
||||
dialog {
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
padding: 24px;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
border-top: 5px solid var(--deep);
|
||||
}
|
||||
dialog::backdrop { background: rgba(22, 32, 43, 0.45); }
|
||||
.modal-field { margin-bottom: 14px; }
|
||||
.modal-field span { display: block; font-size: 13.5px; color: var(--muted); margin-bottom: 5px; }
|
||||
.modal-field input, .modal-field select, .modal-field textarea {
|
||||
width: 100%;
|
||||
padding: 10px 11px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
|
||||
.pin-reveal {
|
||||
font-size: 34px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
color: var(--deep);
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin: 6px 0 16px;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- system */
|
||||
|
||||
#system-body { background: var(--card); padding: 20px; border-radius: 3px; }
|
||||
#system-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
|
||||
#system-body dt { color: var(--muted); }
|
||||
#system-body dd { margin: 0; }
|
||||
.sys-actions { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* --------------------------------------------------------------- toast */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
margin: 0;
|
||||
padding: 13px 18px;
|
||||
border-left: 5px solid var(--deep);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
|
||||
max-width: min(560px, calc(100% - 32px));
|
||||
}
|
||||
.toast.bad { border-left-color: var(--alert); }
|
||||
|
||||
.foot { padding: 0 22px 26px; color: var(--muted); font-size: 12.5px; text-align: center; }
|
||||
|
||||
/* ----------------------------------------------------- site switching */
|
||||
|
||||
.site-switch { display: flex; align-items: center; gap: 8px; color: #cfe0dd; font-size: 14px; }
|
||||
.site-switch select {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #fff;
|
||||
}
|
||||
.site-switch select option { color: var(--ink); }
|
||||
|
||||
.badge-count {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
border-radius: 10px;
|
||||
background: var(--alert);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------ banner, cards */
|
||||
|
||||
.banner {
|
||||
margin: 0;
|
||||
padding: 12px 22px;
|
||||
background: #fdf3d8;
|
||||
border-bottom: 1px solid #e6d5a4;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
.banner.bad { background: #fbeaed; border-bottom-color: #e8c3cb; }
|
||||
|
||||
.notice {
|
||||
padding: 12px 14px;
|
||||
margin: 0 0 16px;
|
||||
background: #fdf3d8;
|
||||
border-left: 4px solid #d9a441;
|
||||
font-size: 14.5px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 3px;
|
||||
padding: 18px 20px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.site-head { display: flex; justify-content: space-between; gap: 16px; flex-wrap: wrap; align-items: flex-start; }
|
||||
.site-head h3 { margin: 0 0 4px; }
|
||||
.site-head .hint { margin: 0; }
|
||||
.site-meta { display: grid; grid-template-columns: minmax(130px, 24%) 1fr; gap: 6px 16px; margin: 14px 0 0; font-size: 14.5px; }
|
||||
.site-meta dt { color: var(--muted); }
|
||||
.site-meta dd { margin: 0; }
|
||||
|
||||
.section-gap { margin-top: 34px; }
|
||||
#account-body { background: var(--card); padding: 20px; border-radius: 3px; }
|
||||
#account-body dl { display: grid; grid-template-columns: minmax(150px, 30%) 1fr; gap: 8px 16px; margin: 0 0 22px; }
|
||||
#account-body dt { color: var(--muted); }
|
||||
#account-body dd { margin: 0; }
|
||||
|
||||
td small { display: block; color: var(--muted); font-size: 13px; }
|
||||
tr.row-warn td { background: #fdf8ec; }
|
||||
tr.row-bad td { background: #fdf0f2; }
|
||||
|
||||
.pill.warn { background: #f7e7c4; color: #7a5308; }
|
||||
.pill.bad { background: #f6d5db; color: var(--alert); }
|
||||
|
||||
.modal-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.modal-section { margin: 20px 0 12px; font-size: 15px; }
|
||||
.pin-reveal.small { font-size: 24px; letter-spacing: 0.06em; word-break: break-all; }
|
||||
#modal img { display: block; margin: 0 auto 12px; border: 1px solid var(--rule); }
|
||||
|
||||
.fingerprint {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
#system-body h3 { margin-bottom: 14px; }
|
||||
|
||||
/* ------------------------------------------------- visitor photo editor */
|
||||
|
||||
.photo-editor { margin-bottom: 6px; }
|
||||
|
||||
.photo-frame {
|
||||
position: relative;
|
||||
width: 152px;
|
||||
/* Square, to match the kiosk camera and the frame printed on the badge. */
|
||||
aspect-ratio: 1 / 1;
|
||||
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;
|
||||
}
|
||||
.photo-frame video { 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);
|
||||
}
|
||||
|
||||
.plain-list { margin: 0 0 14px; padding-left: 20px; font-size: 14.5px; }
|
||||
.plain-list li { margin-bottom: 5px; }
|
||||
|
||||
/* The confirmation for a destructive action should not look like a Save. */
|
||||
#modal.destructive { border-top-color: var(--alert); }
|
||||
#modal.destructive .primary { border-color: var(--alert); background: var(--alert); }
|
||||
|
||||
/* ----------------------------------------------------- branding editor */
|
||||
|
||||
.banner-frame {
|
||||
/* A checkerboard, so a transparent PNG reads as transparent rather than white. */
|
||||
background-color: #fff;
|
||||
background-image:
|
||||
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%),
|
||||
linear-gradient(45deg, #e3e8ed 25%, transparent 25%, transparent 75%, #e3e8ed 75%);
|
||||
background-size: 14px 14px;
|
||||
background-position: 0 0, 7px 7px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 10px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.banner-frame img { max-width: 100%; max-height: 90px; display: block; }
|
||||
.banner-empty { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
|
||||
.colour-row { display: flex; gap: 8px; align-items: center; }
|
||||
.colour-row input[type="color"] {
|
||||
width: 42px;
|
||||
height: 40px;
|
||||
padding: 2px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.colour-row input[type="text"] {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.modal-row-3 { grid-template-columns: 1fr 1fr 1fr; gap: 10px; }
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--rule);
|
||||
vertical-align: -2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
:root {
|
||||
--paper: #e7ecf0;
|
||||
--card: #ffffff;
|
||||
--ink: #16202b;
|
||||
--muted: #5d6b7a;
|
||||
--rule: #c9d3dc;
|
||||
--deep: #0b4f4a;
|
||||
--deep-dark: #083a36;
|
||||
--on-brand: #ffffff;
|
||||
--exit: #2c4a6b;
|
||||
--exit-dark: #1f3650;
|
||||
--on-exit: #ffffff;
|
||||
--alert: #96162f;
|
||||
--focus: #0b4f4a;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* The rules below set display on elements that are toggled with the hidden
|
||||
attribute, and an author rule beats the browser's [hidden] { display: none }.
|
||||
Without this the captured still renders underneath the live camera feed. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.45;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- chrome */
|
||||
|
||||
.bar {
|
||||
/* Three tracks, so the banner can sit centred on the page rather than centred
|
||||
in the space the clock happens to leave over. */
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 14px 22px;
|
||||
background: var(--deep);
|
||||
color: var(--on-brand);
|
||||
}
|
||||
.bar .clock { grid-column: 3; justify-self: end; }
|
||||
.bar.align-left .banner,
|
||||
.bar.align-left .site { grid-column: 1; justify-self: start; }
|
||||
.bar.align-center .banner,
|
||||
.bar.align-center .site { grid-column: 2; justify-self: center; }
|
||||
|
||||
/* An uploaded banner sits in the bar in place of the site name. Transparent PNGs
|
||||
are the point, so nothing is painted behind it. */
|
||||
.banner {
|
||||
max-height: 64px;
|
||||
max-width: min(60vw, 460px);
|
||||
width: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.site { margin: 0; font-size: 17px; font-weight: 600; letter-spacing: -0.01em; }
|
||||
.clock { margin: 0; font-variant-numeric: tabular-nums; font-size: 16px; opacity: 0.85; }
|
||||
|
||||
.foot {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 16px;
|
||||
min-height: 20px;
|
||||
padding: 14px 22px 20px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.foot a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
#app {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 22px 8px;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ screens */
|
||||
|
||||
.screen { display: none; }
|
||||
.screen.on { display: block; animation: rise 180ms ease-out; }
|
||||
|
||||
@keyframes rise {
|
||||
from { opacity: 0; transform: translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.screen.on { animation: none; }
|
||||
}
|
||||
|
||||
h1, h2 {
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.015em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 { font-size: 30px; margin: 8px 0 26px; }
|
||||
h2 { font-size: 25px; margin: 4px 0 20px; }
|
||||
|
||||
.hint { margin: -12px 0 22px; color: var(--muted); font-size: 15px; max-width: 46ch; }
|
||||
|
||||
/* --------------------------------------------------------- home doors */
|
||||
|
||||
.welcome { max-width: 18ch; }
|
||||
|
||||
.doors { display: grid; gap: 14px; }
|
||||
|
||||
.door {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 26px 24px;
|
||||
border: none;
|
||||
border-left: 7px solid var(--deep-dark);
|
||||
border-radius: 3px;
|
||||
background: var(--deep);
|
||||
color: var(--on-brand);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.door-out { background: var(--exit); border-left-color: var(--exit-dark); color: var(--on-exit); }
|
||||
.door:active { transform: translateY(1px); }
|
||||
|
||||
.door-title { display: block; font-size: 27px; font-weight: 650; letter-spacing: -0.01em; }
|
||||
.door-sub { display: block; margin-top: 4px; font-size: 15px; opacity: 0.82; }
|
||||
|
||||
.text-action {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 22px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.text-action:hover { background: var(--card); }
|
||||
.text-action, .field input, .picker, .choice, .host-option { color: var(--ink); }
|
||||
|
||||
/* ------------------------------------------------------- step rail */
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-bottom: 18px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.rail i {
|
||||
display: block;
|
||||
width: 26px;
|
||||
height: 3px;
|
||||
background: var(--rule);
|
||||
border-radius: 2px;
|
||||
}
|
||||
.rail i.done { background: var(--deep); }
|
||||
.rail span { margin-left: 6px; }
|
||||
|
||||
/* ---------------------------------------------------------- fields */
|
||||
|
||||
.field { display: block; margin-bottom: 18px; }
|
||||
.field > span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.field input {
|
||||
width: 100%;
|
||||
padding: 15px 14px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
}
|
||||
.field input:focus-visible,
|
||||
button:focus-visible,
|
||||
a:focus-visible {
|
||||
outline: 3px solid var(--focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.field-pin input {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.5em;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- choices */
|
||||
|
||||
.choices { display: grid; gap: 10px; margin-bottom: 20px; }
|
||||
.choice, .host-option {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 18px 16px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.choice[aria-pressed="true"] {
|
||||
border-color: var(--deep);
|
||||
box-shadow: inset 0 0 0 1px var(--deep);
|
||||
background: #f2f8f6;
|
||||
}
|
||||
.host-list {
|
||||
max-height: 46vh;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.host-option small { display: block; color: var(--muted); font-size: 14px; }
|
||||
.host-empty {
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
margin: -6px 0 18px;
|
||||
padding: 12px 14px;
|
||||
background: var(--card);
|
||||
border-left: 4px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- camera */
|
||||
|
||||
.camera {
|
||||
position: relative;
|
||||
/* Square, matching the crop that is saved and the frame printed on the badge,
|
||||
so the visitor sees exactly what ends up on their pass. */
|
||||
aspect-ratio: 1 / 1;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
margin: 0 auto 18px;
|
||||
background: #0f1720;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.camera video, .camera img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
/* The live feed is mirrored because that is how people expect to see themselves.
|
||||
The captured still is not: it shows what will actually print. */
|
||||
.camera video { transform: scaleX(-1); }
|
||||
.camera-error {
|
||||
margin: -8px 0 18px;
|
||||
padding: 14px;
|
||||
border-left: 4px solid var(--alert);
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- review */
|
||||
|
||||
.review {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 34%) 1fr;
|
||||
gap: 10px 16px;
|
||||
margin: 0 0 24px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--rule);
|
||||
font-size: 16.5px;
|
||||
}
|
||||
.review dt { color: var(--muted); }
|
||||
.review dd { margin: 0; }
|
||||
.review img { width: 78px; aspect-ratio: 1 / 1; object-fit: cover; border-radius: 3px; display: block; }
|
||||
|
||||
/* ------------------------------------------------------------- done */
|
||||
|
||||
.screen-done .mark {
|
||||
display: inline-block;
|
||||
margin: 8px 0 14px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--deep);
|
||||
color: var(--on-brand);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
#screen-done-out .mark { background: var(--exit); color: var(--on-exit); }
|
||||
|
||||
/* ------------------------------------------------------------ buttons */
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 26px;
|
||||
}
|
||||
.nav button {
|
||||
flex: 1 1 auto;
|
||||
min-height: 62px;
|
||||
padding: 16px 22px;
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
font-size: 19px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.nav .primary {
|
||||
border: 1px solid var(--deep);
|
||||
background: var(--deep);
|
||||
color: var(--on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav .ghost {
|
||||
flex: 0 1 auto;
|
||||
border: 1px solid var(--rule);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
}
|
||||
.nav button[disabled] { opacity: 0.55; cursor: progress; }
|
||||
|
||||
/* ------------------------------------------------------------- alert */
|
||||
|
||||
.alert {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 22px;
|
||||
transform: translateX(-50%);
|
||||
width: min(560px, calc(100% - 32px));
|
||||
margin: 0;
|
||||
padding: 16px 18px;
|
||||
border-left: 5px solid var(--alert);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
box-shadow: 0 10px 28px rgba(22, 32, 43, 0.18);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
h1 { font-size: 26px; }
|
||||
h2 { font-size: 22px; }
|
||||
.door-title { font-size: 23px; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------- site + badge bits */
|
||||
|
||||
.foot-link {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#badge-frame {
|
||||
position: fixed;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- host dropdown */
|
||||
|
||||
.picker {
|
||||
width: 100%;
|
||||
padding: 15px 14px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: var(--card);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-size: 20px;
|
||||
/* Tall enough to be a comfortable touch target on a tablet. */
|
||||
min-height: 58px;
|
||||
}
|
||||
.picker:disabled { color: var(--muted); }
|
||||
|
||||
/* An optional field says so quietly, without shouting for attention. */
|
||||
.field > span em { font-style: normal; opacity: 0.75; }
|
||||
@@ -0,0 +1,203 @@
|
||||
:root {
|
||||
--paper: #e7ecf0;
|
||||
--card: #ffffff;
|
||||
--ink: #16202b;
|
||||
--muted: #5d6b7a;
|
||||
--rule: #d4dce3;
|
||||
--deep: #0b4f4a;
|
||||
--alert: #96162f;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
/* Author display rules beat the browser's [hidden] { display: none }, and the
|
||||
sign in screens are toggled with that attribute. */
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "Segoe UI", system-ui, Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
|
||||
button, input { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
:focus-visible { outline: 3px solid var(--deep); outline-offset: 2px; }
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- card */
|
||||
|
||||
.auth-card {
|
||||
width: min(400px, 100%);
|
||||
padding: 26px 30px 30px;
|
||||
background: var(--card);
|
||||
border-radius: 3px;
|
||||
border-top: 5px solid var(--deep);
|
||||
box-shadow: 0 12px 34px rgba(22, 32, 43, 0.1);
|
||||
}
|
||||
|
||||
.auth-head { position: relative; margin-bottom: 20px; }
|
||||
.auth-site {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12.5px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.auth-head h1 { font-size: 22px; font-weight: 620; letter-spacing: -0.015em; margin: 0 0 6px; }
|
||||
.auth-sub { margin: 0; color: var(--muted); font-size: 14px; }
|
||||
|
||||
.auth-back {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: 0;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
.auth-back:hover { background: var(--paper); color: var(--ink); }
|
||||
|
||||
.auth-rail {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.auth-rail i { display: block; width: 22px; height: 3px; border-radius: 2px; background: var(--rule); }
|
||||
.auth-rail i.done { background: var(--deep); }
|
||||
.auth-rail span { margin-left: 5px; }
|
||||
|
||||
/* A floor under the body means most steps occupy the same box, so moving between
|
||||
them swaps content rather than visibly growing the card. */
|
||||
.auth-body { min-height: 232px; }
|
||||
|
||||
.auth-screen { display: block; }
|
||||
.auth-screen.entering { animation: screen-in 200ms cubic-bezier(0.2, 0, 0.2, 1); }
|
||||
|
||||
@keyframes screen-in {
|
||||
from { opacity: 0; transform: translateX(12px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.auth-screen.entering { animation: none; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- fields */
|
||||
|
||||
.auth-screen label { display: block; }
|
||||
.auth-screen label span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
.auth-screen input {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
margin-bottom: 16px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
.auth-screen button[type="submit"],
|
||||
.auth-screen > button,
|
||||
.auth-row button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--deep);
|
||||
border-radius: 3px;
|
||||
background: var(--deep);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
.auth-screen button[disabled] { opacity: 0.6; cursor: progress; }
|
||||
.auth-screen .hint { margin: -4px 0 16px; font-size: 13.5px; color: var(--muted); }
|
||||
|
||||
.auth-row { display: flex; gap: 10px; }
|
||||
.auth-row .secondary {
|
||||
border-color: var(--rule);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#twofa-qr { display: block; margin: 0 auto 14px; border: 1px solid var(--rule); border-radius: 3px; }
|
||||
#twofa-code {
|
||||
letter-spacing: 0.32em;
|
||||
text-align: center;
|
||||
font-size: 22px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.auth-details { margin: 0 0 18px; font-size: 13.5px; }
|
||||
.auth-details summary { cursor: pointer; color: var(--muted); }
|
||||
.auth-details .hint { margin: 10px 0 6px; }
|
||||
code {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
background: var(--paper);
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.recovery {
|
||||
list-style: none;
|
||||
margin: 0 0 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px 14px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.err {
|
||||
margin: 18px 0 0;
|
||||
padding: 11px 13px;
|
||||
border-left: 4px solid var(--alert);
|
||||
background: #fbeaed;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.auth-foot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin: 20px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-foot a { color: var(--muted); text-decoration: underline; text-underline-offset: 3px; }
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="Visitor sign in">
|
||||
<rect width="32" height="32" rx="5" fill="#0b4f4a"/>
|
||||
<!-- a visitor badge: clip at the top, portrait below -->
|
||||
<rect x="13" y="5" width="6" height="3" rx="1" fill="#8fd6c9"/>
|
||||
<rect x="7" y="9" width="18" height="18" rx="2.5" fill="#ffffff"/>
|
||||
<circle cx="16" cy="15.5" r="3.1" fill="#0b4f4a"/>
|
||||
<path d="M10.4 24.2c0-3.1 2.5-5.2 5.6-5.2s5.6 2.1 5.6 5.2z" fill="#0b4f4a"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
@@ -0,0 +1,247 @@
|
||||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0b4f4a">
|
||||
<title>Visitor sign in</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/kiosk.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="bar align-left" id="bar">
|
||||
<img class="banner" id="banner" alt="" hidden>
|
||||
<p class="site" id="siteName">Visitor sign in</p>
|
||||
<p class="clock" id="clock"></p>
|
||||
</header>
|
||||
|
||||
<main id="app">
|
||||
|
||||
<!-- ---------------------------------------------------- site picker -->
|
||||
<section class="screen" id="screen-site">
|
||||
<h1 class="welcome">Which site is this kiosk at?</h1>
|
||||
<p class="hint">This tablet remembers the answer, so you only pick once.</p>
|
||||
<div class="host-list" id="site-list"></div>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------------------- home -->
|
||||
<section class="screen" id="screen-home">
|
||||
<h1 class="welcome">Welcome. Are you coming in, or heading out?</h1>
|
||||
<div class="doors">
|
||||
<button class="door door-in" data-go="guest-name">
|
||||
<span class="door-title">Sign in</span>
|
||||
<span class="door-sub">First time, or an occasional visit</span>
|
||||
</button>
|
||||
<button class="door door-out" data-go="signout-find">
|
||||
<span class="door-title">Sign out</span>
|
||||
<span class="door-sub">Leaving the site</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="text-action" data-go="freq-pin">I have a PIN — I come here regularly</button>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------- guest: name -->
|
||||
<section class="screen" id="screen-guest-name" data-step="1">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>What's your name?</h2>
|
||||
<label class="field">
|
||||
<span>First name</span>
|
||||
<input id="in-first" autocomplete="off" autocapitalize="words" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Last name</span>
|
||||
<input id="in-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Company or organisation <em>(optional)</em></span>
|
||||
<input id="in-company" autocomplete="organization" autocapitalize="words" enterkeyhint="next"
|
||||
placeholder="Leave blank if you're not here for work">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Back</button>
|
||||
<button class="primary" data-next="guest-name">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------- guest: host -->
|
||||
<section class="screen" id="screen-guest-host" data-step="2">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Who are you here to see?</h2>
|
||||
<label class="field">
|
||||
<span>Start typing a name to narrow the list</span>
|
||||
<input id="in-host-search" autocomplete="off" enterkeyhint="search" placeholder="e.g. Rogerson">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Then choose a name</span>
|
||||
<select id="host-select" class="picker"></select>
|
||||
</label>
|
||||
<p class="host-empty" id="host-empty" hidden></p>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-back>Back</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ---------------------------------------------- guest: contact -->
|
||||
<section class="screen" id="screen-guest-contact" data-step="3">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>How can we reach you today?</h2>
|
||||
<p class="hint">One of these is enough. We use it to sign you out and in an emergency.</p>
|
||||
<label class="field">
|
||||
<span>Mobile number</span>
|
||||
<input id="in-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Email address</span>
|
||||
<input id="in-email" type="email" inputmode="email" autocomplete="email" enterkeyhint="next">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-back>Back</button>
|
||||
<button class="primary" data-next="guest-contact">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ guest: check -->
|
||||
<section class="screen" id="screen-guest-check" data-step="4">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Do you hold a WWCC or VIT registration?</h2>
|
||||
<div class="choices" id="check-choices">
|
||||
<button class="choice" data-check="WWCC">Working with Children Check</button>
|
||||
<button class="choice" data-check="VIT">Victorian Institute of Teaching</button>
|
||||
<button class="choice" data-check="NONE">I don't have one</button>
|
||||
</div>
|
||||
<label class="field" id="check-number-field" hidden>
|
||||
<span id="check-number-label">Card number</span>
|
||||
<input id="in-check-number" autocomplete="off" autocapitalize="characters" enterkeyhint="next">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-back>Back</button>
|
||||
<button class="primary" id="check-continue" hidden>Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------- photo -->
|
||||
<section class="screen" id="screen-photo" data-step="5">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Look at the camera</h2>
|
||||
<p class="hint">The photo stays on this site's server. It is not sent anywhere else.</p>
|
||||
<div class="camera">
|
||||
<video id="cam-video" playsinline muted autoplay></video>
|
||||
<img id="cam-shot" alt="The photo you just took" hidden>
|
||||
<canvas id="cam-canvas" hidden></canvas>
|
||||
</div>
|
||||
<p class="camera-error" id="cam-error" hidden></p>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-back>Back</button>
|
||||
<button class="primary" id="cam-take">Take photo</button>
|
||||
<button class="ghost" id="cam-retake" hidden>Retake</button>
|
||||
<button class="primary" id="cam-use" hidden>Use this photo</button>
|
||||
<button class="ghost" id="cam-skip" hidden>Continue without a photo</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------------ review -->
|
||||
<section class="screen" id="screen-review" data-step="6">
|
||||
<div class="rail" data-rail></div>
|
||||
<h2>Check these details, then sign in</h2>
|
||||
<dl class="review" id="review-list"></dl>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Start over</button>
|
||||
<button class="primary" id="do-signin">Sign in</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------- recurring: the PIN -->
|
||||
<section class="screen" id="screen-freq-pin">
|
||||
<h2>Welcome back</h2>
|
||||
<label class="field">
|
||||
<span>Mobile number</span>
|
||||
<input id="in-freq-phone" type="tel" inputmode="tel" autocomplete="tel" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field field-pin">
|
||||
<span>4 digit PIN</span>
|
||||
<input id="in-freq-pin" type="password" inputmode="numeric" maxlength="4" autocomplete="off" enterkeyhint="go">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Back</button>
|
||||
<button class="primary" id="do-freq-auth">Continue</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- --------------------------------------------- recurring: host -->
|
||||
<section class="screen" id="screen-freq-host">
|
||||
<h2 id="freq-greeting">Who are you here to see?</h2>
|
||||
<label class="field">
|
||||
<span>Start typing a name to narrow the list</span>
|
||||
<input id="in-freq-host-search" autocomplete="off" enterkeyhint="search">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Then choose a name</span>
|
||||
<select id="freq-host-select" class="picker"></select>
|
||||
</label>
|
||||
<p class="host-empty" id="freq-host-empty" hidden></p>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Cancel</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- --------------------------------------------------- signed in -->
|
||||
<section class="screen screen-done" id="screen-done-in">
|
||||
<p class="mark">Signed in</p>
|
||||
<h2 id="done-in-message"></h2>
|
||||
<p class="hint" id="done-in-detail"></p>
|
||||
<div class="nav">
|
||||
<button class="primary" data-go="home">Done</button>
|
||||
<button class="ghost" id="reprint-badge" hidden>Print the badge again</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------- sign out: find -->
|
||||
<section class="screen" id="screen-signout-find">
|
||||
<h2>Signing out</h2>
|
||||
<label class="field">
|
||||
<span>Last name</span>
|
||||
<input id="out-last" autocomplete="off" autocapitalize="words" enterkeyhint="next">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Mobile number or email</span>
|
||||
<input id="out-contact" autocomplete="off" enterkeyhint="go">
|
||||
</label>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="home">Back</button>
|
||||
<button class="primary" id="do-signout-find">Find my visit</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ------------------------------------------------ sign out: pick -->
|
||||
<section class="screen" id="screen-signout-pick">
|
||||
<h2>Is this you?</h2>
|
||||
<div class="host-list" id="signout-list"></div>
|
||||
<div class="nav">
|
||||
<button class="ghost" data-go="signout-find">Back</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- -------------------------------------------------- signed out -->
|
||||
<section class="screen screen-done" id="screen-done-out">
|
||||
<p class="mark">Signed out</p>
|
||||
<h2 id="done-out-message"></h2>
|
||||
<p class="hint">Thanks for visiting. Travel safely.</p>
|
||||
<div class="nav">
|
||||
<button class="primary" data-go="home">Done</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<p class="alert" id="alert" role="alert" hidden></p>
|
||||
|
||||
<footer class="foot">
|
||||
<button class="foot-link" id="change-site" hidden></button>
|
||||
</footer>
|
||||
|
||||
<iframe id="badge-frame" title="Badge printing" aria-hidden="true"></iframe>
|
||||
|
||||
<script src="/js/kiosk.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1472
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,716 @@
|
||||
/* Visitor kiosk — single page flow controller. */
|
||||
|
||||
const TOTAL_STEPS = 6;
|
||||
const IDLE_MS = 120000;
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
mode: 'guest',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
company: '',
|
||||
hostId: null,
|
||||
hostName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
checkType: '',
|
||||
checkNumber: '',
|
||||
photo: null,
|
||||
frequentVisitorId: null,
|
||||
hasStoredPhoto: false,
|
||||
};
|
||||
|
||||
let hosts = [];
|
||||
let siteConfig = { requirePhoto: true, siteName: 'Visitor sign in', multiSite: false, site: null };
|
||||
let history = [];
|
||||
let current = 'home';
|
||||
let idleTimer = null;
|
||||
let lastBadgeUrl = null;
|
||||
|
||||
/* ------------------------------------------------------------- site */
|
||||
// Which entrance this tablet belongs to. A ?site=slug in the address wins and is
|
||||
// remembered, so a kiosk can be pointed at a site once during setup.
|
||||
|
||||
const SITE_KEY = 'visitorKioskSite';
|
||||
|
||||
function storedSite() {
|
||||
const fromUrl = new URLSearchParams(location.search).get('site');
|
||||
if (fromUrl) {
|
||||
try {
|
||||
localStorage.setItem(SITE_KEY, fromUrl);
|
||||
} catch {
|
||||
/* private browsing */
|
||||
}
|
||||
return fromUrl;
|
||||
}
|
||||
try {
|
||||
return localStorage.getItem(SITE_KEY) || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
let siteSlug = storedSite();
|
||||
|
||||
function rememberSite(slug) {
|
||||
siteSlug = slug;
|
||||
try {
|
||||
localStorage.setItem(SITE_KEY, slug);
|
||||
} catch {
|
||||
/* private browsing */
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ plumbing */
|
||||
|
||||
async function api(path, body) {
|
||||
const url = body ? path : path + (path.includes('?') ? '&' : '?') + `site=${encodeURIComponent(siteSlug)}`;
|
||||
const res = await fetch(url, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify({ ...body, site: siteSlug }) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || 'Something went wrong. Try the front desk.');
|
||||
return data;
|
||||
}
|
||||
|
||||
let alertTimer = null;
|
||||
function say(message) {
|
||||
const box = $('#alert');
|
||||
box.textContent = message;
|
||||
box.hidden = false;
|
||||
clearTimeout(alertTimer);
|
||||
alertTimer = setTimeout(() => {
|
||||
box.hidden = true;
|
||||
}, 6000);
|
||||
}
|
||||
|
||||
function clearAlert() {
|
||||
$('#alert').hidden = true;
|
||||
}
|
||||
|
||||
function drawRail(screen) {
|
||||
const el = screen.querySelector('[data-rail]');
|
||||
if (!el) return;
|
||||
if (state.mode === 'frequent') {
|
||||
el.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
const step = Number(screen.dataset.step || 0);
|
||||
const bars = Array.from({ length: TOTAL_STEPS }, (_, i) =>
|
||||
`<i class="${i < step ? 'done' : ''}"></i>`
|
||||
).join('');
|
||||
el.innerHTML = `${bars}<span>Step ${step} of ${TOTAL_STEPS}</span>`;
|
||||
}
|
||||
|
||||
function show(name, { push = true } = {}) {
|
||||
const next = document.getElementById(`screen-${name}`);
|
||||
if (!next) return;
|
||||
if (push && current !== name) history.push(current);
|
||||
if (current === 'photo' && name !== 'photo') stopCamera();
|
||||
|
||||
$$('.screen').forEach((s) => s.classList.remove('on'));
|
||||
next.classList.add('on');
|
||||
current = name;
|
||||
clearAlert();
|
||||
drawRail(next);
|
||||
window.scrollTo(0, 0);
|
||||
|
||||
const firstInput = next.querySelector('input');
|
||||
if (firstInput && !('ontouchstart' in window)) firstInput.focus();
|
||||
|
||||
if (name === 'photo') startCamera();
|
||||
if (name === 'home') resetState();
|
||||
resetIdle();
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
const previous = history.pop() || 'home';
|
||||
show(previous, { push: false });
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
Object.assign(state, {
|
||||
mode: 'guest',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
company: '',
|
||||
hostId: null,
|
||||
hostName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
checkType: '',
|
||||
checkNumber: '',
|
||||
photo: null,
|
||||
frequentVisitorId: null,
|
||||
hasStoredPhoto: false,
|
||||
});
|
||||
history = [];
|
||||
$$('#app input').forEach((i) => {
|
||||
i.value = '';
|
||||
});
|
||||
$$('#app select').forEach((sel) => {
|
||||
sel.selectedIndex = 0;
|
||||
});
|
||||
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
|
||||
$('#check-number-field').hidden = true;
|
||||
$('#check-continue').hidden = true;
|
||||
}
|
||||
|
||||
function resetIdle() {
|
||||
clearTimeout(idleTimer);
|
||||
if (current === 'home') return;
|
||||
idleTimer = setTimeout(() => show('home', { push: false }), IDLE_MS);
|
||||
}
|
||||
|
||||
['click', 'keydown', 'touchstart'].forEach((evt) =>
|
||||
document.addEventListener(evt, resetIdle, { passive: true })
|
||||
);
|
||||
|
||||
/* --------------------------------------------------------------- clock */
|
||||
|
||||
function tickClock() {
|
||||
$('#clock').textContent = new Date().toLocaleString('en-AU', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
setInterval(tickClock, 15000);
|
||||
tickClock();
|
||||
|
||||
/* --------------------------------------------------------------- hosts */
|
||||
|
||||
/**
|
||||
* Who you are here to see: type to narrow, then choose from the dropdown. The
|
||||
* dropdown is a native select, so a tablet gives it a proper full-screen picker
|
||||
* with its own scrolling, which handles a long staff list better than a page of
|
||||
* buttons ever did.
|
||||
*/
|
||||
function renderHosts(selectEl, searchValue, onPick, emptyEl = null) {
|
||||
const term = String(searchValue || '').trim().toLowerCase();
|
||||
const matches = term
|
||||
? hosts.filter(
|
||||
(h) => h.name.toLowerCase().includes(term) || (h.area || '').toLowerCase().includes(term)
|
||||
)
|
||||
: hosts;
|
||||
|
||||
const label = !hosts.length
|
||||
? 'Nobody has been added yet'
|
||||
: matches.length === hosts.length
|
||||
? `Choose one of ${hosts.length}`
|
||||
: `${matches.length} ${matches.length === 1 ? 'match' : 'matches'} — choose one`;
|
||||
|
||||
selectEl.innerHTML =
|
||||
`<option value="">${escapeHtml(label)}</option>` +
|
||||
matches
|
||||
.map(
|
||||
(h) =>
|
||||
`<option value="${h.id}">${escapeHtml(h.name)}${h.area ? ` — ${escapeHtml(h.area)}` : ''}</option>`
|
||||
)
|
||||
.join('');
|
||||
selectEl.disabled = matches.length === 0;
|
||||
|
||||
selectEl.onchange = () => {
|
||||
const picked = hosts.find((h) => h.id === Number(selectEl.value));
|
||||
if (!picked) return;
|
||||
state.hostId = picked.id;
|
||||
state.hostName = picked.name;
|
||||
onPick();
|
||||
};
|
||||
|
||||
if (emptyEl) {
|
||||
emptyEl.hidden = matches.length > 0;
|
||||
emptyEl.textContent = hosts.length
|
||||
? 'No one matches that. Check the spelling, or ask the front desk.'
|
||||
: 'No one has been added for this site yet. Please see the front desk.';
|
||||
}
|
||||
|
||||
// Typing a name and pressing enter should just work when only one person is left.
|
||||
selectEl.dataset.only = matches.length === 1 ? String(matches[0].id) : '';
|
||||
}
|
||||
|
||||
/** Enter in the filter box picks the person when the filter leaves exactly one. */
|
||||
function pickOnlyMatch(selectEl) {
|
||||
const only = selectEl.dataset.only;
|
||||
if (!only) return false;
|
||||
selectEl.value = only;
|
||||
selectEl.dispatchEvent(new Event('change'));
|
||||
return true;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value).replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- camera */
|
||||
|
||||
let stream = null;
|
||||
|
||||
async function startCamera() {
|
||||
const video = $('#cam-video');
|
||||
const err = $('#cam-error');
|
||||
err.hidden = true;
|
||||
$('#cam-shot').hidden = true;
|
||||
video.hidden = false;
|
||||
$('#cam-take').hidden = false;
|
||||
$('#cam-retake').hidden = true;
|
||||
$('#cam-use').hidden = true;
|
||||
$('#cam-skip').hidden = siteConfig.requirePhoto;
|
||||
state.photo = null;
|
||||
|
||||
if (stream) return;
|
||||
try {
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error('unsupported');
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = stream;
|
||||
} catch (e) {
|
||||
const insecure = !window.isSecureContext;
|
||||
err.hidden = false;
|
||||
err.textContent = insecure
|
||||
? 'The camera is blocked because this kiosk is not on a secure connection. Ask IT to serve the kiosk over HTTPS, then reload.'
|
||||
: 'No camera is available on this device. Ask the front desk to sign you in.';
|
||||
$('#cam-take').hidden = true;
|
||||
$('#cam-skip').hidden = siteConfig.requirePhoto;
|
||||
}
|
||||
}
|
||||
|
||||
function stopCamera() {
|
||||
if (!stream) return;
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
stream = null;
|
||||
$('#cam-video').srcObject = null;
|
||||
}
|
||||
|
||||
const PHOTO_SIZE = 640;
|
||||
|
||||
/**
|
||||
* Takes a square photo, cropped from the centre of whatever shape the camera
|
||||
* gives us. The preview frame, the saved file and the space on the badge are all
|
||||
* square, so nothing is stretched and what the visitor sees is what prints.
|
||||
*/
|
||||
function capture() {
|
||||
const video = $('#cam-video');
|
||||
const canvas = $('#cam-canvas');
|
||||
const side = Math.min(video.videoWidth, video.videoHeight);
|
||||
if (!side) return say('The camera is not ready yet. Try again in a moment.');
|
||||
|
||||
const sx = (video.videoWidth - side) / 2;
|
||||
const sy = (video.videoHeight - side) / 2;
|
||||
canvas.width = PHOTO_SIZE;
|
||||
canvas.height = PHOTO_SIZE;
|
||||
canvas.getContext('2d').drawImage(video, sx, sy, side, side, 0, 0, PHOTO_SIZE, PHOTO_SIZE);
|
||||
state.photo = canvas.toDataURL('image/jpeg', 0.72);
|
||||
|
||||
const shot = $('#cam-shot');
|
||||
shot.src = state.photo;
|
||||
shot.hidden = false;
|
||||
video.hidden = true;
|
||||
$('#cam-take').hidden = true;
|
||||
$('#cam-skip').hidden = true;
|
||||
$('#cam-retake').hidden = false;
|
||||
$('#cam-use').hidden = false;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------- validation */
|
||||
|
||||
const emailOk = (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
|
||||
const phoneOk = (v) => v.replace(/[^\d]/g, '').length >= 8;
|
||||
|
||||
function afterPhoto() {
|
||||
if (state.mode === 'frequent') {
|
||||
submitSignIn();
|
||||
} else {
|
||||
buildReview();
|
||||
show('review');
|
||||
}
|
||||
}
|
||||
|
||||
function buildReview() {
|
||||
const rows = [
|
||||
['Name', `${state.firstName} ${state.lastName}`],
|
||||
...(state.company ? [['From', state.company]] : []),
|
||||
['Visiting', state.hostName],
|
||||
['Mobile', state.phone || '—'],
|
||||
['Email', state.email || '—'],
|
||||
[
|
||||
'Check',
|
||||
state.checkType === 'NONE' ? 'None held' : `${state.checkType} ${state.checkNumber}`,
|
||||
],
|
||||
];
|
||||
const photoRow = state.photo
|
||||
? `<dt>Photo</dt><dd><img src="${state.photo}" alt="The photo you took"></dd>`
|
||||
: '';
|
||||
$('#review-list').innerHTML =
|
||||
rows.map(([k, v]) => `<dt>${escapeHtml(k)}</dt><dd>${escapeHtml(v)}</dd>`).join('') + photoRow;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ submits */
|
||||
|
||||
async function submitSignIn() {
|
||||
// Called from the review screen, the camera screen, or straight from the host
|
||||
// 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 {
|
||||
const result = await api('/api/signin', {
|
||||
mode: state.mode,
|
||||
frequentVisitorId: state.frequentVisitorId,
|
||||
firstName: state.firstName,
|
||||
lastName: state.lastName,
|
||||
company: state.company,
|
||||
hostId: state.hostId,
|
||||
phone: state.phone,
|
||||
email: state.email,
|
||||
checkType: state.checkType,
|
||||
checkNumber: state.checkNumber,
|
||||
photo: state.photo,
|
||||
});
|
||||
stopCamera();
|
||||
$('#done-in-message').textContent = `You're all set, ${result.firstName}.`;
|
||||
const printing = result.serverPrinted || result.badgeUrl;
|
||||
$('#done-in-detail').textContent = printing
|
||||
? `${result.hostName} has been recorded as your host. Your badge is printing — please wear it, and sign out when you leave.`
|
||||
: `${result.hostName} has been recorded as your host. Please sign out when you leave.`;
|
||||
// With server printing the badge is already coming out of the label printer,
|
||||
// so the kiosk neither prints nor offers to.
|
||||
lastBadgeUrl = result.badgeUrl;
|
||||
$('#reprint-badge').hidden = !result.badgeUrl;
|
||||
if (result.badgeUrl) printBadge(result.badgeUrl);
|
||||
show('done-in', { push: false });
|
||||
setTimeout(() => {
|
||||
if (current === 'done-in') show('home', { push: false });
|
||||
}, 12000);
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------- badge */
|
||||
|
||||
/**
|
||||
* The badge page prints itself once loaded, so dropping it into a hidden iframe
|
||||
* gives one label without the visitor seeing a print dialog on most kiosks.
|
||||
*/
|
||||
function printBadge(url) {
|
||||
const frame = $('#badge-frame');
|
||||
frame.src = `${url}?t=${Date.now()}`;
|
||||
}
|
||||
|
||||
$('#reprint-badge').addEventListener('click', () => {
|
||||
if (lastBadgeUrl) printBadge(lastBadgeUrl);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------- site picker */
|
||||
|
||||
async function chooseSite() {
|
||||
const sites = await api('/api/sites');
|
||||
const list = $('#site-list');
|
||||
if (!sites.length) {
|
||||
list.innerHTML = `<p class="host-empty">No sites are set up yet. An admin needs to add one first.</p>`;
|
||||
} else {
|
||||
list.innerHTML = sites
|
||||
.map(
|
||||
(s) =>
|
||||
`<button class="host-option" data-site="${escapeHtml(s.slug)}">${escapeHtml(s.name)}</button>`
|
||||
)
|
||||
.join('');
|
||||
list.querySelectorAll('[data-site]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
rememberSite(btn.dataset.site);
|
||||
await loadSiteContext();
|
||||
show('home', { push: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
show('site', { push: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the site's colours as CSS variables. Only three are chosen by an admin;
|
||||
* the shades and the text colours that sit on them are derived server-side so a
|
||||
* dark logo colour cannot end up with dark text on it.
|
||||
*/
|
||||
function applyTheme(theme, banner, align = 'left') {
|
||||
if (theme) {
|
||||
const root = document.documentElement.style;
|
||||
root.setProperty('--deep', theme.brand);
|
||||
root.setProperty('--deep-dark', theme.brandDark);
|
||||
root.setProperty('--on-brand', theme.onBrand);
|
||||
root.setProperty('--exit', theme.signout);
|
||||
root.setProperty('--exit-dark', theme.signoutDark);
|
||||
root.setProperty('--on-exit', theme.onSignout);
|
||||
root.setProperty('--paper', theme.page);
|
||||
root.setProperty('--card', theme.card);
|
||||
root.setProperty('--ink', theme.ink);
|
||||
root.setProperty('--muted', theme.muted);
|
||||
root.setProperty('--rule', theme.rule);
|
||||
root.setProperty('--focus', theme.brand);
|
||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme.brand);
|
||||
}
|
||||
|
||||
const img = $('#banner');
|
||||
const bar = $('#bar');
|
||||
bar.classList.toggle('align-center', align === 'center');
|
||||
bar.classList.toggle('align-left', align !== 'center');
|
||||
|
||||
if (banner?.url) {
|
||||
img.src = banner.url;
|
||||
img.style.maxHeight = `${banner.height}px`;
|
||||
img.hidden = false;
|
||||
// The banner carries the branding, so the name beside it would just repeat it.
|
||||
$('#siteName').hidden = true;
|
||||
} else {
|
||||
img.hidden = true;
|
||||
img.removeAttribute('src');
|
||||
$('#siteName').hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSiteContext() {
|
||||
siteConfig = await api('/api/config');
|
||||
const name = siteConfig.site ? siteConfig.site.name : siteConfig.siteName;
|
||||
document.title = name;
|
||||
$('#siteName').textContent = name;
|
||||
$('#banner').alt = name;
|
||||
applyTheme(siteConfig.theme, siteConfig.banner, siteConfig.headerAlign);
|
||||
|
||||
const change = $('#change-site');
|
||||
change.hidden = !siteConfig.multiSite;
|
||||
change.textContent = siteConfig.site ? `Site: ${siteConfig.site.name} — change` : 'Choose site';
|
||||
|
||||
hosts = await api('/api/hosts');
|
||||
renderHosts($('#host-select'), '', () => show('guest-contact'), $('#host-empty'));
|
||||
}
|
||||
|
||||
$('#change-site').addEventListener('click', chooseSite);
|
||||
|
||||
/* --------------------------------------------------------------- wiring */
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const go = event.target.closest('[data-go]');
|
||||
if (go) {
|
||||
const target = go.dataset.go;
|
||||
if (target === 'home') {
|
||||
show('home', { push: false });
|
||||
} else if (target === 'guest-name') {
|
||||
state.mode = 'guest';
|
||||
show('guest-name');
|
||||
} else {
|
||||
show(target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target.closest('[data-back]')) goBack();
|
||||
});
|
||||
|
||||
$('[data-next="guest-name"]').addEventListener('click', () => {
|
||||
const first = $('#in-first').value.trim();
|
||||
const last = $('#in-last').value.trim();
|
||||
if (!first) return say('Enter your first name.');
|
||||
if (!last) return say('Enter your last name.');
|
||||
state.firstName = first;
|
||||
state.lastName = last;
|
||||
state.company = $('#in-company').value.trim();
|
||||
show('guest-host');
|
||||
});
|
||||
|
||||
$('#in-host-search').addEventListener('input', (e) =>
|
||||
renderHosts($('#host-select'), e.target.value, () => show('guest-contact'), $('#host-empty'))
|
||||
);
|
||||
$('#in-host-search').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
pickOnlyMatch($('#host-select'));
|
||||
}
|
||||
});
|
||||
|
||||
$('[data-next="guest-contact"]').addEventListener('click', () => {
|
||||
const phone = $('#in-phone').value.trim();
|
||||
const email = $('#in-email').value.trim();
|
||||
if (!phone && !email) return say('Add a mobile number or an email address.');
|
||||
if (phone && !phoneOk(phone)) return say('That mobile number looks too short.');
|
||||
if (email && !emailOk(email)) return say('That email address does not look right.');
|
||||
state.phone = phone;
|
||||
state.email = email;
|
||||
show('guest-check');
|
||||
});
|
||||
|
||||
$$('.choice').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
$$('.choice').forEach((b) => b.setAttribute('aria-pressed', 'false'));
|
||||
btn.setAttribute('aria-pressed', 'true');
|
||||
state.checkType = btn.dataset.check;
|
||||
const needsNumber = state.checkType !== 'NONE';
|
||||
$('#check-number-field').hidden = !needsNumber;
|
||||
$('#check-number-label').textContent =
|
||||
state.checkType === 'WWCC' ? 'WWCC card number' : 'VIT registration number';
|
||||
$('#check-continue').hidden = false;
|
||||
if (needsNumber) $('#in-check-number').focus();
|
||||
});
|
||||
});
|
||||
|
||||
$('#check-continue').addEventListener('click', () => {
|
||||
if (state.checkType !== 'NONE') {
|
||||
const number = $('#in-check-number').value.trim();
|
||||
if (!number) return say('Enter the number on your card.');
|
||||
state.checkNumber = number;
|
||||
} else {
|
||||
state.checkNumber = '';
|
||||
}
|
||||
show('photo');
|
||||
});
|
||||
|
||||
$('#cam-take').addEventListener('click', capture);
|
||||
$('#cam-retake').addEventListener('click', () => startCamera());
|
||||
$('#cam-use').addEventListener('click', afterPhoto);
|
||||
$('#cam-skip').addEventListener('click', () => {
|
||||
state.photo = null;
|
||||
afterPhoto();
|
||||
});
|
||||
|
||||
$('#do-signin').addEventListener('click', submitSignIn);
|
||||
|
||||
/* recurring visitors */
|
||||
|
||||
$('#do-freq-auth').addEventListener('click', async () => {
|
||||
const phone = $('#in-freq-phone').value.trim();
|
||||
const pin = $('#in-freq-pin').value.trim();
|
||||
if (!phoneOk(phone)) return say('Enter the mobile number on your card.');
|
||||
if (!/^\d{4}$/.test(pin)) return say('Your PIN is 4 digits.');
|
||||
|
||||
const button = $('#do-freq-auth');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const person = await api('/api/frequent/auth', { phone, pin });
|
||||
if (person.openVisit) {
|
||||
say(`${person.firstName}, you are already signed in. Use Sign out instead.`);
|
||||
return;
|
||||
}
|
||||
state.mode = 'frequent';
|
||||
state.frequentVisitorId = person.id;
|
||||
state.firstName = person.firstName;
|
||||
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?`;
|
||||
$('#in-freq-host-search').value = '';
|
||||
renderHosts(
|
||||
$('#freq-host-select'),
|
||||
'',
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-empty')
|
||||
);
|
||||
show('freq-host');
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
$('#in-freq-pin').value = '';
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#in-freq-host-search').addEventListener('input', (e) =>
|
||||
renderHosts(
|
||||
$('#freq-host-select'),
|
||||
e.target.value,
|
||||
() => (state.hasStoredPhoto ? submitSignIn() : show('photo')),
|
||||
$('#freq-host-empty')
|
||||
)
|
||||
);
|
||||
$('#in-freq-host-search').addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
pickOnlyMatch($('#freq-host-select'));
|
||||
}
|
||||
});
|
||||
|
||||
/* sign out */
|
||||
|
||||
$('#do-signout-find').addEventListener('click', async () => {
|
||||
const lastName = $('#out-last').value.trim();
|
||||
const contact = $('#out-contact').value.trim();
|
||||
if (!lastName) return say('Enter your last name.');
|
||||
if (!contact) return say('Enter your mobile number or email.');
|
||||
|
||||
const button = $('#do-signout-find');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const matches = await api('/api/signout/lookup', { lastName, contact });
|
||||
const list = $('#signout-list');
|
||||
list.innerHTML = matches
|
||||
.map(
|
||||
(m) =>
|
||||
`<button class="host-option" data-visit="${m.id}">
|
||||
${escapeHtml(m.firstName)} ${escapeHtml(m.lastName)}
|
||||
<small>Visiting ${escapeHtml(m.hostName)} · in at ${new Date(m.signedInAt).toLocaleTimeString('en-AU', { hour: '2-digit', minute: '2-digit', hour12: false })}</small>
|
||||
</button>`
|
||||
)
|
||||
.join('');
|
||||
list.querySelectorAll('[data-visit]').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const done = await api('/api/signout', { visitId: Number(btn.dataset.visit) });
|
||||
$('#done-out-message').textContent = `Goodbye, ${done.firstName}.`;
|
||||
show('done-out', { push: false });
|
||||
setTimeout(() => {
|
||||
if (current === 'done-out') show('home', { push: false });
|
||||
}, 10000);
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
show('signout-pick');
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* Enter key moves the flow along on every screen. */
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
const screen = document.querySelector('.screen.on');
|
||||
const primary = screen?.querySelector('.primary:not([hidden])');
|
||||
if (primary) {
|
||||
event.preventDefault();
|
||||
primary.click();
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------- start */
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
await loadSiteContext();
|
||||
} catch {
|
||||
/* fall through to the picker below */
|
||||
}
|
||||
// With one site the server resolves it for us; with several, ask once.
|
||||
if (!siteConfig.siteChosen) {
|
||||
await chooseSite();
|
||||
return;
|
||||
}
|
||||
show('home', { push: false });
|
||||
})();
|
||||
@@ -0,0 +1,282 @@
|
||||
/* Visitor sign in — admin login page.
|
||||
*
|
||||
* A page of its own, not a panel hidden inside the console. When it finishes it
|
||||
* navigates to /admin, so the console loads fresh with no login markup in it.
|
||||
*/
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||
|
||||
const esc = (v) =>
|
||||
String(v ?? '').replace(/[&<>"']/g, (c) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||||
);
|
||||
|
||||
async function api(path, { method = 'GET', body } = {}) {
|
||||
const res = await fetch(`/admin/api${path}`, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || `Request failed (${res.status}).`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
const el = $('#login-error');
|
||||
el.textContent = message || '';
|
||||
el.hidden = !message;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- screens */
|
||||
|
||||
const SCREENS = {
|
||||
'step-password': {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Use the email address your account was set up with.',
|
||||
focus: '#login-email',
|
||||
},
|
||||
'step-2fa-verify': {
|
||||
node: 'step-2fa',
|
||||
title: 'Two factor',
|
||||
subtitle: 'Enter the current code from your authenticator app.',
|
||||
focus: '#twofa-code',
|
||||
back: true,
|
||||
},
|
||||
'step-2fa-setup': {
|
||||
node: 'step-2fa',
|
||||
title: 'Set up two factor',
|
||||
subtitle:
|
||||
'Scan this with Google Authenticator, Authy, 1Password or similar, then enter the code it shows.',
|
||||
focus: '#twofa-code',
|
||||
back: true,
|
||||
},
|
||||
'step-recovery': {
|
||||
title: 'Recovery codes',
|
||||
subtitle:
|
||||
'Each works once, if you lose the phone with your authenticator on it. Save them now — they are not shown again.',
|
||||
},
|
||||
'step-newpassword': {
|
||||
title: 'Choose a password',
|
||||
subtitle: 'Set one only you know before you continue.',
|
||||
focus: '#pw-current',
|
||||
},
|
||||
'step-setup': {
|
||||
title: 'Not set up yet',
|
||||
subtitle: 'No admin account exists on this server.',
|
||||
},
|
||||
};
|
||||
|
||||
let flow = ['step-password'];
|
||||
let currentKey = 'step-password';
|
||||
let recoveryCodes = [];
|
||||
|
||||
function setFlow(steps) {
|
||||
flow = steps;
|
||||
}
|
||||
|
||||
function renderRail(key) {
|
||||
const rail = $('#auth-rail');
|
||||
const index = flow.indexOf(key);
|
||||
if (flow.length < 2 || index < 0) {
|
||||
rail.hidden = true;
|
||||
return;
|
||||
}
|
||||
rail.hidden = false;
|
||||
rail.innerHTML =
|
||||
flow.map((_, i) => `<i class="${i <= index ? 'done' : ''}"></i>`).join('') +
|
||||
`<span>Step ${index + 1} of ${flow.length}</span>`;
|
||||
}
|
||||
|
||||
function goto(key) {
|
||||
const meta = SCREENS[key] || SCREENS['step-password'];
|
||||
const nodeId = meta.node || key;
|
||||
|
||||
currentKey = key;
|
||||
$('#auth-title').textContent = meta.title;
|
||||
$('#auth-subtitle').textContent = meta.subtitle || '';
|
||||
$('#auth-subtitle').hidden = !meta.subtitle;
|
||||
$('#auth-back').hidden = !meta.back;
|
||||
renderRail(key);
|
||||
showError('');
|
||||
|
||||
$$('.auth-screen').forEach((el) => {
|
||||
el.hidden = el.id !== nodeId;
|
||||
el.classList.remove('entering');
|
||||
});
|
||||
|
||||
const entering = document.getElementById(nodeId);
|
||||
void entering.offsetWidth; // restart the animation if the same node is reused
|
||||
entering.classList.add('entering');
|
||||
|
||||
const focusTarget = meta.focus ? $(meta.focus) : entering.querySelector('input');
|
||||
if (focusTarget) setTimeout(() => focusTarget.focus(), 50);
|
||||
}
|
||||
|
||||
$('#auth-back').addEventListener('click', async () => {
|
||||
if (!SCREENS[currentKey]?.back) return;
|
||||
await api('/logout', { method: 'POST' }).catch(() => {});
|
||||
$('#twofa-code').value = '';
|
||||
$('#login-password').value = '';
|
||||
setFlow(['step-password']);
|
||||
goto('step-password');
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------- the journey */
|
||||
|
||||
function handle(result) {
|
||||
if (result.status === 'twoFactorSetup') {
|
||||
$('#twofa-setup').hidden = false;
|
||||
$('#twofa-qr').src = result.qr;
|
||||
$('#twofa-secret').textContent = result.secret;
|
||||
$('#twofa-label').textContent = '6 digit code from the app';
|
||||
// Declared in full now, so the step counter never changes its total midway.
|
||||
setFlow([
|
||||
'step-password',
|
||||
'step-2fa-setup',
|
||||
'step-recovery',
|
||||
...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
|
||||
]);
|
||||
goto('step-2fa-setup');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'twoFactorRequired') {
|
||||
$('#twofa-setup').hidden = true;
|
||||
$('#twofa-label').textContent = '6 digit code, or a recovery code';
|
||||
setFlow([
|
||||
'step-password',
|
||||
'step-2fa-verify',
|
||||
...(result.passwordChangeToFollow ? ['step-newpassword'] : []),
|
||||
]);
|
||||
goto('step-2fa-verify');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.recoveryCodes) {
|
||||
recoveryCodes = result.recoveryCodes;
|
||||
$('#recovery-list').innerHTML = recoveryCodes.map((c) => `<li>${esc(c)}</li>`).join('');
|
||||
$('#recovery-done').dataset.next = result.status;
|
||||
goto('step-recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.status === 'passwordChangeRequired') {
|
||||
if (!flow.includes('step-newpassword')) setFlow([...flow, 'step-newpassword']);
|
||||
goto('step-newpassword');
|
||||
return;
|
||||
}
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
/** Leaves the login page entirely; the console loads as a fresh document. */
|
||||
function done() {
|
||||
window.location.href = '/admin';
|
||||
}
|
||||
|
||||
$('#step-password').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
showError('');
|
||||
const button = event.target.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const email = $('#login-email').value.trim();
|
||||
$('#pw-username').value = email;
|
||||
handle(await api('/login', { method: 'POST', body: { email, password: $('#login-password').value } }));
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#step-2fa').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
showError('');
|
||||
const button = event.target.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await api('/login/2fa', { method: 'POST', body: { code: $('#twofa-code').value } });
|
||||
$('#twofa-code').value = '';
|
||||
handle(result);
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
$('#twofa-code').select();
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
$('#recovery-copy').addEventListener('click', async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(recoveryCodes.join('\n'));
|
||||
$('#recovery-copy').textContent = 'Copied';
|
||||
setTimeout(() => {
|
||||
$('#recovery-copy').textContent = 'Copy codes';
|
||||
}, 2500);
|
||||
} catch {
|
||||
showError('Copying was blocked by the browser. Write the codes down instead.');
|
||||
}
|
||||
});
|
||||
|
||||
$('#recovery-done').addEventListener('click', () => {
|
||||
if ($('#recovery-done').dataset.next === 'passwordChangeRequired') goto('step-newpassword');
|
||||
else done();
|
||||
});
|
||||
|
||||
$('#step-newpassword').addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
showError('');
|
||||
if ($('#pw-new').value !== $('#pw-again').value) {
|
||||
return showError('The two new passwords do not match.');
|
||||
}
|
||||
const button = event.target.querySelector('button[type="submit"]');
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api('/account/password', {
|
||||
method: 'POST',
|
||||
body: { currentPassword: $('#pw-current').value, newPassword: $('#pw-new').value },
|
||||
});
|
||||
done();
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------- start */
|
||||
|
||||
(async function init() {
|
||||
try {
|
||||
const session = await api('/session');
|
||||
$('#auth-site').textContent = session.siteName || 'Visitor admin';
|
||||
document.title = `Sign in — ${session.siteName || 'visitor admin'}`;
|
||||
|
||||
if (session.domainRule) {
|
||||
$('#domain-rule').textContent = `Use your ${session.domainRule} address.`;
|
||||
$('#domain-rule').hidden = false;
|
||||
}
|
||||
|
||||
if (session.admin && session.mustChangePassword) {
|
||||
$('#pw-username').value = session.user?.email || '';
|
||||
setFlow(['step-newpassword']);
|
||||
goto('step-newpassword');
|
||||
return;
|
||||
}
|
||||
if (session.admin) return done();
|
||||
|
||||
if (session.setupNeeded) {
|
||||
$('#setup-message').textContent =
|
||||
'Set ADMIN_BOOTSTRAP_EMAIL and ADMIN_BOOTSTRAP_PASSWORD in the .env file and restart the container to create the first account.';
|
||||
setFlow(['step-setup']);
|
||||
goto('step-setup');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* the server is unreachable; the sign in page still renders */
|
||||
}
|
||||
goto('step-password');
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Sign in — visitor admin</title>
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/css/login.css">
|
||||
</head>
|
||||
<body class="auth">
|
||||
|
||||
<div class="auth-card">
|
||||
<header class="auth-head">
|
||||
<button type="button" class="auth-back" id="auth-back" hidden aria-label="Go back">←</button>
|
||||
<p class="auth-site" id="auth-site">Visitor admin</p>
|
||||
<div class="auth-rail" id="auth-rail" hidden></div>
|
||||
<h1 id="auth-title">Sign in</h1>
|
||||
<p class="auth-sub" id="auth-subtitle"></p>
|
||||
</header>
|
||||
|
||||
<div class="auth-body" id="auth-body">
|
||||
<form class="auth-screen" id="step-password">
|
||||
<label>
|
||||
<span>Email address</span>
|
||||
<input type="email" id="login-email" name="email" autocomplete="username" autocapitalize="none" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>Password</span>
|
||||
<input type="password" id="login-password" name="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<p class="hint" id="domain-rule" hidden></p>
|
||||
<button type="submit">Continue</button>
|
||||
</form>
|
||||
|
||||
<form class="auth-screen" id="step-2fa" hidden>
|
||||
<div id="twofa-setup" hidden>
|
||||
<img id="twofa-qr" alt="Two factor setup QR code" width="180" height="180">
|
||||
<details class="auth-details">
|
||||
<summary>Can't scan it?</summary>
|
||||
<p class="hint">Type this key into your authenticator app instead:</p>
|
||||
<p><code id="twofa-secret"></code></p>
|
||||
</details>
|
||||
</div>
|
||||
<label>
|
||||
<span id="twofa-label">6 digit code</span>
|
||||
<input id="twofa-code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="12" required>
|
||||
</label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-screen" id="step-recovery" hidden>
|
||||
<ul class="recovery" id="recovery-list"></ul>
|
||||
<div class="auth-row">
|
||||
<button type="button" class="secondary" id="recovery-copy">Copy codes</button>
|
||||
<button type="button" id="recovery-done">I've saved them</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="auth-screen" id="step-newpassword" hidden>
|
||||
<!-- Hidden but present: password managers and screen readers need to know
|
||||
which account the new password belongs to. -->
|
||||
<input type="text" id="pw-username" name="username" autocomplete="username"
|
||||
tabindex="-1" aria-hidden="true" class="visually-hidden" readonly>
|
||||
<label>
|
||||
<span>Current password</span>
|
||||
<input type="password" id="pw-current" name="current-password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>New password</span>
|
||||
<input type="password" id="pw-new" name="new-password" autocomplete="new-password" required>
|
||||
</label>
|
||||
<label>
|
||||
<span>New password again</span>
|
||||
<input type="password" id="pw-again" name="confirm-password" autocomplete="new-password" required>
|
||||
</label>
|
||||
<p class="hint">At least 12 characters, with upper and lower case and a number.</p>
|
||||
<button type="submit">Save and continue</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-screen" id="step-setup" hidden>
|
||||
<p class="hint" id="setup-message"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="err" id="login-error" role="alert" hidden></p>
|
||||
</div>
|
||||
|
||||
<p class="auth-foot">
|
||||
<a href="/">Back to the kiosk</a>
|
||||
<span>Created by: Jess Rogerson (yelling commands at Claude.AI)</span>
|
||||
</p>
|
||||
|
||||
<script src="/js/login.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user