diff --git a/.env b/.env index 5d1e1f5..efeb503 100644 --- a/.env +++ b/.env @@ -31,9 +31,9 @@ EMAIL_FROM="SkyMoney Budget " EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com EMAIL_REPLY_TO=support@skymoneybudget.com -UPDATE_NOTICE_VERSION=6 -UPDATE_NOTICE_TITLE="Added estimate Fixed Expenses" -UPDATE_NOTICE_BODY="We added estimate amounts for fixed expenses in the onboarding proccess. Now, users can add estimate totals for fixed expenses if they are unsure of the ending total amount due." +UPDATE_NOTICE_VERSION=7 +UPDATE_NOTICE_TITLE="Ui fixes, security updates, input bugs" +UPDATE_NOTICE_BODY="This release includes onboarding and settings UI fixes, stronger local and production verification controls, improved decimal handling for money inputs, and smoother account/session behavior during development and login." ALLOW_INSECURE_AUTH_FOR_DEV=false JWT_ISSUER=skymoney-api JWT_AUDIENCE=skymoney-web @@ -49,3 +49,6 @@ EXPECTED_BACKUP_DB_HOST=127.0.0.1 EXPECTED_BACKUP_DB_NAME=skymoney PROD_DB_VOLUME_NAME=skymoney_pgdata ALLOW_EMPTY_PROD_VOLUME=0 +EMAIL_VERIFY_DEV_CODE_EXPOSE=false +BREAK_GLASS_VERIFY_ENABLED=false +BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ \ No newline at end of file diff --git a/.env.example b/.env.example index 8de0913..71372fd 100644 --- a/.env.example +++ b/.env.example @@ -36,7 +36,11 @@ JWT_SECRET=replace-with-32+-chars JWT_ISSUER=skymoney-api JWT_AUDIENCE=skymoney-web COOKIE_SECRET=replace-with-32+-chars -COOKIE_DOMAIN=skymoneybudget.com +# Leave unset for local development. Set for production (example: skymoneybudget.com). +# COOKIE_DOMAIN=skymoneybudget.com +EMAIL_VERIFY_DEV_CODE_EXPOSE=false +BREAK_GLASS_VERIFY_ENABLED=false +BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars AUTH_MAX_FAILED_ATTEMPTS=5 AUTH_LOCKOUT_WINDOW_MS=900000 PASSWORD_RESET_TTL_MINUTES=30 diff --git a/.env.localdev b/.env.localdev new file mode 100644 index 0000000..c678d03 --- /dev/null +++ b/.env.localdev @@ -0,0 +1,31 @@ +# Local development env (safe defaults; separate from production .env) +NODE_ENV=development +PORT=8080 +# API/web local origins +APP_ORIGIN=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +# Database +POSTGRES_DB=skymoney +POSTGRES_USER=skymoney_app +POSTGRES_PASSWORD=RicearoniSkyMoney124521! +DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney +BACKUP_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney +RESTORE_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/skymoney_restore_test +ADMIN_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@127.0.0.1:5432/postgres +TEST_DATABASE_URL=postgres://skymoney_app:RicearoniSkyMoney124521!@postgres:5432/skymoney_test + +# Auth +JWT_SECRET=AF1dCMElMFR+IeMwA17ZNUo7ft/j4qLsx2C/zndtKug= +JWT_ISSUER=skymoney-api +JWT_AUDIENCE=skymoney-web +COOKIE_SECRET= PYjozZs+CxkU+In/FX/EI/5SB5ETAEw2AzCAF+G4Zgc= +# Leave unset in local dev so host-only cookie is used. +# COOKIE_DOMAIN= +AUTH_DISABLED=false +ALLOW_INSECURE_AUTH_FOR_DEV=false +SEED_DEFAULT_BUDGET=true + +BREAK_GLASS_VERIFY_ENABLED=true +BREAK_GLASS_VERIFY_CODE=cbbb87737a056bc01af63a9fedcf11baf3585aafc26e32bdd9a132103fa955e0i9qwU+KY_3PAaiRZi9qwU+KY_3PAaiRZ +EMAIL_VERIFY_DEV_CODE_EXPOSE=true + diff --git a/.env.localdev.example b/.env.localdev.example new file mode 100644 index 0000000..7eea660 --- /dev/null +++ b/.env.localdev.example @@ -0,0 +1,32 @@ +# Local development env (safe defaults; separate from production .env) +NODE_ENV=development +PORT=8080 + +# API/web local origins +APP_ORIGIN=http://localhost:5173 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 + +# Database +POSTGRES_DB=skymoney +POSTGRES_USER=skymoney_app +POSTGRES_PASSWORD=change-me +DATABASE_URL=postgres://skymoney_app:change-me@postgres:5432/skymoney +BACKUP_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney +RESTORE_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_restore_test +ADMIN_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/postgres +TEST_DATABASE_URL=postgres://skymoney_app:change-me@127.0.0.1:5432/skymoney_test + +# Auth +JWT_SECRET=replace-with-32+-chars +JWT_ISSUER=skymoney-api +JWT_AUDIENCE=skymoney-web +COOKIE_SECRET=replace-with-32+-chars +# Leave unset in local dev so host-only cookie is used. +# COOKIE_DOMAIN= +EMAIL_VERIFY_DEV_CODE_EXPOSE=true +BREAK_GLASS_VERIFY_ENABLED=true +BREAK_GLASS_VERIFY_CODE=replace-with-very-long-secret-32+-chars + +AUTH_DISABLED=false +ALLOW_INSECURE_AUTH_FOR_DEV=false +SEED_DEFAULT_BUDGET=true diff --git a/api/package.json b/api/package.json index 0b57e1e..b10401d 100644 --- a/api/package.json +++ b/api/package.json @@ -12,7 +12,8 @@ "test": "vitest --run", "test:watch": "vitest", "rollover": "tsx src/scripts/run-rollover.ts", - "plan:manage": "tsx src/scripts/manage-plan.ts" + "plan:manage": "tsx src/scripts/manage-plan.ts", + "verify:break-glass": "tsx src/scripts/verify-break-glass.ts" }, "prisma": { "seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts" diff --git a/api/src/env.ts b/api/src/env.ts index f84a879..81dc406 100644 --- a/api/src/env.ts +++ b/api/src/env.ts @@ -49,6 +49,9 @@ const Env = z.object({ JWT_AUDIENCE: z.string().min(1).default("skymoney-web"), COOKIE_SECRET: z.string().min(32), COOKIE_DOMAIN: z.string().optional(), + EMAIL_VERIFY_DEV_CODE_EXPOSE: BoolFromEnv.default(false), + BREAK_GLASS_VERIFY_ENABLED: BoolFromEnv.default(false), + BREAK_GLASS_VERIFY_CODE: z.string().min(32).optional(), AUTH_DISABLED: BoolFromEnv.optional().default(false), ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false), SEED_DEFAULT_BUDGET: BoolFromEnv.default(true), @@ -90,6 +93,9 @@ const rawEnv = { JWT_AUDIENCE: process.env.JWT_AUDIENCE, COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me", COOKIE_DOMAIN: process.env.COOKIE_DOMAIN, + EMAIL_VERIFY_DEV_CODE_EXPOSE: process.env.EMAIL_VERIFY_DEV_CODE_EXPOSE, + BREAK_GLASS_VERIFY_ENABLED: process.env.BREAK_GLASS_VERIFY_ENABLED, + BREAK_GLASS_VERIFY_CODE: process.env.BREAK_GLASS_VERIFY_CODE, AUTH_DISABLED: process.env.AUTH_DISABLED, ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV, SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET, @@ -157,4 +163,10 @@ if (parsed.AUTH_DISABLED && parsed.NODE_ENV !== "test" && !parsed.ALLOW_INSECURE ); } +if (parsed.BREAK_GLASS_VERIFY_ENABLED && !parsed.BREAK_GLASS_VERIFY_CODE) { + throw new Error( + "BREAK_GLASS_VERIFY_ENABLED=true requires BREAK_GLASS_VERIFY_CODE (32+ chars)." + ); +} + export const env = parsed; diff --git a/api/src/scripts/verify-break-glass.ts b/api/src/scripts/verify-break-glass.ts new file mode 100644 index 0000000..4b33a0b --- /dev/null +++ b/api/src/scripts/verify-break-glass.ts @@ -0,0 +1,94 @@ +import { PrismaClient } from "@prisma/client"; +import { timingSafeEqual } from "node:crypto"; + +function parseArgs() { + const args = process.argv.slice(2); + const parsed: Record = {}; + for (const arg of args) { + if (!arg.startsWith("--")) continue; + const [key, ...rest] = arg.slice(2).split("="); + parsed[key] = rest.join("="); + } + return parsed; +} + +function parseBool(value: string | undefined): boolean { + if (!value) return false; + const normalized = value.trim().toLowerCase(); + return normalized === "1" || normalized === "true" || normalized === "yes"; +} + +function safeEqual(a: string, b: string): boolean { + const left = Buffer.from(a, "utf8"); + const right = Buffer.from(b, "utf8"); + if (left.length !== right.length) return false; + return timingSafeEqual(left, right); +} + +const prisma = new PrismaClient(); + +async function main() { + const args = parseArgs(); + const email = (args.email || "").trim().toLowerCase(); + const providedCode = args.code || process.env.BREAK_GLASS_VERIFY_CODE_INPUT || ""; + const expectedCode = process.env.BREAK_GLASS_VERIFY_CODE || ""; + const enabled = parseBool(process.env.BREAK_GLASS_VERIFY_ENABLED); + + if (!enabled) { + throw new Error("BREAK_GLASS_VERIFY_ENABLED must be true to use this command."); + } + if (expectedCode.length < 32) { + throw new Error("BREAK_GLASS_VERIFY_CODE must be set and at least 32 characters."); + } + if (!email || !email.includes("@")) { + throw new Error("Usage: npm run verify:break-glass -- --email=user@example.com --code="); + } + if (!providedCode) { + throw new Error("Missing --code (or BREAK_GLASS_VERIFY_CODE_INPUT)."); + } + if (!safeEqual(providedCode, expectedCode)) { + throw new Error("Invalid break-glass code."); + } + + const user = await prisma.user.findUnique({ + where: { email }, + select: { id: true, email: true, emailVerified: true }, + }); + if (!user) { + throw new Error(`No user found for email: ${email}`); + } + + if (!user.emailVerified) { + await prisma.user.update({ + where: { id: user.id }, + data: { emailVerified: true }, + }); + } + + await prisma.emailToken.deleteMany({ + where: { userId: user.id, type: "signup" }, + }); + + console.log( + JSON.stringify( + { + ok: true, + email: user.email, + wasAlreadyVerified: user.emailVerified, + action: "email_marked_verified_break_glass", + }, + null, + 2 + ) + ); +} + +main() + .catch((err) => { + console.error(err instanceof Error ? err.message : err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); + diff --git a/api/src/server.ts b/api/src/server.ts index bffbf63..cc30790 100644 --- a/api/src/server.ts +++ b/api/src/server.ts @@ -128,6 +128,7 @@ declare module "fastify" { export async function buildApp(overrides: Partial = {}): Promise { const config = { ...env, ...overrides } as AppConfig; const isProd = config.NODE_ENV === "production"; + const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE; const cookieDomain = config.COOKIE_DOMAIN || undefined; const app = Fastify({ @@ -933,7 +934,11 @@ app.post( userId: user.id, emailFingerprint: fingerprintEmail(normalizedEmail), }); - return { ok: true, needsVerification: true }; + return { + ok: true, + needsVerification: true, + ...(exposeDevVerificationCode ? { verificationCode: code } : {}), + }; }); app.post( @@ -1155,7 +1160,7 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => { userId: user.id, emailFingerprint: fingerprintEmail(normalizedEmail), }); - return { ok: true }; + return { ok: true, ...(exposeDevVerificationCode ? { verificationCode: code } : {}) }; }); app.post( diff --git a/api/tests/setup.ts b/api/tests/setup.ts index aac34f5..31c6e14 100644 --- a/api/tests/setup.ts +++ b/api/tests/setup.ts @@ -16,20 +16,24 @@ function readEnvValue(filePath: string, key: string): string | undefined { } function resolveDatabaseUrl(): string { - if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim(); - if (process.env.BACKUP_DATABASE_URL?.trim()) return process.env.BACKUP_DATABASE_URL.trim(); - if (process.env.DATABASE_URL?.trim()) return process.env.DATABASE_URL.trim(); + const normalizeHost = (url: string) => url.replace("@postgres:", "@127.0.0.1:"); + + if (process.env.TEST_DATABASE_URL?.trim()) return normalizeHost(process.env.TEST_DATABASE_URL.trim()); + if (process.env.BACKUP_DATABASE_URL?.trim()) + return normalizeHost(process.env.BACKUP_DATABASE_URL.trim()); const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")]; for (const envPath of envPaths) { const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL"); - if (testUrl) return testUrl; + if (testUrl) return normalizeHost(testUrl); const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL"); - if (backupUrl) return backupUrl; + if (backupUrl) return normalizeHost(backupUrl); const dbUrl = readEnvValue(envPath, "DATABASE_URL"); - if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:"); + if (dbUrl) return normalizeHost(dbUrl); } + if (process.env.DATABASE_URL?.trim()) return normalizeHost(process.env.DATABASE_URL.trim()); + return "postgres://app:app@127.0.0.1:5432/skymoney_test"; } diff --git a/docker-compose.yml b/docker-compose.yml index fc22bbc..86f7732 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,9 @@ services: JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me} COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me} APP_ORIGIN: ${APP_ORIGIN:-http://localhost:5173} + EMAIL_VERIFY_DEV_CODE_EXPOSE: ${EMAIL_VERIFY_DEV_CODE_EXPOSE:-false} + BREAK_GLASS_VERIFY_ENABLED: ${BREAK_GLASS_VERIFY_ENABLED:-false} + BREAK_GLASS_VERIFY_CODE: ${BREAK_GLASS_VERIFY_CODE:-} UPDATE_NOTICE_VERSION: ${UPDATE_NOTICE_VERSION:-0} UPDATE_NOTICE_TITLE: ${UPDATE_NOTICE_TITLE:-SkyMoney Updated} UPDATE_NOTICE_BODY: ${UPDATE_NOTICE_BODY:-We shipped improvements and fixes. Please review the latest changes.} diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100644 index 0000000..cd3acc9 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${ENV_FILE:-.env.localdev}" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing $ENV_FILE" + echo "Copy .env.localdev.example -> .env.localdev and fill secrets/passwords first." + exit 1 +fi + +docker compose --env-file "$ENV_FILE" up -d --build + +echo "Local stack started with $ENV_FILE" diff --git a/web/.env.development b/web/.env.development index a252fe1..2d85331 100644 --- a/web/.env.development +++ b/web/.env.development @@ -1,2 +1,3 @@ -VITE_API_URL=http://localhost:8081 +VITE_API_URL=/api +VITE_PROXY_TARGET=http://localhost:8081 VITE_APP_NAME=SkyMoney diff --git a/web/src/components/CurrencyInput.tsx b/web/src/components/CurrencyInput.tsx index f7fd5d9..c8184d7 100644 --- a/web/src/components/CurrencyInput.tsx +++ b/web/src/components/CurrencyInput.tsx @@ -30,7 +30,7 @@ export default function CurrencyInput({ }: Props) { const mergedClass = ["input", className].filter(Boolean).join(" "); const formatString = (raw: string) => { - const cleanedRaw = raw.replace(/[^0-9.]/g, ""); + const cleanedRaw = raw.replace(/,/g, ".").replace(/[^0-9.]/g, ""); const parts = cleanedRaw.split("."); return parts.length === 1 ? parts[0] diff --git a/web/src/pages/IncomePage.tsx b/web/src/pages/IncomePage.tsx index e16aa69..ef6fe22 100644 --- a/web/src/pages/IncomePage.tsx +++ b/web/src/pages/IncomePage.tsx @@ -16,8 +16,9 @@ function fmt(cents: number) { } function parseCurrencyToCents(value: string) { - const cleaned = value.replace(/[^0-9.]/g, ""); - const [whole, fraction = ""] = cleaned.split("."); + const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, ""); + const [whole, ...fractionParts] = cleaned.split("."); + const fraction = fractionParts.join(""); const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; const parsed = Number.parseFloat(normalized || "0"); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; diff --git a/web/src/pages/OnboardingPage.tsx b/web/src/pages/OnboardingPage.tsx index 106ab60..1ad8e04 100644 --- a/web/src/pages/OnboardingPage.tsx +++ b/web/src/pages/OnboardingPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import CurrencyInput from "../components/CurrencyInput"; @@ -60,8 +60,9 @@ const formatCentsForInput = (cents: number) => { return value.replace(/\.00$/, ""); }; const parseCurrencyToCents = (value: string) => { - const cleaned = value.replace(/[^0-9.]/g, ""); - const [whole, fraction = ""] = cleaned.split("."); + const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, ""); + const [whole, ...fractionParts] = cleaned.split("."); + const fraction = fractionParts.join(""); const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; const parsed = Number.parseFloat(normalized || "0"); @@ -548,6 +549,23 @@ export default function OnboardingPage() { } // ── UI + const topNavBack = step > 1 ? { label: "Back", onClick: back } : undefined; + const topNavNext = step === 6 + ? { + label: saving ? "Setting up your budget..." : "Complete Setup", + onClick: handleFinish, + disabled: saving, + } + : step === 5 + ? { label: "Review Setup", onClick: next, disabled: !canNext5 } + : step === 4 + ? { label: "Continue", onClick: next, disabled: !canNext4 } + : step === 3 + ? { label: "Continue", onClick: next, disabled: !canNext3 } + : step === 2 + ? { label: "Continue", onClick: next, disabled: !canNext2 } + : { label: "Continue", onClick: next, disabled: !canNext1 }; + return (
{/* Hero Header */} @@ -569,6 +587,10 @@ export default function OnboardingPage() {
+
+ +
+ {/* Recovery Banner */} {hasPartialServerState && (
@@ -630,7 +652,9 @@ export default function OnboardingPage() {

How do you receive income?

-

This helps us optimize your budget allocation

+

+ This helps us optimize your budget allocation. Next, you'll enter your total budget amount. +

@@ -705,7 +729,7 @@ export default function OnboardingPage() {

What's your total budget?

- This is the total amount you plan to allocate across all your expenses and savings + This is the total amount you plan to allocate across all your expenses and savings.

{incomeType === "regular" && ( @@ -787,6 +811,9 @@ export default function OnboardingPage() { )}
+

+ Enter your total budget in the amount field below (decimals are supported, e.g. 2450.75). +

0 ? `${whole}.${fraction.slice(0, 2)}` : whole; const parsed = Number.parseFloat(normalized || "0"); return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; diff --git a/web/src/pages/settings/PlansSettings.tsx b/web/src/pages/settings/PlansSettings.tsx index dcb442c..e99606c 100644 --- a/web/src/pages/settings/PlansSettings.tsx +++ b/web/src/pages/settings/PlansSettings.tsx @@ -9,6 +9,7 @@ import { type FormEvent, } from "react"; import { Money } from "../../components/ui"; +import CurrencyInput from "../../components/CurrencyInput"; import { useDashboard } from "../../hooks/useDashboard"; import { useToast } from "../../components/Toast"; import { @@ -41,6 +42,15 @@ type FixedPlan = { type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean }; +function parseCurrencyToCents(value: string): number { + const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, ""); + const [whole, ...fractionParts] = cleaned.split("."); + const fraction = fractionParts.join(""); + const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole; + const parsed = Number.parseFloat(normalized || "0"); + return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0; +} + export type PlansSettingsHandle = { save: () => Promise; }; @@ -132,14 +142,14 @@ const PlansSettings = forwardRef( // Form state for adding new plan const [name, setName] = useState(""); - const [total, setTotal] = useState(""); + const [totalInput, setTotalInput] = useState(""); const [priority, setPriority] = useState(""); const [due, setDue] = useState(getTodayInTimezone(userTimezone)); const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly"); const [autoPayEnabled, setAutoPayEnabled] = useState(false); const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed"); - const totalCents = Math.max(0, Math.round((Number(total) || 0) * 100)); + const totalCents = Math.max(0, parseCurrencyToCents(totalInput)); const parsedPriority = Math.max(0, Math.floor(Number(priority) || 0)); const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving; @@ -188,7 +198,7 @@ const PlansSettings = forwardRef( }; setLocalPlans((prev) => [...prev, newPlan]); setName(""); - setTotal(""); + setTotalInput(""); setPriority(""); setDue(getTodayInTimezone(userTimezone)); setFrequency("monthly"); @@ -540,8 +550,8 @@ const PlansSettings = forwardRef( push("err", "Save this plan before applying an actual amount."); return; } - const raw = actualInputs[plan.id]; - const actualCents = Math.max(0, Math.round((Number(raw) || 0) * 100)); + const raw = actualInputs[plan.id] ?? ""; + const actualCents = Math.max(0, parseCurrencyToCents(raw)); setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true })); setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" })); try { @@ -629,18 +639,15 @@ const PlansSettings = forwardRef( value={name} onChange={(e) => setName(e.target.value)} /> - setTotal(e.target.value)} + placeholder={amountMode === "estimated" ? "Estimated amount" : "Total amount"} + value={totalInput} + onValue={setTotalInput} /> {amountMode === "estimated" && ( -
- Tip: Always over-estimate variable bills to avoid due-date shortfalls. +
+ Tip: For variable bills (utilities, usage-based charges), set a slightly higher estimate to avoid due-date shortfalls.
)} (
- Using a slightly higher estimate helps prevent last-minute shortages. + Keep this estimate slightly high for variable bills, then true-up with the actual amount when posted.
{trueUpMessages[plan.id] ? (
{trueUpMessages[plan.id]}
@@ -958,7 +962,7 @@ function InlineEditMoney({ useEffect(() => setV((cents / 100).toFixed(2)), [cents]); const commit = () => { - const newCents = Math.max(0, Math.round((Number(v) || 0) * 100)); + const newCents = Math.max(0, parseCurrencyToCents(v)); if (newCents !== cents) onChange(newCents); setEditing(false); }; diff --git a/web/src/styles.css b/web/src/styles.css index 70ed3ec..b8c10b0 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1355,6 +1355,14 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); } margin-top: 1.5rem; } +.onboarding-top-nav { + padding: 0.75rem 1rem; +} + +.onboarding-top-nav .onboarding-actions { + margin-top: 0; +} + .onboarding-btn-next { width: 100%; padding: 0.875rem 2rem; @@ -1389,6 +1397,10 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); } width: auto; min-width: 100px; } + + .onboarding-top-nav .onboarding-actions { + justify-content: space-between; + } } /* sm: breakpoint utilities */ @@ -1812,6 +1824,13 @@ html[data-theme="light"].scheme-orange body { min-width: 120px; } +.settings-add-form-tip { + flex: 1 1 100%; + font-size: 0.75rem; + color: var(--color-muted); + margin-top: -0.1rem; +} + .settings-checkbox-label { display: flex; align-items: center; diff --git a/web/vite.config.ts b/web/vite.config.ts index 892d3fd..9fd28e6 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,20 +1,22 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' +import { defineConfig, loadEnv } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; -export default defineConfig({ - plugins: [ - react(), - tailwindcss(), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + const proxyTarget = env.VITE_PROXY_TARGET || "http://localhost:8081"; - ], - server: { - proxy: { - "/api": { - target: "http://localhost:8080", - changeOrigin: true, + return { + plugins: [react(), tailwindcss()], + server: { + proxy: { + "/api": { + target: proxyTarget, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ""), + }, }, }, - }, -}) + }; +});