Skip to content
Merged
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
19 changes: 12 additions & 7 deletions docs/architecture/authentication-and-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,20 +208,25 @@ both ending in the same write (`applyOwnerRecovery` in

- **Master PIN:** `POST /api/auth/recover-password` with the owner email, the Master PIN, and the
new password. Works offline.
- **FloAdmin email approval:** available when cloud account services are on and the store has a
registered owner email (`cloudSync.isEmailRecoveryAvailable()`, advertised as
`cloudRecoveryAvailable` by `GET /api/auth/setup/status`). If FloAdmin has not verified that
- **FloAdmin email approval:** available when cloud account services are on, the cloud server URL
is the official FloAdmin endpoint in both SQLite and active runtime state, and an active owner
account matches the registered owner email (`cloudSync.isEmailRecoveryAvailable()`, advertised as
`cloudRecoveryAvailable` by `GET /api/auth/setup/status`). Updating an active owner's email in
staff management keeps `settings.email` in sync, and database backup restore and import preserve
and resync `settings.email` from the preserved active owner account. If FloAdmin has not verified that
email, the request first sends the verification email (`step: 'verify_email'`) and the recover
page polls `GET /api/auth/recover-password/cloud/verification`; once verified it requests the
reset automatically. FloAdmin then emails the registered owner a link, the POS polls the
approval status, and after approval the owner chooses the new password on the POS.
`POST /api/auth/recover-password/cloud/complete` resolves the owner whose email matches the
`POST /api/auth/recover-password/cloud/complete` resolves the active owner whose email matches the
registered cloud email, redeems the single-use grant with FloAdmin, and only then writes the
password. The password is never sent to FloAdmin. Cloud requests are signed with the
install's `api_key` like every other `/api/pos/*` call.
password. The password is never sent to FloAdmin. Recovery requests are pinned to the trusted
FloAdmin server URL and signed with the install's `api_key` like every other `/api/pos/*` call.

If the owner email matches no active owner, nothing is redeemed. When no active administrator
remains, either path restores owner access to the matched account.
remains, the Master PIN path restores owner access to the matched active account; the FloAdmin
email path always requires an active owner matching the registered email and never promotes a
non-owner account.

## Master PIN as a second factor

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,8 +620,8 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`.
| `GET` | `/order-numbering` | `ROLE_ACCESS.allStaff` | none | - |
| `PUT` | `/order-numbering` | `ROLE_ACCESS.ownerManager` | body: `order_number_prefix`, `order_number_include_date`, `order_number_reset_daily`, `invoice_number_prefix`, `invoice_number_include_period`, `invoice_number_reset_period`, `invoice_financial_year_start_month`, `invoice_financial_year_start_day` | - |
| `GET` | `/cloud` | `ROLE_ACCESS.ownerManager` | none | - |
| `PUT` | `/cloud` | `ROLE_ACCESS.ownerManager` | body: `cloud_server_url`, `cloud_api_key`, `cloud_store_id`, `cloud_sync_enabled`, `cloud_orders_enabled`, `cloud_reports_enabled`, `cloud_command_polling_enabled` | - |
| `POST` | `/cloud/register` | `ROLE_ACCESS.ownerManager` | body: `cloud_server_url` | - |
| `PUT` | `/cloud` | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`, `cloud_api_key`, `cloud_store_id`) | body: `cloud_server_url`, `cloud_api_key`, `cloud_store_id`, `cloud_sync_enabled`, `cloud_orders_enabled`, `cloud_reports_enabled`, `cloud_command_polling_enabled` | `403` when a non-owner supplies `cloud_server_url`, an unmasked `cloud_api_key`, or `cloud_store_id`. |
| `POST` | `/cloud/register` | `ROLE_ACCESS.ownerManager` (`owner` for `cloud_server_url`) | body: `cloud_server_url` | `403` when a non-owner supplies `cloud_server_url`. |
| `POST` | `/cloud/test` | `ROLE_ACCESS.ownerManager` | none | - |
| `GET` | `/cloud/account` | `ROLE_ACCESS.owner` | none | - |
| `PUT` | `/cloud/account/preferences` | `ROLE_ACCESS.owner` | body: `product_updates`, `email_marketing` | Body `product_updates` and `email_marketing`. `409` when no cloud account is linked. |
Expand Down
24 changes: 24 additions & 0 deletions main/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3092,6 +3092,7 @@ export type RestoreOutboxState = {
};
const RESTORE_PROTECTED_SETTING_KEYS = [
'jwt_secret',
'email',
'cloud_api_key',
'cloud_device_secret',
'cloud_pos_hash',
Expand Down Expand Up @@ -3151,6 +3152,29 @@ export function mergeRestoreProtectedSettings(
else if (!state.key.startsWith('cloud_'))
dbInstance.prepare('DELETE FROM settings WHERE key = ?').run(state.key);
}
try {
const activeOwners = dbInstance
.prepare(
"SELECT lower(trim(email)) AS email FROM users WHERE role = 'owner' AND is_active = 1 AND email IS NOT NULL AND trim(email) != '' ORDER BY created_at ASC, id ASC",
)
.all() as { email: string }[];
if (activeOwners.length > 0) {
const currentEmail = String(
(
dbInstance.prepare("SELECT value FROM settings WHERE key = 'email'").get() as
| { value: string | null }
| undefined
)?.value || '',
)
.trim()
.toLowerCase();
if (!activeOwners.some((row) => row.email === currentEmail)) {
upsert.run('email', activeOwners[0].email, now());
}
}
} catch {
// Skip owner email resync if users table is unavailable in a partial fixture.
}
const hasDeviceSecret = states.some(
(state) => state.key === 'cloud_device_secret' && state.present,
);
Expand Down
22 changes: 12 additions & 10 deletions main/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@
const db = getDatabase();
const user = db
.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1')
.get(email) as any;

Check warning on line 259 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:259:24: Unexpected `any`. Specify a different type.
let passwordMatches = false;
if (user) {
try {
Expand Down Expand Up @@ -300,7 +300,7 @@
// Single tenant — frontend auto-selects when tenants.length === 1
tenants: [tenant],
});
} catch (error: any) {

Check warning on line 303 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:303:21: Unexpected `any`. Specify a different type.
console.error('[Auth] Login error:', error);
console.error('[API] Internal error:', error);
res.status(500).json({ error: 'Internal server error' });
Expand All @@ -322,14 +322,14 @@
if (isTokenRevoked(token)) {
return res.status(401).json({ error: 'Invalid token' });
}
const decoded = jwt.verify(token, getJWTSecret()) as any;

Check warning on line 325 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:325:58: Unexpected `any`. Specify a different type.

const db = getDatabase();
const user = db
.prepare(
'SELECT id, name, email, role, is_active, tokens_valid_after FROM users WHERE id = ?',
)
.get(decoded.userId) as any;

Check warning on line 332 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:332:31: Unexpected `any`. Specify a different type.
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.is_active !== 1 || isTokenStale(decoded.iat, user.tokens_valid_after)) {
return res.status(401).json({ error: 'Invalid token' });
Expand Down Expand Up @@ -357,7 +357,7 @@
token_type: 'bearer',
tenant,
});
} catch (error: any) {

Check warning on line 360 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:360:19: Unexpected `any`. Specify a different type.

Check warning on line 360 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

eslint(no-unused-vars)

main/routes/auth.ts:360:12: Catch parameter 'error' is caught but never used.
res.status(401).json({ error: 'Invalid token' });
}
});
Expand Down Expand Up @@ -392,14 +392,14 @@
if (isTokenRevoked(token)) {
return res.status(401).json({ error: 'Invalid token' });
}
const decoded = jwt.verify(token, getJWTSecret()) as any;

Check warning on line 395 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:395:58: Unexpected `any`. Specify a different type.

// Without this, a token minted before a password/PIN change (#173) could
// keep refreshing itself into new tokens forever, bypassing revocation entirely.
const db = getDatabase();
const user = db
.prepare('SELECT role, is_active, tokens_valid_after FROM users WHERE id = ?')
.get(decoded.userId) as any;

Check warning on line 402 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:402:31: Unexpected `any`. Specify a different type.
if (!user || user.is_active !== 1 || isTokenStale(decoded.iat, user.tokens_valid_after)) {
return res.status(401).json({ error: 'Invalid token' });
}
Expand Down Expand Up @@ -441,14 +441,14 @@
if (isTokenRevoked(token)) {
return res.status(401).json({ error: 'Invalid token' });
}
const decoded = jwt.verify(token, getJWTSecret()) as any;

Check warning on line 444 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:444:58: Unexpected `any`. Specify a different type.

const db = getDatabase();
const user = db
.prepare(
'SELECT id, name, email, role, is_active, tokens_valid_after FROM users WHERE id = ?',
)
.get(decoded.userId) as any;

Check warning on line 451 in main/routes/auth.ts

View workflow job for this annotation

GitHub Actions / Lint & Build Validation (Linux)

typescript(no-explicit-any)

main/routes/auth.ts:451:31: Unexpected `any`. Specify a different type.
if (!user) return res.status(404).json({ error: 'User not found' });
if (user.is_active !== 1 || isTokenStale(decoded.iat, user.tokens_valid_after)) {
return res.status(401).json({ error: 'Invalid token' });
Expand Down Expand Up @@ -544,7 +544,7 @@
const updated = db.transaction(() => {
// Re-checked in the transaction, so a concurrent write cannot turn a
// plain reset into a promotion.
if (hasActiveAdministrator()) {
if (via === 'FloAdmin email approval' || hasActiveAdministrator()) {
return db
.prepare(
'UPDATE users SET password = ?, tokens_valid_after = ?, updated_at = ? WHERE id = ? AND role = ? AND is_active = 1',
Expand Down Expand Up @@ -706,13 +706,20 @@
authRateLimit(),
asyncHandler(async (req: Request, res: Response) => {
if (!cloudRecoveryPrecheck(req, res)) return;
const email = cloudSync.getRegisteredOwnerEmail();
const owner = getDatabase()
.prepare('SELECT id FROM users WHERE lower(email) = ? AND role = ? AND is_active = 1')
.get(email, INITIAL_ADMIN_ROLE);
if (!owner) {
return res.status(409).json({ error: 'Email recovery is not available on this install.' });
}
try {
const signal = getHttpRequestSignal(req);
// An unverified owner email gets the verification email first; the reset follows once verified.
if (!(await cloudSync.ensureOwnerEmailVerification(signal))) {
return res.status(202).json({
step: 'verify_email',
email_hint: maskEmail(cloudSync.getRegisteredOwnerEmail()),
email_hint: maskEmail(email),
});
}
const data = await cloudSync.requestPasswordReset(signal);
Expand Down Expand Up @@ -778,14 +785,9 @@

// Resolve the account before consuming the single-use grant.
const email = cloudSync.getRegisteredOwnerEmail();
const stranded = !hasActiveAdministrator();
const user = (
stranded
? db.prepare('SELECT * FROM users WHERE email = ? AND is_active = 1').get(email)
: db
.prepare('SELECT * FROM users WHERE email = ? AND role = ? AND is_active = 1')
.get(email, INITIAL_ADMIN_ROLE)
) as any;
const user = db
.prepare('SELECT * FROM users WHERE lower(email) = ? AND role = ? AND is_active = 1')
.get(email, INITIAL_ADMIN_ROLE) as any;
if (!user) {
return res
.status(404)
Expand Down
63 changes: 34 additions & 29 deletions main/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,16 @@ router.put('/cloud', requirePermission('cloud.manage'), (req: Request, res: Resp
cloud_reports_enabled,
cloud_command_polling_enabled,
} = req.body;
const isOwner = (req as Request & { user?: { role?: string } }).user?.role === 'owner';
const hasUnmaskedApiKey = cloud_api_key !== undefined && !isMaskedSecret(cloud_api_key);
if (
!isOwner &&
(cloud_server_url !== undefined || hasUnmaskedApiKey || cloud_store_id !== undefined)
) {
return res
.status(403)
.json({ error: 'Only the store owner can modify cloud credentials or server URL' });
}
const db = getDatabase();
const updates: Record<string, string | undefined> = {
cloud_store_id: cloud_store_id === undefined ? undefined : String(cloud_store_id || ''),
Expand All @@ -1061,18 +1071,11 @@ router.put('/cloud', requirePermission('cloud.manage'), (req: Request, res: Resp
};

if (cloud_server_url !== undefined) {
const requestedUrl = normalizeCloudServerUrl(cloud_server_url || DEFAULT_CLOUD_SERVER_URL);
if (
requestedUrl !== normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL) &&
(req as Request & { user?: { role?: string } }).user?.role !== 'owner'
) {
return res
.status(403)
.json({ error: 'Only the store owner can configure a custom cloud server URL' });
}
updates.cloud_server_url = requestedUrl;
updates.cloud_server_url = normalizeCloudServerUrl(
cloud_server_url || DEFAULT_CLOUD_SERVER_URL,
);
}
if (cloud_api_key !== undefined && !isMaskedSecret(cloud_api_key)) {
if (hasUnmaskedApiKey) {
updates.cloud_api_key = String(cloud_api_key || '');
}
const enablingCloud = [
Expand Down Expand Up @@ -1114,6 +1117,17 @@ router.post(
requirePermission('cloud.manage'),
asyncHandler(async (req: Request, res: Response) => {
try {
let requestedUrl: string | undefined;
if (req.body?.cloud_server_url !== undefined) {
if ((req as Request & { user?: { role?: string } }).user?.role !== 'owner') {
return res
.status(403)
.json({ error: 'Only the store owner can modify cloud credentials or server URL' });
}
requestedUrl = normalizeCloudServerUrl(
req.body.cloud_server_url || DEFAULT_CLOUD_SERVER_URL,
);
}
const deletionRequest = await cloudSync.getDeletionRequestStatus({
allowRemote: cloudSync.isCloudAccountAvailable(),
signal: getHttpRequestSignal(req),
Expand All @@ -1124,31 +1138,22 @@ router.post(
'A cloud deletion request is pending review. Cancel it before re-enabling cloud services.',
});
}
if (cloudSync.getStatus().cloud_services_disabled_by_user) {
const currentStatus = cloudSync.getStatus();
if (currentStatus.cloud_services_disabled_by_user) {
return res.status(409).json({ error: CLOUD_ACCOUNT_UNAVAILABLE_ERROR });
}
if (req.body?.cloud_server_url !== undefined) {
const requestedUrl = normalizeCloudServerUrl(
req.body.cloud_server_url || DEFAULT_CLOUD_SERVER_URL,
);
if (
requestedUrl !== normalizeCloudServerUrl(DEFAULT_CLOUD_SERVER_URL) &&
(req as Request & { user?: { role?: string } }).user?.role !== 'owner'
) {
return res
.status(403)
.json({ error: 'Only the store owner can configure a custom cloud server URL' });
}
upsertSettings(getDatabase(), {
cloud_server_url: requestedUrl,
});
}
if (cloudSync.getStatus().cloud_deletion_blocked) {
if (currentStatus.cloud_deletion_blocked) {
return res.status(409).json({
error:
'Cloud deletion is unresolved; retry or cancel it before re-enabling cloud services.',
});
}
if (requestedUrl !== undefined) {
upsertSettings(getDatabase(), {
cloud_server_url: requestedUrl,
});
cloudSync.reload();
}
// Registration sends contact metadata for FloAdmin support; it does not
// create a cloud owner account or grant authentication access.
await cloudSync.register(getHttpRequestSignal(req));
Expand Down
Loading
Loading