diff --git a/api/chore_nfc_rotate.php b/api/chore_nfc_rotate.php new file mode 100644 index 0000000..1c328d8 --- /dev/null +++ b/api/chore_nfc_rotate.php @@ -0,0 +1,72 @@ + 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, +]); diff --git a/api/chore_nfc_toggle.php b/api/chore_nfc_toggle.php new file mode 100644 index 0000000..71825ff --- /dev/null +++ b/api/chore_nfc_toggle.php @@ -0,0 +1,43 @@ + 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]); diff --git a/api/chore_review.php b/api/chore_review.php index f4fc6cd..bd02521 100644 --- a/api/chore_review.php +++ b/api/chore_review.php @@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$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 review chores'], 403); @@ -46,13 +46,10 @@ if ($decision === 'reject') { sendJson(['success' => true]); } -$assignees = $row['assignee_ids'] ?? []; -if (!is_array($assignees)) { - $assignees = []; -} +$submittedBy = trim((string) ($pending['submitted_by'] ?? '')); $value = (float) ($row['value'] ?? 0); -$n = count($assignees); -$share = $n > 0 ? round($value / $n, 2) : 0.0; +$creditedEach = 0.0; +$creditedRecipients = 0; $row['pending_submission'] = null; @@ -73,15 +70,33 @@ if (!writeJsonFile('chores.json', $chores)) { foreach ($people as $pi => $p) { $pid = (string) ($p['id'] ?? ''); - if ($pid === '' || !in_array($pid, $assignees, true)) { + if ($pid === '') { 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; - $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)) { 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, +]); diff --git a/api/chore_save.php b/api/chore_save.php index 68f28cd..8ca273e 100644 --- a/api/chore_save.php +++ b/api/chore_save.php @@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); $actor = requireActivePerson($people); $body = readJsonBody(); @@ -37,6 +37,8 @@ if ($id !== '') { 'author_id' => (string) ($actor['id'] ?? ''), 'pending_submission' => null, 'status' => 'active', + 'anyone_can_complete' => false, + 'nfc' => normalizeChoreNfcMeta(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['lists'] = normalizeChoreLists($body['lists'] ?? []); $row['assignee_ids'] = $assigneeIdsClean; +$row['anyone_can_complete'] = !empty($body['anyone_can_complete']); $val = isset($body['value']) ? $body['value'] : 0; $row['value'] = is_numeric($val) ? max(0.0, (float) $val) : 0.0; diff --git a/api/chore_submit.php b/api/chore_submit.php index 7fc17b0..4b8b2f0 100644 --- a/api/chore_submit.php +++ b/api/chore_submit.php @@ -7,7 +7,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); $actor = requireActivePerson($people); $actorId = (string) ($actor['id'] ?? ''); if ($actorId === '') { @@ -34,7 +34,7 @@ if (($row['status'] ?? '') !== 'active') { } $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); } diff --git a/api/chore_submit_nfc.php b/api/chore_submit_nfc.php new file mode 100644 index 0000000..3f03d87 --- /dev/null +++ b/api/chore_submit_nfc.php @@ -0,0 +1,123 @@ +'; + } + echo ''; + echo '' . $safeTitle . '' . $meta; + echo ''; + echo '
'; + echo '
'; + echo '
' . $safeTitle . '
'; + echo '

' . $safeMessage . '

'; + if ($redirectUrl !== '' && !$autoRedirect) { + echo 'Open Family Hub'; + } + echo '
'; + 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); diff --git a/api/family_nfc_admin.php b/api/family_nfc_admin.php new file mode 100644 index 0000000..e91ff4b --- /dev/null +++ b/api/family_nfc_admin.php @@ -0,0 +1,58 @@ + 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]); diff --git a/api/family_settings_save.php b/api/family_settings_save.php index dac3ff6..a07c9f4 100644 --- a/api/family_settings_save.php +++ b/api/family_settings_save.php @@ -50,6 +50,23 @@ if (isset($body['week_starts_on'])) { } $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); diff --git a/api/people_create.php b/api/people_create.php index 242eee5..eb1bd86 100644 --- a/api/people_create.php +++ b/api/people_create.php @@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); if (count($people) === 0) { sendJson(['success' => false, 'error' => 'Use first-time setup to create the initial Head of Household'], 400); } @@ -58,6 +58,8 @@ $newPerson = [ 'birthday' => $birthday, 'favoriteColor' => $favoriteColor, 'currency_balance' => 0, + 'nfc_submit_token_hash' => '', + 'nfc_submit_token_updated_at' => '', 'created_at' => gmdate('c'), ]; diff --git a/api/people_create_first.php b/api/people_create_first.php index dbe5a84..38813c5 100644 --- a/api/people_create_first.php +++ b/api/people_create_first.php @@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); if (count($people) > 0) { sendJson(['success' => false, 'error' => 'People already exist; use signed-in Head of Household'], 403); } @@ -46,6 +46,8 @@ $newPerson = [ 'birthday' => $birthday, 'favoriteColor' => $favoriteColor, 'currency_balance' => 0, + 'nfc_submit_token_hash' => '', + 'nfc_submit_token_updated_at' => '', 'created_at' => gmdate('c'), ]; diff --git a/api/people_delete.php b/api/people_delete.php index 243d6f9..49d27e4 100644 --- a/api/people_delete.php +++ b/api/people_delete.php @@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); assertHoHCanManagePeople($people); $body = readJsonBody(); diff --git a/api/people_reset_pin.php b/api/people_reset_pin.php index b55187a..d7bb6d5 100644 --- a/api/people_reset_pin.php +++ b/api/people_reset_pin.php @@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); assertHoHCanManagePeople($people); $body = readJsonBody(); diff --git a/api/people_update.php b/api/people_update.php index 788e2f3..6a994fa 100644 --- a/api/people_update.php +++ b/api/people_update.php @@ -6,7 +6,7 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { sendJson(['success' => false, 'error' => 'Method not allowed'], 405); } -$people = normalizePeopleList(readJsonFile('people.json')); +$people = migrateAllPeople(normalizePeopleList(readJsonFile('people.json'))); assertHoHCanManagePeople($people); $body = readJsonBody(); diff --git a/api/person_nfc_token_rotate.php b/api/person_nfc_token_rotate.php new file mode 100644 index 0000000..2e15341 --- /dev/null +++ b/api/person_nfc_token_rotate.php @@ -0,0 +1,46 @@ + 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, +]); diff --git a/assets/js/main.js b/assets/js/main.js index f840515..35d3694 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -36,6 +36,15 @@ $el.text(message); } + function escapeHtml(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + var pendingPersonId = null; var pinModal = null; @@ -179,7 +188,10 @@ currency_symbol: $('#currency_symbol').val(), currency_name: $('#currency_name').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) .then(function () { @@ -191,6 +203,36 @@ $('#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() { @@ -285,6 +327,7 @@ schedule: $('#chore_schedule').val(), recurrence_days: parseInt($('#chore_recurrence_days').val(), 10) || 7, assignee_ids: assignees, + anyone_can_complete: $('#chore_anyone_can_complete').is(':checked'), lists: choreListPayloadFromForm() }; postJson('/chore_save.php', payload) @@ -353,6 +396,67 @@ 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 = '
NFC links (copy per person)
'; + if (!links.length) { + html += '
No links were returned.
'; + } else { + links.forEach(function (row) { + html += ''; + html += '
'; + html += ''; + html += ''; + html += '
'; + }); + } + 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() { @@ -967,6 +1071,21 @@ 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 () { diff --git a/includes/chore_helpers.php b/includes/chore_helpers.php index de8e996..db83b37 100644 --- a/includes/chore_helpers.php +++ b/includes/chore_helpers.php @@ -4,6 +4,7 @@ require_once __DIR__ . '/persona.php'; const CHORE_SCHEDULE_ONCE = 'once'; const CHORE_SCHEDULE_RECURRING = 'recurring'; +const CHORE_NFC_HASH_ALGO = 'sha256'; /** * @param mixed $raw @@ -106,9 +107,60 @@ function migrateLegacyChoreRow(array $c, array $people): array { if ($c['pending_submission'] !== null && !is_array($c['pending_submission'])) { $c['pending_submission'] = null; } + $c['anyone_can_complete'] = !empty($c['anyone_can_complete']); + $c['nfc'] = normalizeChoreNfcMeta($c['nfc'] ?? null); return $c; } +/** + * @param mixed $raw + * @return array + */ +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 * @return array}> diff --git a/includes/family_settings.php b/includes/family_settings.php index 1c092b5..3dd02a2 100644 --- a/includes/family_settings.php +++ b/includes/family_settings.php @@ -34,6 +34,9 @@ function familySettingsDefaultsRaw(): array { 'week_starts_on' => 0, 'calendar_two_way_google' => false, '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; $s['calendar_two_way_google'] = $v === true || $v === 1 || $v === '1' || $v === 'true'; $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; } @@ -96,6 +104,28 @@ function loadFamilySettings(): array { 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"). */ diff --git a/includes/persona.php b/includes/persona.php index 8cf6ae9..1a15945 100644 --- a/includes/persona.php +++ b/includes/persona.php @@ -97,3 +97,54 @@ function normalizePeopleList($raw): array { } return $out; } + +/** + * @param array $person + * @return array + */ +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> $people + * @return array> + */ +function migrateAllPeople(array $people): array { + $out = []; + foreach ($people as $person) { + if (!is_array($person)) { + continue; + } + $out[] = migrateLegacyPersonRow($person); + } + return $out; +} + +/** + * @param array> $people + * @return array{index:int, person:array}|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; +} diff --git a/readme.md b/readme.md index d99303e..a92e68f 100644 --- a/readme.md +++ b/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. +## 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=&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 ``` 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 │ ├── css/ # CSS files │ │ └── style.css diff --git a/tabs/chores.php b/tabs/chores.php index 3121fe0..026ea02 100644 --- a/tabs/chores.php +++ b/tabs/chores.php @@ -206,6 +206,15 @@ if ($editChore) { +
+
+ > + +
+
+
> +
+
+ + > +
+
+
+ + > + +
+
+
+
Emergency NFC actions
+
+ + +
+
@@ -266,6 +293,7 @@ $permanenceOptions = [ Name Role Balance + NFC submit token @@ -276,6 +304,9 @@ $permanenceOptions = [ + + +