From 4d25564d1d838e0173b0c74aefe8b508a884f003 Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 11 Aug 2026 03:28:46 -0400 Subject: [PATCH 1/2] fix: handle cloud account offline states safely --- .../src/app/(dashboard)/settings/page.tsx | 174 +++++++++++--- frontend/src/components/layout/Sidebar.tsx | 7 +- main/db.ts | 14 ++ main/ipc.ts | 3 + main/routes/database.ts | 3 + main/routes/settings.ts | 103 ++++++-- main/services/cloud-sync.ts | 51 +++- package.json | 3 +- tests/cloud-account-status.test.ts | 221 ++++++++++++++++++ tests/cloud-deletion-recovery.test.ts | 7 +- tests/security-hardening.test.ts | 8 + 11 files changed, 531 insertions(+), 63 deletions(-) create mode 100644 tests/cloud-account-status.test.ts diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index b6dfc4bf..73105571 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -293,15 +293,23 @@ export default function SettingsPage() { // The mount effect below always fetches backups unconditionally, so this starts true // rather than being set synchronously inside that effect. const [backupsLoading, setBackupsLoading] = useState(true); - const [cloudAccount, setCloudAccount] = useState<{ email?: string; verified?: boolean; verified_at?: string | null; verification_sent_at?: string | null; product_updates?: boolean; marketing?: boolean; deletion_request?: { id?: string; status?: 'pending' | 'approved' | 'rejected' | 'cancelled'; requested_at?: string; reviewed_at?: string | null; decision_note?: string | null } | null } | null>(null); + const [cloudAccount, setCloudAccount] = useState<{ email?: string | null; cloud_account_available?: boolean; verified?: boolean; verified_at?: string | null; verification_sent_at?: string | null; product_updates?: boolean; marketing?: boolean; deletion_request?: { id?: string; status?: 'pending' | 'processing' | 'approved' | 'completed' | 'deleted' | 'failed' | 'rejected' | 'cancelled'; requested_at?: string; reviewed_at?: string | null; decision_note?: string | null } | null } | null>(null); const [cloudAccountBusy, setCloudAccountBusy] = useState(false); + const [cloudAccountLoadFailed, setCloudAccountLoadFailed] = useState(false); + const [refreshingDeletionStatus, setRefreshingDeletionStatus] = useState(false); + const cloudAccountAvailable = !cloudAccountLoadFailed && cloudAccount?.cloud_account_available !== false; + const cloudDeletionStatus = cloudAccount?.deletion_request?.status || ''; + const cloudDeletionPending = cloudDeletionStatus === 'pending'; + const cloudDeletionNeedsResolution = ['pending', 'processing', 'failed'].includes(cloudDeletionStatus); + const cloudDeletionCanCancel = ['pending', 'processing'].includes(cloudDeletionStatus) && Boolean(cloudAccount?.deletion_request?.id); const fetchCloudAccount = async () => { try { const { data } = await api.get('/settings/cloud/account'); setCloudAccount(data); + setCloudAccountLoadFailed(false); } catch { - setCloudAccount(null); + setCloudAccountLoadFailed(true); } }; @@ -352,8 +360,11 @@ export default function SettingsPage() { .finally(() => setBackupsLoading(false)); if (currentTenant?.role === 'owner') { api.get('/settings/cloud/account') - .then(({ data }) => setCloudAccount(data)) - .catch(() => setCloudAccount(null)); + .then(({ data }) => { + setCloudAccount(data); + setCloudAccountLoadFailed(false); + }) + .catch(() => setCloudAccountLoadFailed(true)); } if (searchParams?.get('action') === 'health-check') { @@ -478,10 +489,11 @@ export default function SettingsPage() { try { await api.post('/settings/cloud/delete-data', { master_pin: pin, confirmation: 'DELETE CLOUD DATA' }); toast.success('Cloud deletion request submitted for manual review. Cloud services have been stopped on this device.'); - await fetchCloudAccount(); + await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); setPinGate(null); return { success: true }; } catch (err: unknown) { + await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); const error = err as { response?: { data?: { error?: string } } }; return { success: false, error: error.response?.data?.error || 'Cloud data deletion failed' }; } @@ -491,7 +503,7 @@ export default function SettingsPage() { try { await api.post('/settings/cloud/delete-data/cancel', { master_pin: pin }); toast.success('Cloud deletion request cancelled. Cloud services remain off until you explicitly re-enable them.'); - await fetchCloudAccount(); + await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); setPinGate(null); return { success: true }; } catch (err: unknown) { @@ -1092,16 +1104,64 @@ export default function SettingsPage() { const [savedCloudSettings, setSavedCloudSettings] = useState(cloudSettings); const [cloudStatus, setCloudStatus] = useState({ cloud_registration_status: 'unregistered', + cloud_services_disabled_by_user: false, cloud_connected: false, cloud_relay_mode: 'disconnected', cloud_last_heartbeat: null as string | null, cloud_last_error: null as string | null, + cloud_deletion_status: '', }); const [savingCloud, setSavingCloud] = useState(false); const [registeringCloud, setRegisteringCloud] = useState(false); const [showInitializeCloudConfirm, setShowInitializeCloudConfirm] = useState(false); + const cloudServicesStopped = cloudStatus.cloud_services_disabled_by_user; + const cloudDeletionFinal = cloudStatus.cloud_registration_status === 'deleted' || ['approved', 'completed', 'deleted'].includes(cloudStatus.cloud_deletion_status); + const cloudDeletionNeedsAction = !cloudDeletionFinal && (cloudDeletionNeedsResolution || ['processing', 'failed'].includes(cloudStatus.cloud_deletion_status)); + + const refreshCloudStatus = async () => { + try { + const { data } = await api.get('/settings/cloud'); + setCloudStatus({ + cloud_registration_status: data.cloud_registration_status || 'unregistered', + cloud_services_disabled_by_user: !!data.cloud_services_disabled_by_user, + cloud_connected: !!data.cloud_connected, + cloud_relay_mode: data.cloud_relay_mode || 'disconnected', + cloud_last_heartbeat: data.cloud_last_heartbeat || null, + cloud_last_error: data.cloud_last_error || null, + cloud_deletion_status: data.cloud_deletion_status || '', + }); + setCloudSettings((previous) => ({ + ...previous, + cloud_sync_enabled: !!data.cloud_sync_enabled, + cloud_orders_enabled: !!data.cloud_orders_enabled, + cloud_last_sync: data.cloud_last_sync || null, + })); + setSavedCloudSettings((previous) => ({ + ...previous, + cloud_sync_enabled: !!data.cloud_sync_enabled, + cloud_orders_enabled: !!data.cloud_orders_enabled, + cloud_last_sync: data.cloud_last_sync || null, + })); + } catch { + // Keep the last known status if the local settings request fails. + } + }; + + const refreshDeletionStatus = async () => { + setRefreshingDeletionStatus(true); + try { + await api.get('/settings/cloud/delete-data/status'); + await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); + toast.success('Cloud deletion status refreshed'); + } catch { + toast.error('Could not refresh cloud deletion status'); + } finally { + setRefreshingDeletionStatus(false); + } + }; + const [telemetryEnabled, setTelemetryEnabled] = useState(false); const [savingTelemetry, setSavingTelemetry] = useState(false); @@ -1347,10 +1407,12 @@ export default function SettingsPage() { setSavedCloudSettings(settings); setCloudStatus({ cloud_registration_status: res.data.cloud_registration_status || 'unregistered', + cloud_services_disabled_by_user: !!res.data.cloud_services_disabled_by_user, cloud_connected: !!res.data.cloud_connected, cloud_relay_mode: res.data.cloud_relay_mode || 'disconnected', cloud_last_heartbeat: res.data.cloud_last_heartbeat || null, cloud_last_error: res.data.cloud_last_error || null, + cloud_deletion_status: res.data.cloud_deletion_status || '', }); // Mobile pairing requires cloud registration — skip the requests entirely @@ -1416,6 +1478,16 @@ export default function SettingsPage() { const next = { ...cloudSettings, ...res.data }; setCloudSettings(next); setSavedCloudSettings(next); + setCloudStatus({ + cloud_registration_status: res.data.cloud_registration_status || 'unregistered', + cloud_services_disabled_by_user: !!res.data.cloud_services_disabled_by_user, + cloud_connected: !!res.data.cloud_connected, + cloud_relay_mode: res.data.cloud_relay_mode || 'disconnected', + cloud_last_heartbeat: res.data.cloud_last_heartbeat || null, + cloud_last_error: res.data.cloud_last_error || null, + cloud_deletion_status: res.data.cloud_deletion_status || '', + }); + await fetchCloudAccount(); if (!silent) toast.success(t('settings.cloudSaved')); } catch (err) { if (!silent) toast.error(t('settings.cloudSaveFailed')); @@ -1435,16 +1507,19 @@ export default function SettingsPage() { const res = await api.post('/settings/cloud/register', { email }); setCloudStatus({ cloud_registration_status: res.data.cloud_registration_status || 'unregistered', + cloud_services_disabled_by_user: !!res.data.cloud_services_disabled_by_user, cloud_connected: !!res.data.cloud_connected, cloud_relay_mode: res.data.cloud_relay_mode || 'disconnected', cloud_last_heartbeat: res.data.cloud_last_heartbeat || null, cloud_last_error: res.data.cloud_last_error || null, + cloud_deletion_status: res.data.cloud_deletion_status || '', }); setCloudSettings((prev) => ({ ...prev, cloud_api_key: res.data.cloud_api_key || prev.cloud_api_key, cloud_store_id: res.data.cloud_store_id || prev.cloud_store_id, })); + await fetchCloudAccount(); if (res.data.cloud_registration_status === 'registered') { toast.success(t('settings.cloudRegistrationSuccess')); } @@ -1911,7 +1986,7 @@ export default function SettingsPage() {

{t('settings.navGroupAccount')}

- + @@ -2964,18 +3039,21 @@ export default function SettingsPage() { {currentTenant?.role === 'owner' && ( -
+

Contact email

-

{cloudAccount?.email || user?.email || 'No cloud contact email'}

+

{cloudAccountLoadFailed ? 'Unable to load cloud account status' : cloudAccountAvailable ? (cloudAccount?.email || user?.email || 'No cloud contact email') : 'Cloud account services are currently unavailable'}

- - {cloudAccount?.verified ? 'Verified' : 'Pending verification'} + + {cloudAccountLoadFailed ? 'Status unavailable' : !cloudAccountAvailable ? 'Unavailable' : cloudAccount?.verified ? 'Verified' : 'Pending verification'}
-

Verification is important for product service notices, security updates, and other account communication.

- {!cloudAccount?.verified && ( +

{cloudAccountLoadFailed ? 'Check the local API connection and retry. No cloud account changes were made.' : cloudAccountAvailable ? 'Verification is important for product service notices, security updates, and other account communication.' : cloudDeletionPending ? 'A cloud deletion request is pending review. Cancel it or wait for review before re-enabling Cloud Services.' : cloudDeletionStatus === 'processing' ? 'Cloud deletion is being processed. Refresh its status or cancel it if cancellation is available.' : cloudDeletionStatus === 'failed' || cloudStatus.cloud_deletion_status === 'failed' ? 'The cloud deletion request needs attention. Refresh its status or retry the request from the privacy controls.' : 'Enable Cloud Services from Mobile Access to use cloud account features.'}

+ {cloudAccountLoadFailed && ( + + )} + {cloudAccountAvailable && !cloudAccount?.verified && ( )} -
- - -

Essential service and security notices are separate from these optional subscriptions.

-
+ {cloudAccountAvailable && ( +
+ + +

Essential service and security notices are separate from these optional subscriptions.

+
+ )}
)}
@@ -3039,7 +3119,7 @@ export default function SettingsPage() {

Cloud privacy controls

Stopping cloud services is reversible. A cloud deletion request is reviewed manually in FloAdmin before data is permanently removed. Neither action deletes your local orders, bills, customers, products, or database.

{cloudAccount?.deletion_request && ( -
+

Deletion request: {cloudAccount.deletion_request.status}

{cloudAccount.deletion_request.id &&

{cloudAccount.deletion_request.id}

} {cloudAccount.deletion_request.decision_note &&

{cloudAccount.deletion_request.decision_note}

} @@ -3048,16 +3128,38 @@ export default function SettingsPage() {
- - {cloudAccount?.deletion_request?.status === 'pending' && ( - + }}>Request cloud data deletion} + {cloudDeletionNeedsAction && ( + <> + + {cloudDeletionCanCancel && } + )}

Anonymous telemetry has no store or email link, so existing anonymous events cannot be identified as yours. This action stops future telemetry and rotates the anonymous identifier.

@@ -3919,26 +4021,36 @@ export default function SettingsPage() { <>
- {cloudStatus.cloud_registration_status === 'registered' ? ( + {cloudStatus.cloud_registration_status === 'registered' && !cloudServicesStopped ? ( ) : ( )}

- {cloudStatus.cloud_registration_status === 'registered' && (cloudStatus.cloud_connected ? t('settings.connectedToFloadmin') : t('settings.registeredReconnecting'))} + {cloudStatus.cloud_registration_status === 'registered' && cloudServicesStopped && 'Cloud services stopped'} + {cloudStatus.cloud_registration_status === 'registered' && !cloudServicesStopped && (cloudStatus.cloud_connected ? t('settings.connectedToFloadmin') : t('settings.registeredReconnecting'))} {cloudStatus.cloud_registration_status === 'rejected' && t('settings.registrationRejected')} + {cloudStatus.cloud_registration_status === 'deletion_pending' && (cloudStatus.cloud_last_error || cloudStatus.cloud_deletion_status === 'failed') && 'Cloud deletion request failed'} + {cloudStatus.cloud_registration_status === 'deletion_pending' && cloudStatus.cloud_deletion_status === 'processing' && 'Cloud deletion processing'} + {cloudStatus.cloud_registration_status === 'deletion_pending' && !cloudStatus.cloud_last_error && cloudStatus.cloud_deletion_status !== 'failed' && cloudStatus.cloud_deletion_status !== 'processing' && 'Cloud deletion request pending'} + {cloudStatus.cloud_registration_status === 'deleted' && 'Cloud data deleted'} {(cloudStatus.cloud_registration_status === 'unregistered' || cloudStatus.cloud_registration_status === 'registration_failed') && t('settings.notRegistered')}

- {cloudStatus.cloud_registration_status === 'registered' && (cloudStatus.cloud_last_heartbeat ? t('settings.liveChannelHeartbeat', { mode: cloudStatus.cloud_relay_mode.replace('_', ' '), time: formatTime(cloudStatus.cloud_last_heartbeat) }) : t('settings.liveChannel', { mode: cloudStatus.cloud_relay_mode.replace('_', ' ') }))} + {cloudStatus.cloud_registration_status === 'registered' && cloudServicesStopped && 'Enable Cloud Services below and save changes to resume cloud services.'} + {cloudStatus.cloud_registration_status === 'registered' && !cloudServicesStopped && (cloudStatus.cloud_last_heartbeat ? t('settings.liveChannelHeartbeat', { mode: cloudStatus.cloud_relay_mode.replace('_', ' '), time: formatTime(cloudStatus.cloud_last_heartbeat) }) : t('settings.liveChannel', { mode: cloudStatus.cloud_relay_mode.replace('_', ' ') }))} {cloudStatus.cloud_registration_status === 'rejected' && t('settings.registrationContactSupport')} {cloudStatus.cloud_registration_status === 'registration_failed' && (cloudStatus.cloud_last_error ? t('settings.registrationLastError', { error: cloudStatus.cloud_last_error }) : t('settings.registrationLastFailed'))} + {cloudStatus.cloud_registration_status === 'deletion_pending' && (cloudStatus.cloud_last_error || cloudStatus.cloud_deletion_status === 'failed') && 'The deletion request failed. You can refresh its status or retry the request from the privacy controls below.'} + {cloudStatus.cloud_registration_status === 'deletion_pending' && cloudStatus.cloud_deletion_status === 'processing' && 'Cloud deletion is being processed. Refresh its status or cancel it if cancellation is available.'} + {cloudStatus.cloud_registration_status === 'deletion_pending' && !cloudStatus.cloud_last_error && cloudStatus.cloud_deletion_status !== 'failed' && cloudStatus.cloud_deletion_status !== 'processing' && 'Cloud services remain stopped until the deletion request is resolved.'} + {cloudStatus.cloud_registration_status === 'deleted' && 'Cloud data has been deleted from FloCafe servers. Cloud services cannot be re-enabled on this installation.'} {cloudStatus.cloud_registration_status === 'unregistered' && t('settings.registrationRegisterHelp')}

- {cloudStatus.cloud_registration_status !== 'registered' && ( + {cloudStatus.cloud_registration_status !== 'registered' && cloudStatus.cloud_registration_status !== 'deletion_pending' && cloudStatus.cloud_registration_status !== 'deleted' && (
+ {cloudStatus.cloud_registration_status !== 'deleted' && (

{t('settings.cloudManagedAutomatically')}

@@ -3960,8 +4073,8 @@ export default function SettingsPage() { className="mt-0.5 rounded border-gray-300 text-brand focus:ring-brand" />
- {t('settings.enableBillSync')} -

{t('settings.enableBillSyncHint')}

+ {cloudServicesStopped ? 'Enable Cloud Services' : t('settings.enableBillSync')} +

{cloudServicesStopped ? 'Resume cloud services and bill sync on this device.' : t('settings.enableBillSyncHint')}

@@ -3969,6 +4082,7 @@ export default function SettingsPage() {

{t('settings.lastSync', { time: formatDateTime(cloudSettings.cloud_last_sync) })}

)}
+ )} )}
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 21ce9dba..cb904600 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -98,9 +98,12 @@ export default function AppSidebar() { useEffect(() => { if (role !== 'owner') return; api.get('/settings/cloud/account') - .then((res) => setEmailNeedsAttention((Boolean(res.data?.email) && !res.data?.verified) || res.data?.deletion_request?.status === 'pending')) + .then((res) => setEmailNeedsAttention( + (res.data?.cloud_account_available !== false && Boolean(res.data?.email) && !res.data?.verified) + || res.data?.deletion_request?.status === 'pending' + )) .catch(() => setEmailNeedsAttention(false)); - }, [role, pathname]); + }, [role]); return ( diff --git a/main/db.ts b/main/db.ts index a94a9f91..c6850b14 100644 --- a/main/db.ts +++ b/main/db.ts @@ -3494,6 +3494,20 @@ export const MIGRATIONS: { version: number; name: string; up: () => void }[] = [ `).run(now()); }, }, + { + version: 62, + name: 'normalize_cloud_last_error', + up: () => { + // Older builds persisted upstream error text here. It can contain + // reflected credentials, so replace all legacy values before exposing + // settings or exporting the database. + db.prepare(` + UPDATE settings + SET value = 'Cloud service request failed', updated_at = ? + WHERE key = 'cloud_last_error' AND value <> '' + `).run(now()); + }, + }, ]; function syncBackupBeforeMigration(fromVersion: number, toVersion: number): void { diff --git a/main/ipc.ts b/main/ipc.ts index 43756e89..746bd43d 100644 --- a/main/ipc.ts +++ b/main/ipc.ts @@ -28,9 +28,12 @@ const SENSITIVE_SETTING_KEYS = new Set([ 'jwt_secret', 'cloud_api_key', 'cloud_device_secret', + 'cloud_deletion_status_token', + 'cloud_last_error', ]); function maskSetting(key: string, value: string): string { + if (key === 'cloud_last_error') return value ? 'Cloud service request failed' : ''; if (!SENSITIVE_SETTING_KEYS.has(key)) return value; return value ? `****${value.slice(-4)}` : ''; } diff --git a/main/routes/database.ts b/main/routes/database.ts index 48011cd3..4ed85780 100644 --- a/main/routes/database.ts +++ b/main/routes/database.ts @@ -22,6 +22,9 @@ const EXPORT_SETTINGS_REDACT = new Set([ // (see main/services/cloud-sync.ts) — same exposure risk as the cloud // credentials above. 'cloud_deletion_status_token', + // Legacy builds persisted arbitrary upstream errors here; keep exports + // from carrying that text even before an upgraded database is reopened. + 'cloud_last_error', ]); // User columns stripped from export — hashes must never leave the server. diff --git a/main/routes/settings.ts b/main/routes/settings.ts index d110f2c1..14165f64 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -44,9 +44,12 @@ const SENSITIVE_SETTING_KEYS = new Set([ 'jwt_secret', 'cloud_api_key', 'cloud_device_secret', + 'cloud_deletion_status_token', + 'cloud_last_error', ]); function maskSetting(key: string, value: string): string { + if (key === 'cloud_last_error') return value ? 'Cloud service request failed' : ''; if (!SENSITIVE_SETTING_KEYS.has(key)) return value; return value ? `****${value.slice(-4)}` : ''; } @@ -394,6 +397,31 @@ router.put('/order-numbering', requireRole('owner', 'manager'), (req: Request, r } }); +function publicDeletionRequest(request: Record | null): Record | null { + if (!request) return null; + const safe: Record = {}; + const requestId = request.request_id ?? request.id; + if (typeof requestId === 'string' && requestId) safe.id = requestId; + if (typeof request.status === 'string') safe.status = request.status; + if (typeof request.requested_at === 'string') safe.requested_at = request.requested_at; + if (typeof request.reviewed_at === 'string' || request.reviewed_at === null) safe.reviewed_at = request.reviewed_at; + if (typeof request.decision_note === 'string') safe.decision_note = request.decision_note; + return safe; +} + +function publicEmailPreferences(data: Record): Record { + return { + email: typeof data.email === 'string' ? data.email : null, + verified: data.verified === true, + verified_at: typeof data.verified_at === 'string' || data.verified_at === null ? data.verified_at : null, + verification_sent_at: typeof data.verification_sent_at === 'string' || data.verification_sent_at === null ? data.verification_sent_at : null, + product_updates: data.product_updates === true, + marketing: data.marketing === true, + }; +} + +const CLOUD_ACCOUNT_UNAVAILABLE_ERROR = 'Cloud account services are unavailable while Cloud services are stopped or unregistered'; + // ─── Cloud Sync settings (must come BEFORE /:key wildcard) ────────────────── router.get('/cloud', requireRole('owner', 'manager'), (req: Request, res: Response) => { @@ -433,6 +461,7 @@ router.put('/cloud', requireRole('owner', 'manager'), (req: Request, res: Respon } const enablingCloud = [cloud_sync_enabled, cloud_orders_enabled, cloud_reports_enabled, cloud_command_polling_enabled] .some((value) => bool01Flag(value) === '1'); + if (enablingCloud) updates.cloud_services_disabled_by_user = 'false'; if (enablingCloud && cloudSync.getStatus().cloud_deletion_blocked) { return res.status(409).json({ error: 'Cloud deletion is unresolved; retry or cancel it before re-enabling cloud services.' }); } @@ -449,10 +478,15 @@ router.put('/cloud', requireRole('owner', 'manager'), (req: Request, res: Respon router.post('/cloud/register', requireRole('owner', 'manager'), async (req: Request, res: Response) => { try { - const deletionRequest = await cloudSync.getDeletionRequestStatus(); + const deletionRequest = await cloudSync.getDeletionRequestStatus({ + allowRemote: cloudSync.isCloudAccountAvailable(), + }); if (deletionRequest?.status === 'pending') { return res.status(409).json({ error: 'A cloud deletion request is pending review. Cancel it before re-enabling cloud services.' }); } + if (cloudSync.getStatus().cloud_services_disabled_by_user) { + return res.status(409).json({ error: CLOUD_ACCOUNT_UNAVAILABLE_ERROR }); + } if (req.body?.cloud_server_url !== undefined) { upsertSettings(getDatabase(), { cloud_server_url: normalizeCloudServerUrl(req.body.cloud_server_url || DEFAULT_CLOUD_SERVER_URL), @@ -488,32 +522,65 @@ router.post('/cloud/test', requireRole('owner', 'manager'), async (_req: Request router.get('/cloud/account', requireRole('owner'), async (_req: Request, res: Response) => { try { - const deletionRequest = await cloudSync.getDeletionRequestStatus(); - if (deletionRequest?.status === 'approved') { - return res.json({ email: null, verified: false, product_updates: false, marketing: false, deletion_request: deletionRequest }); + const cloudAccountAvailable = cloudSync.isCloudAccountAvailable(); + const deletionRequest = await cloudSync.getDeletionRequestStatus({ allowRemote: cloudAccountAvailable }); + const safeDeletionRequest = publicDeletionRequest(deletionRequest); + if (deletionRequest?.status === 'approved' || !cloudSync.isCloudAccountAvailable()) { + return res.json({ + email: null, + verified: false, + verified_at: null, + verification_sent_at: null, + product_updates: false, + marketing: false, + cloud_account_available: false, + deletion_request: safeDeletionRequest, + }); } - res.json({ ...(await cloudSync.getEmailPreferences()), deletion_request: deletionRequest }); - } catch (error: any) { - res.status(502).json({ error: error.message || 'Could not load cloud account status' }); + res.json({ + ...publicEmailPreferences(await cloudSync.getEmailPreferences()), + cloud_account_available: true, + deletion_request: safeDeletionRequest, + }); + } catch { + res.status(502).json({ error: 'Could not load cloud account status' }); } }); router.put('/cloud/account/preferences', requireRole('owner'), async (req: Request, res: Response) => { + if (!cloudSync.isCloudAccountAvailable()) { + return res.status(409).json({ error: CLOUD_ACCOUNT_UNAVAILABLE_ERROR }); + } try { - res.json(await cloudSync.updateEmailPreferences({ + res.json(publicEmailPreferences(await cloudSync.updateEmailPreferences({ product_updates: req.body?.product_updates, marketing: req.body?.marketing, - })); - } catch (error: any) { - res.status(502).json({ error: error.message || 'Could not update email preferences' }); + }))); + } catch { + res.status(502).json({ error: 'Could not update email preferences' }); } }); router.post('/cloud/account/verification', requireRole('owner'), async (_req: Request, res: Response) => { + if (!cloudSync.isCloudAccountAvailable()) { + return res.status(409).json({ error: CLOUD_ACCOUNT_UNAVAILABLE_ERROR }); + } try { - res.json(await cloudSync.requestEmailVerification({ source: 'settings' })); - } catch (error: any) { - res.status(502).json({ error: error.message || 'Could not send verification email' }); + res.json(publicEmailPreferences(await cloudSync.requestEmailVerification({ source: 'settings' }))); + } catch { + res.status(502).json({ error: 'Could not send verification email' }); + } +}); + +router.get('/cloud/delete-data/status', requireRole('owner'), async (_req: Request, res: Response) => { + try { + const deletionRequest = await cloudSync.getDeletionRequestStatus({ allowRemote: true }); + res.json({ + cloud_account_available: cloudSync.isCloudAccountAvailable(), + deletion_request: publicDeletionRequest(deletionRequest), + }); + } catch { + res.status(502).json({ error: 'Could not refresh cloud deletion status' }); } }); @@ -527,16 +594,16 @@ router.post('/cloud/delete-data', requireRole('owner'), requireMasterPin, async } try { res.json(await cloudSync.deleteCloudData()); - } catch (error: any) { - res.status(502).json({ error: error.message || 'Cloud data deletion failed' }); + } catch { + res.status(502).json({ error: 'Cloud data deletion failed' }); } }); router.post('/cloud/delete-data/cancel', requireRole('owner'), requireMasterPin, async (_req: Request, res: Response) => { try { res.json(await cloudSync.cancelDeletionRequest()); - } catch (error: any) { - res.status(502).json({ error: error.message || 'Could not cancel deletion request' }); + } catch { + res.status(502).json({ error: 'Could not cancel deletion request' }); } }); diff --git a/main/services/cloud-sync.ts b/main/services/cloud-sync.ts index 2553d73d..bba35481 100644 --- a/main/services/cloud-sync.ts +++ b/main/services/cloud-sync.ts @@ -353,6 +353,19 @@ class CloudSyncService { this.relayMode = 'disconnected'; } + /** + * Email preferences are an optional cloud-account feature. Keep callers + * from attempting an outbound request when this install has no usable + * cloud account or the owner explicitly stopped cloud services. + */ + isCloudAccountAvailable(): boolean { + const settings = this.readSettings(getDatabase()); + return settings.cloud_registration_status === 'registered' + && Boolean(settings.cloud_api_key) + && settings.cloud_services_disabled_by_user !== 'true' + && !isCloudDeletionBlocking(settings.cloud_deletion_status); + } + getStatus() { const db = getDatabase(); const s = this.readSettings(db); @@ -368,10 +381,17 @@ class CloudSyncService { cloud_reports_enabled: refreshed.cloud_reports_enabled === '1', cloud_command_polling_enabled: refreshed.cloud_command_polling_enabled === '1', cloud_registration_status: refreshed.cloud_registration_status || 'unregistered', + cloud_services_disabled_by_user: refreshed.cloud_services_disabled_by_user === 'true', cloud_connected: refreshed.cloud_connected === 'true', cloud_last_sync: refreshed.cloud_last_sync || null, cloud_last_heartbeat: refreshed.cloud_last_heartbeat || null, - cloud_last_error: refreshed.cloud_last_error || null, + cloud_last_error: refreshed.cloud_last_error + ? (refreshed.cloud_registration_status === 'registration_failed' + ? 'Cloud registration failed' + : refreshed.cloud_deletion_status === 'failed' + ? 'Cloud deletion request failed' + : 'Cloud service request failed') + : null, cloud_deletion_status: refreshed.cloud_deletion_status || '', cloud_deletion_outcome: refreshed.cloud_deletion_outcome || '', cloud_deletion_blocked: isCloudDeletionBlocking(refreshed.cloud_deletion_status), @@ -470,7 +490,7 @@ class CloudSyncService { this.upsertSettings({ cloud_registration_status: 'registration_failed', cloud_connected: 'false', - cloud_last_error: message, + cloud_last_error: 'Cloud registration failed', }); } throw err; @@ -481,7 +501,7 @@ class CloudSyncService { async testConnection(): Promise> { if (this.cloudDeletionInProgress) throw new Error('Cloud deletion in progress'); const res = await this.signedFetch('/api/pos/connection-test', { method: 'POST', body: '{}' }); - const data = await res.json().catch(() => ({})); + await res.json().catch(() => ({})); if (this.cloudDeletionInProgress) throw new Error('Cloud deletion in progress'); if (!res.ok) throw new Error(`Cloud test failed (${res.status})`); this.upsertSettings({ @@ -489,7 +509,7 @@ class CloudSyncService { cloud_last_error: '', cloud_last_heartbeat: new Date().toISOString(), }); - return { ok: true, data, status: this.getStatus() }; + return { ok: true, status: this.getStatus() }; } async getEmailPreferences(): Promise> { @@ -580,7 +600,8 @@ class CloudSyncService { throw new Error(String(data?.error || `Cloud deletion failed (${res.status})`)); } const deletion = validateCloudDeletionResponse(data); - const responseData = data as Record; + const responseData: Record = { status: deletion.status }; + if (deletion.requestId) responseData.request_id = deletion.requestId; if (deletion.status === 'rejected' || deletion.status === 'cancelled') { deletionOutcome = 'rejected'; throw new Error(`Cloud deletion was ${deletion.status}`); @@ -614,19 +635,25 @@ class CloudSyncService { }).catch((error) => { this.upsertSettings({ cloud_deletion_status: 'failed', cloud_deletion_outcome: deletionOutcome, - cloud_connected: 'false', cloud_last_error: (error as Error).message, + cloud_connected: 'false', cloud_last_error: 'Cloud data deletion failed', }, true); this.settings = this.loadSettings(false); throw error; }).finally(() => { this.cloudDeletionInProgress = false; }); } - async getDeletionRequestStatus(): Promise | null> { + async getDeletionRequestStatus(options: { allowRemote?: boolean } = {}): Promise | null> { return withDatabaseRequest(async () => { const settings = this.readSettings(getDatabase()); const requestId = settings.cloud_deletion_request_id; const statusToken = settings.cloud_deletion_status_token; if (!requestId || !statusToken) return null; + if (options.allowRemote === false) { + return { + request_id: requestId, + status: settings.cloud_deletion_status || 'pending', + }; + } const serverUrl = normalizeCloudServerUrl(settings.cloud_server_url || DEFAULT_CLOUD_SERVER_URL); const url = endpoint(serverUrl, `/api/cloud-data/deletion-request/status?id=${encodeURIComponent(requestId)}&token=${encodeURIComponent(statusToken)}`); const res = await this.trackedFetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); @@ -639,6 +666,7 @@ class CloudSyncService { cloud_deletion_status: status, cloud_deletion_request_id: deletion.requestId, cloud_deletion_status_token: deletion.statusToken, + cloud_last_error: '', }); if (CLOUD_DELETION_FINAL_STATUSES.has(status)) { this.upsertSettings({ @@ -660,7 +688,7 @@ class CloudSyncService { if (!this.cloudDeletionInProgress) { this.upsertSettings({ cloud_deletion_status: 'failed', cloud_deletion_outcome: 'unknown', - cloud_connected: 'false', cloud_last_error: (error as Error).message, + cloud_connected: 'false', cloud_last_error: 'Cloud deletion status check failed', }); this.settings = this.loadSettings(false); } @@ -685,7 +713,7 @@ class CloudSyncService { cloud_deletion_request_id: '', cloud_deletion_status_token: '', cloud_deletion_outcome: '', }); - return data; + return { status: typeof data.status === 'string' ? data.status : 'cancelled' }; }); } @@ -1639,6 +1667,9 @@ class CloudSyncService { } const cfg = this.settings ?? this.loadSettings(); if (!cfg?.api_key) throw new Error('Cloud POS is not registered'); + if (!allowDuringDeletion && !this.isCloudAccountAvailable()) { + throw new Error('Cloud account services are unavailable while Cloud services are stopped'); + } const method = (init.method || 'GET').toUpperCase(); const url = endpoint(cfg.server_url, pathname); @@ -1772,7 +1803,7 @@ class CloudSyncService { if (settings.cloud_services_disabled_by_user === 'true') return; this.upsertSettings({ cloud_connected: 'false', - cloud_last_error: message, + cloud_last_error: 'Cloud service request failed', }); log.warn('[CloudSync]', message); } diff --git a/package.json b/package.json index 80f0b048..9ca0ea11 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "pretest": "bash tests/run-test.sh npm run test:payment-methods-split", "start": "electron .", "rebuild": "HOME=~/.electron-gyp node-gyp rebuild --target=$(node -p \"require('./node_modules/electron/package.json').version\") --arch=$(node -p \"process.arch\") --dist-url=https://electronjs.org/headers --runtime=electron --directory node_modules/better-sqlite3", - "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling", + "test": "bash tests/run-test.sh npm run test:smoke && bash tests/run-test.sh npm run test:kds-integration && bash tests/run-test.sh npm run test:kds-contract && bash tests/run-test.sh npm run test:kds-frontend-conflict && bash tests/run-test.sh npm run test:cors && bash tests/run-test.sh npm run test:release-config && bash tests/run-test.sh npm run test:telemetry && bash tests/run-test.sh npm run test:first-run && bash tests/run-test.sh npm run test:security && bash tests/run-test.sh npm run test:staff-authz && bash tests/run-test.sh npm run test:orders-authz && bash tests/run-test.sh npm run test:authz-phase3 && bash tests/run-test.sh npm run test:customer-auth && bash tests/run-test.sh npm run test:customer-pagination && bash tests/run-test.sh npm run test:backup && bash tests/run-test.sh npm run test:recovery-cloud && bash tests/run-test.sh npm run test:cloud-account-status && bash tests/run-test.sh npm run test:printer && bash tests/run-test.sh npm run test:translations && bash tests/run-test.sh npm run test:phone && bash tests/run-test.sh npm run test:currency && bash tests/run-test.sh npm run test:tax-engine && bash tests/run-test.sh npm run test:tax-components && bash tests/run-test.sh npm run test:tax-pack-catalog && bash tests/run-test.sh npm run test:tax-pack-management && bash tests/run-test.sh npm run test:manual-tax-config && bash tests/run-test.sh npm run test:legacy-tax-pack-digest && bash tests/run-test.sh npm run test:support-ticket && bash tests/run-test.sh npm run test:customer-phone-search && bash tests/run-test.sh npm run test:phone-search-integration && bash tests/run-test.sh npm run test:receipt-column-width && bash tests/run-test.sh npm run test:notes-validation && bash tests/run-test.sh npm run test:receipt-printing && bash tests/run-test.sh npm run test:cancel-override && bash tests/run-test.sh npm run test:kitchen-addons && bash tests/run-test.sh npm run test:order-item-addons && bash tests/run-test.sh npm run test:issue-125-addon-reads && bash tests/run-test.sh npm run test:windows-country-code-crash && bash tests/run-test.sh npm run test:reports-insights && bash tests/run-test.sh npm run test:sequence && bash tests/run-test.sh npm run test:integration-happy && bash tests/run-test.sh npm run test:integration-tax && bash tests/run-test.sh npm run test:integration-payments && bash tests/run-test.sh npm run test:issue-214 && bash tests/run-test.sh npm run test:issue-214-auth && bash tests/run-test.sh npm run test:issue-214-migration && bash tests/run-test.sh npm run test:integration-lifecycle && bash tests/run-test.sh npm run test:integration-reconciliation && bash tests/run-test.sh npm run test:integration-loyalty && bash tests/run-test.sh npm run test:integration-discount && bash tests/run-test.sh npm run test:loyalty-toggle && bash tests/run-test.sh npm run test:discount-system && bash tests/run-test.sh npm run test:integration-discount-settings && bash tests/run-test.sh npm run test:integration-loyalty-global && bash tests/run-test.sh npm run test:integration-loyalty-redemption && bash tests/run-test.sh npm run test:bills-print-api && bash tests/run-test.sh npm run test:issue-24 && bash tests/run-test.sh npm run test:issue-134-routing && bash tests/run-test.sh npm run test:issue-134-mgmt && bash tests/run-test.sh npm run test:issue-137-barcode && bash tests/run-test.sh npm run test:tables-string-ids && bash tests/run-test.sh npm run test:held-orders && bash tests/run-test.sh npm run test:schema-health && bash tests/run-test.sh npm run test:upgrade-path && bash tests/run-test.sh npm run test:migration-v56-v57 && bash tests/run-test.sh npm run test:master-pin && bash tests/run-test.sh npm run test:google-drive && bash tests/run-test.sh npm run test:database-tools-api && bash tests/run-test.sh npm run test:phone-validation && bash tests/run-test.sh npm run test:phone-migration && bash tests/run-test.sh npm run test:issue-133-kds-kot-toggles && bash tests/run-test.sh npm run test:whatsapp-schema && bash tests/run-test.sh npm run test:whatsapp-service && bash tests/run-test.sh npm run test:whatsapp-middleware && bash tests/run-test.sh npm run test:issue-127-password-recovery && bash tests/run-test.sh npm run test:dev-tooling", "test:dev-tooling": "ts-node --transpile-only -P tests/tsconfig.json tests/dev-tooling-scripts.test.ts && npm run test:phase2 && npm run test:url-allowlist && npm run test:static-routes", "test:phase2": "ts-node --transpile-only -P tests/tsconfig.json tests/auth-state-recovery.test.ts && node tests/run-electron-node-test.cjs tests/login-email-normalization.test.ts && node tests/run-electron-node-test.cjs tests/master-pin.test.ts && node tests/run-electron-node-test.cjs tests/manager-pin-verification.test.ts && node tests/run-electron-node-test.cjs tests/jwt-logout-lifecycle.test.ts && node tests/run-electron-node-test.cjs tests/kds-websocket-revalidation.test.ts", "test:url-allowlist": "ts-node --transpile-only -P tests/tsconfig.json tests/url-allowlist.test.ts", @@ -56,6 +56,7 @@ "test:customer-auth": "node tests/run-electron-node-test.cjs tests/customer-auth.test.ts", "test:backup": "ts-node --transpile-only -P tests/tsconfig.json tests/backup-restore.test.ts && node tests/run-electron-node-test.cjs tests/backup-restore-production.test.ts", "test:recovery-cloud": "node tests/run-electron-node-test.cjs tests/recovery-legacy-fk.test.ts && node tests/run-electron-node-test.cjs tests/cloud-deletion-recovery.test.ts", + "test:cloud-account-status": "node tests/run-electron-node-test.cjs tests/cloud-account-status.test.ts", "test:printer": "ts-node --transpile-only -P tests/tsconfig.json tests/printer.test.ts", "audit:db": "ts-node --transpile-only -P tests/tsconfig.json tests/db-audit.test.ts", "test:receipt-printing": "ts-node --transpile-only -P tests/tsconfig.json tests/receipt-printing.test.ts", diff --git a/tests/cloud-account-status.test.ts b/tests/cloud-account-status.test.ts new file mode 100644 index 00000000..182aa41e --- /dev/null +++ b/tests/cloud-account-status.test.ts @@ -0,0 +1,221 @@ +/** + * Cloud account status route tests. + * + * The account status is optional cloud metadata. An unregistered or explicitly + * stopped cloud service must not turn a normal dashboard navigation into a 502 + * or attempt an outbound request to FloAdmin. + */ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const Module = require('module'); +const originalLoad = Module._load; +const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'flo-cloud-account-status-')); +Module._load = function (request: string, parent: unknown, isMain: boolean) { + if (request === 'electron') { + return { app: { isPackaged: true, getPath: () => testDir, getVersion: () => 'test' } }; + } + return originalLoad.apply(this, arguments as any); +}; + +const request = require('supertest'); +const { + initTestDb, + seedOwnerUser, + seedManagerUser, + assert, + assertEqual, + getResults, + closeDatabase, + getDatabase, + now, + createApp, +} = require('./helpers/test-setup'); +const { settingsRoutes } = require('../main/routes/settings'); +const { cloudSync } = require('../main/services/cloud-sync'); + +function setSettings(entries: Record) { + const db = getDatabase(); + for (const [key, value] of Object.entries(entries)) { + db.prepare(` + INSERT INTO settings (key, value, updated_at) VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `).run(key, value, now()); + } +} + +async function run() { + const originalFetch = globalThis.fetch; + let upstreamCalls = 0; + + try { + initTestDb(); + const db = getDatabase(); + const owner = seedOwnerUser(db); + const manager = seedManagerUser(db); + const app = createApp({ '/api/settings': settingsRoutes }); + + const managerAccount = await request(app) + .get('/api/settings/cloud/account') + .set(manager.authHeader); + assertEqual(managerAccount.status, 403, 'non-owner cannot read cloud account status'); + + globalThis.fetch = (async () => { + upstreamCalls++; + throw new Error('unexpected outbound cloud request'); + }) as typeof fetch; + + console.log('Cloud account status route tests'); + console.log('='.repeat(50)); + + setSettings({ + cloud_api_key: 'stale-api-key', + cloud_registration_status: 'unregistered', + cloud_services_disabled_by_user: 'false', + cloud_deletion_request_id: '', + cloud_deletion_status_token: '', + cloud_deletion_status: '', + cloud_last_error: 'legacy-upstream-token-reflection', + }); + const publicCloudStatus = await request(app) + .get('/api/settings/cloud') + .set(owner.authHeader); + assertEqual(publicCloudStatus.body.cloud_last_error, 'Cloud service request failed', 'legacy cloud errors are normalized in cloud status'); + const publicSettings = await request(app) + .get('/api/settings') + .set(owner.authHeader); + assert(!JSON.stringify(publicSettings.body).includes('legacy-upstream-token-reflection'), 'legacy cloud errors are not exposed in generic settings'); + const unregistered = await request(app) + .get('/api/settings/cloud/account') + .set(owner.authHeader); + assertEqual(unregistered.status, 200, 'unregistered cloud account status is not an error'); + assertEqual(unregistered.body.cloud_account_available, false, 'unregistered response marks account unavailable'); + assertEqual(unregistered.body.email, null, 'unregistered response has no cloud email'); + assertEqual(upstreamCalls, 0, 'unregistered account status does not call FloAdmin'); + + upstreamCalls = 0; + setSettings({ + cloud_api_key: 'registered-api-key', + cloud_pos_hash: 'registered-pos-hash', + cloud_registration_status: 'registered', + cloud_services_disabled_by_user: 'true', + cloud_deletion_request_id: 'deletion-id', + cloud_deletion_status_token: 'deletion-status-token', + cloud_deletion_status: 'pending', + }); + const stopped = await request(app) + .get('/api/settings/cloud/account') + .set(owner.authHeader); + assertEqual(stopped.status, 200, 'stopped cloud account status is not an error'); + assertEqual(stopped.body.cloud_account_available, false, 'stopped response marks account unavailable'); + assertEqual(stopped.body.deletion_request?.id, 'deletion-id', 'stopped response preserves local deletion request ID'); + assert(!('status_token' in (stopped.body.deletion_request || {})), 'stopped response does not expose deletion status token'); + assertEqual(upstreamCalls, 0, 'stopped account status does not call FloAdmin'); + + const directToken = await request(app) + .get('/api/settings/cloud_deletion_status_token') + .set(owner.authHeader); + assertEqual(directToken.status, 403, 'deletion status token cannot be read through the settings API'); + const allSettings = await request(app) + .get('/api/settings') + .set(owner.authHeader); + assert(!JSON.stringify(allSettings.body).includes('deletion-status-token'), 'settings list does not expose the deletion status token'); + + const stoppedPreferences = await request(app) + .put('/api/settings/cloud/account/preferences') + .set(owner.authHeader) + .send({ product_updates: true }); + assertEqual(stoppedPreferences.status, 409, 'stopped cloud preferences are rejected locally'); + const stoppedVerification = await request(app) + .post('/api/settings/cloud/account/verification') + .set(owner.authHeader); + assertEqual(stoppedVerification.status, 409, 'stopped cloud verification is rejected locally'); + const stoppedRegister = await request(app) + .post('/api/settings/cloud/register') + .set(owner.authHeader) + .send({}); + assertEqual(stoppedRegister.status, 409, 'stopped cloud registration preflight is rejected locally'); + setSettings({ cloud_deletion_request_id: '', cloud_deletion_status_token: '' }); + const stoppedRegisterWithoutDeletion = await request(app) + .post('/api/settings/cloud/register') + .set(owner.authHeader) + .send({}); + assertEqual(stoppedRegisterWithoutDeletion.status, 409, 'stopped registration without a deletion request is rejected locally'); + assertEqual(upstreamCalls, 0, 'stopped registration never calls FloAdmin without a deletion request'); + setSettings({ cloud_deletion_request_id: 'deletion-id', cloud_deletion_status_token: 'deletion-status-token', cloud_deletion_status: 'pending' }); + + globalThis.fetch = (async () => { + upstreamCalls++; + return new Response(JSON.stringify({ status: 'approved', request_id: 'deletion-id', status_token: 'new-status-token' }), { status: 200 }); + }) as typeof fetch; + const refreshedDeletion = await request(app) + .get('/api/settings/cloud/delete-data/status') + .set(owner.authHeader); + assertEqual(refreshedDeletion.status, 200, 'explicit deletion status refresh succeeds'); + assertEqual(refreshedDeletion.body.deletion_request?.status, 'approved', 'explicit refresh returns the latest deletion status'); + assert(!('status_token' in (refreshedDeletion.body.deletion_request || {})), 'explicit refresh does not expose deletion status token'); + assertEqual(upstreamCalls, 1, 'explicit deletion status refresh is the only stopped-state outbound call'); + + globalThis.fetch = (async () => { + upstreamCalls++; + return new Response(JSON.stringify({ + email: 'owner@example.com', + verified: true, + product_updates: true, + marketing: false, + status_token: 'must-not-leak', + unexpected: 'must-not-leak', + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + }) as typeof fetch; + upstreamCalls = 0; + setSettings({ + cloud_api_key: 'registered-api-key', + cloud_pos_hash: 'registered-pos-hash', + cloud_registration_status: 'registered', + cloud_services_disabled_by_user: 'false', + cloud_deletion_request_id: '', + cloud_deletion_status_token: '', + cloud_deletion_status: '', + cloud_deletion_outcome: '', + }); + cloudSync.reload(); + const registered = await request(app) + .get('/api/settings/cloud/account') + .set(owner.authHeader); + assertEqual(registered.status, 200, 'registered cloud account status succeeds'); + assertEqual(registered.body.cloud_account_available, true, 'registered response marks account available'); + assertEqual(registered.body.email, 'owner@example.com', 'registered response preserves account data'); + assert(!('status_token' in registered.body), 'registered response does not expose deletion status token'); + assert(!('unexpected' in registered.body), 'registered response allowlists account fields'); + assertEqual(upstreamCalls, 1, 'registered account status calls FloAdmin'); + + globalThis.fetch = (async () => { + upstreamCalls++; + throw new Error('simulated cloud outage'); + }) as typeof fetch; + const unavailableUpstream = await request(app) + .get('/api/settings/cloud/account') + .set(owner.authHeader); + assertEqual(unavailableUpstream.status, 502, 'registered cloud outage remains distinguishable as a gateway error'); + assertEqual(upstreamCalls, 2, 'registered cloud outage attempts the upstream request'); + + const results = getResults(); + if (results.failed > 0) throw new Error(`${results.failed} cloud account status assertions failed`); + console.log('✅ Cloud account status tests passed'); + } finally { + globalThis.fetch = originalFetch; + cloudSync.stop(); + try { closeDatabase(); } catch { } + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + } +} + +run().catch((error) => { + try { closeDatabase(); } catch { } + Module._load = originalLoad; + fs.rmSync(testDir, { recursive: true, force: true }); + console.error(error); + process.exit(1); +}); diff --git a/tests/cloud-deletion-recovery.test.ts b/tests/cloud-deletion-recovery.test.ts index 595d42e9..a9928158 100644 --- a/tests/cloud-deletion-recovery.test.ts +++ b/tests/cloud-deletion-recovery.test.ts @@ -50,7 +50,7 @@ async function run() { } if (url.endsWith('/api/pos/cloud-data/delete')) { if (deleteMode === 'transport') throw new Error('simulated connection drop after upstream accepted the request'); - if (deleteMode === 'accepted') return new Response(JSON.stringify({ status: 'deleted' }), { status: 200 }); + if (deleteMode === 'accepted') return new Response(JSON.stringify({ status: 'deleted', status_token: 'must-not-leak' }), { status: 200 }); if (deleteMode === 'pending-missing') return new Response(JSON.stringify({ status: 'pending' }), { status: 200 }); if (deleteMode === 'unknown') return new Response(JSON.stringify({ status: 'queued', request_id: 'queued-id', status_token: 'queued-token' }), { status: 200 }); return new Response(JSON.stringify({ error: 'simulated upstream failure' }), { status: 503 }); @@ -66,6 +66,7 @@ async function run() { assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_connected'").get() as { value: string }).value, 'false'); assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_services_disabled_by_user'").get() as { value: string }).value, 'true'); assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_deletion_outcome'").get() as { value: string }).value, 'rejected'); + assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_last_error'").get() as { value: string }).value, 'Cloud data deletion failed'); assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_deletion_request_id'").get() as { value?: string } | undefined)?.value || '', ''); // Missing tracking details and unknown statuses become a blocked but @@ -99,7 +100,9 @@ async function run() { await assert.rejects(() => cloudSync.register(), /Cloud deletion is pending/); deleteMode = 'accepted'; - await cloudSync.deleteCloudData(); + const deletionResult = await cloudSync.deleteCloudData(); + assert.equal((deletionResult as any).status, 'deleted'); + assert.equal(!('status_token' in (deletionResult as any)), true, 'cloud deletion result does not expose the status token'); assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_deletion_status'").get() as { value: string }).value, 'deleted'); assert.equal((db.prepare("SELECT value FROM settings WHERE key = 'cloud_api_key'").get() as { value: string }).value, ''); console.log('✅ Cloud deletion failure recovery tests passed'); diff --git a/tests/security-hardening.test.ts b/tests/security-hardening.test.ts index f75c5bbe..da621251 100644 --- a/tests/security-hardening.test.ts +++ b/tests/security-hardening.test.ts @@ -146,6 +146,9 @@ async function main() { db.prepare(` INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('cloud_deletion_status_token', 'super-secret-deletion-token', ?) `).run(now()); + db.prepare(` + INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('cloud_last_error', 'legacy-upstream-token-reflection', ?) + `).run(now()); const exportRes = await request(app).get('/api/db/export').set(ownerAuth); assertEqual(exportRes.status, 200, 'owner can call /api/db/export'); @@ -168,6 +171,11 @@ async function main() { 'cloud_deletion_status_token listed in redacted_fields', ); + const cloudLastErrorRow = settingsRows.find((r: any) => r.key === 'cloud_last_error'); + assert(!!cloudLastErrorRow, 'cloud_last_error setting is present to be redacted'); + assert(cloudLastErrorRow?.value === '[REDACTED]', 'legacy cloud_last_error is redacted in export'); + assert(exportBody.redacted_fields.includes('settings.cloud_last_error'), 'cloud_last_error listed in redacted_fields'); + // password and pin_hash must be absent from all user rows const userRows: Record[] = exportBody.data?.users ?? []; for (const user of userRows) { From c35bbe6391608cf7af990ae8807e586140a69dbb Mon Sep 17 00:00:00 2001 From: khaira777 <777gurkirat@gmail.com> Date: Tue, 11 Aug 2026 09:25:42 -0400 Subject: [PATCH 2/2] fix: restore cloud services after stop --- .../src/app/(dashboard)/settings/page.tsx | 18 +++++- frontend/src/components/layout/Sidebar.tsx | 29 ++++++++-- main/routes/settings.ts | 9 +++ main/services/cloud-sync.ts | 1 + tests/cloud-account-status.test.ts | 55 ++++++++++++++++++- 5 files changed, 103 insertions(+), 9 deletions(-) diff --git a/frontend/src/app/(dashboard)/settings/page.tsx b/frontend/src/app/(dashboard)/settings/page.tsx index 73105571..625c7b4e 100644 --- a/frontend/src/app/(dashboard)/settings/page.tsx +++ b/frontend/src/app/(dashboard)/settings/page.tsx @@ -27,6 +27,12 @@ import { useFormatDate } from '@/hooks/useFormatDate'; import { useUpdateStatus } from '@/hooks/useUpdateStatus'; import { TENANT_STATUS_LABEL_KEYS } from '@/lib/i18n-enums'; +const CLOUD_ACCOUNT_STATUS_CHANGED_EVENT = 'flo:cloud-account-status-changed'; + +function notifyCloudAccountStatusChanged(): void { + if (typeof window !== 'undefined') window.dispatchEvent(new Event(CLOUD_ACCOUNT_STATUS_CHANGED_EVENT)); +} + const CLASSIC_PREVIEW = ` STORE NAME Jane Doe +91 98765... @@ -490,10 +496,12 @@ export default function SettingsPage() { await api.post('/settings/cloud/delete-data', { master_pin: pin, confirmation: 'DELETE CLOUD DATA' }); toast.success('Cloud deletion request submitted for manual review. Cloud services have been stopped on this device.'); await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); + notifyCloudAccountStatusChanged(); setPinGate(null); return { success: true }; } catch (err: unknown) { await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); + notifyCloudAccountStatusChanged(); const error = err as { response?: { data?: { error?: string } } }; return { success: false, error: error.response?.data?.error || 'Cloud data deletion failed' }; } @@ -504,6 +512,7 @@ export default function SettingsPage() { await api.post('/settings/cloud/delete-data/cancel', { master_pin: pin }); toast.success('Cloud deletion request cancelled. Cloud services remain off until you explicitly re-enable them.'); await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); + notifyCloudAccountStatusChanged(); setPinGate(null); return { success: true }; } catch (err: unknown) { @@ -1154,6 +1163,7 @@ export default function SettingsPage() { try { await api.get('/settings/cloud/delete-data/status'); await Promise.all([fetchCloudAccount(), refreshCloudStatus()]); + notifyCloudAccountStatusChanged(); toast.success('Cloud deletion status refreshed'); } catch { toast.error('Could not refresh cloud deletion status'); @@ -1471,9 +1481,12 @@ export default function SettingsPage() { const saveCloud = async (silent = false) => { setSavingCloud(true); try { + const resumingStoppedCloud = cloudServicesStopped && cloudSettings.cloud_sync_enabled; const res = await api.put('/settings/cloud', { cloud_sync_enabled: cloudSettings.cloud_sync_enabled, - cloud_orders_enabled: cloudSettings.cloud_orders_enabled, + cloud_orders_enabled: resumingStoppedCloud ? true : cloudSettings.cloud_orders_enabled, + cloud_reports_enabled: resumingStoppedCloud ? true : undefined, + cloud_command_polling_enabled: resumingStoppedCloud ? true : undefined, }); const next = { ...cloudSettings, ...res.data }; setCloudSettings(next); @@ -1488,6 +1501,7 @@ export default function SettingsPage() { cloud_deletion_status: res.data.cloud_deletion_status || '', }); await fetchCloudAccount(); + notifyCloudAccountStatusChanged(); if (!silent) toast.success(t('settings.cloudSaved')); } catch (err) { if (!silent) toast.error(t('settings.cloudSaveFailed')); @@ -1520,6 +1534,7 @@ export default function SettingsPage() { cloud_store_id: res.data.cloud_store_id || prev.cloud_store_id, })); await fetchCloudAccount(); + notifyCloudAccountStatusChanged(); if (res.data.cloud_registration_status === 'registered') { toast.success(t('settings.cloudRegistrationSuccess')); } @@ -3144,6 +3159,7 @@ export default function SettingsPage() { setTelemetryEnabled(false); setDiagnosticsConsent(false); await fetchCloudAccount(); + notifyCloudAccountStatusChanged(); toast.success('All cloud services and telemetry stopped'); } catch { toast.error('Could not stop cloud services'); } diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index cb904600..8c3d5c1b 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -97,12 +97,29 @@ export default function AppSidebar() { useEffect(() => { if (role !== 'owner') return; - api.get('/settings/cloud/account') - .then((res) => setEmailNeedsAttention( - (res.data?.cloud_account_available !== false && Boolean(res.data?.email) && !res.data?.verified) - || res.data?.deletion_request?.status === 'pending' - )) - .catch(() => setEmailNeedsAttention(false)); + let active = true; + const refreshCloudAttention = async () => { + try { + const [accountResponse, cloudResponse] = await Promise.all([ + api.get('/settings/cloud/account'), + api.get('/settings/cloud'), + ]); + if (!active) return; + const deletionStatus = accountResponse.data?.deletion_request?.status || cloudResponse.data?.cloud_deletion_status; + setEmailNeedsAttention( + (accountResponse.data?.cloud_account_available !== false && Boolean(accountResponse.data?.email) && !accountResponse.data?.verified) + || ['pending', 'processing', 'failed'].includes(deletionStatus) + ); + } catch { + if (active) setEmailNeedsAttention(false); + } + }; + void refreshCloudAttention(); + window.addEventListener('flo:cloud-account-status-changed', refreshCloudAttention); + return () => { + active = false; + window.removeEventListener('flo:cloud-account-status-changed', refreshCloudAttention); + }; }, [role]); return ( diff --git a/main/routes/settings.ts b/main/routes/settings.ts index 14165f64..0692e3f6 100644 --- a/main/routes/settings.ts +++ b/main/routes/settings.ts @@ -461,6 +461,15 @@ router.put('/cloud', requireRole('owner', 'manager'), (req: Request, res: Respon } const enablingCloud = [cloud_sync_enabled, cloud_orders_enabled, cloud_reports_enabled, cloud_command_polling_enabled] .some((value) => bool01Flag(value) === '1'); + const resumingStoppedCloud = cloudSync.getStatus().cloud_services_disabled_by_user && enablingCloud; + if (resumingStoppedCloud) { + // Stop All disables every cloud feature. Re-enabling the Cloud Services + // control is a resume action, not just a sync preference change. + updates.cloud_sync_enabled = '1'; + updates.cloud_orders_enabled = '1'; + updates.cloud_reports_enabled = '1'; + updates.cloud_command_polling_enabled = '1'; + } if (enablingCloud) updates.cloud_services_disabled_by_user = 'false'; if (enablingCloud && cloudSync.getStatus().cloud_deletion_blocked) { return res.status(409).json({ error: 'Cloud deletion is unresolved; retry or cancel it before re-enabling cloud services.' }); diff --git a/main/services/cloud-sync.ts b/main/services/cloud-sync.ts index bba35481..272ca981 100644 --- a/main/services/cloud-sync.ts +++ b/main/services/cloud-sync.ts @@ -679,6 +679,7 @@ class CloudSyncService { this.settings = this.loadSettings(false); } else if (['cancelled', 'rejected'].includes(status)) { this.upsertSettings({ + cloud_registration_status: 'registered', cloud_deletion_request_id: '', cloud_deletion_status_token: '', cloud_deletion_outcome: '', }); this.settings = this.loadSettings(false); diff --git a/tests/cloud-account-status.test.ts b/tests/cloud-account-status.test.ts index 182aa41e..9df92ee3 100644 --- a/tests/cloud-account-status.test.ts +++ b/tests/cloud-account-status.test.ts @@ -143,12 +143,63 @@ async function run() { .send({}); assertEqual(stoppedRegisterWithoutDeletion.status, 409, 'stopped registration without a deletion request is rejected locally'); assertEqual(upstreamCalls, 0, 'stopped registration never calls FloAdmin without a deletion request'); - setSettings({ cloud_deletion_request_id: 'deletion-id', cloud_deletion_status_token: 'deletion-status-token', cloud_deletion_status: 'pending' }); + // Stop All disables every cloud feature. Re-enabling the single Cloud + // Services control must restore the order relay as well as sync. + setSettings({ + cloud_api_key: 'registered-api-key', + cloud_pos_hash: 'registered-pos-hash', + cloud_registration_status: 'registered', + cloud_services_disabled_by_user: 'true', + cloud_deletion_request_id: '', + cloud_deletion_status_token: '', + cloud_deletion_status: '', + }); + await cloudSync.stopAllCloudServices(); + globalThis.fetch = (async () => new Response('{}', { status: 200 })) as typeof fetch; + const reenabledCloud = await request(app) + .put('/api/settings/cloud') + .set(owner.authHeader) + .send({ cloud_sync_enabled: true, cloud_orders_enabled: false }); + assertEqual(reenabledCloud.status, 200, 're-enabling Cloud Services succeeds'); + for (const key of ['cloud_sync_enabled', 'cloud_orders_enabled', 'cloud_reports_enabled', 'cloud_command_polling_enabled']) { + assertEqual((db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string }).value, '1', `${key} is restored when Cloud Services resume`); + } + const orderId = Number(db.prepare(` + INSERT INTO orders (order_number, type, status, subtotal, total, created_at, updated_at) + VALUES ('cloud-reenable-order', 'takeaway', 'pending', 10, 10, datetime('now'), datetime('now')) + `).run().lastInsertRowid); + cloudSync.recordOrderChanged(orderId); + await new Promise((resolve) => setImmediate(resolve)); + assert((db.prepare("SELECT COUNT(*) AS count FROM cloud_sync_outbox WHERE entity_type = 'order' AND entity_id = ?").get(String(orderId)) as { count: number }).count > 0, 'order changes enter the cloud outbox after Cloud Services resume'); + cloudSync.stop(); + + upstreamCalls = 0; + setSettings({ cloud_deletion_request_id: 'deletion-id', cloud_deletion_status_token: 'deletion-status-token', cloud_deletion_status: 'pending', cloud_registration_status: 'deletion_pending', cloud_services_disabled_by_user: 'true' }); + let remoteDeletionStatus: 'cancelled' | 'rejected' | 'approved' = 'cancelled'; globalThis.fetch = (async () => { upstreamCalls++; - return new Response(JSON.stringify({ status: 'approved', request_id: 'deletion-id', status_token: 'new-status-token' }), { status: 200 }); + return new Response(JSON.stringify({ status: remoteDeletionStatus, request_id: 'deletion-id', status_token: 'new-status-token' }), { status: 200 }); }) as typeof fetch; + const cancelledDeletion = await request(app) + .get('/api/settings/cloud/delete-data/status') + .set(owner.authHeader); + assertEqual(cancelledDeletion.status, 200, 'remote cancelled deletion status refresh succeeds'); + assertEqual((db.prepare("SELECT value FROM settings WHERE key = 'cloud_registration_status'").get() as { value: string }).value, 'registered', 'remote cancellation restores registered state'); + assertEqual((db.prepare("SELECT value FROM settings WHERE key = 'cloud_services_disabled_by_user'").get() as { value: string }).value, 'true', 'remote cancellation keeps services stopped'); + + setSettings({ cloud_deletion_request_id: 'deletion-id', cloud_deletion_status_token: 'deletion-status-token', cloud_deletion_status: 'pending', cloud_registration_status: 'deletion_pending', cloud_services_disabled_by_user: 'true' }); + remoteDeletionStatus = 'rejected'; + const rejectedDeletion = await request(app) + .get('/api/settings/cloud/delete-data/status') + .set(owner.authHeader); + assertEqual(rejectedDeletion.status, 200, 'remote rejected deletion status refresh succeeds'); + assertEqual((db.prepare("SELECT value FROM settings WHERE key = 'cloud_registration_status'").get() as { value: string }).value, 'registered', 'remote rejection restores registered state'); + assertEqual((db.prepare("SELECT value FROM settings WHERE key = 'cloud_services_disabled_by_user'").get() as { value: string }).value, 'true', 'remote rejection keeps services stopped'); + + setSettings({ cloud_deletion_request_id: 'deletion-id', cloud_deletion_status_token: 'deletion-status-token', cloud_deletion_status: 'pending', cloud_registration_status: 'deletion_pending', cloud_services_disabled_by_user: 'true' }); + remoteDeletionStatus = 'approved'; + upstreamCalls = 0; const refreshedDeletion = await request(app) .get('/api/settings/cloud/delete-data/status') .set(owner.authHeader);