Added Global Synced Prefs (#6234)

* Added Global Synced Prefs

* [autofix.ci] apply automated fixes

* Add release notes for PR #6234

* typecheck

* lint fix

* Refactor global synced preferences to server preferences

- Removed global synced preferences implementation and related files.
- Introduced server preferences with a new slice and hooks for managing user settings.
- Updated components and hooks to utilize server preferences instead of global synced preferences.
- Adjusted Redux store and mock configurations to reflect the changes.
- Enhanced user settings consistency across devices with the new server preferences structure.

* Implement server preferences for feature flags and enhance admin permissions

- Updated the Experimental component to conditionally display based on user permissions and login method.
- Refactored feature flag handling to use 'flags.plugins' instead of 'plugins'.
- Introduced server-side checks to restrict access to server preferences for admin users only.
- Added comprehensive tests for server preferences management, ensuring proper handling of user roles and preferences.

* Enhance error handling in saveServerPrefs thunk

- Updated the saveServerPrefs async thunk to handle potential errors from the server response.
- Added a check for the presence of an error in the result and return it accordingly.
- Ensured that preferences are still dispatched to the store upon successful save.

* Feedback: strict "flags.plugins" typing

* Feedback: move state slice

* Feedback: localstorage pref

* Feedback: move serverPrefsSlide into prefsSlice

* Refactor: Remove duplicate import of PostError in app.ts

* Rename serverPrefs state slice property to server (#6596)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Matiss Janis Aboltins <matiss@mja.lv>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
lelemm
2026-01-09 05:17:36 -03:00
committed by GitHub
parent da1a2457ba
commit fed1cd7d30
12 changed files with 442 additions and 18 deletions

View File

@@ -5,14 +5,22 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import type { FeatureFlag, SyncedPrefs } from 'loot-core/types/prefs';
import type { FeatureFlag, ServerPrefs } from 'loot-core/types/prefs';
import { Setting } from './UI';
import { useAuth } from '@desktop-client/auth/AuthProvider';
import { Permissions } from '@desktop-client/auth/types';
import { Link } from '@desktop-client/components/common/Link';
import { Checkbox } from '@desktop-client/components/forms';
import {
useLoginMethod,
useMultiuserEnabled,
} from '@desktop-client/components/ServerContext';
import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useServerPref } from '@desktop-client/hooks/useServerPref';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { useSyncServerStatus } from '@desktop-client/hooks/useSyncServerStatus';
type FeatureToggleProps = {
flag: FeatureFlag;
@@ -68,22 +76,42 @@ function FeatureToggle({
);
}
type GlobalFeatureToggleProps = {
prefName: keyof SyncedPrefs;
type ServerFeatureToggleProps = {
prefName: keyof ServerPrefs;
disableToggle?: boolean;
error?: ReactNode;
children: ReactNode;
feedbackLink?: string;
};
function GlobalFeatureToggle({
function ServerFeatureToggle({
prefName,
disableToggle = false,
feedbackLink,
error,
children,
}: GlobalFeatureToggleProps) {
const [enabled, setEnabled] = useSyncedPref(prefName);
}: ServerFeatureToggleProps) {
const [enabled, setEnabled] = useServerPref(prefName);
const syncServerStatus = useSyncServerStatus();
const isUsingServer = syncServerStatus !== 'no-server';
const isServerOffline = syncServerStatus === 'offline';
const { hasPermission } = useAuth();
const loginMethod = useLoginMethod();
const multiuserEnabled = useMultiuserEnabled();
if (!isUsingServer || isServerOffline) {
return null;
}
// Show to admins if OIDC is enabled, or to everyone if multi-user is not enabled
const isAdmin = hasPermission(Permissions.ADMINISTRATOR);
const oidcEnabled = loginMethod === 'openid';
const shouldShow = (oidcEnabled && isAdmin) || !multiuserEnabled;
if (!shouldShow) {
return null;
}
return (
<label style={{ display: 'flex' }}>
@@ -131,6 +159,9 @@ export function ExperimentalFeatures() {
(goalTemplatesEnabled &&
localStorage.getItem('devEnableGoalTemplatesUI') === 'true');
const showServerPrefs =
localStorage.getItem('devEnableServerPrefs') === 'true';
return (
<Setting
primaryAction={
@@ -174,13 +205,15 @@ export function ExperimentalFeatures() {
<FeatureToggle flag="forceReload">
<Trans>Force reload app button</Trans>
</FeatureToggle>
<GlobalFeatureToggle
prefName="plugins"
disableToggle
feedbackLink="https://github.com/actualbudget/actual/issues/5950"
>
<Trans>Client-Side plugins (soon)</Trans>
</GlobalFeatureToggle>
{showServerPrefs && (
<ServerFeatureToggle
prefName="flags.plugins"
disableToggle
feedbackLink="https://github.com/actualbudget/actual/issues/5950"
>
<Trans>Client-Side plugins (soon)</Trans>
</ServerFeatureToggle>
)}
</View>
) : (
<Link

View File

@@ -9,7 +9,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
formulaMode: false,
currency: false,
crossoverReport: false,
plugins: false,
forceReload: false,
};

View File

@@ -0,0 +1,31 @@
import { useCallback } from 'react';
import { type ServerPrefs } from 'loot-core/types/prefs';
import { saveServerPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetServerPrefAction<K extends keyof ServerPrefs> = (
value: ServerPrefs[K],
) => void;
export function useServerPref<K extends keyof ServerPrefs>(
prefName: K,
): [ServerPrefs[K], SetServerPrefAction<K>] {
const dispatch = useDispatch();
const setPref = useCallback<SetServerPrefAction<K>>(
value => {
dispatch(
saveServerPrefs({
prefs: { [prefName]: value },
}),
);
},
[dispatch, prefName],
);
const pref = useSelector(state => state.prefs.server[prefName]);
return [pref, setPref];
}

View File

@@ -5,6 +5,7 @@ import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util';
import {
type GlobalPrefs,
type MetadataPrefs,
type ServerPrefs,
type SyncedPrefs,
} from 'loot-core/types/prefs';
@@ -12,6 +13,7 @@ import { resetApp } from '@desktop-client/app/appSlice';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { closeModal } from '@desktop-client/modals/modalsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
import { getUserData } from '@desktop-client/users/usersSlice';
const sliceName = 'prefs';
@@ -19,12 +21,14 @@ type PrefsState = {
local: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
server: ServerPrefs;
};
const initialState: PrefsState = {
local: {},
global: {},
synced: {},
server: {},
};
export const loadPrefs = createAppAsyncThunk(
@@ -125,6 +129,23 @@ export const saveSyncedPrefs = createAppAsyncThunk(
},
);
type SaveServerPrefsPayload = {
prefs: ServerPrefs;
};
export const saveServerPrefs = createAppAsyncThunk(
`${sliceName}/saveServerPrefs`,
async ({ prefs }: SaveServerPrefsPayload, { dispatch }) => {
const result = await send('save-server-prefs', { prefs });
if (result && 'error' in result) {
return { error: result.error };
}
dispatch(mergeServerPrefs(prefs));
return {};
},
);
type SetPrefsPayload = {
local: MetadataPrefs;
global: GlobalPrefs;
@@ -134,6 +155,7 @@ type SetPrefsPayload = {
type MergeLocalPrefsPayload = MetadataPrefs;
type MergeGlobalPrefsPayload = GlobalPrefs;
type MergeSyncedPrefsPayload = SyncedPrefs;
type MergeServerPrefsPayload = ServerPrefs;
const prefsSlice = createSlice({
name: sliceName,
@@ -153,12 +175,34 @@ const prefsSlice = createSlice({
mergeSyncedPrefs(state, action: PayloadAction<MergeSyncedPrefsPayload>) {
state.synced = { ...state.synced, ...action.payload };
},
mergeServerPrefs(state, action: PayloadAction<MergeServerPrefsPayload>) {
state.server = { ...state.server, ...action.payload };
},
},
extraReducers: builder => {
builder.addCase(resetApp, state => ({
...initialState,
global: state.global || initialState.global,
server: state.server || initialState.server,
}));
builder.addCase(getUserData.fulfilled, (state, action) => {
if (!action.payload || typeof action.payload !== 'object') {
return state;
}
const { serverPrefs } = action.payload as {
serverPrefs?: ServerPrefs | null;
};
if (!serverPrefs) {
return state;
}
state.server = {
...state.server,
...serverPrefs,
};
});
},
});
@@ -171,7 +215,13 @@ export const actions = {
loadGlobalPrefs,
saveGlobalPrefs,
saveSyncedPrefs,
saveServerPrefs,
};
export const { mergeGlobalPrefs, mergeLocalPrefs, mergeSyncedPrefs, setPrefs } =
actions;
export const {
mergeGlobalPrefs,
mergeLocalPrefs,
mergeServerPrefs,
mergeSyncedPrefs,
setPrefs,
} = actions;

View File

@@ -174,6 +174,7 @@ async function getUser() {
userId = null,
displayName = null,
loginMethod = null,
prefs: serverPrefs,
} = {},
} = JSON.parse(res) || {};
@@ -195,6 +196,7 @@ async function getUser() {
displayName,
loginMethod,
tokenExpired,
serverPrefs,
};
} catch (e) {
logger.log(e);

View File

@@ -8,6 +8,7 @@ import {
} from '../../types/prefs';
import { createApp } from '../app';
import * as db from '../db';
import { PostError } from '../errors';
import { getDefaultDocumentDir } from '../main';
import { mutator } from '../mutators';
import { post } from '../post';
@@ -25,6 +26,7 @@ export type PreferencesHandlers = {
'load-global-prefs': typeof loadGlobalPrefs;
'save-prefs': typeof saveMetadataPrefs;
'load-prefs': typeof loadMetadataPrefs;
'save-server-prefs': typeof saveServerPrefs;
};
export const app = createApp<PreferencesHandlers>();
@@ -35,6 +37,7 @@ app.method('save-global-prefs', saveGlobalPrefs);
app.method('load-global-prefs', loadGlobalPrefs);
app.method('save-prefs', saveMetadataPrefs);
app.method('load-prefs', loadMetadataPrefs);
app.method('save-server-prefs', saveServerPrefs);
async function saveSyncedPrefs({
id,
@@ -198,3 +201,31 @@ async function saveMetadataPrefs(prefsToSet: MetadataPrefs) {
async function loadMetadataPrefs(): Promise<MetadataPrefs> {
return _getMetadataPrefs();
}
async function saveServerPrefs({ prefs }: { prefs: Record<string, string> }) {
const userToken = await asyncStorage.getItem('user-token');
if (!userToken) {
return { error: 'not-logged-in' };
}
try {
const serverConfig = getServer();
if (!serverConfig) {
throw new Error('No sync server configured.');
}
await post(serverConfig.SIGNUP_SERVER + '/server-prefs', {
token: userToken,
prefs,
});
} catch (err) {
if (err instanceof PostError) {
return {
error: err.reason || 'network-failure',
};
}
throw err;
}
return {};
}

View File

@@ -5,7 +5,6 @@ export type FeatureFlag =
| 'formulaMode'
| 'currency'
| 'crossoverReport'
| 'plugins'
| 'forceReload';
/**
@@ -23,7 +22,6 @@ export type SyncedPrefs = Partial<
| 'currencySymbolPosition'
| 'currencySpaceBetweenAmountAndSymbol'
| 'defaultCurrencyCode'
| 'plugins'
| `show-account-${string}-net-worth-chart`
| `side-nav.show-balance-history-${string}`
| `show-balances-${string}`
@@ -153,3 +151,7 @@ export type GlobalPrefsJson = Partial<{
}>;
export type AuthMethods = 'password' | 'openid';
export type ServerPrefs = Partial<{
'flags.plugins': 'true' | 'false';
}>;

View File

@@ -0,0 +1,19 @@
import { getAccountDb } from '../src/account-db';
export const up = async function () {
const accountDb = getAccountDb();
accountDb.exec(`
CREATE TABLE IF NOT EXISTS server_prefs
(key TEXT NOT NULL PRIMARY KEY,
value TEXT);
`);
};
export const down = async function () {
const accountDb = getAccountDb();
accountDb.exec(`
DROP TABLE IF EXISTS server_prefs;
`);
};

View File

@@ -227,6 +227,33 @@ export function getUserPermission(userId) {
return role;
}
export function getServerPrefs() {
const accountDb = getAccountDb();
const rows = accountDb.all('SELECT key, value FROM server_prefs') || [];
return rows.reduce((prefs, row) => {
prefs[row.key] = row.value;
return prefs;
}, {});
}
export function setServerPrefs(prefs) {
const accountDb = getAccountDb();
if (!prefs) {
return;
}
accountDb.transaction(() => {
Object.entries(prefs).forEach(([key, value]) => {
accountDb.mutate(
'INSERT INTO server_prefs (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value',
[key, value],
);
});
});
}
export function clearExpiredSessions() {
const clearThreshold = Math.floor(Date.now() / 1000) - 3600;

View File

@@ -7,6 +7,9 @@ import {
listLoginMethods,
getUserInfo,
getActiveLoginMethod,
getServerPrefs,
setServerPrefs,
isAdmin,
} from './account-db';
import { isValidRedirectUrl, loginWithOpenIdSetup } from './accounts/openid';
import { changePassword, loginWithPassword } from './accounts/password';
@@ -128,6 +131,31 @@ app.post('/change-password', (req, res) => {
res.send({ status: 'ok', data: {} });
});
app.post('/server-prefs', (req, res) => {
const session = validateSession(req, res);
if (!session) return;
if (!isAdmin(session.user_id)) {
res.status(403).send({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
return;
}
const { prefs } = req.body || {};
if (!prefs || typeof prefs !== 'object') {
res.status(400).send({ status: 'error', reason: 'invalid-prefs' });
return;
}
setServerPrefs(prefs);
res.send({ status: 'ok', data: {} });
});
app.get('/validate', (req, res) => {
const session = validateSession(req, res);
if (session) {
@@ -146,6 +174,7 @@ app.get('/validate', (req, res) => {
userId: session?.user_id,
displayName: user?.display_name,
loginMethod: session?.auth_method,
prefs: getServerPrefs(),
},
});
}

View File

@@ -0,0 +1,195 @@
import request from 'supertest';
import { v4 as uuidv4 } from 'uuid';
import { getAccountDb, getServerPrefs } from './account-db';
import { handlers as app } from './app-account';
const ADMIN_ROLE = 'ADMIN';
const BASIC_ROLE = 'BASIC';
// Create user helper function
const createUser = (userId, userName, role, owner = 0, enabled = 1) => {
getAccountDb().mutate(
'INSERT INTO users (id, user_name, display_name, enabled, owner, role) VALUES (?, ?, ?, ?, ?, ?)',
[userId, userName, `${userName} display`, enabled, owner, role],
);
};
const deleteUser = userId => {
getAccountDb().mutate('DELETE FROM user_access WHERE user_id = ?', [userId]);
getAccountDb().mutate('DELETE FROM users WHERE id = ?', [userId]);
};
const createSession = (userId, sessionToken) => {
getAccountDb().mutate(
'INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)',
[sessionToken, userId, Math.floor(Date.now() / 1000) + 60 * 60], // Expire in 1 hour (stored in seconds)
);
};
const generateSessionToken = () => `token-${uuidv4()}`;
const clearServerPrefs = () => {
getAccountDb().mutate('DELETE FROM server_prefs');
};
describe('/server-prefs', () => {
describe('POST /server-prefs', () => {
let adminUserId, basicUserId, adminSessionToken, basicSessionToken;
beforeEach(() => {
adminUserId = uuidv4();
basicUserId = uuidv4();
adminSessionToken = generateSessionToken();
basicSessionToken = generateSessionToken();
createUser(adminUserId, 'admin', ADMIN_ROLE);
createUser(basicUserId, 'user', BASIC_ROLE);
createSession(adminUserId, adminSessionToken);
createSession(basicUserId, basicSessionToken);
});
afterEach(() => {
deleteUser(adminUserId);
deleteUser(basicUserId);
clearServerPrefs();
});
it('should return 401 if no session token is provided', async () => {
const res = await request(app)
.post('/server-prefs')
.send({
prefs: { 'flags.plugins': 'true' },
});
expect(res.statusCode).toEqual(401);
expect(res.body).toHaveProperty('status', 'error');
expect(res.body).toHaveProperty('reason', 'unauthorized');
});
it('should return 403 if user is not an admin', async () => {
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', basicSessionToken)
.send({
prefs: { 'flags.plugins': 'true' },
});
expect(res.statusCode).toEqual(403);
expect(res.body).toEqual({
status: 'error',
reason: 'forbidden',
details: 'permission-not-found',
});
});
it('should return 400 if prefs is not an object', async () => {
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({
prefs: 'invalid',
});
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({
status: 'error',
reason: 'invalid-prefs',
});
});
it('should return 400 if prefs is missing', async () => {
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({});
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({
status: 'error',
reason: 'invalid-prefs',
});
});
it('should return 400 if prefs is null', async () => {
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({
prefs: null,
});
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({
status: 'error',
reason: 'invalid-prefs',
});
});
it('should return 200 and save server preferences for admin user', async () => {
const prefs = { 'flags.plugins': 'true' };
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({ prefs });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({
status: 'ok',
data: {},
});
// Verify that preferences were saved
const savedPrefs = getServerPrefs();
expect(savedPrefs).toEqual(prefs);
});
it('should update existing server preferences', async () => {
// First, set initial preferences
getAccountDb().mutate(
'INSERT INTO server_prefs (key, value) VALUES (?, ?)',
['flags.plugins', 'false'],
);
// Update preferences
const updatedPrefs = { 'flags.plugins': 'true' };
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({ prefs: updatedPrefs });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({
status: 'ok',
data: {},
});
// Verify that preferences were updated
const savedPrefs = getServerPrefs();
expect(savedPrefs).toEqual(updatedPrefs);
});
it('should save multiple server preferences', async () => {
const prefs = {
'flags.plugins': 'true',
anotherKey: 'anotherValue',
};
const res = await request(app)
.post('/server-prefs')
.set('x-actual-token', adminSessionToken)
.send({ prefs });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({
status: 'ok',
data: {},
});
// Verify that all preferences were saved
const savedPrefs = getServerPrefs();
expect(savedPrefs).toEqual(prefs);
});
});
});

View File

@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [lelemm]
---
Add server preferences for improved user settings consistency across devices.