Skip to content

feat: consolidated auth + session management + multi-family isolation - #9

Merged
isaJrKai merged 2 commits into
mainfrom
fix/consolidated-auth-isolation
Jul 8, 2026
Merged

feat: consolidated auth + session management + multi-family isolation#9
isaJrKai merged 2 commits into
mainfrom
fix/consolidated-auth-isolation

Conversation

@isaJrKai

@isaJrKai isaJrKai commented Jul 2, 2026

Copy link
Copy Markdown
Owner

What this does

Combines 4 critical fixes into one clean PR:

  1. JWT_SECRET hardening — removes hardcoded fallback, throws if missing
  2. Auth gates on 4 API routes — achievements, lessons, complete, recommendations
  3. Server-side session management — sessionId in JWT, DB-backed sessions, proper logout, multi-device
  4. Multi-family isolation — familyId on all financial models, all queries scoped

⚠️ MIGRATION REQUIRED

Run prisma/migrations/20260625020000_consolidated_auth_isolation.sql on Neon BEFORE merging.

⚠️ All sessions invalidated

All existing JWTs are auto-rejected (no sessionId). Every user must log in again.

After merge

  • Founder sees only their family data
  • New signups get empty dashboard (own family)
  • Logout actually works (session revoked server-side)
  • Multi-device sessions are independent

Supersedes PRs #1, #2, #6, #7

Close those after merging this.

Verification

  • npx tsc --noEmit — clean

Summary by CodeRabbit

  • New Features

    • Added per-family isolation across core app data (children, finances, goals, and settings).
    • Added server-backed session tracking, including a new admin endpoint to list and revoke sessions.
    • Updated login flow to redirect authenticated users safely, and refreshed the dashboard Overview layout with a responsive bento grid.
  • Bug Fixes

    • Hardened authentication by requiring a valid authenticated session (and family context) for state, mutations, lessons completion, and recommendations.
    • Improved session handling in login/logout/security flows, including session ID–aware token signing and logout.

Combines 4 PRs:
1. JWT_SECRET hardening
2. Auth gates on 4 API routes
3. Server-side session management
4. Multi-family isolation

BREAKING: All existing JWTs invalidated. Migration required before merge.
@coderabbitai

coderabbitai Bot commented Jul 2, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds family-scoped data isolation, DB-backed session records with revocation support, auth checks across API routes, and a new bento-grid layout for the dashboard overview.

Changes

Family isolation and session security

Layer / File(s) Summary
Schema and migration for familyId and session revocation
prisma/schema.prisma, prisma/migrations/20260625020000_consolidated_auth_isolation.sql
Adds familyId fields and indexes across core models, adds Session.revokedAt and Session.lastSeenAt, backfills and enforces familyId, and migrates FamilySettings to per-family rows.
Family-scoped query and mutation functions
src/lib/db-queries.ts
Threads familyId through state hydration and write APIs, persists it on new rows, and adds family ownership checks for selected updates and deletes.
Core session lifecycle and JWT hardening
src/lib/auth.ts, src/lib/auth-edge.ts, src/app/api/admin/security/route.ts
Requires a strict JWT secret, adds sessionId to session payloads, adds familyId to authenticated users, creates and validates DB-backed sessions, and updates email/session handling paths.
API routes and login page gated by auth/familyId
src/app/api/admin/sessions/route.ts, src/app/api/auth/child-login/route.ts, src/app/api/auth/logout/route.ts, src/app/api/child/state/route.ts, src/app/api/lessons/complete/route.ts, src/app/api/mutations/route.ts, src/app/api/recommendations/route.ts, src/app/api/state/route.ts, src/app/login/page.tsx
Adds auth and family checks, session creation and revocation endpoints, family-scoped mutation/state access, and authenticated login-page redirects.
Overview dashboard bento grid
src/app/DashboardClient.tsx
Reworks OverviewTab data derivation and replaces the prior layout with a responsive bento-grid view and updated styling.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant auth_ts
  participant Database

  Client->>auth_ts: login or founder setup
  auth_ts->>Database: create session record
  Database-->>auth_ts: sessionId
  auth_ts->>auth_ts: signSession(payload with sessionId)
  auth_ts-->>Client: set session cookie

  Client->>auth_ts: authenticated request
  auth_ts->>Database: validate session and load user
  Database-->>auth_ts: session valid
  auth_ts-->>Client: AuthUser with familyId
Loading
sequenceDiagram
  participant Founder
  participant AdminSessionsRoute
  participant auth_ts
  participant Database

  Founder->>AdminSessionsRoute: GET or POST /api/admin/sessions
  AdminSessionsRoute->>auth_ts: getAuthUser()
  auth_ts-->>AdminSessionsRoute: FOUNDER user
  AdminSessionsRoute->>Database: list or revoke sessions
  Database-->>AdminSessionsRoute: sessions or revoked count
  AdminSessionsRoute-->>Founder: JSON response
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main changes: auth/session management and multi-family isolation.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/consolidated-auth-isolation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
src/lib/db-queries.ts (2)

372-382: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Scope updateGoal by familyId familyId is ignored here, so a caller who knows a goal ID can patch another family’s goal. Use where: { id, familyId } to match deleteGoal and block the IDOR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/db-queries.ts` around lines 372 - 382, The updateGoal function is
ignoring familyId, which allows updating a goal by id alone and can cross family
boundaries. Update the db.goal.update call in updateGoal so the where clause
includes both id and familyId, matching the scoping used by deleteGoal. Keep the
patch behavior the same, but ensure the lookup is restricted to the caller’s
family context.

255-296: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Verify childId belongs to familyId before mutating child data.

giveTokens, redeemTokens, addTransaction, and investNow accept familyId but never confirm that childId belongs to that family. They write rows under the caller’s familyId and update child by id only, so a mismatched childId can affect another family’s balances and mislabel records. Add a findFirst({ where: { id: childId, familyId } }) guard before these writes, like setChildName/setChildPhoto.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/db-queries.ts` around lines 255 - 296, The token mutation helpers are
missing a family ownership check, so `giveTokens`, `redeemTokens`,
`addTransaction`, and `investNow` can write records for one `familyId` while
updating a `child` from another family. Add a guard in each of these functions
using `findFirst({ where: { id: childId, familyId } })` before any ledger,
transaction, or child updates, and stop with an error if no matching child is
found. Use the same pattern already used by `setChildName` and `setChildPhoto`
so the `childId`/`familyId` pair is verified consistently.
src/app/api/recommendations/route.ts (1)

8-21: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope recommendations to the caller’s family
familyId still comes from the query string, forParent ignores it, and forChild accepts any childId without checking ownership. Any authenticated user can read another family’s recommendations. Use user.familyId and reject child IDs outside that family before calling the service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/recommendations/route.ts` around lines 8 - 21, The GET handler in
recommendations/route.ts is not enforcing family scoping, so an authenticated
user can query another family’s data. Update GET to use the authenticated user’s
familyId instead of trusting the familyId query param, and validate any childId
belongs to that same family before calling RecommendationService.forChild or
RecommendationService.forParent. If the child is not in user.familyId, return an
authorization/validation error rather than fetching recommendations.
src/app/api/lessons/complete/route.ts (1)

17-28: 🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authorize lesson completion by the authenticated child, not caller-supplied childId.
getAuthUser() only proves the caller is signed in; input.childId is still arbitrary and is passed straight into EducationService.completeLesson, which writes quiz attempts and progress for that userId. Resolve the child from the authenticated user or enforce a family/ownership check before calling the service.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/lessons/complete/route.ts` around lines 17 - 28, The lesson
completion route is trusting caller-supplied childId from the request body
instead of the authenticated user, which allows writing progress for an
arbitrary child. Update the handler in the complete lesson route to derive the
target child from getAuthUser() or validate ownership/family membership before
calling EducationService.completeLesson, and keep the service call tied to that
authenticated child identity rather than input.childId.
🧹 Nitpick comments (2)
prisma/schema.prisma (1)

90-93: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider composite indexes for family-scoped ordered reads.

getFullState queries these tables with where: { familyId } and orderBy: { timestamp: "desc" } (and createdAt for goals). Separate single-column indexes on familyId and timestamp can't serve both the filter and the sort optimally; a composite index does.

Applies equally to SpendingEntry, TokenLedgerEntry (familyId+timestamp) and Goal (familyId+createdAt).

♻️ Composite index for Transaction
-  @@index([familyId])
   @@index([childId])
-  @@index([timestamp])
+  @@index([familyId, timestamp])
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/schema.prisma` around lines 90 - 93, The current single-column indexes
on the Prisma models do not optimize family-scoped ordered reads in
getFullState. Update the relevant model definitions in the schema to add
composite indexes that match the query patterns: pair familyId with timestamp
for SpendingEntry and TokenLedgerEntry, and pair familyId with createdAt for
Goal. Keep the existing unique identifiers in the schema model blocks so the
indexes are easy to locate and maintain.
src/app/login/page.tsx (1)

10-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate next/safeNext derivation.

The searchParams await and safeNext computation are repeated at Lines 10-11 and Lines 14-15. Extract once above the if (user) block to keep the validation single-sourced (and to avoid fixing the backslash check in only one place).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/login/page.tsx` around lines 10 - 15, The `next` and `safeNext`
derivation in the login page is duplicated around the `user` redirect logic, so
the validation is split across two places. Move the `searchParams` await and
`safeNext` computation in `page.tsx` into a single shared block before the `if
(user)` check, then reuse that value for the `redirect` call in the login flow
to keep the safety check single-sourced.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@prisma/migrations/20260625020000_consolidated_auth_isolation.sql`:
- Around line 26-52: The migration backfill in the DO block for founder_fam can
be skipped when no FOUNDER user exists, but the subsequent ALTER TABLE ... SET
NOT NULL statements for Child, ParentProfile, Account, Transaction,
SpendingCategory, SpendingEntry, Investment, TokenLedgerEntry, and Goal still
run unconditionally. Update the migration logic around the founder_fam lookup to
either fail fast with a clear error when no founder is found, or gate the NOT
NULL step behind a successful backfill path so the migration does not partially
apply. Use the existing DO $$ block and the Step 4 ALTER TABLE statements as the
main place to adjust this flow.
- Around line 84-85: The singleton delete in the migration is unconditional and
can remove the only FamilySettings row when no founder family exists. Update the
consolidated_auth_isolation migration so the delete is performed only after a
successful founder-family insert, by moving it into the IF founder_fam IS NOT
NULL block or otherwise gating it alongside the founder branch in the migration
logic.

In `@src/app/api/admin/sessions/route.ts`:
- Around line 20-24: The SESSION_REVOKE_ALL branch in the sessions route
currently calls revokeAllUserSessions(user.id) without preserving the caller’s
active session, so “revoke all” signs the requester out too. Update the handler
in the route’s all=true path to pass the current session id as exceptSessionId
to revokeAllUserSessions, using the existing session context available in this
handler, so the action becomes “sign out other devices” while keeping the
current login intact.

In `@src/app/login/page.tsx`:
- Around line 9-13: The safeNext validation in the login page still allows
backslash-prefixed paths like /\evil.com, which can be interpreted as an
external redirect. Update the redirect guard in the logic around searchParams,
safeNext, and redirect() to reject any path containing backslashes as well as
double slashes, so only true same-site relative paths are allowed. Use the
existing safeNext computation in page.tsx as the place to tighten the check.

In `@src/lib/auth-edge.ts`:
- Around line 9-19: getSessionFromCookieHeader currently only verifies the JWT,
so revoked but still-valid cookies continue to authenticate at the edge. Update
this edge-path logic to include a revocation/version check that can be enforced
before returning a SessionPayload, or otherwise shorten the JWT lifetime so
revocation is reflected quickly. Use the getSessionFromCookieHeader and
SessionPayload flow as the place to locate and apply the change, keeping
getAuthUser unchanged.

In `@src/lib/auth.ts`:
- Line 487: The audit payload in auth.ts is recording `backupCodesRemaining` as
a boolean instead of the remaining count, so update the 2FA success path in the
`auditLog` call to use the actual number of backup codes left after
`usedBackupIndex` is removed; if the field is meant to be boolean, rename it to
match the intent. Use the surrounding 2FA logic and the `auditLog` call to
locate the fix, and keep `LOGIN_2FA_BACKUP_SUCCESS`/`LOGIN_2FA_SUCCESS` behavior
unchanged.
- Around line 497-506: The logout flow in auth.ts incorrectly gates revocation
behind params.sessionId, so a revokeAll request with only params.userId skips
session revocation and only clears the cookie. Update logout to handle the
revokeAll path independently of sessionId, using revokeAllUserSessions when
revokeAll and userId are present, and keep revokeSession as the fallback for
single-session logout; make sure the existing auditLog and clearSessionCookie
behavior still run as appropriate.

In `@src/lib/db-queries.ts`:
- Around line 390-396: `contributeToGoal` currently ignores `familyId`, so the
atomic increment can target a goal from another family. Update the
`db.goal.update` call to scope by both `id` and `familyId`, matching the same
ownership check used in `updateGoal` (or add a `findFirst`/verify step before
updating). Keep the increment behavior, but ensure only the caller’s family goal
can be modified.

---

Outside diff comments:
In `@src/app/api/lessons/complete/route.ts`:
- Around line 17-28: The lesson completion route is trusting caller-supplied
childId from the request body instead of the authenticated user, which allows
writing progress for an arbitrary child. Update the handler in the complete
lesson route to derive the target child from getAuthUser() or validate
ownership/family membership before calling EducationService.completeLesson, and
keep the service call tied to that authenticated child identity rather than
input.childId.

In `@src/app/api/recommendations/route.ts`:
- Around line 8-21: The GET handler in recommendations/route.ts is not enforcing
family scoping, so an authenticated user can query another family’s data. Update
GET to use the authenticated user’s familyId instead of trusting the familyId
query param, and validate any childId belongs to that same family before calling
RecommendationService.forChild or RecommendationService.forParent. If the child
is not in user.familyId, return an authorization/validation error rather than
fetching recommendations.

In `@src/lib/db-queries.ts`:
- Around line 372-382: The updateGoal function is ignoring familyId, which
allows updating a goal by id alone and can cross family boundaries. Update the
db.goal.update call in updateGoal so the where clause includes both id and
familyId, matching the scoping used by deleteGoal. Keep the patch behavior the
same, but ensure the lookup is restricted to the caller’s family context.
- Around line 255-296: The token mutation helpers are missing a family ownership
check, so `giveTokens`, `redeemTokens`, `addTransaction`, and `investNow` can
write records for one `familyId` while updating a `child` from another family.
Add a guard in each of these functions using `findFirst({ where: { id: childId,
familyId } })` before any ledger, transaction, or child updates, and stop with
an error if no matching child is found. Use the same pattern already used by
`setChildName` and `setChildPhoto` so the `childId`/`familyId` pair is verified
consistently.

---

Nitpick comments:
In `@prisma/schema.prisma`:
- Around line 90-93: The current single-column indexes on the Prisma models do
not optimize family-scoped ordered reads in getFullState. Update the relevant
model definitions in the schema to add composite indexes that match the query
patterns: pair familyId with timestamp for SpendingEntry and TokenLedgerEntry,
and pair familyId with createdAt for Goal. Keep the existing unique identifiers
in the schema model blocks so the indexes are easy to locate and maintain.

In `@src/app/login/page.tsx`:
- Around line 10-15: The `next` and `safeNext` derivation in the login page is
duplicated around the `user` redirect logic, so the validation is split across
two places. Move the `searchParams` await and `safeNext` computation in
`page.tsx` into a single shared block before the `if (user)` check, then reuse
that value for the `redirect` call in the login flow to keep the safety check
single-sourced.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 314f1d28-1940-4f00-b0d9-3cd454634146

📥 Commits

Reviewing files that changed from the base of the PR and between 7053878 and 9cf13ce.

📒 Files selected for processing (15)
  • prisma/migrations/20260625020000_consolidated_auth_isolation.sql
  • prisma/schema.prisma
  • src/app/api/admin/security/route.ts
  • src/app/api/admin/sessions/route.ts
  • src/app/api/auth/child-login/route.ts
  • src/app/api/auth/logout/route.ts
  • src/app/api/child/state/route.ts
  • src/app/api/lessons/complete/route.ts
  • src/app/api/mutations/route.ts
  • src/app/api/recommendations/route.ts
  • src/app/api/state/route.ts
  • src/app/login/page.tsx
  • src/lib/auth-edge.ts
  • src/lib/auth.ts
  • src/lib/db-queries.ts

Comment on lines +26 to +52
DO $$
DECLARE founder_fam TEXT;
BEGIN
SELECT "familyId" INTO founder_fam FROM "User" WHERE "platformRole" = 'FOUNDER' LIMIT 1;
IF founder_fam IS NOT NULL THEN
UPDATE "Child" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "ParentProfile" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "Account" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "Transaction" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "SpendingCategory" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "SpendingEntry" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "Investment" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "TokenLedgerEntry" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
UPDATE "Goal" SET "familyId" = founder_fam WHERE "familyId" IS NULL;
END IF;
END $$;

-- Step 4: Make familyId NOT NULL
ALTER TABLE "Child" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "ParentProfile" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Account" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Transaction" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "SpendingCategory" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "SpendingEntry" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Investment" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "TokenLedgerEntry" ALTER COLUMN "familyId" SET NOT NULL;
ALTER TABLE "Goal" ALTER COLUMN "familyId" SET NOT NULL;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Migration silently corrupts/fails when no FOUNDER exists.

The whole backfill (Steps 3 and 6) is gated on founder_fam IS NOT NULL, but Step 4 (SET NOT NULL, lines 44-52) is unconditional. If there's no FOUNDER user, backfill is skipped and every SET NOT NULL aborts on the still-NULL rows, leaving the migration partially applied (columns added, indexes/constraints not).

Guard Step 4 on successful backfill, or fail fast with a clear error if no founder is found, so the DB isn't left in a half-migrated state.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/migrations/20260625020000_consolidated_auth_isolation.sql` around
lines 26 - 52, The migration backfill in the DO block for founder_fam can be
skipped when no FOUNDER user exists, but the subsequent ALTER TABLE ... SET NOT
NULL statements for Child, ParentProfile, Account, Transaction,
SpendingCategory, SpendingEntry, Investment, TokenLedgerEntry, and Goal still
run unconditionally. Update the migration logic around the founder_fam lookup to
either fail fast with a clear error when no founder is found, or gate the NOT
NULL step behind a successful backfill path so the migration does not partially
apply. Use the existing DO $$ block and the Step 4 ALTER TABLE statements as the
main place to adjust this flow.

Comment on lines +84 to +85
-- Delete old singleton row
DELETE FROM "FamilySettings" WHERE "id" = 'singleton';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP "platformRole|FOUNDER|familyId" prisma/schema.prisma
rg -nP "SET NOT NULL|singleton|founder_fam" prisma/migrations/20260625020000_consolidated_auth_isolation.sql

Repository: isaJrKai/planned

Length of output: 3179


🏁 Script executed:

#!/bin/bash
sed -n '1,120p' prisma/migrations/20260625020000_consolidated_auth_isolation.sql | cat -n

Repository: isaJrKai/planned

Length of output: 5317


🏁 Script executed:

#!/bin/bash
sed -n '180,230p' prisma/schema.prisma | cat -n
rg -n "FOUNDER|platformRole|familyId" prisma -g '!prisma/migrations/20260625020000_consolidated_auth_isolation.sql'

Repository: isaJrKai/planned

Length of output: 3706


Guard the singleton delete on the founder family branch. User.familyId is nullable, so if no FOUNDER row exists this block is skipped, but the final DELETE FROM "FamilySettings" WHERE "id" = 'singleton' still runs and removes the only settings row. Move the delete inside the IF founder_fam IS NOT NULL block, or tie it to a successful insert.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/migrations/20260625020000_consolidated_auth_isolation.sql` around
lines 84 - 85, The singleton delete in the migration is unconditional and can
remove the only FamilySettings row when no founder family exists. Update the
consolidated_auth_isolation migration so the delete is performed only after a
successful founder-family insert, by moving it into the IF founder_fam IS NOT
NULL block or otherwise gating it alongside the founder branch in the migration
logic.

Comment on lines +20 to +24
if (all === true) {
const count = await revokeAllUserSessions(user.id);
await auditLog({ userId: user.id, action: "SESSION_REVOKE_ALL", entityType: "user", entityId: user.id, ipAddress: ip, userAgent: ua, success: true, after: { count } });
return NextResponse.json({ ok: true, revoked: count });
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

all: true revokes the caller's current session as well.

revokeAllUserSessions(user.id) is called without exceptSessionId, so the founder issuing "revoke all" is logged out of their own current session too. If the intent is "sign out other devices," pass the current session id as the exception; otherwise this is fine but worth confirming the UX expectation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/admin/sessions/route.ts` around lines 20 - 24, The
SESSION_REVOKE_ALL branch in the sessions route currently calls
revokeAllUserSessions(user.id) without preserving the caller’s active session,
so “revoke all” signs the requester out too. Update the handler in the route’s
all=true path to pass the current session id as exceptSessionId to
revokeAllUserSessions, using the existing session context available in this
handler, so the action becomes “sign out other devices” while keeping the
current login intact.

Comment thread src/app/login/page.tsx
Comment on lines +9 to +13
if (user) {
const { next } = await searchParams;
const safeNext = next && next.startsWith("/") && !next.startsWith("//") ? next : "/";
redirect(user.familyRole === "CHILD" ? "/child" : safeNext);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

browser Location header backslash normalization open redirect bypass

💡 Result:

The browser backslash-normalization open redirect bypass is a vulnerability that occurs due to a discrepancy between how server-side URL validators and web browsers parse URLs containing backslashes () [1][2]. The vulnerability arises because the WHATWG URL Standard requires that, in special URL schemes like http and https, backslashes be treated as equivalent to forward slashes (path separators) [3][4]. Browsers strictly follow this standard, automatically normalizing backslashes to forward slashes before navigating [3][1]. However, many server-side URL validation libraries (such as those compliant with RFC 3986 or those that do not account for WHATWG rules) do not treat backslashes as path separators [1][4][5]. An attacker can exploit this by crafting a URL that bypasses server-side safety checks but is interpreted as a malicious redirect by the browser [1][4]. For example: 1. An application validates that a redirect URL is relative by checking if it starts with a single forward slash (e.g., /) and not two (e.g., //) [6][4]. 2. An attacker provides a URL like /\evil.com [6][4]. 3. The server-side validator sees this as a valid, same-origin path because it starts with a single / and does not recognize the \ as a directory separator [4]. 4. When the browser receives the resulting Location header (Location: /\evil.com), it normalizes the \ to a / [3][6]. 5. The browser interprets the resulting //evil.com as a protocol-relative absolute URL and redirects the user to the attacker-controlled domain [4][2]. This issue commonly affects OAuth implementations, login/logout redirect flows, and any application that uses custom URL parsing to enforce "same-origin" or "relative" redirect policies [6][1][4][7]. To mitigate this vulnerability, developers must ensure that their server-side URL validation logic mirrors the browser's behavior by explicitly normalizing or rejecting backslashes in redirect paths [6][4][8]. Common fixes include: - Rejecting any redirect URL containing a backslash character (or its encoded forms, like %5c) [4][7][2]. - Using URL parsing libraries that are compliant with the WHATWG URL Standard [1][5]. - Sanitizing leading slashes/backslashes to ensure they cannot form unintended protocol-relative URLs (e.g., replacing all leading [/\]+ with a single /) [6]. Top results: [3], [6], [1], [4]

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the target file with line numbers.
sed -n '1,120p' src/app/login/page.tsx | cat -n

# Find other usages of the same parameter or redirect sanitization.
rg -n "searchParams.*next|next\.startsWith|redirect\(" src/app -g '!**/*.map' || true

Repository: isaJrKai/planned

Length of output: 2631


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,120p' src/app/login/page.tsx | cat -n
rg -n "searchParams.*next|next\.startsWith|redirect\(" src/app -g '!**/*.map' || true

Repository: isaJrKai/planned

Length of output: 2631


Reject backslashes in safeNext
/\evil.com still passes startsWith("/") && !startsWith("//"), but browsers can treat it as //evil.com and leave the site via redirect(). Reject \ alongside // (for example ^\/(?![\\/])).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/login/page.tsx` around lines 9 - 13, The safeNext validation in the
login page still allows backslash-prefixed paths like /\evil.com, which can be
interpreted as an external redirect. Update the redirect guard in the logic
around searchParams, safeNext, and redirect() to reject any path containing
backslashes as well as double slashes, so only true same-site relative paths are
allowed. Use the existing safeNext computation in page.tsx as the place to
tighten the check.

Comment thread src/lib/auth-edge.ts
Comment on lines 9 to 19
export async function getSessionFromCookieHeader(cookieHeader: string | null | undefined): Promise<SessionPayload | null> {
if (!cookieHeader) return null;
const cookies = Object.fromEntries(cookieHeader.split(";").map((c) => c.trim().split("=")).filter(([k]) => k).map(([k, ...rest]) => [k, decodeURIComponent(rest.join("="))]));
const token = cookies[SESSION_COOKIE];
if (!token) return null;
try {
const { payload } = await jwtVerify(token, JWT_SECRET, { algorithms: ["HS256"] });
if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.platformRole !== "string") return null;
return { sub: payload.sub, email: payload.email, platformRole: payload.platformRole as string, familyRole: (payload.familyRole as string) || "PARENT", iat: payload.iat, exp: payload.exp };
if (typeof payload.sub !== "string" || typeof payload.email !== "string" || typeof payload.platformRole !== "string" || typeof payload.sessionId !== "string") return null;
return { sub: payload.sub, email: payload.email, platformRole: payload.platformRole as string, familyRole: (payload.familyRole as string) || "PARENT", sessionId: payload.sessionId as string, iat: payload.iat, exp: payload.exp };
} catch { return null; }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find middleware and edge consumers of getSessionFromCookieHeader
rg -nP -C3 'getSessionFromCookieHeader' --type=ts
fd -i 'middleware.ts'

Repository: isaJrKai/planned

Length of output: 154


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## files\n'
git ls-files 'src/lib/auth-edge.ts' 'src/**/middleware.ts' 'middleware.ts' | sed 's#^`#-` #'

printf '\n## auth-edge references\n'
rg -n --hidden --glob '!node_modules' 'getSessionFromCookieHeader' src . || true

printf '\n## session validation references\n'
rg -n --hidden --glob '!node_modules' 'isSessionValid|getAuthUser|Session table|revoked' src || true

Repository: isaJrKai/planned

Length of output: 6406


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '## outline auth-edge\n'
ast-grep outline src/lib/auth-edge.ts --view expanded || true

printf '\n## read auth-edge\n'
wc -l src/lib/auth-edge.ts
sed -n '1,220p' src/lib/auth-edge.ts

Repository: isaJrKai/planned

Length of output: 2274


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate any edge or middleware entrypoints and inspect their imports/usages.
fd -a 'middleware.ts' .
printf '\n## possible edge entrypoints\n'
git ls-files | rg '(^|/)(middleware|edge|auth-edge)\.(ts|js|tsx|jsx)$' || true

printf '\n## direct import chain for auth-edge\n'
rg -n --hidden --glob '!node_modules' 'from .*/lib/auth-edge|from .*auth-edge|getSessionFromCookieHeader' src . || true

Repository: isaJrKai/planned

Length of output: 2197


🏁 Script executed:

#!/bin/bash
set -euo pipefail

wc -l src/middleware.ts
sed -n '1,220p' src/middleware.ts

Repository: isaJrKai/planned

Length of output: 5093


Edge middleware still bypasses session revocation. getSessionFromCookieHeader() only validates the JWT, so revoked but unexpired tokens still pass the /api/* and browser-route guards here. getAuthUser() is safe, but the edge layer cannot be the revocation boundary; add an edge-accessible revocation/version check or shorten the JWT lifetime.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth-edge.ts` around lines 9 - 19, getSessionFromCookieHeader
currently only verifies the JWT, so revoked but still-valid cookies continue to
authenticate at the edge. Update this edge-path logic to include a
revocation/version check that can be enforced before returning a SessionPayload,
or otherwise shorten the JWT lifetime so revocation is reflected quickly. Use
the getSessionFromCookieHeader and SessionPayload flow as the place to locate
and apply the change, keeping getAuthUser unchanged.

Comment thread src/lib/auth.ts
await setSessionCookie(token);
await auditLog({ userId: user.id, action: usedBackupIndex !== null ? "LOGIN_2FA_BACKUP_SUCCESS" : "LOGIN_2FA_SUCCESS", entityType: "user", entityId: user.id, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true, after: { backupCodesRemaining: usedBackupIndex !== null } });
return { ok: true, user: { id: user.id, email: user.email, platformRole: user.platformRole, familyRole: user.familyRole, name: user.name, twoFactorEnabled: user.twoFactorEnabled } };
await auditLog({ userId: user.id, action: usedBackupIndex !== null ? "LOGIN_2FA_BACKUP_SUCCESS" : "LOGIN_2FA_SUCCESS", entityType: "user", entityId: user.id, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true, after: { backupCodesRemaining: usedBackupIndex !== null, sessionId: sessionRecord.id } });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

backupCodesRemaining logs a boolean, not a count.

usedBackupIndex !== null is truthy whenever a backup code was used, so this audit field records true/false rather than the remaining backup-code count. The remaining count is computable after splicing (see the 2FA path around Line 471-473). Consider logging the actual remaining number (or rename the field) so security forensics stay accurate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth.ts` at line 487, The audit payload in auth.ts is recording
`backupCodesRemaining` as a boolean instead of the remaining count, so update
the 2FA success path in the `auditLog` call to use the actual number of backup
codes left after `usedBackupIndex` is removed; if the field is meant to be
boolean, rename it to match the intent. Use the surrounding 2FA logic and the
`auditLog` call to locate the fix, and keep
`LOGIN_2FA_BACKUP_SUCCESS`/`LOGIN_2FA_SUCCESS` behavior unchanged.

Comment thread src/lib/auth.ts
Comment on lines +497 to 506
export async function logout(params?: { userId?: string; sessionId?: string; ipAddress?: string; userAgent?: string; revokeAll?: boolean }): Promise<void> {
if (params?.sessionId) {
if (params.revokeAll && params.userId) await revokeAllUserSessions(params.userId);
else await revokeSession(params.sessionId);
}
await clearSessionCookie();
if (params?.userId) {
await auditLog({ userId: params.userId, action: "LOGOUT", entityType: "user", entityId: params.userId, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true });
await auditLog({ userId: params.userId, action: "LOGOUT", entityType: "user", entityId: params.userId, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true, after: { sessionId: params.sessionId } });
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

revokeAll is gated behind sessionId presence.

Revocation only runs inside if (params?.sessionId), so a caller that requests revokeAll with userId but no sessionId clears the cookie without revoking any DB sessions. If "revoke all my sessions" can be invoked independently of the current sessionId, this leaves other devices' sessions active.

🔒 Proposed guard
-  if (params?.sessionId) {
-    if (params.revokeAll && params.userId) await revokeAllUserSessions(params.userId);
-    else await revokeSession(params.sessionId);
-  }
+  if (params?.revokeAll && params.userId) {
+    await revokeAllUserSessions(params.userId);
+  } else if (params?.sessionId) {
+    await revokeSession(params.sessionId);
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function logout(params?: { userId?: string; sessionId?: string; ipAddress?: string; userAgent?: string; revokeAll?: boolean }): Promise<void> {
if (params?.sessionId) {
if (params.revokeAll && params.userId) await revokeAllUserSessions(params.userId);
else await revokeSession(params.sessionId);
}
await clearSessionCookie();
if (params?.userId) {
await auditLog({ userId: params.userId, action: "LOGOUT", entityType: "user", entityId: params.userId, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true });
await auditLog({ userId: params.userId, action: "LOGOUT", entityType: "user", entityId: params.userId, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true, after: { sessionId: params.sessionId } });
}
}
export async function logout(params?: { userId?: string; sessionId?: string; ipAddress?: string; userAgent?: string; revokeAll?: boolean }): Promise<void> {
if (params?.revokeAll && params.userId) {
await revokeAllUserSessions(params.userId);
} else if (params?.sessionId) {
await revokeSession(params.sessionId);
}
await clearSessionCookie();
if (params?.userId) {
await auditLog({ userId: params.userId, action: "LOGOUT", entityType: "user", entityId: params.userId, ipAddress: params.ipAddress, userAgent: params.userAgent, success: true, after: { sessionId: params.sessionId } });
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth.ts` around lines 497 - 506, The logout flow in auth.ts
incorrectly gates revocation behind params.sessionId, so a revokeAll request
with only params.userId skips session revocation and only clears the cookie.
Update logout to handle the revokeAll path independently of sessionId, using
revokeAllUserSessions when revokeAll and userId are present, and keep
revokeSession as the fallback for single-session logout; make sure the existing
auditLog and clearSessionCookie behavior still run as appropriate.

Comment thread src/lib/db-queries.ts
Comment on lines +390 to 396
export async function contributeToGoal(familyId: string, id: string, amount: number) {
// Atomic increment — no race condition
return db.goal.update({
where: { id },
data: { currentAmount: { increment: amount } },
});
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Same isolation bypass in contributeToGoal.

familyId is unused; where: { id } lets a caller increment currentAmount on another family's goal. Apply the same { id, familyId } scoping (or findFirst verify) as suggested for updateGoal.

🛡️ Scope the increment by familyId
   return db.goal.update({
-    where: { id },
+    where: { id, familyId },
     data: { currentAmount: { increment: amount } },
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function contributeToGoal(familyId: string, id: string, amount: number) {
// Atomic increment — no race condition
return db.goal.update({
where: { id },
data: { currentAmount: { increment: amount } },
});
}
export async function contributeToGoal(familyId: string, id: string, amount: number) {
// Atomic increment — no race condition
return db.goal.update({
where: { id, familyId },
data: { currentAmount: { increment: amount } },
});
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/db-queries.ts` around lines 390 - 396, `contributeToGoal` currently
ignores `familyId`, so the atomic increment can target a goal from another
family. Update the `db.goal.update` call to scope by both `id` and `familyId`,
matching the same ownership check used in `updateGoal` (or add a
`findFirst`/verify step before updating). Keep the increment behavior, but
ensure only the caller’s family goal can be modified.

- Schema provider: postgresql (was accidentally sqlite)
- Bento grid: parent dashboard overview reorganized into modern grid
  - Hero cell: Total Family Savings (2x2)
  - Small cells: This Month, Tokens, Investments, Children
  - Wide cells: Savings Trend, Children Portfolios
  - Tall cell: Recent Activity
  - Full-width: Family Spending, Recommendations
  - Responsive: 3-col desktop, stacked mobile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/app/DashboardClient.tsx (2)

347-363: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Prefer existing store selectors over duplicated memo logic.

The familySpending and spendingBreakdown memos duplicate the exact logic already exported from the store as familySpendingThisMonth and familySpendingBreakdown (store.ts:482-507). Using the store selectors eliminates duplication, ensures consistency if the month-scoping logic changes, and reduces re-renders since Zustand handles selector memoization internally.

♻️ Proposed refactor using store selectors
- const allSpending = useStore((s) => s.spending);
- const familySpending = useMemo(
-    () => allSpending.filter((e) => { const d = new Date(e.timestamp); const now = new Date(); return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth(); }).reduce((sum, e) => sum + e.amount, 0),
-    [allSpending]
-  );
-  const spendingBreakdown = useMemo(() => {
-    const now = new Date();
-    const map = new Map<string, { ownerKind: "parent" | "child"; ownerName: string; total: number }>();
-    for (const e of allSpending) {
-      const d = new Date(e.timestamp);
-      if (d.getFullYear() !== now.getFullYear() || d.getMonth() !== now.getMonth()) continue;
-      const existing = map.get(e.ownerId);
-      if (existing) existing.total += e.amount;
-      else map.set(e.ownerId, { ownerKind: e.ownerKind, ownerName: e.ownerName, total: e.amount });
-    }
-    return Array.from(map.entries()).map(([ownerId, v]) => ({ ownerId, ...v })).sort((a, b) => b.total - a.total);
-  }, [allSpending]);
+ const familySpending = useStore(familySpendingThisMonth);
+ const spendingBreakdown = useStore(familySpendingBreakdown);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/DashboardClient.tsx` around lines 347 - 363, The `DashboardClient`
component is duplicating month-scoped spending calculations that already exist
in the store. Replace the local `useMemo` logic for `familySpending` and
`spendingBreakdown` with the existing store selectors `familySpendingThisMonth`
and `familySpendingBreakdown` so the component stays consistent with `store.ts`
and avoids redundant re-computation. Use the selector exports directly from
`useStore` and remove the inline filtering/reduction logic in `DashboardClient`.

519-521: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

Transaction feed shows "undefined" when child lookup fails.

When child is not found in children (e.g., a transaction for a child that was removed), child?.name.split(" ")[0] renders as the string "undefined" in the UI. Add a fallback display name.

✨ Proposed fix
-                      <div className="micro-label mt-0.5 truncate">{child?.name.split(" ")[0]} · {txTypeLabel[t.type]} · {timeAgo(t.timestamp)}</div>
+                      <div className="micro-label mt-0.5 truncate">{child?.name.split(" ")[0] ?? "Unknown"} · {txTypeLabel[t.type]} · {timeAgo(t.timestamp)}</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/DashboardClient.tsx` around lines 519 - 521, The transaction feed
item in DashboardClient renders "undefined" when the child lookup fails because
the display name expression assumes a valid child name. Update the child-name
formatting near the transaction row to safely handle a missing child by using a
fallback label (for example, a generic removed/missing child name) before
applying the first-name split. Keep the existing txTypeLabel and timeAgo
rendering unchanged, and make the fallback logic easy to find around the
child?.name usage in the feed item markup.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/DashboardClient.tsx`:
- Around line 457-463: The Tokens action in DashboardClient is missing the same
undefined guard used by the Spend button, so `children[0]` can be passed as
`undefined` into `onGiveTokens`. Update the button’s `onClick` handler to check
for an existing child before calling `onGiveTokens`, and keep the behavior
aligned with the `parents[0]`/`onLogSpend` pattern in the same block.

---

Nitpick comments:
In `@src/app/DashboardClient.tsx`:
- Around line 347-363: The `DashboardClient` component is duplicating
month-scoped spending calculations that already exist in the store. Replace the
local `useMemo` logic for `familySpending` and `spendingBreakdown` with the
existing store selectors `familySpendingThisMonth` and `familySpendingBreakdown`
so the component stays consistent with `store.ts` and avoids redundant
re-computation. Use the selector exports directly from `useStore` and remove the
inline filtering/reduction logic in `DashboardClient`.
- Around line 519-521: The transaction feed item in DashboardClient renders
"undefined" when the child lookup fails because the display name expression
assumes a valid child name. Update the child-name formatting near the
transaction row to safely handle a missing child by using a fallback label (for
example, a generic removed/missing child name) before applying the first-name
split. Keep the existing txTypeLabel and timeAgo rendering unchanged, and make
the fallback logic easy to find around the child?.name usage in the feed item
markup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: b65c8afe-2290-4b4f-993e-d575471ce8d5

📥 Commits

Reviewing files that changed from the base of the PR and between 9cf13ce and 4388211.

📒 Files selected for processing (1)
  • src/app/DashboardClient.tsx

Comment on lines +457 to +463
<div className="flex gap-2">
<button onClick={() => { const p = parents[0]; if (p) onLogSpend({ id: p.id, kind: "parent", name: p.name }); }} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Wallet className="h-2.5 w-2.5" /> Spend
</button>
<button onClick={() => onGiveTokens(children[0])} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Plus className="h-2.5 w-2.5" /> Tokens
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Missing guard on Tokens button when children array is empty.

The Spend button guards against parents[0] being undefined (if (p)), but the Tokens button calls onGiveTokens(children[0]) without a similar check. When there are no children, this passes undefined to a function typed (c: Child) => void, causing a runtime error.

🐛 Proposed fix
-              <button onClick={() => onGiveTokens(children[0])} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
+              <button onClick={() => { const c = children[0]; if (c) onGiveTokens(c); }} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className="flex gap-2">
<button onClick={() => { const p = parents[0]; if (p) onLogSpend({ id: p.id, kind: "parent", name: p.name }); }} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Wallet className="h-2.5 w-2.5" /> Spend
</button>
<button onClick={() => onGiveTokens(children[0])} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Plus className="h-2.5 w-2.5" /> Tokens
</button>
<div className="flex gap-2">
<button onClick={() => { const p = parents[0]; if (p) onLogSpend({ id: p.id, kind: "parent", name: p.name }); }} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Wallet className="h-2.5 w-2.5" /> Spend
</button>
<button onClick={() => { const c = children[0]; if (c) onGiveTokens(c); }} className="btn-outline px-2.5 py-1 rounded text-[10px] tracking-wider flex items-center gap-1.5">
<Plus className="h-2.5 w-2.5" /> Tokens
</button>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/DashboardClient.tsx` around lines 457 - 463, The Tokens action in
DashboardClient is missing the same undefined guard used by the Spend button, so
`children[0]` can be passed as `undefined` into `onGiveTokens`. Update the
button’s `onClick` handler to check for an existing child before calling
`onGiveTokens`, and keep the behavior aligned with the `parents[0]`/`onLogSpend`
pattern in the same block.

@isaJrKai
isaJrKai merged commit c2aea41 into main Jul 8, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant