ui fixes, input fixes, better dev workflow
This commit is contained in:
9
.env
9
.env
@@ -31,9 +31,9 @@ EMAIL_FROM="SkyMoney Budget <no-reply@skymoneybudget.com>"
|
|||||||
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
EMAIL_BOUNCE_FROM=bounces@skymoneybudget.com
|
||||||
EMAIL_REPLY_TO=support@skymoneybudget.com
|
EMAIL_REPLY_TO=support@skymoneybudget.com
|
||||||
|
|
||||||
UPDATE_NOTICE_VERSION=6
|
UPDATE_NOTICE_VERSION=7
|
||||||
UPDATE_NOTICE_TITLE="Added estimate Fixed Expenses"
|
UPDATE_NOTICE_TITLE="Ui fixes, security updates, input bugs"
|
||||||
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_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
|
ALLOW_INSECURE_AUTH_FOR_DEV=false
|
||||||
JWT_ISSUER=skymoney-api
|
JWT_ISSUER=skymoney-api
|
||||||
JWT_AUDIENCE=skymoney-web
|
JWT_AUDIENCE=skymoney-web
|
||||||
@@ -49,3 +49,6 @@ EXPECTED_BACKUP_DB_HOST=127.0.0.1
|
|||||||
EXPECTED_BACKUP_DB_NAME=skymoney
|
EXPECTED_BACKUP_DB_NAME=skymoney
|
||||||
PROD_DB_VOLUME_NAME=skymoney_pgdata
|
PROD_DB_VOLUME_NAME=skymoney_pgdata
|
||||||
ALLOW_EMPTY_PROD_VOLUME=0
|
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
|
||||||
@@ -36,7 +36,11 @@ JWT_SECRET=replace-with-32+-chars
|
|||||||
JWT_ISSUER=skymoney-api
|
JWT_ISSUER=skymoney-api
|
||||||
JWT_AUDIENCE=skymoney-web
|
JWT_AUDIENCE=skymoney-web
|
||||||
COOKIE_SECRET=replace-with-32+-chars
|
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_MAX_FAILED_ATTEMPTS=5
|
||||||
AUTH_LOCKOUT_WINDOW_MS=900000
|
AUTH_LOCKOUT_WINDOW_MS=900000
|
||||||
PASSWORD_RESET_TTL_MINUTES=30
|
PASSWORD_RESET_TTL_MINUTES=30
|
||||||
|
|||||||
31
.env.localdev
Normal file
31
.env.localdev
Normal file
@@ -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
|
||||||
|
|
||||||
32
.env.localdev.example
Normal file
32
.env.localdev.example
Normal file
@@ -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
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"test": "vitest --run",
|
"test": "vitest --run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"rollover": "tsx src/scripts/run-rollover.ts",
|
"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": {
|
"prisma": {
|
||||||
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
|
"seed": "tsx --tsconfig prisma/tsconfig.seed.json prisma/seed.ts"
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ const Env = z.object({
|
|||||||
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
|
JWT_AUDIENCE: z.string().min(1).default("skymoney-web"),
|
||||||
COOKIE_SECRET: z.string().min(32),
|
COOKIE_SECRET: z.string().min(32),
|
||||||
COOKIE_DOMAIN: z.string().optional(),
|
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),
|
AUTH_DISABLED: BoolFromEnv.optional().default(false),
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
ALLOW_INSECURE_AUTH_FOR_DEV: BoolFromEnv.optional().default(false),
|
||||||
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
SEED_DEFAULT_BUDGET: BoolFromEnv.default(true),
|
||||||
@@ -90,6 +93,9 @@ const rawEnv = {
|
|||||||
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
|
JWT_AUDIENCE: process.env.JWT_AUDIENCE,
|
||||||
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
COOKIE_SECRET: process.env.COOKIE_SECRET ?? "dev-cookie-secret-change-me",
|
||||||
COOKIE_DOMAIN: process.env.COOKIE_DOMAIN,
|
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,
|
AUTH_DISABLED: process.env.AUTH_DISABLED,
|
||||||
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
ALLOW_INSECURE_AUTH_FOR_DEV: process.env.ALLOW_INSECURE_AUTH_FOR_DEV,
|
||||||
SEED_DEFAULT_BUDGET: process.env.SEED_DEFAULT_BUDGET,
|
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;
|
export const env = parsed;
|
||||||
|
|||||||
94
api/src/scripts/verify-break-glass.ts
Normal file
94
api/src/scripts/verify-break-glass.ts
Normal file
@@ -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<string, string> = {};
|
||||||
|
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=<long-secret>");
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
@@ -128,6 +128,7 @@ declare module "fastify" {
|
|||||||
export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<FastifyInstance> {
|
export async function buildApp(overrides: Partial<AppConfig> = {}): Promise<FastifyInstance> {
|
||||||
const config = { ...env, ...overrides } as AppConfig;
|
const config = { ...env, ...overrides } as AppConfig;
|
||||||
const isProd = config.NODE_ENV === "production";
|
const isProd = config.NODE_ENV === "production";
|
||||||
|
const exposeDevVerificationCode = !isProd && config.EMAIL_VERIFY_DEV_CODE_EXPOSE;
|
||||||
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
const cookieDomain = config.COOKIE_DOMAIN || undefined;
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -933,7 +934,11 @@ app.post(
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
});
|
});
|
||||||
return { ok: true, needsVerification: true };
|
return {
|
||||||
|
ok: true,
|
||||||
|
needsVerification: true,
|
||||||
|
...(exposeDevVerificationCode ? { verificationCode: code } : {}),
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
@@ -1155,7 +1160,7 @@ app.post("/auth/verify/resend", codeIssueRateLimit, async (req, reply) => {
|
|||||||
userId: user.id,
|
userId: user.id,
|
||||||
emailFingerprint: fingerprintEmail(normalizedEmail),
|
emailFingerprint: fingerprintEmail(normalizedEmail),
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true, ...(exposeDevVerificationCode ? { verificationCode: code } : {}) };
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post(
|
app.post(
|
||||||
|
|||||||
@@ -16,20 +16,24 @@ function readEnvValue(filePath: string, key: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveDatabaseUrl(): string {
|
function resolveDatabaseUrl(): string {
|
||||||
if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim();
|
const normalizeHost = (url: string) => url.replace("@postgres:", "@127.0.0.1:");
|
||||||
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();
|
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")];
|
const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")];
|
||||||
for (const envPath of envPaths) {
|
for (const envPath of envPaths) {
|
||||||
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
|
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
|
||||||
if (testUrl) return testUrl;
|
if (testUrl) return normalizeHost(testUrl);
|
||||||
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
|
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
|
||||||
if (backupUrl) return backupUrl;
|
if (backupUrl) return normalizeHost(backupUrl);
|
||||||
const dbUrl = readEnvValue(envPath, "DATABASE_URL");
|
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";
|
return "postgres://app:app@127.0.0.1:5432/skymoney_test";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ services:
|
|||||||
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
|
JWT_SECRET: ${JWT_SECRET:-dev-jwt-secret-change-me}
|
||||||
COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me}
|
COOKIE_SECRET: ${COOKIE_SECRET:-dev-cookie-secret-change-me}
|
||||||
APP_ORIGIN: ${APP_ORIGIN:-http://localhost:5173}
|
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_VERSION: ${UPDATE_NOTICE_VERSION:-0}
|
||||||
UPDATE_NOTICE_TITLE: ${UPDATE_NOTICE_TITLE:-SkyMoney Updated}
|
UPDATE_NOTICE_TITLE: ${UPDATE_NOTICE_TITLE:-SkyMoney Updated}
|
||||||
UPDATE_NOTICE_BODY: ${UPDATE_NOTICE_BODY:-We shipped improvements and fixes. Please review the latest changes.}
|
UPDATE_NOTICE_BODY: ${UPDATE_NOTICE_BODY:-We shipped improvements and fixes. Please review the latest changes.}
|
||||||
|
|||||||
14
scripts/dev-up.sh
Normal file
14
scripts/dev-up.sh
Normal file
@@ -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"
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
VITE_API_URL=http://localhost:8081
|
VITE_API_URL=/api
|
||||||
|
VITE_PROXY_TARGET=http://localhost:8081
|
||||||
VITE_APP_NAME=SkyMoney
|
VITE_APP_NAME=SkyMoney
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export default function CurrencyInput({
|
|||||||
}: Props) {
|
}: Props) {
|
||||||
const mergedClass = ["input", className].filter(Boolean).join(" ");
|
const mergedClass = ["input", className].filter(Boolean).join(" ");
|
||||||
const formatString = (raw: string) => {
|
const formatString = (raw: string) => {
|
||||||
const cleanedRaw = raw.replace(/[^0-9.]/g, "");
|
const cleanedRaw = raw.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||||
const parts = cleanedRaw.split(".");
|
const parts = cleanedRaw.split(".");
|
||||||
return parts.length === 1
|
return parts.length === 1
|
||||||
? parts[0]
|
? parts[0]
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ function fmt(cents: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseCurrencyToCents(value: string) {
|
function parseCurrencyToCents(value: string) {
|
||||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||||
const [whole, fraction = ""] = cleaned.split(".");
|
const [whole, ...fractionParts] = cleaned.split(".");
|
||||||
|
const fraction = fractionParts.join("");
|
||||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||||
const parsed = Number.parseFloat(normalized || "0");
|
const parsed = Number.parseFloat(normalized || "0");
|
||||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||||
|
|||||||
@@ -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 { useNavigate } from "react-router-dom";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import CurrencyInput from "../components/CurrencyInput";
|
import CurrencyInput from "../components/CurrencyInput";
|
||||||
@@ -60,8 +60,9 @@ const formatCentsForInput = (cents: number) => {
|
|||||||
return value.replace(/\.00$/, "");
|
return value.replace(/\.00$/, "");
|
||||||
};
|
};
|
||||||
const parseCurrencyToCents = (value: string) => {
|
const parseCurrencyToCents = (value: string) => {
|
||||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||||
const [whole, fraction = ""] = cleaned.split(".");
|
const [whole, ...fractionParts] = cleaned.split(".");
|
||||||
|
const fraction = fractionParts.join("");
|
||||||
const normalized =
|
const normalized =
|
||||||
fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||||
const parsed = Number.parseFloat(normalized || "0");
|
const parsed = Number.parseFloat(normalized || "0");
|
||||||
@@ -548,6 +549,23 @@ export default function OnboardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── UI
|
// ── 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 (
|
return (
|
||||||
<div className="min-h-screen bg-[--color-bg] fade-in onboarding-page">
|
<div className="min-h-screen bg-[--color-bg] fade-in onboarding-page">
|
||||||
{/* Hero Header */}
|
{/* Hero Header */}
|
||||||
@@ -569,6 +587,10 @@ export default function OnboardingPage() {
|
|||||||
<Stepper step={step} />
|
<Stepper step={step} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="onboarding-top-nav card">
|
||||||
|
<Actions back={topNavBack} next={topNavNext} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Recovery Banner */}
|
{/* Recovery Banner */}
|
||||||
{hasPartialServerState && (
|
{hasPartialServerState && (
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
@@ -630,7 +652,9 @@ export default function OnboardingPage() {
|
|||||||
<div className="card stack fade-in onboarding-card">
|
<div className="card stack fade-in onboarding-card">
|
||||||
<div className="text-center mb-4 sm:mb-6">
|
<div className="text-center mb-4 sm:mb-6">
|
||||||
<h2 className="section-title text-xl">How do you receive income?</h2>
|
<h2 className="section-title text-xl">How do you receive income?</h2>
|
||||||
<p className="muted text-sm">This helps us optimize your budget allocation</p>
|
<p className="muted text-sm">
|
||||||
|
This helps us optimize your budget allocation. Next, you'll enter your total budget amount.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 max-w-4xl mx-auto">
|
<div className="grid gap-3 sm:gap-4 md:grid-cols-2 max-w-4xl mx-auto">
|
||||||
@@ -705,7 +729,7 @@ export default function OnboardingPage() {
|
|||||||
<div className="card stack fade-in text-center onboarding-card">
|
<div className="card stack fade-in text-center onboarding-card">
|
||||||
<h2 className="section-title text-xl">What's your total budget?</h2>
|
<h2 className="section-title text-xl">What's your total budget?</h2>
|
||||||
<p className="muted text-sm mb-4">
|
<p className="muted text-sm mb-4">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{incomeType === "regular" && (
|
{incomeType === "regular" && (
|
||||||
@@ -787,6 +811,9 @@ export default function OnboardingPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="w-full px-2">
|
<div className="w-full px-2">
|
||||||
|
<p className="muted text-xs mb-2">
|
||||||
|
Enter your total budget in the amount field below (decimals are supported, e.g. 2450.75).
|
||||||
|
</p>
|
||||||
<CurrencyInput
|
<CurrencyInput
|
||||||
className="input text-center text-xl font-bold w-full"
|
className="input text-center text-xl font-bold w-full"
|
||||||
value={budgetInput}
|
value={budgetInput}
|
||||||
|
|||||||
@@ -17,8 +17,9 @@ const LS_KEY = "spend.lastKind";
|
|||||||
const OTHER_CATEGORY_ID = "__other__";
|
const OTHER_CATEGORY_ID = "__other__";
|
||||||
|
|
||||||
function parseCurrencyToCents(value: string) {
|
function parseCurrencyToCents(value: string) {
|
||||||
const cleaned = value.replace(/[^0-9.]/g, "");
|
const cleaned = value.replace(/,/g, ".").replace(/[^0-9.]/g, "");
|
||||||
const [whole, fraction = ""] = cleaned.split(".");
|
const [whole, ...fractionParts] = cleaned.split(".");
|
||||||
|
const fraction = fractionParts.join("");
|
||||||
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
const normalized = fraction.length > 0 ? `${whole}.${fraction.slice(0, 2)}` : whole;
|
||||||
const parsed = Number.parseFloat(normalized || "0");
|
const parsed = Number.parseFloat(normalized || "0");
|
||||||
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
return Number.isFinite(parsed) ? Math.round(parsed * 100) : 0;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type FormEvent,
|
type FormEvent,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { Money } from "../../components/ui";
|
import { Money } from "../../components/ui";
|
||||||
|
import CurrencyInput from "../../components/CurrencyInput";
|
||||||
import { useDashboard } from "../../hooks/useDashboard";
|
import { useDashboard } from "../../hooks/useDashboard";
|
||||||
import { useToast } from "../../components/Toast";
|
import { useToast } from "../../components/Toast";
|
||||||
import {
|
import {
|
||||||
@@ -41,6 +42,15 @@ type FixedPlan = {
|
|||||||
|
|
||||||
type LocalPlan = FixedPlan & { _isNew?: boolean; _isDeleted?: boolean };
|
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 = {
|
export type PlansSettingsHandle = {
|
||||||
save: () => Promise<boolean>;
|
save: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
@@ -132,14 +142,14 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
|
|
||||||
// Form state for adding new plan
|
// Form state for adding new plan
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [total, setTotal] = useState("");
|
const [totalInput, setTotalInput] = useState("");
|
||||||
const [priority, setPriority] = useState("");
|
const [priority, setPriority] = useState("");
|
||||||
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
const [due, setDue] = useState(getTodayInTimezone(userTimezone));
|
||||||
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
const [frequency, setFrequency] = useState<"" | FixedPlan["frequency"]>("monthly");
|
||||||
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
const [autoPayEnabled, setAutoPayEnabled] = useState(false);
|
||||||
const [amountMode, setAmountMode] = useState<"fixed" | "estimated">("fixed");
|
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 parsedPriority = Math.max(0, Math.floor(Number(priority) || 0));
|
||||||
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
|
const addDisabled = !name.trim() || totalCents <= 0 || !due || isSaving;
|
||||||
|
|
||||||
@@ -188,7 +198,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
};
|
};
|
||||||
setLocalPlans((prev) => [...prev, newPlan]);
|
setLocalPlans((prev) => [...prev, newPlan]);
|
||||||
setName("");
|
setName("");
|
||||||
setTotal("");
|
setTotalInput("");
|
||||||
setPriority("");
|
setPriority("");
|
||||||
setDue(getTodayInTimezone(userTimezone));
|
setDue(getTodayInTimezone(userTimezone));
|
||||||
setFrequency("monthly");
|
setFrequency("monthly");
|
||||||
@@ -540,8 +550,8 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
push("err", "Save this plan before applying an actual amount.");
|
push("err", "Save this plan before applying an actual amount.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const raw = actualInputs[plan.id];
|
const raw = actualInputs[plan.id] ?? "";
|
||||||
const actualCents = Math.max(0, Math.round((Number(raw) || 0) * 100));
|
const actualCents = Math.max(0, parseCurrencyToCents(raw));
|
||||||
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true }));
|
setApplyingActualByPlan((prev) => ({ ...prev, [plan.id]: true }));
|
||||||
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" }));
|
setTrueUpMessages((prev) => ({ ...prev, [plan.id]: "" }));
|
||||||
try {
|
try {
|
||||||
@@ -629,18 +639,15 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<input
|
<CurrencyInput
|
||||||
className="input"
|
className="input"
|
||||||
placeholder={amountMode === "estimated" ? "Estimated $" : "Total $"}
|
placeholder={amountMode === "estimated" ? "Estimated amount" : "Total amount"}
|
||||||
type="number"
|
value={totalInput}
|
||||||
min="0"
|
onValue={setTotalInput}
|
||||||
step="0.01"
|
|
||||||
value={total}
|
|
||||||
onChange={(e) => setTotal(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
{amountMode === "estimated" && (
|
{amountMode === "estimated" && (
|
||||||
<div className="text-xs muted col-span-full">
|
<div className="settings-add-form-tip">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<input
|
<input
|
||||||
@@ -777,14 +784,11 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
<div className="flex flex-wrap items-end gap-2">
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
<label className="stack gap-1">
|
<label className="stack gap-1">
|
||||||
<span className="label">Actual this cycle</span>
|
<span className="label">Actual this cycle</span>
|
||||||
<input
|
<CurrencyInput
|
||||||
className="input w-36"
|
className="input w-36"
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
step="0.01"
|
|
||||||
value={actualInputs[plan.id] ?? ""}
|
value={actualInputs[plan.id] ?? ""}
|
||||||
onChange={(e) =>
|
onValue={(nextValue) =>
|
||||||
setActualInputs((prev) => ({ ...prev, [plan.id]: e.target.value }))
|
setActualInputs((prev) => ({ ...prev, [plan.id]: nextValue }))
|
||||||
}
|
}
|
||||||
placeholder={((plan.totalCents ?? 0) / 100).toFixed(2)}
|
placeholder={((plan.totalCents ?? 0) / 100).toFixed(2)}
|
||||||
/>
|
/>
|
||||||
@@ -799,7 +803,7 @@ const PlansSettings = forwardRef<PlansSettingsHandle, PlansSettingsProps>(
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs muted mt-2">
|
<div className="text-xs muted mt-2">
|
||||||
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.
|
||||||
</div>
|
</div>
|
||||||
{trueUpMessages[plan.id] ? (
|
{trueUpMessages[plan.id] ? (
|
||||||
<div className="text-xs mt-2">{trueUpMessages[plan.id]}</div>
|
<div className="text-xs mt-2">{trueUpMessages[plan.id]}</div>
|
||||||
@@ -958,7 +962,7 @@ function InlineEditMoney({
|
|||||||
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
|
useEffect(() => setV((cents / 100).toFixed(2)), [cents]);
|
||||||
|
|
||||||
const commit = () => {
|
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);
|
if (newCents !== cents) onChange(newCents);
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1355,6 +1355,14 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); }
|
|||||||
margin-top: 1.5rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-top-nav {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-top-nav .onboarding-actions {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.onboarding-btn-next {
|
.onboarding-btn-next {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0.875rem 2rem;
|
padding: 0.875rem 2rem;
|
||||||
@@ -1389,6 +1397,10 @@ ol > li.bg-\[--color-panel\] { color: var(--color-muted); }
|
|||||||
width: auto;
|
width: auto;
|
||||||
min-width: 100px;
|
min-width: 100px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.onboarding-top-nav .onboarding-actions {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sm: breakpoint utilities */
|
/* sm: breakpoint utilities */
|
||||||
@@ -1812,6 +1824,13 @@ html[data-theme="light"].scheme-orange body {
|
|||||||
min-width: 120px;
|
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 {
|
.settings-checkbox-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from "vite";
|
||||||
import react from '@vitejs/plugin-react'
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
react(),
|
const proxyTarget = env.VITE_PROXY_TARGET || "http://localhost:8081";
|
||||||
tailwindcss(),
|
|
||||||
|
|
||||||
],
|
return {
|
||||||
server: {
|
plugins: [react(), tailwindcss()],
|
||||||
proxy: {
|
server: {
|
||||||
"/api": {
|
proxy: {
|
||||||
target: "http://localhost:8080",
|
"/api": {
|
||||||
changeOrigin: true,
|
target: proxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ""),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
})
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user