diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..48ea01f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,48 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is inspired by [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/). + +[//]: # (## [Unreleased]) + +[//]: # () +[//]: # (### Added) + +[//]: # (- _No entries yet._) + +## [1.1.0] + +### Added +- Excel export for boards using `.xlsx` output. +- Super-board export support with multiple worksheets (one worksheet per sub-board). +- Optional summary worksheet in super-board exports. +- Safer Excel export sanitization for sheet names, file names, and formula-like text values. +- Explicit direct vs. inherited collaborator display in board collaboration management. + +### Changed +- Documentation refresh across README, security policy, and release notes for the `v1.1.0` release. +- Version alignment across frontend and Cloud Functions packages to `1.1.0`. + +### Fixed +- Export date handling and workbook formatting improvements in generated Excel files. +- Export-related CSP allowance updates for ExcelJS-hosted assets. +- Board/super-board export UI flow improvements and related stability fixes. + +### Security +- Security policy updated to supported-version policy for `1.1.x` and private reporting guidance. + +## [1.0.4] + +### Changed +- Dependency and maintenance updates before `1.1.0`. + +## [1.0.1] + +### Changed +- Early post-`1.0.0` maintenance updates. + +## [1.0.0] + +### Added +- Initial public release of the Expense Management application. diff --git a/README.md b/README.md index 6ed0e90..8fa9c11 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,14 @@ # Expense Management -Expense Management is a real-time collaborative web app for managing shared expenses in Hebrew (RTL). -It is designed for families, roommates, and partners who need one reliable place to track spending, collaborators, -and board hierarchies without exposing broad user data. +Expense Management is a Firebase + React web app for collaborative expense tracking, with a Hebrew RTL UI. +It supports shared boards, one-level board hierarchies ("super boards" with sub-boards), invites, and Excel export. -The project combines a React frontend with Firebase Authentication, Firestore, Hosting, and callable Cloud Functions. -Invite, collaborator, and account management flows are implemented server-side to preserve a least-privilege security model. - -๐ŸŒ **[Try the live app โ†’](https://of8-expense-management.web.app/)** +๐ŸŒ **Live app:** https://of8-expense-management.web.app/

License: MIT top-language + Release v1.1.0

react @@ -23,41 +20,84 @@ Invite, collaborator, and account management flows are implemented server-side t ## Table of Contents - [Overview](#overview) -- [Key Features](#key-features) -- [Tech Stack & Architecture](#tech-stack--architecture) +- [Features](#Features) +- [Boards vs. Super Boards](#boards-vs-super-boards) +- [Access Model (Direct vs. Inherited)](#access-model-direct-vs-inherited) +- [Invitation Flow](#invitation-flow) +- [Architecture](#architecture) - [Project Structure](#project-structure) -- [Getting Started](#getting-started) - - [Prerequisites](#prerequisites) - - [Installation](#installation) - - [Environment Configuration](#environment-configuration) - - [Run Locally](#run-locally) +- [Setup](#setup) +- [Development](#development) - [Deployment](#deployment) -- [Security Notes](#security-notes) +- [Security & Privacy Notes](#security--privacy-notes) +- [Release Status](#release-status) - [License](#license) ## Overview -The app centers around collaborative boards. Each board contains transactions, collaborators, and optional sub-boards. -Members see real-time updates, while ownership and membership operations are enforced by Firestore rules and Cloud Functions. +The app is designed for small groups (families, roommates, partners) who manage shared spending. +Each board has members, transactions, and optional hierarchy relationships. +Data is stored in Firestore and updates in real time. + +## Features + +- **Authentication:** Email/password and Google sign-in. +- **Boards:** Create, rename, delete, and view shared boards. +- **Super boards:** Group regular boards under one parent board (one-level hierarchy). +- **Invitations:** Board owners can invite by email; invitees can accept or decline. +- **Membership model:** + - `directMemberUids`: explicitly invited to a board. + - `memberUids`: effective access (direct + inherited from parent board). +- **Inherited access:** Membership flows **down** from a super board to its sub-boards. +- **Transactions:** Create, edit, and delete transactions on regular boards. +- **Amounts:** Positive and negative amounts are supported (useful for refunds/credits). +- **Future dates:** Optional `transactionDate` accepts valid `YYYY-MM-DD` dates, including future dates. +- **Excel export:** + - Regular board: single worksheet export. + - Super board: multi-sheet export (one sheet per sub-board) + optional summary sheet. +- **Account management:** Update nickname, sign out, and delete account (with server-side cleanup). + +## Boards vs. Super Boards + +- **Regular board:** A board without `subBoardIds`; it contains transactions directly. +- **Super board:** A board with one or more `subBoardIds`; it aggregates totals from sub-boards and does not show a transaction-entry view. +- **Sub-board:** A board with `parentBoardId` set. + +Hierarchy is intentionally **one level**: +- A board can be top-level, or a child of one parent board. +- A sub-board cannot itself have sub-boards. + +## Access Model (Direct vs. Inherited) + +This project uses two membership fields: + +- `directMemberUids` = users directly added to that board. +- `memberUids` = users with effective access to that board. + +Behavior: +- Direct membership on a super board grants inherited access to descendant sub-boards. +- Direct membership on a sub-board does **not** grant access to its parent. +- Removing a direct member cascades membership recalculation through descendants. -## Key Features +## Invitation Flow -- Firebase Auth (email/password + Google) -- Shared expense boards with real-time transaction updates -- Board hierarchy (parent/sub-board relationships) -- Installment-aware credit-card tracking -- Email-based invite flow (create/accept/decline/revoke) -- Owner/member management (remove member, leave board) -- Account deletion with server-side data cleanup -- Hebrew RTL interface with light/dark theme +1. Board owner sends an invite by email. +2. Invite document is created under `boards/{boardId}/invites`. +3. Invitee sees incoming invites and can accept/decline. +4. Accepting adds membership and deletes the invite. +5. Declining deletes the invite. -## Tech Stack & Architecture +Notes: +- Invites include `expiresAt` and are treated as pending while the document exists. +- Functions enforce ownership/auth checks before invite and membership mutations. -- **Frontend:** React 19, Vite 8, React Router 7, Tailwind CSS 4 -- **Backend:** Firebase Cloud Functions (Node.js 22) -- **Data/Auth:** Firestore + Firebase Authentication -- **Hosting:** Firebase Hosting -- **Security:** Firestore rules + App Check enforcement on callable functions +## Architecture + +- **Frontend:** React 19 + Vite 8 + React Router 7 + Tailwind CSS 4. +- **Backend:** Firebase Cloud Functions (Node.js 22 runtime). +- **Data/Auth:** Firestore + Firebase Authentication. +- **Hosting:** Firebase Hosting. +- **Security controls:** Firestore Security Rules, App Check integration in the client, and callable functions with `enforceAppCheck: true`. ## Project Structure @@ -75,15 +115,20 @@ ExpenseManagement/ โ””โ”€โ”€ .github/workflows/ # Deploy + CodeQL workflows ``` -## Getting Started +## Setup ### Prerequisites -- Node.js 22+ +- Node.js 22+ (recommended for local dev, including functions) - npm -- Firebase project with Authentication, Firestore, Hosting, and Functions enabled +- Firebase project with: + - Authentication + - Cloud Firestore + - Cloud Functions + - Hosting + - App Check (recommended/enabled for production) -### Installation +### Install ```bash git clone https://github.com/OrF8/ExpenseManagement.git @@ -92,15 +137,15 @@ npm ci npm --prefix functions ci ``` -### Environment Configuration +### Environment Variables -Create `.env` from `.env.example` and fill your Firebase values. +Copy `.env.example` to `.env`: ```bash cp .env.example .env ``` -Required frontend variables: +Required variables: - `VITE_FIREBASE_API_KEY` - `VITE_FIREBASE_AUTH_DOMAIN` @@ -110,63 +155,103 @@ Required frontend variables: - `VITE_FIREBASE_APP_ID` - `VITE_RECAPTCHA_V3_SITE_KEY` -#### Optional: Preview deployment configuration (`.env.preview`) -For preview deployments using the PowerShell script (`npm run deploy:preview -- -PrNumber pr_num`), -create a `.env.preview` file with the same Firebase variables as `.env`, -with two optional variables (if you want to enable App Check debug mode for the preview channel): -- `VITE_APPCHECK_DEBUG=true` -- `VITE_APPCHECK_DEBUG_TOKEN=your_app_check_debug_token_here` - -Another variable is `FIREBASE_PROJECT_ID`. This should be set to the same -project as `.env` if you want to deploy to a preview channel in the same project, -or it can be set to a different project ID if you want to deploy the preview channel to a separate Firebase project. - -This allows you to deploy a Firebase Hosting preview channel separately from your main environment. +Optional preview deployment file: ```bash cp .env.preview.example .env.preview ``` -This is useful for testing changes without affecting the live app. +Additional preview variables: + +- `VITE_APPCHECK_DEBUG` (optional) +- `VITE_APPCHECK_DEBUG_TOKEN` (optional) +- `FIREBASE_PROJECT_ID` (required for preview deploy script) + +## Development -### Run Locally +### Run locally ```bash npm run dev ``` -App URL: `http://localhost:5173` +Vite dev server: `http://localhost:5173` -Production build preview: +### Production build preview ```bash npm run build npm run preview ``` -App URL: `http://localhost:4173` +Preview server: `http://localhost:4173` + +### Linting + +- Frontend linting is configured via ESLint: + +```bash +npm run lint +``` + +- Functions package currently has a placeholder lint script (`Skipping lint`). ## Deployment -Main deployment is automated through GitHub Actions (`.github/workflows/deploy.yml`) using -**Google Workload Identity Federation** (OIDC) with a deploy service account. +### CI/CD (main branch) + +GitHub Actions workflow `.github/workflows/deploy.yml`: +- builds the frontend, +- deploys **Functions + Hosting** to Firebase, +- authenticates with Google via Workload Identity Federation (OIDC). -Typical manual deploy commands (**after** building with `npm run build`): +### Manual deploy ```bash -firebase deploy --only firestore:rules --project +npm run build firebase deploy --only functions,hosting --project ``` -The repo also includes a PowerShell preview script (`npm run deploy:preview -- -PrNumber pr_num`) that deploys functions and a hosting preview channel using `.env.preview`. +To deploy Firestore rules explicitly: + +```bash +firebase deploy --only firestore:rules --project +``` + +### Preview deployment script + +A PowerShell script is provided for preview channels: + +```bash +npm run deploy:preview -- -PrNumber +``` + +This script: +- loads `.env.preview`, +- deploys functions, +- builds with `--mode preview`, +- deploys a Firebase Hosting preview channel. + +## Security & Privacy Notes + +- Firestore rules restrict board reads/writes to authorized users and owners by role. +- Invite and membership mutations are handled through callable functions instead of broad client-side user reads. +- Callable functions are configured with App Check enforcement. +- Account deletion is handled server-side to remove owned data and membership links. +- This is a collaborative app: data shared to a board is visible to that boardโ€™s members. + +For vulnerability reporting, see [SECURITY.md](./SECURITY.md). + +## Release Status + +Current release target: **v1.1.0**. -## Security Notes +Notable release focus: +- hierarchy-aware collaboration, +- Excel export improvements (including super-board multi-sheet export), +- documentation refresh and version alignment. -- Firestore access is scoped to board membership and document ownership; `/users/{uid}` is owner-readable and owner-writable only. -- Invite creation, acceptance, declination, revocation, member removal, and account deletion are all implemented as callable Cloud Functions to ensure server-side validation and least-privilege access. -- Callable functions enforce App Check (`enforceAppCheck: true`), and Hosting serves strict security headers (including CSP, X-Frame-Options, and Referrer-Policy). -- App Check is also enforced on Firestore and authentication operations to prevent abuse from unauthorized clients. -- Invite queries use collection-group access constrained by authenticated email match in Firestore rules. +See [CHANGELOG.md](./CHANGELOG.md) for release notes. ## License diff --git a/SECURITY.md b/SECURITY.md index 5d6857a..c49ecf5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,53 +2,54 @@ ## Supported Versions -This project is currently in early development. Only the latest version is supported with security updates. +Security fixes are provided for the latest minor line only. -| Version | Supported | -| -------- | ------------------ | -| 1.0.x | :white_check_mark: | -| < 1.0.0 | :x: | +| Version | Supported | +| --- | --- | +| 1.1.x | โœ… | +| 1.0.x | โŒ | +| < 1.0.0 | โŒ | -We strongly recommend always using the latest version of the application. - ---- +If you run a self-hosted deployment, upgrade to the latest `1.1.x` patch release as soon as practical. ## Reporting a Vulnerability -If you discover a security vulnerability, please report it responsibly. +Please report vulnerabilities **privately**. -### ๐Ÿ“ฌ How to report -- Open a **private security advisory** on GitHub (preferred), or -- Contact the maintainer directly via GitHub, or -- Contact us via email [expensemanagementwebsite@gmail.com](mailto:expensemanagementwebsite@gmail.com) +### Preferred reporting channel + +Use GitHubโ€™s private vulnerability reporting for this repository: + +- Open a private report via the repositoryโ€™s **Security** tab ("Report a vulnerability"). +- If private reporting is not enabled in your view, open a GitHub Security Advisory draft for the repository maintainers. -Please include: -- A clear description of the vulnerability -- Steps to reproduce the issue -- Potential impact -- Suggested fixes (if available) +Do **not** open a public issue for suspected vulnerabilities. + +#### Other reporting channels + +- Contact the maintainer directly via GitHub +- Contact us via email [expensemanagementwebsite@gmail.com](mailto:expensemanagementwebsite@gmail.com) -### โฑ๏ธ Response timeline -- Initial response: within **48โ€“72 hours** -- Status update: within **5โ€“7 days** -### ๐Ÿ”’ Responsible disclosure -Please **do not publicly disclose** the vulnerability until it has been reviewed and addressed. +### What to include -### โœ… What to expect -- If the vulnerability is accepted: - - It will be fixed as soon as possible - - A patched version will be released -- If declined: - - You will receive an explanation +Please include as much detail as possible: ---- +- Affected area (frontend, Firestore rules, Cloud Functions, deployment config, etc.) +- Steps to reproduce +- Expected vs. actual behavior +- Impact assessment (confidentiality / integrity / availability) +- Proof-of-concept details, logs, or screenshots (if safe to share) +- Suggested remediation (optional) -## Additional Notes +### Disclosure expectations -This project uses: -- Firebase (Firestore, Authentication, Hosting) +- Please do not publicly disclose the issue before a fix is available. +- Maintainers will acknowledge reports and triage based on severity and reproducibility. +- Resolution timelines vary by complexity and maintainer availability. +- When a report is confirmed, the fix will be shipped in a supported release line. -While Firebase provides strong security features, proper configuration (e.g., Firestore rules, API restrictions) is critical. Misconfiguration may lead to vulnerabilities. +## Scope Notes -If your report relates to Firebase configuration, please include relevant details. +This project relies on Firebase services (Authentication, Firestore, Functions, Hosting). +Configuration issues can be security-sensitive; include relevant Firebase project and rule/function context in reports. diff --git a/functions/package-lock.json b/functions/package-lock.json index 3e45e73..71ee4ba 100644 --- a/functions/package-lock.json +++ b/functions/package-lock.json @@ -1,10 +1,12 @@ { "name": "expense-management-functions", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "expense-management-functions", + "version": "1.1.0", "dependencies": { "firebase-admin": "^13.7.0", "firebase-functions": "^7.2.3" diff --git a/functions/package.json b/functions/package.json index 42ae3ad..b5ea2a3 100644 --- a/functions/package.json +++ b/functions/package.json @@ -22,5 +22,5 @@ "eslint-config-google": "^0.14.0" }, "private": true, - "version": "1.0.4" + "version": "1.1.0" } diff --git a/package-lock.json b/package-lock.json index da458b2..2a633a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "expense-management", - "version": "1.0.4", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "expense-management", - "version": "1.0.4", + "version": "1.1.0", "dependencies": { "firebase": "^12.11.0", "react": "^19.2.4", @@ -1451,9 +1451,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1471,9 +1468,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1491,9 +1485,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1511,9 +1502,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1531,9 +1519,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1551,9 +1536,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 40bdfdc..954568b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "expense-management", "private": true, - "version": "1.0.4", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src/hooks/useBoards.js b/src/hooks/useBoards.js index 24da487..e700e2a 100644 --- a/src/hooks/useBoards.js +++ b/src/hooks/useBoards.js @@ -5,49 +5,71 @@ import { subscribeWithAppCheckRetry } from '../utils/appCheckRetry'; export function useBoards() { const { user } = useAuth(); - const [boards, setBoards] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [retryingSecureConnection, setRetryingSecureConnection] = useState(false); + const uid = user?.uid ?? null; + const [state, setState] = useState({ + boards: [], + error: null, + retryingSecureConnection: false, + forUid: null, + forUser: null, + retryingForUser: null, + }); useEffect(() => { - if (!user?.uid) { - setBoards([]); - setLoading(false); - setError(null); - setRetryingSecureConnection(false); - return; - } - - setLoading(true); - setError(null); - setRetryingSecureConnection(false); + if (!uid) return; const unsubscribe = subscribeWithAppCheckRetry( - (onData, onError) => subscribeToBoards(user.uid, onData, onError), + (onData, onError) => subscribeToBoards(uid, onData, onError), (data) => { - setBoards(data); - setError(null); - setRetryingSecureConnection(false); - setLoading(false); + setState({ + boards: data, + error: null, + retryingSecureConnection: false, + forUid: uid, + forUser: user, + retryingForUser: null, + }); }, (err) => { - setBoards([]); - setError(err?.message || 'ืฉื’ื™ืื” ื‘ื˜ืขื™ื ืช ื”ืœื•ื—ื•ืช'); - setRetryingSecureConnection(false); - setLoading(false); + setState({ + boards: [], + error: err?.message || 'ืฉื’ื™ืื” ื‘ื˜ืขื™ื ืช ื”ืœื•ื—ื•ืช', + retryingSecureConnection: false, + forUid: uid, + forUser: user, + retryingForUser: null, + }); }, { onRetryAttempt: () => { - setRetryingSecureConnection(true); - setLoading(true); - setError(null); + setState((prev) => ({ + ...prev, + retryingSecureConnection: true, + error: null, + retryingForUser: user, + })); }, }, ); return () => unsubscribe(); - }, [user?.uid]); + }, [uid, user]); + + if (!uid) { + return { + boards: [], + loading: false, + error: null, + retryingSecureConnection: false, + }; + } - return { boards, loading, error, retryingSecureConnection }; + const loading = state.forUid !== uid || state.forUser !== user; + return { + boards: loading ? [] : state.boards, + loading, + error: loading ? null : state.error, + retryingSecureConnection: + state.retryingSecureConnection && state.retryingForUser === user, + }; } diff --git a/src/hooks/useIncomingInvites.js b/src/hooks/useIncomingInvites.js index dbd4944..5e9c5ce 100644 --- a/src/hooks/useIncomingInvites.js +++ b/src/hooks/useIncomingInvites.js @@ -4,37 +4,52 @@ import { useAuth } from '../context/AuthContext'; export function useIncomingInvites() { const { user } = useAuth(); - const [invites, setInvites] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const email = user?.email ?? null; + const [state, setState] = useState({ + invites: [], + error: null, + forEmail: null, + forUser: null, + }); useEffect(() => { - if (!user?.email) { - setInvites([]); - setLoading(false); - setError(null); - return; - } - - setLoading(true); - setError(null); + if (!email) return; const unsubscribe = subscribeToIncomingInvites( - user.email, + email, (data) => { - setInvites(data); - setError(null); - setLoading(false); + setState({ + invites: data, + error: null, + forEmail: email, + forUser: user, + }); }, (err) => { - setInvites([]); - setError(err?.message || 'ืฉื’ื™ืื” ื‘ื˜ืขื™ื ืช ื”ื”ื–ืžื ื•ืช'); - setLoading(false); - } + setState({ + invites: [], + error: err?.message || 'ืฉื’ื™ืื” ื‘ื˜ืขื™ื ืช ื”ื”ื–ืžื ื•ืช', + forEmail: email, + forUser: user, + }); + }, ); return () => unsubscribe(); - }, [user?.email]); + }, [email, user]); + + if (!email) { + return { + invites: [], + loading: false, + error: null, + }; + } - return { invites, loading, error }; + const loading = state.forEmail !== email || state.forUser !== user; + return { + invites: loading ? [] : state.invites, + loading, + error: loading ? null : state.error, + }; } diff --git a/src/pages/BoardPage.jsx b/src/pages/BoardPage.jsx index 2121912..88ee808 100644 --- a/src/pages/BoardPage.jsx +++ b/src/pages/BoardPage.jsx @@ -271,7 +271,7 @@ export function BoardPage() { b.id !== boardId && isMergeValid(boardId, b.id, allBoards), ); - }, [isOwner, board, allBoards, boardId, user?.uid]); + }, [isOwner, board, isSuperBoard, isSubBoard, allBoards, boardId, user?.uid]); async function handleMoveUnder(parentId) { const parent = allBoards.find((b) => b.id === parentId);