Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 161 additions & 31 deletions frontend/src/app/(dashboard)/settings/page.tsx

Large diffs are not rendered by default.

28 changes: 24 additions & 4 deletions frontend/src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,30 @@ 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'))
.catch(() => setEmailNeedsAttention(false));
}, [role, pathname]);
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 (
<Sidebar collapsible="icon">
Expand Down
14 changes: 14 additions & 0 deletions main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,7 @@
throw new Error('invalid stale replacement journal');
}
removeReplacementArtifacts(journalPath, journal.recoveryPath);
} catch (error) {

Check warning on line 343 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

'error' is defined but never used
// The newest journal has already established the recovery decision. Do
// not let an unrelated stale/corrupt older journal brick every startup;
// remove only that journal and its same-basename snapshot.
Expand Down Expand Up @@ -560,13 +560,13 @@
}

/** Safely append an object to a JSON-array column. Creates the array if missing/invalid. */
export function appendJsonArray(table: string, idColumn: string, idValue: any, column: string, value: any): void {

Check warning on line 563 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type

Check warning on line 563 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
// Validate identifiers to prevent SQL injection
if (!isSafeIdentifier(table) || !isSafeIdentifier(idColumn) || !isSafeIdentifier(column)) {
throw new Error(`Invalid identifier: table=${table}, idColumn=${idColumn}, column=${column}`);
}
const row = db.prepare(`SELECT ${column} AS v FROM ${table} WHERE ${idColumn} = ?`).get(idValue) as any;

Check warning on line 568 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
let arr: any[] = [];

Check warning on line 569 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
if (row && row.v) {
try {
const parsed = JSON.parse(row.v);
Expand All @@ -592,13 +592,13 @@
console.log('[DB] integrity_check: ok');
}

const fkViolations = db.prepare('PRAGMA foreign_key_check').all() as any[];

Check warning on line 595 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
if (fkViolations.length > 0) {
console.error(`[DB] ⚠ ${fkViolations.length} foreign-key violation(s):`, fkViolations.slice(0, 5));
} else {
console.log('[DB] foreign_key_check: clean');
}
} catch (err: any) {

Check warning on line 601 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
console.error('[DB] Startup integrity check failed:', err.message);
}
}
Expand Down Expand Up @@ -629,7 +629,7 @@

for (const row of orderRows) {
if (!row.date || !row.max_val) continue;
const existing = db.prepare(`SELECT current_value FROM sequences WHERE name = 'orders' AND date = ?`).get(row.date) as any;

Check warning on line 632 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
if (!existing) {
db.prepare(`INSERT INTO sequences (name, date, current_value) VALUES ('orders', ?, ?)`).run(row.date, row.max_val);
} else if (existing.current_value < row.max_val) {
Expand All @@ -642,7 +642,7 @@

for (const row of billRows) {
if (!row.date || !row.max_val) continue;
const existing = db.prepare(`SELECT current_value FROM sequences WHERE name = 'bills' AND date = ?`).get(row.date) as any;

Check warning on line 645 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
if (!existing) {
db.prepare(`INSERT INTO sequences (name, date, current_value) VALUES ('bills', ?, ?)`).run(row.date, row.max_val);
} else if (existing.current_value < row.max_val) {
Expand All @@ -658,7 +658,7 @@
* Only runs when rows are detected as malformed AND the deduped sum matches `paid_amount`. */
function autoRepairPaymentDetails(): void {
try {
const rows = db.prepare(`SELECT id, payment_details, paid_amount FROM bills WHERE payment_details IS NOT NULL AND payment_details != ''`).all() as any[];

Check warning on line 661 in main/db.ts

View workflow job for this annotation

GitHub Actions / linux-baseline

Unexpected any. Specify a different type
const toFix: { id: number; value: string }[] = [];

for (const row of rows) {
Expand Down Expand Up @@ -3494,6 +3494,20 @@
`).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 {
Expand Down
3 changes: 3 additions & 0 deletions main/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}` : '';
}
Expand Down
3 changes: 3 additions & 0 deletions main/routes/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
112 changes: 94 additions & 18 deletions main/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}` : '';
}
Expand Down Expand Up @@ -394,6 +397,31 @@ router.put('/order-numbering', requireRole('owner', 'manager'), (req: Request, r
}
});

function publicDeletionRequest(request: Record<string, unknown> | null): Record<string, unknown> | null {
if (!request) return null;
const safe: Record<string, unknown> = {};
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<string, unknown>): Record<string, unknown> {
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) => {
Expand Down Expand Up @@ -433,6 +461,16 @@ 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.' });
}
Expand All @@ -449,10 +487,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),
Expand Down Expand Up @@ -488,32 +531,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' });
}
});

Expand All @@ -527,16 +603,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' });
}
});

Expand Down
Loading
Loading