Files
2026-07-12 10:17:17 -04:00

3.7 KiB

Phone-Verification Gate for Public Share/Approval Pages

Problem

You have a public share link (UUID-based token in URL) that lets customers approve or decline services. Anyone with the URL can access it — forwarding the link gives full control. You need a low-friction identity check without adding SMS gateways, email infrastructure, or database changes.

Solution

Gate the page behind a last-4-digits phone verification screen. The customer's phone number is already stored on the record (customerPhone). Show a simple 4-digit input before revealing the decision UI.

Pattern

1. Add a new page status

type PageStatus = 'loading' | 'invalid' | 'expired' | 'done' | 'error' | 'phone_verify';
const MAX_PHONE_ATTEMPTS = 3;

2. Add state variables

const [phoneInput, setPhoneInput] = useState('');
const [phoneAttempts, setPhoneAttempts] = useState(0);

3. Extract phone from record and gate

After loading the record, check if a phone exists (≥4 digits). If yes → 'phone_verify', else skip to 'done':

const rawPhone = (record.customerPhone as string) || '';
const hasPhone = rawPhone.replace(/\D/g, '').length >= 4;
setPageStatus(hasPhone ? 'phone_verify' : 'done');

Important: Do NOT call setPageStatus('done') unconditionally after loading — the phone-verify check must be the one that sets it.

4. Verification handler

function handlePhoneVerify() {
  if (!record) return;
  const digitsOnly = record.customerPhone.replace(/\D/g, '');
  const last4 = digitsOnly.slice(-4);
  const inputDigits = phoneInput.replace(/\D/g, '');
  if (inputDigits === last4) {
    setPhoneAttempts(0);
    setPageStatus('done');
  } else {
    const newAttempts = phoneAttempts + 1;
    setPhoneAttempts(newAttempts);
    setPhoneInput('');
    if (newAttempts >= MAX_PHONE_ATTEMPTS) {
      setError('Too many incorrect attempts. Please contact the shop directly.');
    } else {
      setError(`Incorrect. ${MAX_PHONE_ATTEMPTS - newAttempts} attempt(s) remaining.`);
    }
  }
}

5. Verification UI (screen component)

Render this when pageStatus === 'phone_verify':

  • Logo/branding header (reuse same accent color)
  • Customer name display ("This quote is for John")
  • Instruction: "Enter last 4 digits of phone on file"
  • Large centered input (24px font, letter-spacing, inputMode="numeric")
  • Submit button (disabled until 4 digits entered)
  • Enter key support (onKeyDown)
  • Locked state after max attempts (show shop phone number, hide input)

6. Skip when no phone

If the record has no phone or <4 digits, the verification screen never shows — backward compatible with existing records.

Key Details

  • Stripping non-digits: Always normalize both sides with .replace(/\D/g, '') before comparing. Phone formats vary (555-0100, +1 555 0100, 555.0100).
  • Last 4 only: Slice with .slice(-4) — handles any phone length globally.
  • 3 attempts max: Prevents brute force. After lockout, show a "call the shop" message with the shop's business phone from settings.
  • No backend changes: The phone is already on the record the token gives access to. The verification is client-side — anyone who already has the token can see the record, but can't submit decisions without the verification step.
  • Field name: customerPhone matches the PocketBase collection field name used in SPQ-v2's quoteWriteSchema.

Design Notes

  • The input uses tracking-[0.5em] for wide spacing between digits (easy to read)
  • autoFocus and maxLength={4} make it feel like a PIN entry
  • On Enter, submit the verify, not the whole form
  • The shop's business phone number (settings.businessPhone) is shown on the lockout screen