mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 17:47:00 -05:00
✨ 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:
@@ -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
|
||||
|
||||
@@ -9,7 +9,6 @@ const DEFAULT_FEATURE_FLAG_STATE: Record<FeatureFlag, boolean> = {
|
||||
formulaMode: false,
|
||||
currency: false,
|
||||
crossoverReport: false,
|
||||
plugins: false,
|
||||
forceReload: false,
|
||||
};
|
||||
|
||||
|
||||
31
packages/desktop-client/src/hooks/useServerPref.ts
Normal file
31
packages/desktop-client/src/hooks/useServerPref.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}>;
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
195
packages/sync-server/src/app-account.test.js
Normal file
195
packages/sync-server/src/app-account.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
6
upcoming-release-notes/6234.md
Normal file
6
upcoming-release-notes/6234.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add server preferences for improved user settings consistency across devices.
|
||||
Reference in New Issue
Block a user