Add NFC chore submission feature and enhance family settings
- Introduced NFC support for chore submissions, allowing specific person credit after Head of Household approval. - Updated family settings to include NFC base URL, scan cooldown, and confirmation page options. - Enhanced chore management with options for anyone to complete chores and NFC link generation. - Improved API endpoints for handling NFC tokens and chore submissions. - Updated readme to reflect new NFC features and settings.
This commit is contained in:
parent
9a78c4564e
commit
f14de0b7e1
72
api/chore_nfc_rotate.php
Normal file
72
api/chore_nfc_rotate.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||||
|
require_once __DIR__ . '/../includes/family_settings.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
|
$actor = requireActivePerson($people);
|
||||||
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||||
|
if ($id === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||||
|
$chores = migrateAllChores($rawChores, $people);
|
||||||
|
$idx = findChoreIndexById($chores, $id);
|
||||||
|
if ($idx === null) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$choreToken = generateOpaqueToken();
|
||||||
|
$chore = $chores[$idx];
|
||||||
|
$nfcMeta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
|
||||||
|
$nfcMeta['token_hash'] = hashOpaqueToken($choreToken);
|
||||||
|
$nfcMeta['enabled'] = true;
|
||||||
|
$nfcMeta['created_at'] = gmdate('c');
|
||||||
|
$chore['nfc'] = $nfcMeta;
|
||||||
|
$chores[$idx] = migrateLegacyChoreRow($chore, $people);
|
||||||
|
|
||||||
|
$updatedPeople = $people;
|
||||||
|
$links = [];
|
||||||
|
$familySettings = loadFamilySettings();
|
||||||
|
$base = familyHubAppUrl($familySettings) . '/api/chore_submit_nfc.php';
|
||||||
|
|
||||||
|
foreach ($updatedPeople as $pi => $person) {
|
||||||
|
$personId = (string) ($person['id'] ?? '');
|
||||||
|
if ($personId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$personName = (string) ($person['name'] ?? $personId);
|
||||||
|
$personToken = generateOpaqueToken();
|
||||||
|
$updatedPeople[$pi]['nfc_submit_token_hash'] = hashOpaqueToken($personToken);
|
||||||
|
$updatedPeople[$pi]['nfc_submit_token_updated_at'] = gmdate('c');
|
||||||
|
$links[] = [
|
||||||
|
'person_id' => $personId,
|
||||||
|
'person_name' => $personName,
|
||||||
|
'url' => $base . '?id=' . rawurlencode($id) . '&token=' . rawurlencode($choreToken) . '&person_token=' . rawurlencode($personToken),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeJsonFile('people.json', $updatedPeople)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save person NFC tokens'], 500);
|
||||||
|
}
|
||||||
|
if (!writeJsonFile('chores.json', $chores)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save chore NFC token'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson([
|
||||||
|
'success' => true,
|
||||||
|
'enabled' => true,
|
||||||
|
'links' => $links,
|
||||||
|
'nfc' => $nfcMeta,
|
||||||
|
]);
|
||||||
43
api/chore_nfc_toggle.php
Normal file
43
api/chore_nfc_toggle.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
|
$actor = requireActivePerson($people);
|
||||||
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$id = isset($body['id']) ? trim((string) $body['id']) : '';
|
||||||
|
$enabled = !empty($body['enabled']);
|
||||||
|
if ($id === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||||
|
$chores = migrateAllChores($rawChores, $people);
|
||||||
|
$idx = findChoreIndexById($chores, $id);
|
||||||
|
if ($idx === null) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Chore not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$chore = $chores[$idx];
|
||||||
|
$nfcMeta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
|
||||||
|
if ((string) ($nfcMeta['token_hash'] ?? '') === '' && $enabled) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Generate an NFC token first'], 400);
|
||||||
|
}
|
||||||
|
$nfcMeta['enabled'] = $enabled;
|
||||||
|
$chore['nfc'] = $nfcMeta;
|
||||||
|
$chores[$idx] = migrateLegacyChoreRow($chore, $people);
|
||||||
|
|
||||||
|
if (!writeJsonFile('chores.json', $chores)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save chore'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(['success' => true, 'enabled' => $enabled, 'nfc' => $nfcMeta]);
|
||||||
@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
$actor = requireActivePerson($people);
|
$actor = requireActivePerson($people);
|
||||||
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can review chores'], 403);
|
||||||
@ -46,13 +46,10 @@ if ($decision === 'reject') {
|
|||||||
sendJson(['success' => true]);
|
sendJson(['success' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$assignees = $row['assignee_ids'] ?? [];
|
$submittedBy = trim((string) ($pending['submitted_by'] ?? ''));
|
||||||
if (!is_array($assignees)) {
|
|
||||||
$assignees = [];
|
|
||||||
}
|
|
||||||
$value = (float) ($row['value'] ?? 0);
|
$value = (float) ($row['value'] ?? 0);
|
||||||
$n = count($assignees);
|
$creditedEach = 0.0;
|
||||||
$share = $n > 0 ? round($value / $n, 2) : 0.0;
|
$creditedRecipients = 0;
|
||||||
|
|
||||||
$row['pending_submission'] = null;
|
$row['pending_submission'] = null;
|
||||||
|
|
||||||
@ -73,15 +70,33 @@ if (!writeJsonFile('chores.json', $chores)) {
|
|||||||
|
|
||||||
foreach ($people as $pi => $p) {
|
foreach ($people as $pi => $p) {
|
||||||
$pid = (string) ($p['id'] ?? '');
|
$pid = (string) ($p['id'] ?? '');
|
||||||
if ($pid === '' || !in_array($pid, $assignees, true)) {
|
if ($pid === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if ($submittedBy !== '') {
|
||||||
|
if ($pid !== $submittedBy) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$creditedEach = $value;
|
||||||
|
} else {
|
||||||
|
$assignees = $row['assignee_ids'] ?? [];
|
||||||
|
if (!is_array($assignees) || !in_array($pid, $assignees, true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$n = count($assignees);
|
||||||
|
$creditedEach = $n > 0 ? round($value / $n, 2) : 0.0;
|
||||||
|
}
|
||||||
$bal = $p['currency_balance'] ?? 0;
|
$bal = $p['currency_balance'] ?? 0;
|
||||||
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $share;
|
$people[$pi]['currency_balance'] = (is_numeric($bal) ? (float) $bal : 0.0) + $creditedEach;
|
||||||
|
$creditedRecipients++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!writeJsonFile('people.json', $people)) {
|
if (!writeJsonFile('people.json', $people)) {
|
||||||
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
|
sendJson(['success' => false, 'error' => 'Failed to save people balances'], 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendJson(['success' => true, 'credited_each' => $share]);
|
sendJson([
|
||||||
|
'success' => true,
|
||||||
|
'credited_each' => $creditedEach,
|
||||||
|
'credited_recipients' => $creditedRecipients,
|
||||||
|
]);
|
||||||
|
|||||||
@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
$actor = requireActivePerson($people);
|
$actor = requireActivePerson($people);
|
||||||
|
|
||||||
$body = readJsonBody();
|
$body = readJsonBody();
|
||||||
@ -37,6 +37,8 @@ if ($id !== '') {
|
|||||||
'author_id' => (string) ($actor['id'] ?? ''),
|
'author_id' => (string) ($actor['id'] ?? ''),
|
||||||
'pending_submission' => null,
|
'pending_submission' => null,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
|
'anyone_can_complete' => false,
|
||||||
|
'nfc' => normalizeChoreNfcMeta(null),
|
||||||
];
|
];
|
||||||
$idx = null;
|
$idx = null;
|
||||||
}
|
}
|
||||||
@ -68,6 +70,7 @@ $row['description'] = isset($body['description']) ? trim((string) $body['descrip
|
|||||||
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
|
$row['image'] = isset($body['image']) ? trim((string) $body['image']) : '';
|
||||||
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
|
$row['lists'] = normalizeChoreLists($body['lists'] ?? []);
|
||||||
$row['assignee_ids'] = $assigneeIdsClean;
|
$row['assignee_ids'] = $assigneeIdsClean;
|
||||||
|
$row['anyone_can_complete'] = !empty($body['anyone_can_complete']);
|
||||||
|
|
||||||
$val = isset($body['value']) ? $body['value'] : 0;
|
$val = isset($body['value']) ? $body['value'] : 0;
|
||||||
$row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0;
|
$row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0;
|
||||||
|
|||||||
@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
$actor = requireActivePerson($people);
|
$actor = requireActivePerson($people);
|
||||||
$actorId = (string) ($actor['id'] ?? '');
|
$actorId = (string) ($actor['id'] ?? '');
|
||||||
if ($actorId === '') {
|
if ($actorId === '') {
|
||||||
@ -34,7 +34,7 @@ if (($row['status'] ?? '') !== 'active') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assignees = $row['assignee_ids'] ?? [];
|
$assignees = $row['assignee_ids'] ?? [];
|
||||||
if (!is_array($assignees) || !in_array($actorId, $assignees, true)) {
|
if (empty($row['anyone_can_complete']) && (!is_array($assignees) || !in_array($actorId, $assignees, true))) {
|
||||||
sendJson(['success' => false, 'error' => 'Only assignees can mark this chore complete'], 403);
|
sendJson(['success' => false, 'error' => 'Only assignees can mark this chore complete'], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
123
api/chore_submit_nfc.php
Normal file
123
api/chore_submit_nfc.php
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/db.php';
|
||||||
|
require_once __DIR__ . '/../includes/utils.php';
|
||||||
|
require_once __DIR__ . '/../includes/persona.php';
|
||||||
|
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||||
|
require_once __DIR__ . '/../includes/family_settings.php';
|
||||||
|
|
||||||
|
function renderNfcResultPage(string $title, string $message, bool $success, string $redirectUrl = '', bool $autoRedirect = false): void {
|
||||||
|
$safeTitle = htmlspecialchars($title, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeMessage = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
|
||||||
|
$safeRedirect = htmlspecialchars($redirectUrl, ENT_QUOTES, 'UTF-8');
|
||||||
|
$statusClass = $success ? 'success' : 'danger';
|
||||||
|
$meta = '';
|
||||||
|
if ($autoRedirect && $redirectUrl !== '') {
|
||||||
|
$meta = '<meta http-equiv="refresh" content="0;url=' . $safeRedirect . '">';
|
||||||
|
}
|
||||||
|
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">';
|
||||||
|
echo '<title>' . $safeTitle . '</title>' . $meta;
|
||||||
|
echo '<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"></head><body class="bg-light">';
|
||||||
|
echo '<main class="container py-4"><div class="row justify-content-center"><div class="col-12 col-md-8 col-lg-6">';
|
||||||
|
echo '<div class="card border-' . $statusClass . '">';
|
||||||
|
echo '<div class="card-header bg-' . $statusClass . '-subtle"><strong>' . $safeTitle . '</strong></div>';
|
||||||
|
echo '<div class="card-body"><p class="mb-3">' . $safeMessage . '</p>';
|
||||||
|
if ($redirectUrl !== '' && !$autoRedirect) {
|
||||||
|
echo '<a class="btn btn-primary" href="' . $safeRedirect . '">Open Family Hub</a>';
|
||||||
|
}
|
||||||
|
echo '</div></div></div></div></main></body></html>';
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
renderNfcResultPage('Method not allowed', 'This NFC URL only supports GET requests.', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$familySettings = loadFamilySettings();
|
||||||
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
|
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||||
|
$chores = migrateAllChores($rawChores, $people);
|
||||||
|
|
||||||
|
$id = isset($_GET['id']) ? trim((string) $_GET['id']) : '';
|
||||||
|
$token = isset($_GET['token']) ? trim((string) $_GET['token']) : '';
|
||||||
|
$personToken = isset($_GET['person_token']) ? trim((string) $_GET['person_token']) : '';
|
||||||
|
$hubUrl = familyHubAppUrl($familySettings);
|
||||||
|
$redirectUrl = $hubUrl . '/?tab=chores';
|
||||||
|
$showConfirmation = !empty($familySettings['nfc_show_confirmation']);
|
||||||
|
|
||||||
|
if ($id === '' || $token === '' || $personToken === '') {
|
||||||
|
http_response_code(400);
|
||||||
|
renderNfcResultPage('Invalid NFC link', 'Required token information is missing from this NFC URL.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$personMatch = findPersonBySubmitToken($people, $personToken);
|
||||||
|
if ($personMatch === null) {
|
||||||
|
http_response_code(403);
|
||||||
|
renderNfcResultPage('Invalid submitter token', 'This NFC submitter token is not valid.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$submitter = $personMatch['person'];
|
||||||
|
$submitterId = (string) ($submitter['id'] ?? '');
|
||||||
|
$submitterName = (string) ($submitter['name'] ?? 'Unknown');
|
||||||
|
|
||||||
|
$idx = findChoreIndexById($chores, $id);
|
||||||
|
if ($idx === null) {
|
||||||
|
http_response_code(404);
|
||||||
|
renderNfcResultPage('Chore not found', 'This chore no longer exists.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $chores[$idx];
|
||||||
|
if (($row['status'] ?? '') !== 'active') {
|
||||||
|
http_response_code(400);
|
||||||
|
renderNfcResultPage('Chore inactive', 'This chore is not currently active.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$nfcMeta = normalizeChoreNfcMeta($row['nfc'] ?? null);
|
||||||
|
if (empty($nfcMeta['enabled']) || !validateTokenHash($token, (string) ($nfcMeta['token_hash'] ?? ''))) {
|
||||||
|
http_response_code(403);
|
||||||
|
renderNfcResultPage('Invalid chore token', 'This NFC chore token is invalid or disabled.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($row['pending_submission']) && is_array($row['pending_submission'])) {
|
||||||
|
http_response_code(400);
|
||||||
|
renderNfcResultPage('Already pending review', 'This chore is already waiting for Head of household approval.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
$anyoneCanComplete = !empty($row['anyone_can_complete']);
|
||||||
|
$assignees = $row['assignee_ids'] ?? [];
|
||||||
|
if (!$anyoneCanComplete) {
|
||||||
|
if (!is_array($assignees) || !in_array($submitterId, $assignees, true)) {
|
||||||
|
http_response_code(403);
|
||||||
|
renderNfcResultPage('Not assigned', 'Only assigned family members can submit this chore.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$cooldownSeconds = (int) ($familySettings['nfc_scan_cooldown_seconds'] ?? 0);
|
||||||
|
if ($cooldownSeconds > 0) {
|
||||||
|
$lastUsedAt = trim((string) ($nfcMeta['last_used_at'] ?? ''));
|
||||||
|
if ($lastUsedAt !== '') {
|
||||||
|
$lastTs = strtotime($lastUsedAt);
|
||||||
|
if ($lastTs !== false && (time() - $lastTs) < $cooldownSeconds) {
|
||||||
|
http_response_code(429);
|
||||||
|
renderNfcResultPage('Please wait', 'This tag was just scanned. Try again in a few moments.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$row['pending_submission'] = [
|
||||||
|
'submitted_at' => gmdate('c'),
|
||||||
|
'submitted_by' => $submitterId,
|
||||||
|
'note' => 'Submitted via NFC by ' . $submitterName,
|
||||||
|
];
|
||||||
|
$nfcMeta['last_used_at'] = gmdate('c');
|
||||||
|
$nfcMeta['last_used_ip'] = requestClientAddress();
|
||||||
|
$row['nfc'] = $nfcMeta;
|
||||||
|
$chores[$idx] = migrateLegacyChoreRow($row, $people);
|
||||||
|
|
||||||
|
if (!writeJsonFile('chores.json', $chores)) {
|
||||||
|
http_response_code(500);
|
||||||
|
renderNfcResultPage('Save failed', 'Could not save this submission. Please try again.', false, $redirectUrl, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderNfcResultPage('Submitted for approval', 'Submitted as ' . $submitterName . '. A Head of household will approve before credit is applied.', true, $redirectUrl, !$showConfirmation);
|
||||||
58
api/family_nfc_admin.php
Normal file
58
api/family_nfc_admin.php
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
|
$actor = requireActivePerson($people);
|
||||||
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can manage NFC'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$action = isset($body['action']) ? trim((string) $body['action']) : '';
|
||||||
|
|
||||||
|
if (!in_array($action, ['disable_all_chore_nfc', 'rotate_all_person_tokens'], true)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Invalid action'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($action === 'disable_all_chore_nfc') {
|
||||||
|
$rawChores = normalizeChoresList(readJsonFile('chores.json'));
|
||||||
|
$chores = migrateAllChores($rawChores, $people);
|
||||||
|
foreach ($chores as $i => $chore) {
|
||||||
|
$meta = normalizeChoreNfcMeta($chore['nfc'] ?? null);
|
||||||
|
$meta['enabled'] = false;
|
||||||
|
$chore['nfc'] = $meta;
|
||||||
|
$chores[$i] = migrateLegacyChoreRow($chore, $people);
|
||||||
|
}
|
||||||
|
if (!writeJsonFile('chores.json', $chores)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save chores'], 500);
|
||||||
|
}
|
||||||
|
sendJson(['success' => true, 'action' => $action]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tokens = [];
|
||||||
|
foreach ($people as $i => $person) {
|
||||||
|
$personId = (string) ($person['id'] ?? '');
|
||||||
|
if ($personId === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$token = generateOpaqueToken();
|
||||||
|
$people[$i]['nfc_submit_token_hash'] = hashOpaqueToken($token);
|
||||||
|
$people[$i]['nfc_submit_token_updated_at'] = gmdate('c');
|
||||||
|
$tokens[] = [
|
||||||
|
'person_id' => $personId,
|
||||||
|
'person_name' => (string) ($person['name'] ?? $personId),
|
||||||
|
'person_token' => $token,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!writeJsonFile('people.json', $people)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save people'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson(['success' => true, 'action' => $action, 'tokens' => $tokens]);
|
||||||
@ -50,6 +50,23 @@ if (isset($body['week_starts_on'])) {
|
|||||||
}
|
}
|
||||||
$merged['week_starts_on'] = $w;
|
$merged['week_starts_on'] = $w;
|
||||||
}
|
}
|
||||||
|
if (array_key_exists('nfc_base_url', $body)) {
|
||||||
|
$baseUrl = trim((string) $body['nfc_base_url']);
|
||||||
|
if ($baseUrl !== '' && !preg_match('#^https?://#i', $baseUrl)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'nfc_base_url must start with http:// or https://'], 400);
|
||||||
|
}
|
||||||
|
$merged['nfc_base_url'] = rtrim($baseUrl, '/');
|
||||||
|
}
|
||||||
|
if (array_key_exists('nfc_show_confirmation', $body)) {
|
||||||
|
$merged['nfc_show_confirmation'] = !empty($body['nfc_show_confirmation']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('nfc_scan_cooldown_seconds', $body)) {
|
||||||
|
$cooldown = (int) $body['nfc_scan_cooldown_seconds'];
|
||||||
|
if ($cooldown < 0 || $cooldown > 600) {
|
||||||
|
sendJson(['success' => false, 'error' => 'nfc_scan_cooldown_seconds must be 0-600'], 400);
|
||||||
|
}
|
||||||
|
$merged['nfc_scan_cooldown_seconds'] = $cooldown;
|
||||||
|
}
|
||||||
|
|
||||||
$merged = normalizeLoadedFamilySettings($merged);
|
$merged = normalizeLoadedFamilySettings($merged);
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
if (count($people) === 0) {
|
if (count($people) === 0) {
|
||||||
sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400);
|
sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400);
|
||||||
}
|
}
|
||||||
@ -58,6 +58,8 @@ $newPerson = [
|
|||||||
'birthday' => $birthday,
|
'birthday' => $birthday,
|
||||||
'favoriteColor' => $favoriteColor,
|
'favoriteColor' => $favoriteColor,
|
||||||
'currency_balance' => 0,
|
'currency_balance' => 0,
|
||||||
|
'nfc_submit_token_hash' => '',
|
||||||
|
'nfc_submit_token_updated_at' => '',
|
||||||
'created_at' => gmdate('c'),
|
'created_at' => gmdate('c'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
if (count($people) > 0) {
|
if (count($people) > 0) {
|
||||||
sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403);
|
sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403);
|
||||||
}
|
}
|
||||||
@ -46,6 +46,8 @@ $newPerson = [
|
|||||||
'birthday' => $birthday,
|
'birthday' => $birthday,
|
||||||
'favoriteColor' => $favoriteColor,
|
'favoriteColor' => $favoriteColor,
|
||||||
'currency_balance' => 0,
|
'currency_balance' => 0,
|
||||||
|
'nfc_submit_token_hash' => '',
|
||||||
|
'nfc_submit_token_updated_at' => '',
|
||||||
'created_at' => gmdate('c'),
|
'created_at' => gmdate('c'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
assertHoHCanManagePeople($people);
|
assertHoHCanManagePeople($people);
|
||||||
|
|
||||||
$body = readJsonBody();
|
$body = readJsonBody();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
assertHoHCanManagePeople($people);
|
assertHoHCanManagePeople($people);
|
||||||
|
|
||||||
$body = readJsonBody();
|
$body = readJsonBody();
|
||||||
|
|||||||
@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
|||||||
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
$people = normalizePeopleList(readJsonFile('people.json'));
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
assertHoHCanManagePeople($people);
|
assertHoHCanManagePeople($people);
|
||||||
|
|
||||||
$body = readJsonBody();
|
$body = readJsonBody();
|
||||||
|
|||||||
46
api/person_nfc_token_rotate.php
Normal file
46
api/person_nfc_token_rotate.php
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../includes/api_bootstrap.php';
|
||||||
|
require_once __DIR__ . '/../includes/chore_helpers.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||||
|
sendJson(['success' => false, 'error' => 'Method not allowed'], 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json')));
|
||||||
|
$actor = requireActivePerson($people);
|
||||||
|
if (($actor['role'] ?? '') !== ROLE_HEAD || !isHohVerified()) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Only a verified Head of household can rotate NFC person tokens'], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = readJsonBody();
|
||||||
|
$personId = isset($body['person_id']) ? trim((string) $body['person_id']) : '';
|
||||||
|
if ($personId === '') {
|
||||||
|
sendJson(['success' => false, 'error' => 'person_id is required'], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$match = null;
|
||||||
|
foreach ($people as $i => $person) {
|
||||||
|
if (($person['id'] ?? '') === $personId) {
|
||||||
|
$match = $i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($match === null) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Person not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = generateOpaqueToken();
|
||||||
|
$people[$match]['nfc_submit_token_hash'] = hashOpaqueToken($token);
|
||||||
|
$people[$match]['nfc_submit_token_updated_at'] = gmdate('c');
|
||||||
|
|
||||||
|
if (!writeJsonFile('people.json', $people)) {
|
||||||
|
sendJson(['success' => false, 'error' => 'Failed to save person token'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendJson([
|
||||||
|
'success' => true,
|
||||||
|
'person_id' => $personId,
|
||||||
|
'person_name' => (string) ($people[$match]['name'] ?? $personId),
|
||||||
|
'person_token' => $token,
|
||||||
|
]);
|
||||||
@ -36,6 +36,15 @@
|
|||||||
$el.text(message);
|
$el.text(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
var pendingPersonId = null;
|
var pendingPersonId = null;
|
||||||
var pinModal = null;
|
var pinModal = null;
|
||||||
|
|
||||||
@ -179,7 +188,10 @@
|
|||||||
currency_symbol: $('#currency_symbol').val(),
|
currency_symbol: $('#currency_symbol').val(),
|
||||||
currency_name: $('#currency_name').val(),
|
currency_name: $('#currency_name').val(),
|
||||||
currency_permanence: $('#currency_permanence').val(),
|
currency_permanence: $('#currency_permanence').val(),
|
||||||
week_starts_on: parseInt($('#week_starts_on').val(), 10)
|
week_starts_on: parseInt($('#week_starts_on').val(), 10),
|
||||||
|
nfc_base_url: ($('#nfc_base_url').val() || '').trim(),
|
||||||
|
nfc_show_confirmation: $('#nfc_show_confirmation').is(':checked'),
|
||||||
|
nfc_scan_cooldown_seconds: parseInt($('#nfc_scan_cooldown_seconds').val(), 10) || 0
|
||||||
};
|
};
|
||||||
postJson('/family_settings_save.php', payload)
|
postJson('/family_settings_save.php', payload)
|
||||||
.then(function () {
|
.then(function () {
|
||||||
@ -191,6 +203,36 @@
|
|||||||
$('#familySettingsFeedback').removeClass('d-none');
|
$('#familySettingsFeedback').removeClass('d-none');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('#btnDisableAllChoreNfc').on('click', function () {
|
||||||
|
if (!window.confirm('Disable NFC links for all chores?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postJson('/family_nfc_admin.php', { action: 'disable_all_chore_nfc' })
|
||||||
|
.then(function () {
|
||||||
|
showAlert($('#familySettingsFeedback'), 'warning', 'All chore NFC links disabled.');
|
||||||
|
$('#familySettingsFeedback').removeClass('d-none');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not disable NFC links');
|
||||||
|
$('#familySettingsFeedback').removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btnRotateAllPersonNfcTokens').on('click', function () {
|
||||||
|
if (!window.confirm('Rotate NFC submitter tokens for all people? Existing person-token links will stop working.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postJson('/family_nfc_admin.php', { action: 'rotate_all_person_tokens' })
|
||||||
|
.then(function () {
|
||||||
|
showAlert($('#familySettingsFeedback'), 'success', 'All person NFC tokens rotated.');
|
||||||
|
$('#familySettingsFeedback').removeClass('d-none');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showAlert($('#familySettingsFeedback'), 'danger', err.message || 'Could not rotate tokens');
|
||||||
|
$('#familySettingsFeedback').removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectBillDaysFromDom() {
|
function collectBillDaysFromDom() {
|
||||||
@ -285,6 +327,7 @@
|
|||||||
schedule: $('#chore_schedule').val(),
|
schedule: $('#chore_schedule').val(),
|
||||||
recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7,
|
recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7,
|
||||||
assignee_ids: assignees,
|
assignee_ids: assignees,
|
||||||
|
anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'),
|
||||||
lists: choreListPayloadFromForm()
|
lists: choreListPayloadFromForm()
|
||||||
};
|
};
|
||||||
postJson('/chore_save.php', payload)
|
postJson('/chore_save.php', payload)
|
||||||
@ -353,6 +396,67 @@
|
|||||||
window.alert(err.message || 'Could not reject');
|
window.alert(err.message || 'Could not reject');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.btn-chore-nfc-generate', function () {
|
||||||
|
var id = $(this).data('id');
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postJson('/chore_nfc_rotate.php', { id: id })
|
||||||
|
.then(function (data) {
|
||||||
|
var links = Array.isArray(data.links) ? data.links : [];
|
||||||
|
var html = '<div class="small text-muted mb-1"><strong>NFC links (copy per person)</strong></div>';
|
||||||
|
if (!links.length) {
|
||||||
|
html += '<div class="small text-muted">No links were returned.</div>';
|
||||||
|
} else {
|
||||||
|
links.forEach(function (row) {
|
||||||
|
html += '<label class="form-label small mb-1">' + escapeHtml(row.person_name || row.person_id || 'Person') + '</label>';
|
||||||
|
html += '<div class="input-group input-group-sm mb-2">';
|
||||||
|
html += '<input type="text" class="form-control chore-nfc-link-input" readonly value="' + escapeHtml(row.url || '') + '">';
|
||||||
|
html += '<button class="btn btn-outline-secondary btn-copy-nfc-link" type="button">Copy</button>';
|
||||||
|
html += '</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var $links = $('#chore_nfc_links_' + id);
|
||||||
|
$links.html(html).removeClass('d-none');
|
||||||
|
showAlert($('#chore_nfc_feedback_' + id), 'success', 'Generated new NFC links.');
|
||||||
|
$('#chore_nfc_feedback_' + id).removeClass('d-none');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not generate links');
|
||||||
|
$('#chore_nfc_feedback_' + id).removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.btn-copy-nfc-link', function () {
|
||||||
|
var $input = $(this).closest('.input-group').find('.chore-nfc-link-input');
|
||||||
|
var val = $input.val() || '';
|
||||||
|
if (!val) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
navigator.clipboard.writeText(String(val));
|
||||||
|
} else {
|
||||||
|
$input.trigger('focus').trigger('select');
|
||||||
|
document.execCommand('copy');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.btn-chore-nfc-toggle', function () {
|
||||||
|
var id = $(this).data('id');
|
||||||
|
var enable = String($(this).data('enable')) === '1';
|
||||||
|
if (!id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postJson('/chore_nfc_toggle.php', { id: id, enabled: enable })
|
||||||
|
.then(function () {
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showAlert($('#chore_nfc_feedback_' + id), 'danger', err.message || 'Could not update NFC status');
|
||||||
|
$('#chore_nfc_feedback_' + id).removeClass('d-none');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function groceryReloadKeepParams() {
|
function groceryReloadKeepParams() {
|
||||||
@ -967,6 +1071,21 @@
|
|||||||
window.alert(err.message || 'Could not reset PIN');
|
window.alert(err.message || 'Could not reset PIN');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.btn-rotate-person-nfc-token', function () {
|
||||||
|
var id = $(this).data('id');
|
||||||
|
var name = $(this).data('name') || 'this person';
|
||||||
|
if (!id || !window.confirm('Rotate NFC submitter token for ' + name + '? Existing personal NFC links will stop working.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
postJson('/person_nfc_token_rotate.php', { person_id: id })
|
||||||
|
.then(function () {
|
||||||
|
window.alert('Token rotated for ' + name + '. Generate fresh chore NFC links as needed.');
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.alert(err.message || 'Could not rotate NFC token');
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$(function () {
|
$(function () {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ require_once __DIR__ . '/persona.php';
|
|||||||
|
|
||||||
const CHORE_SCHEDULE_ONCE = 'once';
|
const CHORE_SCHEDULE_ONCE = 'once';
|
||||||
const CHORE_SCHEDULE_RECURRING = 'recurring';
|
const CHORE_SCHEDULE_RECURRING = 'recurring';
|
||||||
|
const CHORE_NFC_HASH_ALGO = 'sha256';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $raw
|
* @param mixed $raw
|
||||||
@ -106,9 +107,60 @@ function migrateLegacyChoreRow(array $c, array $people): array {
|
|||||||
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
|
if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) {
|
||||||
$c['pending_submission'] = null;
|
$c['pending_submission'] = null;
|
||||||
}
|
}
|
||||||
|
$c['anyone_can_complete'] = !empty($c['anyone_can_complete']);
|
||||||
|
$c['nfc'] = normalizeChoreNfcMeta($c['nfc'] ?? null);
|
||||||
return $c;
|
return $c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $raw
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function normalizeChoreNfcMeta($raw): array {
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
return [
|
||||||
|
'token_hash' => '',
|
||||||
|
'enabled' => false,
|
||||||
|
'created_at' => '',
|
||||||
|
'last_used_at' => null,
|
||||||
|
'last_used_ip' => null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$tokenHash = isset($raw['token_hash']) ? trim((string) $raw['token_hash']) : '';
|
||||||
|
$createdAt = isset($raw['created_at']) ? trim((string) $raw['created_at']) : '';
|
||||||
|
$enabled = !empty($raw['enabled']) && $tokenHash !== '';
|
||||||
|
$lastUsedAt = isset($raw['last_used_at']) ? trim((string) $raw['last_used_at']) : '';
|
||||||
|
$lastUsedIp = isset($raw['last_used_ip']) ? trim((string) $raw['last_used_ip']) : '';
|
||||||
|
return [
|
||||||
|
'token_hash' => $tokenHash,
|
||||||
|
'enabled' => $enabled,
|
||||||
|
'created_at' => $createdAt,
|
||||||
|
'last_used_at' => $lastUsedAt !== '' ? $lastUsedAt : null,
|
||||||
|
'last_used_ip' => $lastUsedIp !== '' ? $lastUsedIp : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateOpaqueToken(int $bytes = 24): string {
|
||||||
|
$raw = random_bytes($bytes);
|
||||||
|
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashOpaqueToken(string $token): string {
|
||||||
|
return hash(CHORE_NFC_HASH_ALGO, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateTokenHash(string $token, string $storedHash): bool {
|
||||||
|
if ($token === '' || $storedHash === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return hash_equals($storedHash, hashOpaqueToken($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestClientAddress(): ?string {
|
||||||
|
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||||
|
return $ip !== '' ? $ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param mixed $lists
|
* @param mixed $lists
|
||||||
* @return array<int, array{type: string, items: array<int, string>}>
|
* @return array<int, array{type: string, items: array<int, string>}>
|
||||||
|
|||||||
@ -34,6 +34,9 @@ function familySettingsDefaultsRaw(): array {
|
|||||||
'week_starts_on' => 0,
|
'week_starts_on' => 0,
|
||||||
'calendar_two_way_google' => false,
|
'calendar_two_way_google' => false,
|
||||||
'calendar_bill_days' => [],
|
'calendar_bill_days' => [],
|
||||||
|
'nfc_base_url' => '',
|
||||||
|
'nfc_show_confirmation' => true,
|
||||||
|
'nfc_scan_cooldown_seconds' => 0,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,6 +83,11 @@ function normalizeLoadedFamilySettings(array $s): array {
|
|||||||
$v = $s['calendar_two_way_google'] ?? false;
|
$v = $s['calendar_two_way_google'] ?? false;
|
||||||
$s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true';
|
$s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true';
|
||||||
$s['calendar_bill_days'] = normalizeCalendarBillDaysRaw($s['calendar_bill_days'] ?? []);
|
$s['calendar_bill_days'] = normalizeCalendarBillDaysRaw($s['calendar_bill_days'] ?? []);
|
||||||
|
$s['nfc_base_url'] = trim((string) ($s['nfc_base_url'] ?? ''));
|
||||||
|
$showConfirmation = $s['nfc_show_confirmation'] ?? true;
|
||||||
|
$s['nfc_show_confirmation'] = $showConfirmation === true || $showConfirmation === 1 || $showConfirmation === '1' || $showConfirmation === 'true';
|
||||||
|
$cooldown = (int) ($s['nfc_scan_cooldown_seconds'] ?? 0);
|
||||||
|
$s['nfc_scan_cooldown_seconds'] = max(0, min(600, $cooldown));
|
||||||
return $s;
|
return $s;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,6 +104,28 @@ function loadFamilySettings(): array {
|
|||||||
return normalizeLoadedFamilySettings($merged);
|
return normalizeLoadedFamilySettings($merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base app URL (without trailing slash) used for externally shared links.
|
||||||
|
*/
|
||||||
|
function familyHubAppUrl(array $familySettings): string {
|
||||||
|
$base = trim((string) ($familySettings['nfc_base_url'] ?? ''));
|
||||||
|
if ($base !== '') {
|
||||||
|
return rtrim($base, '/');
|
||||||
|
}
|
||||||
|
$https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off';
|
||||||
|
$scheme = $https ? 'https' : 'http';
|
||||||
|
$host = trim((string) ($_SERVER['HTTP_HOST'] ?? 'localhost'));
|
||||||
|
$script = trim((string) ($_SERVER['SCRIPT_NAME'] ?? '/index.php'));
|
||||||
|
$dir = rtrim(str_replace('\\', '/', dirname($script)), '/');
|
||||||
|
if ($dir === '' || $dir === '.') {
|
||||||
|
$dir = '';
|
||||||
|
}
|
||||||
|
if (substr($dir, -4) === '/api') {
|
||||||
|
$dir = substr($dir, 0, -4);
|
||||||
|
}
|
||||||
|
return $scheme . '://' . $host . $dir;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab label: symbol + name (e.g. "★ Stars").
|
* Tab label: symbol + name (e.g. "★ Stars").
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -97,3 +97,54 @@ function normalizePeopleList($raw): array {
|
|||||||
}
|
}
|
||||||
return $out;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $person
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
function migrateLegacyPersonRow(array $person): array {
|
||||||
|
if (!array_key_exists('nfc_submit_token_hash', $person) || !is_string($person['nfc_submit_token_hash'])) {
|
||||||
|
$person['nfc_submit_token_hash'] = '';
|
||||||
|
}
|
||||||
|
if (!array_key_exists('nfc_submit_token_updated_at', $person) || !is_string($person['nfc_submit_token_updated_at'])) {
|
||||||
|
$person['nfc_submit_token_updated_at'] = '';
|
||||||
|
}
|
||||||
|
return $person;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $people
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
function migrateAllPeople(array $people): array {
|
||||||
|
$out = [];
|
||||||
|
foreach ($people as $person) {
|
||||||
|
if (!is_array($person)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$out[] = migrateLegacyPersonRow($person);
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $people
|
||||||
|
* @return array{index:int, person:array<string, mixed>}|null
|
||||||
|
*/
|
||||||
|
function findPersonBySubmitToken(array $people, string $token): ?array {
|
||||||
|
$token = trim($token);
|
||||||
|
if ($token === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$candidateHash = hash('sha256', $token);
|
||||||
|
foreach ($people as $index => $person) {
|
||||||
|
$storedHash = trim((string) ($person['nfc_submit_token_hash'] ?? ''));
|
||||||
|
if ($storedHash === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (hash_equals($storedHash, $candidateHash)) {
|
||||||
|
return ['index' => (int) $index, 'person' => $person];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
55
readme.md
55
readme.md
@ -114,10 +114,63 @@ These are **not** in `.env`; they live in `data/family_settings.json` and are ed
|
|||||||
|
|
||||||
**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone.
|
**API:** `GET api/calendar_events.php?from=YYYY-MM-DD&to=YYYY-MM-DD` returns `{ success, events, rangeStart, rangeEnd, today }` for the signed-in profile (same session rules as other APIs). Omit `from`/`to` to use the default two-week window from **today** in the family timezone.
|
||||||
|
|
||||||
|
## NFC chore submission (HoH)
|
||||||
|
|
||||||
|
NFC support uses per-chore tokens plus per-person submitter tokens so a scan can submit as a specific person and credit that person after HoH approval.
|
||||||
|
|
||||||
|
### Family settings for NFC
|
||||||
|
|
||||||
|
In **Family settings → Chore economy**:
|
||||||
|
|
||||||
|
- **NFC base URL override** (`nfc_base_url`): optional app base URL used when generating NFC links. Leave blank to auto-detect from the current request.
|
||||||
|
- **NFC scan cooldown seconds** (`nfc_scan_cooldown_seconds`): optional soft replay dampener (0-600 seconds).
|
||||||
|
- **Show NFC confirmation page after scan** (`nfc_show_confirmation`): when off, NFC scans redirect directly to chores.
|
||||||
|
- **Emergency NFC actions**:
|
||||||
|
- Disable all chore NFC links
|
||||||
|
- Rotate all person NFC submitter tokens
|
||||||
|
|
||||||
|
### HoH setup checklist (per chore)
|
||||||
|
|
||||||
|
1. Open **Chores** as a verified Head of household.
|
||||||
|
2. For a chore, optionally enable **Allow anyone to complete this chore**.
|
||||||
|
3. Click **Generate NFC links**.
|
||||||
|
4. Copy each person-specific URL and write it to that person’s NFC card/tag (NDEF URI).
|
||||||
|
5. Label tags physically (name + chore).
|
||||||
|
6. Test scan once per person.
|
||||||
|
|
||||||
|
### NFC URL format
|
||||||
|
|
||||||
|
Generated links use:
|
||||||
|
|
||||||
|
`/api/chore_submit_nfc.php?id=<chore_id>&token=<chore_token>&person_token=<person_token>`
|
||||||
|
|
||||||
|
- `token` identifies the chore NFC token.
|
||||||
|
- `person_token` identifies who is submitting (credit recipient on approval).
|
||||||
|
|
||||||
|
### Completion and credit rules
|
||||||
|
|
||||||
|
- **Assignee-only chore** (`anyone_can_complete = false`): only assignees may submit, even with valid person tokens.
|
||||||
|
- **Anyone-can-complete chore** (`anyone_can_complete = true`): any valid person token holder may submit.
|
||||||
|
- Credit is always applied only on HoH approval (not at scan time), to the `submitted_by` person on the pending submission.
|
||||||
|
|
||||||
|
### Troubleshooting
|
||||||
|
|
||||||
|
- **Invalid chore token**: regenerate chore NFC links and rewrite tags.
|
||||||
|
- **Invalid submitter token**: rotate that person token and regenerate links.
|
||||||
|
- **Already pending review**: wait for HoH approve/reject.
|
||||||
|
- **Chore inactive**: reactivate/update chore before scanning again.
|
||||||
|
- **Lost/shared card**: rotate token(s) or disable chore NFC immediately.
|
||||||
|
|
||||||
|
### Security notes
|
||||||
|
|
||||||
|
- NFC links are bearer-style secrets. Anyone with a valid URL can submit as that URL’s person token.
|
||||||
|
- Use HTTPS in production and rotate tokens on loss/suspected leakage.
|
||||||
|
- Keep HoH approval enabled as the payout gate.
|
||||||
|
|
||||||
## Directory Structure
|
## Directory Structure
|
||||||
```
|
```
|
||||||
familyHub/
|
familyHub/
|
||||||
├── api/ # JSON POST endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, …)
|
├── api/ # JSON endpoints (chores, groceries, meals, people, settings, expenses, calendar_events, NFC helpers, …)
|
||||||
├── assets/ # Static assets
|
├── assets/ # Static assets
|
||||||
│ ├── css/ # CSS files
|
│ ├── css/ # CSS files
|
||||||
│ │ └── style.css
|
│ │ └── style.css
|
||||||
|
|||||||
@ -206,6 +206,15 @@ if ($editChore) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="chore_anyone_can_complete" <?= $editChore && !empty($editChore['anyone_can_complete']) ? 'checked' : '' ?>>
|
||||||
|
<label class="form-check-label" for="chore_anyone_can_complete">
|
||||||
|
Allow anyone to complete this chore using their own NFC/person token.
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<label class="form-label" for="chore_list_type">Checklist / list type</label>
|
<label class="form-label" for="chore_list_type">Checklist / list type</label>
|
||||||
<select class="form-select" id="chore_list_type">
|
<select class="form-select" id="chore_list_type">
|
||||||
@ -253,6 +262,8 @@ if ($editChore) {
|
|||||||
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
$cid = htmlspecialchars($c['id'] ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$assignees = $c['assignee_ids'] ?? [];
|
$assignees = $c['assignee_ids'] ?? [];
|
||||||
$isAssignee = $actorId !== '' && is_array($assignees) && in_array((string) $actorId, $assignees, true);
|
$isAssignee = $actorId !== '' && is_array($assignees) && in_array((string) $actorId, $assignees, true);
|
||||||
|
$canAnyoneSubmit = !empty($c['anyone_can_complete']);
|
||||||
|
$canSubmit = $isAssignee || ($activePerson !== null && $canAnyoneSubmit);
|
||||||
$canEdit = $activePerson && ((string) ($c['author_id'] ?? '') === (string) $actorId || $canReview);
|
$canEdit = $activePerson && ((string) ($c['author_id'] ?? '') === (string) $actorId || $canReview);
|
||||||
$pending = is_array($c['pending_submission'] ?? null);
|
$pending = is_array($c['pending_submission'] ?? null);
|
||||||
?>
|
?>
|
||||||
@ -275,7 +286,27 @@ if ($editChore) {
|
|||||||
<?php if ($pending): ?>
|
<?php if ($pending): ?>
|
||||||
<span class="badge text-bg-warning">Waiting for approval</span>
|
<span class="badge text-bg-warning">Waiting for approval</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($c['anyone_can_complete'])): ?>
|
||||||
|
<span class="badge text-bg-info">Anyone can complete</span>
|
||||||
|
<?php endif; ?>
|
||||||
</p>
|
</p>
|
||||||
|
<?php if ($canReview): ?>
|
||||||
|
<?php $nfcMeta = is_array($c['nfc'] ?? null) ? $c['nfc'] : []; ?>
|
||||||
|
<p class="small text-muted mb-2">
|
||||||
|
NFC:
|
||||||
|
<?php if (!empty($nfcMeta['enabled'])): ?>
|
||||||
|
<span class="badge text-bg-success">Enabled</span>
|
||||||
|
<?php elseif (!empty($nfcMeta['token_hash'])): ?>
|
||||||
|
<span class="badge text-bg-secondary">Disabled</span>
|
||||||
|
<?php else: ?>
|
||||||
|
<span class="badge text-bg-light text-dark border">Not generated</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($nfcMeta['last_used_at'])): ?>
|
||||||
|
<span class="ms-1">Last used <?= sanitizeInput((string) $nfcMeta['last_used_at']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</p>
|
||||||
|
<p class="small text-muted mb-2">HoH setup: generate links, write each person link to their card/tag, label it, then test one scan.</p>
|
||||||
|
<?php endif; ?>
|
||||||
<?php if (!empty($c['lists']) && is_array($c['lists'])): ?>
|
<?php if (!empty($c['lists']) && is_array($c['lists'])): ?>
|
||||||
<p class="small text-muted mb-1"><strong>Steps</strong> (reference — use the button below when you’re done)</p>
|
<p class="small text-muted mb-1"><strong>Steps</strong> (reference — use the button below when you’re done)</p>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@ -291,7 +322,7 @@ if ($editChore) {
|
|||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<div class="d-flex flex-wrap gap-2 mt-auto align-items-center">
|
<div class="d-flex flex-wrap gap-2 mt-auto align-items-center">
|
||||||
<?php if ($isAssignee && !$pending): ?>
|
<?php if ($canSubmit && !$pending): ?>
|
||||||
<button type="button" class="btn btn-sm btn-success btn-chore-submit" data-id="<?= $cid ?>">Submit for approval</button>
|
<button type="button" class="btn btn-sm btn-success btn-chore-submit" data-id="<?= $cid ?>">Submit for approval</button>
|
||||||
<?php elseif ($activePerson !== null && !$pending && !$isAssignee): ?>
|
<?php elseif ($activePerson !== null && !$pending && !$isAssignee): ?>
|
||||||
<?php
|
<?php
|
||||||
@ -306,7 +337,17 @@ if ($editChore) {
|
|||||||
<?php if ($canEdit): ?>
|
<?php if ($canEdit): ?>
|
||||||
<a class="btn btn-sm btn-outline-primary" href="?tab=chores&edit=<?= $cid ?>">Edit</a>
|
<a class="btn btn-sm btn-outline-primary" href="?tab=chores&edit=<?= $cid ?>">Edit</a>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
|
<?php if ($canReview): ?>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-dark btn-chore-nfc-generate" data-id="<?= $cid ?>">Generate NFC links</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-chore-nfc-toggle" data-id="<?= $cid ?>" data-enable="<?= !empty(($c['nfc']['enabled'] ?? false)) ? '0' : '1' ?>">
|
||||||
|
<?= !empty(($c['nfc']['enabled'] ?? false)) ? 'Disable NFC' : 'Enable NFC' ?>
|
||||||
|
</button>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
<?php if ($canReview): ?>
|
||||||
|
<div class="alert d-none mt-2 p-2 small chore-nfc-feedback" id="chore_nfc_feedback_<?= $cid ?>"></div>
|
||||||
|
<div class="d-none mt-2 chore-nfc-links" id="chore_nfc_links_<?= $cid ?>"></div>
|
||||||
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -164,10 +164,37 @@ $permanenceOptions = [
|
|||||||
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
|
value="<?= (int) ($familySettings['week_starts_on'] ?? 0) ?>"
|
||||||
<?= !$canManage ? 'disabled' : '' ?>>
|
<?= !$canManage ? 'disabled' : '' ?>>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="nfc_base_url">NFC base URL override (optional)</label>
|
||||||
|
<input class="form-control" id="nfc_base_url" name="nfc_base_url" placeholder="https://your-domain/familyHub"
|
||||||
|
value="<?= sanitizeInput($familySettings['nfc_base_url'] ?? '') ?>"
|
||||||
|
<?= !$canManage ? 'disabled' : '' ?>>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label" for="nfc_scan_cooldown_seconds">NFC scan cooldown seconds</label>
|
||||||
|
<input type="number" class="form-control" id="nfc_scan_cooldown_seconds" name="nfc_scan_cooldown_seconds" min="0" max="600"
|
||||||
|
value="<?= (int) ($familySettings['nfc_scan_cooldown_seconds'] ?? 0) ?>"
|
||||||
|
<?= !$canManage ? 'disabled' : '' ?>>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 d-flex align-items-end">
|
||||||
|
<div class="form-check mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="nfc_show_confirmation" name="nfc_show_confirmation" value="1"
|
||||||
|
<?= !empty($familySettings['nfc_show_confirmation']) ? 'checked' : '' ?>
|
||||||
|
<?= !$canManage ? 'disabled' : '' ?>>
|
||||||
|
<label class="form-check-label" for="nfc_show_confirmation">Show NFC confirmation page after scan</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php if ($canManage): ?>
|
<?php if ($canManage): ?>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<button type="submit" class="btn btn-primary">Save chore economy</button>
|
<button type="submit" class="btn btn-primary">Save chore economy</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="small text-muted mb-2">Emergency NFC actions</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<button type="button" class="btn btn-outline-warning btn-sm" id="btnDisableAllChoreNfc">Disable all chore NFC links</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger btn-sm" id="btnRotateAllPersonNfcTokens">Rotate all person NFC tokens</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="alert d-none" id="familySettingsFeedback" role="status"></div>
|
<div class="alert d-none" id="familySettingsFeedback" role="status"></div>
|
||||||
@ -266,6 +293,7 @@ $permanenceOptions = [
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
<th class="text-end">Balance</th>
|
<th class="text-end">Balance</th>
|
||||||
|
<?php if ($canManage): ?><th>NFC submit token</th><?php endif; ?>
|
||||||
<?php if ($canManage): ?><th></th><?php endif; ?>
|
<?php if ($canManage): ?><th></th><?php endif; ?>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -276,6 +304,9 @@ $permanenceOptions = [
|
|||||||
<td><?= sanitizeInput($p['role'] ?? '') ?></td>
|
<td><?= sanitizeInput($p['role'] ?? '') ?></td>
|
||||||
<td class="text-end"><?= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> <?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?></td>
|
<td class="text-end"><?= sanitizeInput((string) ($p['currency_balance'] ?? 0)) ?> <?= sanitizeInput($familySettings['currency_symbol'] ?? '') ?></td>
|
||||||
<?php if ($canManage): ?>
|
<?php if ($canManage): ?>
|
||||||
|
<td class="text-nowrap">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-rotate-person-nfc-token" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>" data-name="<?= htmlspecialchars((string) ($p['name'] ?? ''), ENT_QUOTES, 'UTF-8') ?>">Rotate token</button>
|
||||||
|
</td>
|
||||||
<td class="text-end text-nowrap">
|
<td class="text-end text-nowrap">
|
||||||
<?php if (($p['role'] ?? '') === ROLE_HEAD): ?>
|
<?php if (($p['role'] ?? '') === ROLE_HEAD): ?>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary btn-reset-pin" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Reset PIN</button>
|
<button type="button" class="btn btn-sm btn-outline-secondary btn-reset-pin" data-id="<?= htmlspecialchars($p['id'] ?? '', ENT_QUOTES, 'UTF-8') ?>">Reset PIN</button>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user