A proper shared-device PIN kiosk for clients who don't want NFC: photo-tile grid (+search) -> tap -> PIN (or first-use create) -> optional master-gated selfie -> clock, in the NFC kiosk's dark glass + brand-gradient style. Built as an Odoo 19 Interaction; new pin_kiosk.scss (scoped); reworked clock_kiosk.py (search +avatar/has_pin, verify_pin needs_setup, set_pin, clock via kiosk location). Drops the redundant kiosk_pin_required (PIN always required); relabels the company kiosk location; adds a PIN-kiosk app icon. Opt-in via enable_kiosk (off by default). HttpCase tests added. Bump 19.0.4.0.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
289 lines
14 KiB
JavaScript
289 lines
14 KiB
JavaScript
/** @odoo-module **/
|
|
// Fusion Clock PIN Kiosk — tap your photo, enter a PIN, clock in/out.
|
|
// Built as an Odoo 19 public Interaction. Employee-derived strings are always
|
|
// inserted via textContent (never interpolated into innerHTML) to avoid XSS.
|
|
|
|
import { Interaction } from "@web/public/interaction";
|
|
import { registry } from "@web/core/registry";
|
|
import { rpc } from "@web/core/network/rpc";
|
|
|
|
export class PinKiosk extends Interaction {
|
|
static selector = "#pin_kiosk_root";
|
|
|
|
setup() {
|
|
this.grid = this.el.querySelector("#pin_kiosk_grid");
|
|
this.searchEl = this.el.querySelector("#pin_kiosk_search");
|
|
this.stage = this.el.querySelector("#pin_state_container");
|
|
this.photoRequired = this.el.dataset.photo === "1";
|
|
this.soundsOn = this.el.dataset.sounds === "1";
|
|
this.employees = [];
|
|
this.filtered = [];
|
|
this.pinBuf = "";
|
|
this.startClock();
|
|
this.initBrandHue();
|
|
this.searchEl.addEventListener("input", () => this.onSearch());
|
|
const gear = this.el.querySelector("#pin_kiosk_settings");
|
|
if (gear) gear.addEventListener("click", () => this.toggleFullscreen());
|
|
this._load();
|
|
}
|
|
|
|
toggleFullscreen() {
|
|
try {
|
|
if (document.fullscreenElement) document.exitFullscreen();
|
|
else if (this.el.requestFullscreen) this.el.requestFullscreen().catch(() => {});
|
|
} catch (e) { /* unsupported */ }
|
|
}
|
|
|
|
destroy() {
|
|
if (this._clockTimer) clearInterval(this._clockTimer);
|
|
if (this._stream) this._stream.getTracks().forEach((t) => t.stop());
|
|
}
|
|
|
|
async _load() {
|
|
const res = await rpc("/fusion_clock/kiosk/search", { query: "" });
|
|
this.employees = res.employees || [];
|
|
this.filtered = this.employees;
|
|
this.renderGrid();
|
|
}
|
|
|
|
// ---- brand hue (mirrors fusion_clock_nfc_kiosk.js) ----
|
|
rgbToHue(r, g, b) {
|
|
r /= 255; g /= 255; b /= 255;
|
|
const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
|
|
if (d === 0) return null;
|
|
let h = mx === r ? ((g - b) / d) % 6 : mx === g ? (b - r) / d + 2 : (r - g) / d + 4;
|
|
h = Math.round(h * 60); if (h < 0) h += 360; return h;
|
|
}
|
|
extractHue(img) {
|
|
try {
|
|
const w = Math.min(img.naturalWidth, 200), h = Math.min(img.naturalHeight, 200);
|
|
if (!w || !h) return null;
|
|
const c = document.createElement("canvas"); c.width = w; c.height = h;
|
|
const ctx = c.getContext("2d", { willReadFrequently: true });
|
|
ctx.drawImage(img, 0, 0, w, h);
|
|
const data = ctx.getImageData(0, 0, w, h).data;
|
|
let rs = 0, gs = 0, bs = 0, n = 0;
|
|
for (let i = 0; i < data.length; i += 4) {
|
|
if (data[i + 3] < 128) continue;
|
|
const r = data[i], g = data[i + 1], b = data[i + 2];
|
|
const lum = (r + g + b) / 3;
|
|
if (lum > 235 || lum < 25) continue;
|
|
if (Math.max(r, g, b) - Math.min(r, g, b) < 25) continue;
|
|
rs += r; gs += g; bs += b; n++;
|
|
}
|
|
if (n < 50) return null;
|
|
return this.rgbToHue(Math.round(rs / n), Math.round(gs / n), Math.round(bs / n));
|
|
} catch (e) { return null; }
|
|
}
|
|
initBrandHue() {
|
|
const img = this.el.querySelector("#pin_kiosk_logo");
|
|
if (!img) return;
|
|
const apply = () => { const hue = this.extractHue(img); if (hue != null) document.documentElement.style.setProperty("--pk-h", String(hue)); };
|
|
if (img.complete && img.naturalWidth) apply();
|
|
else img.addEventListener("load", apply);
|
|
}
|
|
|
|
// ---- clock ----
|
|
startClock() {
|
|
const tick = () => {
|
|
const d = new Date();
|
|
let h = d.getHours(); const m = String(d.getMinutes()).padStart(2, "0");
|
|
const ap = h >= 12 ? "PM" : "AM"; h = h % 12 || 12;
|
|
const clock = this.el.querySelector("#pin_kiosk_clock");
|
|
clock.textContent = `${h}:${m}`;
|
|
const span = document.createElement("span");
|
|
span.className = "ampm"; span.textContent = ap;
|
|
clock.appendChild(span);
|
|
this.el.querySelector("#pin_kiosk_date").textContent =
|
|
d.toLocaleDateString(undefined, { weekday: "long", month: "long", day: "numeric" });
|
|
};
|
|
tick(); this._clockTimer = setInterval(tick, 1000);
|
|
}
|
|
|
|
// ---- grid ----
|
|
initials(name) { return (name || "").split(" ").filter(Boolean).slice(0, 2).map((p) => p[0].toUpperCase()).join(""); }
|
|
onSearch() {
|
|
const q = this.searchEl.value.trim().toLowerCase();
|
|
this.filtered = q ? this.employees.filter((e) => e.name.toLowerCase().includes(q)) : this.employees;
|
|
this.renderGrid();
|
|
}
|
|
renderGrid() {
|
|
this.grid.replaceChildren();
|
|
for (const emp of this.filtered) {
|
|
const tile = document.createElement("div");
|
|
tile.className = "pin-kiosk__tile";
|
|
const av = document.createElement("div");
|
|
av.className = "pin-kiosk__tile-av";
|
|
if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`;
|
|
else av.textContent = this.initials(emp.name);
|
|
const nm = document.createElement("div");
|
|
nm.className = "pin-kiosk__tile-nm"; nm.textContent = emp.name;
|
|
tile.append(av, nm);
|
|
tile.addEventListener("click", () => this.onTile(emp));
|
|
this.grid.appendChild(tile);
|
|
}
|
|
}
|
|
|
|
// ---- PIN / first-use setup ----
|
|
onTile(emp) {
|
|
this.current = emp; this.pinBuf = ""; this.attempts = 0; this._newPin = null;
|
|
this.showPin(emp, emp.has_pin ? "Enter your PIN" : "Create a PIN", !emp.has_pin, false);
|
|
}
|
|
showPin(emp, sub, isSetup, confirming) {
|
|
this.isSetup = isSetup; this.confirming = confirming; this.pinBuf = "";
|
|
this.stage.replaceChildren();
|
|
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
|
const panel = document.createElement("div"); panel.className = "pin-kiosk__panel";
|
|
panel.innerHTML =
|
|
'<div class="pin-kiosk__av"></div><div class="pin-kiosk__name"></div>' +
|
|
'<div class="pin-kiosk__sub"></div><div class="pin-kiosk__dots"></div>' +
|
|
'<div class="pin-kiosk__err"></div><div class="pin-kiosk__pad"></div>' +
|
|
'<button class="pin-kiosk__cancel">✕ Cancel</button>';
|
|
const av = panel.querySelector(".pin-kiosk__av");
|
|
if (emp.avatar_url) av.style.backgroundImage = `url(${encodeURI(emp.avatar_url)})`;
|
|
else av.textContent = this.initials(emp.name);
|
|
panel.querySelector(".pin-kiosk__name").textContent = emp.name;
|
|
panel.querySelector(".pin-kiosk__sub").textContent = confirming ? "Re-enter to confirm" : sub;
|
|
const pad = panel.querySelector(".pin-kiosk__pad");
|
|
for (const k of ["1", "2", "3", "4", "5", "6", "7", "8", "9", "⌫", "0", "✓"]) {
|
|
const b = document.createElement("button");
|
|
b.className = "pin-kiosk__key" + (k === "✓" ? " ok" : "");
|
|
b.textContent = k;
|
|
b.addEventListener("click", () => this.onKey(k));
|
|
pad.appendChild(b);
|
|
}
|
|
panel.querySelector(".pin-kiosk__cancel").addEventListener("click", () => this.reset());
|
|
ov.appendChild(panel); this.stage.appendChild(ov);
|
|
this._panel = panel; this.renderDots();
|
|
}
|
|
renderDots() {
|
|
const dots = this._panel.querySelector(".pin-kiosk__dots"); dots.replaceChildren();
|
|
const len = Math.max(4, this.pinBuf.length);
|
|
for (let i = 0; i < len; i++) {
|
|
const d = document.createElement("span");
|
|
d.className = "pin-kiosk__dot" + (i < this.pinBuf.length ? " on" : "");
|
|
dots.appendChild(d);
|
|
}
|
|
}
|
|
err(msg) {
|
|
this._panel.querySelector(".pin-kiosk__err").textContent = msg;
|
|
this._panel.classList.add("shake");
|
|
setTimeout(() => this._panel.classList.remove("shake"), 360);
|
|
}
|
|
onKey(k) {
|
|
if (k === "⌫") { this.pinBuf = this.pinBuf.slice(0, -1); this.renderDots(); return; }
|
|
if (k === "✓") { this.submitPin(); return; }
|
|
if (this.pinBuf.length < 6) { this.pinBuf += k; this.renderDots(); }
|
|
}
|
|
async submitPin() {
|
|
const emp = this.current, pin = this.pinBuf;
|
|
if (pin.length < 4) return this.err("PIN must be at least 4 digits");
|
|
if (this.isSetup && !this.confirming) {
|
|
this._newPin = pin;
|
|
return this.showPin(emp, "Create a PIN", true, true);
|
|
}
|
|
try {
|
|
if (this.isSetup && this.confirming) {
|
|
if (pin !== this._newPin) { this.pinBuf = ""; this.renderDots(); return this.err("PINs didn't match"); }
|
|
const r = await rpc("/fusion_clock/kiosk/set_pin", { employee_id: emp.id, pin });
|
|
if (r.error) return this.err("Couldn't save PIN");
|
|
return this.afterPin(emp);
|
|
}
|
|
const v = await rpc("/fusion_clock/kiosk/verify_pin", { employee_id: emp.id, pin });
|
|
if (v.success) return this.afterPin(emp);
|
|
this.attempts++; this.pinBuf = ""; this.renderDots();
|
|
if (this.attempts >= 3) return this.reset();
|
|
this.err("Wrong PIN — try again");
|
|
} catch (e) {
|
|
this.pinBuf = ""; this.renderDots();
|
|
this.err("Connection error — try again");
|
|
}
|
|
}
|
|
|
|
// ---- photo (optional) then clock ----
|
|
async afterPin(emp) {
|
|
let photo = "";
|
|
if (this.photoRequired) {
|
|
try { photo = await this.capturePhoto(emp); } catch (e) { photo = ""; }
|
|
}
|
|
let r;
|
|
try {
|
|
r = await rpc("/fusion_clock/kiosk/clock", { employee_id: emp.id, photo_b64: photo });
|
|
} catch (e) {
|
|
r = { error: "Connection error" };
|
|
}
|
|
this.showResult(emp, r);
|
|
}
|
|
showResult(emp, r) {
|
|
this.stage.replaceChildren();
|
|
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
|
const card = document.createElement("div");
|
|
const success = !!(r && r.success);
|
|
card.className = "pin-kiosk__result" + (success ? "" : " pin-kiosk__result--error");
|
|
card.innerHTML =
|
|
'<div class="pin-kiosk__check"></div><div class="name"></div>' +
|
|
'<div class="action"></div><div class="meta"></div>';
|
|
const check = card.querySelector(".pin-kiosk__check");
|
|
if (success) {
|
|
check.textContent = "✓";
|
|
card.querySelector(".action").textContent = r.action === "clock_out" ? "Clocked Out" : "Clocked In";
|
|
card.querySelector(".meta").textContent = r.message || "";
|
|
if (this.soundsOn) this.beep();
|
|
} else {
|
|
check.textContent = "!";
|
|
check.style.cssText = "color:#f87171;background:rgba(217,55,78,.18);border-color:rgba(217,55,78,.6)";
|
|
const act = card.querySelector(".action"); act.textContent = "Couldn't clock"; act.style.color = "#f87171";
|
|
card.querySelector(".meta").textContent = (r && r.error) || "Try again";
|
|
}
|
|
card.querySelector(".name").textContent = emp.name;
|
|
ov.appendChild(card); this.stage.appendChild(ov);
|
|
setTimeout(() => this.reset(), 3000);
|
|
}
|
|
beep() {
|
|
try {
|
|
const a = new (window.AudioContext || window.webkitAudioContext)();
|
|
const o = a.createOscillator(); o.frequency.value = 880; o.connect(a.destination);
|
|
o.start(); o.stop(a.currentTime + 0.12);
|
|
} catch (e) { /* no audio */ }
|
|
}
|
|
|
|
// ---- camera capture (oval guide + 3s countdown; mirrors NFC kiosk) ----
|
|
capturePhoto(emp) {
|
|
return new Promise(async (resolve, reject) => {
|
|
let stream;
|
|
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" } }); }
|
|
catch (e) { return reject(e); }
|
|
this._stream = stream;
|
|
this.stage.replaceChildren();
|
|
const ov = document.createElement("div"); ov.className = "pin-kiosk__overlay";
|
|
const panel = document.createElement("div"); panel.className = "pin-kiosk__photo";
|
|
const h2 = document.createElement("h2"); h2.textContent = emp.name; panel.appendChild(h2);
|
|
const stage = document.createElement("div"); stage.className = "stage";
|
|
stage.innerHTML = '<video autoplay="autoplay" playsinline="playsinline"></video><div class="guide"></div><div class="countdown"></div>';
|
|
panel.appendChild(stage); ov.appendChild(panel); this.stage.appendChild(ov);
|
|
const video = stage.querySelector("video"); video.srcObject = stream;
|
|
const cd = stage.querySelector(".countdown"); let n = 3; cd.textContent = String(n);
|
|
const timer = setInterval(() => {
|
|
n--; if (n > 0) { cd.textContent = String(n); return; }
|
|
clearInterval(timer);
|
|
const c = document.createElement("canvas"); c.width = video.videoWidth || 480; c.height = video.videoHeight || 640;
|
|
c.getContext("2d").drawImage(video, 0, 0, c.width, c.height);
|
|
stream.getTracks().forEach((t) => t.stop()); this._stream = null;
|
|
resolve(c.toDataURL("image/jpeg", 0.8));
|
|
}, 1000);
|
|
});
|
|
}
|
|
|
|
reset() {
|
|
if (this._stream) { this._stream.getTracks().forEach((t) => t.stop()); this._stream = null; }
|
|
this.stage.replaceChildren();
|
|
this.pinBuf = ""; this.current = null; this._newPin = null;
|
|
this.searchEl.value = ""; this.filtered = this.employees; this.renderGrid();
|
|
rpc("/fusion_clock/kiosk/search", { query: "" }).then((res) => {
|
|
this.employees = res.employees || []; this.filtered = this.employees;
|
|
});
|
|
}
|
|
}
|
|
|
|
registry.category("public.interactions").add("fusion_clock.pin_kiosk", PinKiosk);
|