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 && (
+
void fetchCloudAccount()}>Retry
+ )}
+ {cloudAccountAvailable && !cloudAccount?.verified && (
{
setCloudAccountBusy(true);
try { await api.post('/settings/cloud/account/verification'); toast.success('Verification email queued'); await fetchCloudAccount(); }
@@ -2986,11 +3064,13 @@ export default function SettingsPage() {
finally { setCloudAccountBusy(false); }
}}>{cloudAccountBusy ? 'Sending…' : 'Send verification email'}
)}
-
-
Product updates and release notes { setCloudAccountBusy(true); try { const { data } = await api.put('/settings/cloud/account/preferences', { product_updates: value }); setCloudAccount(data); } catch { toast.error('Could not save preference'); } finally { setCloudAccountBusy(false); } }} />
-
Marketing messages, offers, and surveys { setCloudAccountBusy(true); try { const { data } = await api.put('/settings/cloud/account/preferences', { marketing: value }); setCloudAccount(data); } catch { toast.error('Could not save preference'); } finally { setCloudAccountBusy(false); } }} />
-
Essential service and security notices are separate from these optional subscriptions.
-
+ {cloudAccountAvailable && (
+
+
Product updates and release notes { setCloudAccountBusy(true); try { const { data } = await api.put('/settings/cloud/account/preferences', { product_updates: value }); setCloudAccount(data); } catch { toast.error('Could not save preference'); } finally { setCloudAccountBusy(false); } }} />
+
Marketing messages, offers, and surveys { setCloudAccountBusy(true); try { const { data } = await api.put('/settings/cloud/account/preferences', { marketing: value }); setCloudAccount(data); } catch { toast.error('Could not save preference'); } finally { setCloudAccountBusy(false); } }} />
+
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() {
{
if (!await confirm('Stop all FloCafe cloud services, identified diagnostics, and future anonymous telemetry on this device? Local POS data will remain available.')) return;
- try { await api.post('/settings/cloud/stop-all'); toast.success('All cloud services and telemetry stopped'); }
+ try {
+ const { data } = await api.post('/settings/cloud/stop-all');
+ 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 }));
+ setTelemetryEnabled(false);
+ setDiagnosticsConsent(false);
+ await fetchCloudAccount();
+ toast.success('All cloud services and telemetry stopped');
+ }
catch { toast.error('Could not stop cloud services'); }
}}> Stop all cloud services
- {
+ {!cloudDeletionFinal && {
const phrase = window.prompt('This submits a deletion request to FloAdmin for manual review and immediately stops cloud services here. After approval, store-linked server data is permanently deleted. Local POS data stays on this device. Type DELETE CLOUD DATA to continue.');
if (phrase === 'DELETE CLOUD DATA') setPinGate({ mode: 'delete-cloud' });
else if (phrase !== null) toast.error('Confirmation phrase did not match');
- }}> Request cloud data deletion
- {cloudAccount?.deletion_request?.status === 'pending' && (
- setPinGate({ mode: 'cancel-cloud-deletion' })}>Cancel deletion request
+ }}> Request cloud data deletion }
+ {cloudDeletionNeedsAction && (
+ <>
+ void refreshDeletionStatus()} disabled={refreshingDeletionStatus}>
+ {refreshingDeletionStatus ? 'Refreshing…' : 'Refresh deletion status'}
+
+ {cloudDeletionCanCancel && setPinGate({ mode: 'cancel-cloud-deletion' })}>Cancel deletion request }
+ >
)}
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' && (
registerCloud('')}
disabled={registeringCloud}
@@ -3949,6 +4061,7 @@ export default function SettingsPage() {
)}
+ {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);