Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b1c8e84
fix: considering savings as a expense in balance
fsousac Jul 18, 2026
00c11bf
fix: transactions screen shows only month transactions
fsousac Jul 18, 2026
29ef795
feat: internationalize landing page and follow system theme by defaul…
fsousac Jul 18, 2026
571021a
feat: require Terms of Use and Privacy Policy acceptance on sign up (…
fsousac Jul 18, 2026
1f287b8
feat: add Playwright E2E test suite covering critical workflows (#56)
fsousac Jul 20, 2026
bf7273e
fix: polishing changes and enhancing eslint
fsousac Jul 20, 2026
5b5afc3
lint: enhancing eslint (fix code smells and clean code)
fsousac Jul 23, 2026
30e0e83
fix: payments income calculus and budget colors fix
fsousac Jul 24, 2026
60a6dc1
fix: next-env
fsousac Jul 24, 2026
98d8bee
fix: fixing unsafe dependencies
fsousac Jul 24, 2026
917e031
Merge remote-tracking branch 'origin/main' into develop
fsousac Jul 24, 2026
50397d1
fix: postcss up version
fsousac Jul 24, 2026
1bf168c
Merge branch 'main' of https://github.com/fsousac/Dragg into develop
fsousac Jul 24, 2026
79f6fb1
fix: fixing broken tests
fsousac Jul 31, 2026
db37a25
feat: encrypt profile/transaction PII fields at rest
fsousac Aug 10, 2026
ea31fed
chore: updating dependencies
fsousac Aug 10, 2026
d98b901
fix: broken tests fix
fsousac Aug 10, 2026
feeb2e0
fix: increasing coverage
fsousac Aug 10, 2026
126191c
chore: CI/docs updates (unrelated to OAuth loop debug)
fsousac Aug 10, 2026
4a10656
fix: restoring dev server from loop and cleaning
fsousac Aug 21, 2026
ac6b409
update: up deps versions
fsousac Aug 21, 2026
d8e6fe4
fix: update next-env
fsousac Aug 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
NEXT_PUBLIC_SUPABASE_URL=your_supabase_url_here
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=your_publishable_key_here

# Server-only. AES-256-GCM key for application-layer PII encryption (base64, 32 bytes).
# Generate with: openssl rand -base64 32
FIELD_ENCRYPTION_KEY=

# Keep secrets out of the repository.
# Add any future public-only client variables here as placeholders.
1 change: 1 addition & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ jobs:
PUBLISHABLE_KEY=$(supabase status -o env | grep '^PUBLISHABLE_KEY=' | cut -d'"' -f2)
echo "NEXT_PUBLIC_SUPABASE_URL=$API_URL" >> "$GITHUB_ENV"
echo "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=$PUBLISHABLE_KEY" >> "$GITHUB_ENV"
echo "FIELD_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV"

- name: Run Playwright E2E suite
run: pnpm run e2e
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/zap-baseline.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate field encryption key
run: echo "FIELD_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV"

- name: Build app
run: pnpm run build

Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/zap-full-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate field encryption key
run: echo "FIELD_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV"

- name: Build app
run: pnpm run build

Expand Down
18 changes: 17 additions & 1 deletion app/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,27 @@ export async function GET(request: NextRequest) {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const next = requestUrl.searchParams.get("next");
const flowId = requestUrl.searchParams.get("sb_flow_id");
const redirectPath = getSafeRedirectPath(next);

if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
// flowId deixa exchangeCodeForSession limpar o cookie verificador
// PKCE deste próprio login (por que é passado via query param: ver
// lib/supabase/client.ts).
const { data } = await supabase.auth.exchangeCodeForSession(
code,
flowId ? { flowId } : undefined,
);

// Os tokens do provider (access/refresh do Google) nunca são lidos
// por este app; re-salvar via setSession os descarta da sessão.
if (data.session) {
await supabase.auth.setSession({
access_token: data.session.access_token,
refresh_token: data.session.refresh_token,
});
}
}

return NextResponse.redirect(new URL(redirectPath, requestUrl.origin));
Expand Down
6 changes: 6 additions & 0 deletions components/dashboard/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MobileNav } from "@/components/dashboard/mobile-nav";
import { NavigationPrefetcher } from "@/components/dashboard/navigation-prefetcher";
import { PageTransition } from "@/components/dashboard/page-transition";
import { Sidebar } from "@/components/dashboard/sidebar";
import { ensureEncryptedProfile } from "@/lib/auth/encrypted-profile";
import { requireAcceptedTerms } from "@/lib/auth/terms";
import type {
AuthenticatedUserClaims,
Expand Down Expand Up @@ -203,6 +204,11 @@ export async function AppShell({
claims,
);

await ensureEncryptedProfile(supabase, user.id, {
email: userEmail,
name: userName,
});

return (
<div className="flex min-h-screen bg-background">
<NavigationPrefetcher />
Expand Down
14 changes: 6 additions & 8 deletions components/dashboard/form-inputs/currency-input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useRef, useState, type ReactNode } from "react";
import { useState, type ReactNode } from "react";

import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand Down Expand Up @@ -70,18 +70,16 @@ function useCurrencyDigits(
onValueChange: (value: number) => void,
) {
const [digits, setDigits] = useState(() => digitsFromValue(value));
const prevValueRef = useRef(value);
const [prevValue, setPrevValue] = useState(value);

/* c8 ignore start */
useEffect(() => {
if (value !== prevValue) {
setPrevValue(value);
const externalCents = Math.round(value * 100);
const internalCents = digits ? Number.parseInt(digits, 10) : 0;
if (externalCents !== internalCents && value !== prevValueRef.current) {
if (externalCents !== internalCents) {
setDigits(digitsFromValue(value));
}
prevValueRef.current = value;
}, [value, digits]);
/* c8 ignore stop */
}

function pushDigits(newDigits: string) {
// Remove leading zeros, cap at 13 digits (~999 billion cents)
Expand Down
19 changes: 9 additions & 10 deletions components/ui/resizable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,13 @@ import { cn } from '@/lib/utils'
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) {
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
// ponytail: v4 sets flex-direction inline based on `orientation`, no more
// data-panel-group-direction attribute to key off of in CSS.
return (
<ResizablePrimitive.PanelGroup
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
'flex h-full w-full data-[panel-group-direction=vertical]:flex-col',
className,
)}
className={cn('flex h-full w-full', className)}
{...props}
/>
)
Expand All @@ -32,14 +31,14 @@ function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90',
'bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden aria-[orientation=vertical]:h-px aria-[orientation=vertical]:w-full aria-[orientation=vertical]:after:left-0 aria-[orientation=vertical]:after:h-1 aria-[orientation=vertical]:after:w-full aria-[orientation=vertical]:after:translate-x-0 aria-[orientation=vertical]:after:-translate-y-1/2 [&[aria-orientation=vertical]>div]:rotate-90',
className,
)}
{...props}
Expand All @@ -49,7 +48,7 @@ function ResizableHandle({
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
}

Expand Down
13 changes: 8 additions & 5 deletions docs/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ The current committed schema defines these Supabase tables:
- `002_security_lgpd_hardening.sql`: adds `updated_at` and `deleted_at` columns, moves helper functions into the private schema, adds privacy requests, hardens grants and RLS policies, validates transaction ownership references, and adds LGPD-oriented comments.
- `005_add_installment_group_metadata.sql`: adds stable installment grouping metadata and an authenticated-user scoped installment group index.
- `006_add_installment_prepayment_metadata.sql`: adds installment prepayment metadata and an authenticated-user scoped prepayment month index.
- `010_stop_writing_plaintext_profile_pii.sql`: stops the signup trigger from writing plaintext `profiles.name`/`email` (application-layer encryption now owns these fields, see `lib/crypto/field-encryption.ts`), and drops the now-unused `transactions_user_id_notes_idx` partial index.

## What belongs in the repository

Expand All @@ -44,12 +45,14 @@ The repository must not include:
## Profile fields used by the app

- `id`
- `email`
- `name`
- `email` — application-layer encrypted (`lib/crypto/field-encryption.ts`), never plaintext at rest. Distinct from `auth.users.email`, which Supabase Auth manages and is unaffected.
- `name` — application-layer encrypted (`lib/crypto/field-encryption.ts`), never plaintext at rest.
- `created_at`
- `updated_at`
- `deleted_at`

`private.handle_new_user()` no longer writes plaintext `name`/`email` into `profiles` (see migration `010`). `lib/auth/encrypted-profile.ts` fills them in, encrypted, on the user's next authenticated request.

## Category fields used by the app

- `id`
Expand Down Expand Up @@ -85,14 +88,14 @@ The application has backward-compatible fallbacks for older environments that do
- `amount`
- `category_id`
- `date`
- `description`
- `description` — application-layer encrypted with a deterministic IV (`lib/crypto/field-encryption.ts`) so equality lookups (subscription grouping) still work; never plaintext at rest.
- `kind`
- `installment_group_id`
- `installment_number`
- `installment_total`
- `advanced_to_month`
- `advanced_at`
- `notes`
- `notes` — application-layer encrypted (`lib/crypto/field-encryption.ts`), random IV; never plaintext at rest.
- `payment_method_id`
- `created_at`
- `updated_at`
Expand All @@ -102,7 +105,7 @@ Installments and subscriptions are modeled as multiple transaction rows. Install

Installment prepayment uses `advanced_to_month` and `advanced_at`. The original transaction `date`, category, payment method, and installment metadata are preserved for auditability. Payment and invoice views use `advanced_to_month` as the payment context so advanced installments appear in the target month and no longer appear as future obligations.

Subscription rows use `notes` values beginning with `subscription`; paused subscriptions use `subscription paused`.
Subscription rows use `notes` values beginning with `subscription`; paused subscriptions use `subscription paused`. Since `notes` is stored encrypted, this prefix check happens in application code against the decrypted value (`lib/finance/transactions.ts`), not as a SQL `LIKE` predicate.

## Monthly budget fields in the schema

Expand Down
35 changes: 35 additions & 0 deletions lib/auth/encrypted-profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import "server-only";

import type { SupabaseClient } from "@supabase/supabase-js";

import {
encryptField,
isAlreadyEncrypted,
} from "@/lib/crypto/field-encryption";

export async function ensureEncryptedProfile(
supabase: SupabaseClient,
userId: string,
identity: { email: string; name: string },
) {
const { data } = await supabase
.from("profiles")
.select("name, email")
.eq("id", userId)
.maybeSingle();

if (
isAlreadyEncrypted(data?.name ?? null) &&
isAlreadyEncrypted(data?.email ?? null)
) {
return;
}

await supabase
.from("profiles")
.update({
email: encryptField(identity.email),
name: encryptField(identity.name),
})
.eq("id", userId);
}
10 changes: 9 additions & 1 deletion lib/auth/terms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ export async function requireAcceptedTerms(
supabase: SupabaseClient,
userId: string,
) {
const { data } = await supabase
const { data, error } = await supabase
.from("profiles")
.select("terms_accepted")
.eq("id", userId)
.single();

// PGRST116 = nenhuma linha encontrada (genuinamente "termos não
// aceitos"). Qualquer outro erro não pode ser mascarado como isso,
// ou uma falha transitória entra em loop com o redirect assim que
// a falha passa.
if (error && error.code !== "PGRST116") {
throw error;
}

if (!data?.terms_accepted) {
redirect("/auth/accept-terms");
}
Expand Down
114 changes: 114 additions & 0 deletions lib/crypto/field-encryption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import {
createCipheriv,
createDecipheriv,
createHmac,
hkdfSync,
randomBytes,
} from "node:crypto";

const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const KEY_LENGTH = 32;

let cachedKeys: { cipherKey: Buffer; hmacKey: Buffer } | null = null;

function deriveKeys(): { cipherKey: Buffer; hmacKey: Buffer } {
if (cachedKeys) return cachedKeys;

const masterKeyBase64 = process.env.FIELD_ENCRYPTION_KEY;
if (!masterKeyBase64) {
throw new Error("FIELD_ENCRYPTION_KEY is not set.");
}

const masterKey = Buffer.from(masterKeyBase64, "base64");
if (masterKey.length !== KEY_LENGTH) {
throw new Error(
"FIELD_ENCRYPTION_KEY must be a base64-encoded 32-byte key.",
);
}

const cipherKey = Buffer.from(
hkdfSync(
"sha256",
masterKey,
Buffer.alloc(0),
"field-encryption:cipher",
KEY_LENGTH,
),
);
const hmacKey = Buffer.from(
hkdfSync(
"sha256",
masterKey,
Buffer.alloc(0),
"field-encryption:hmac",
KEY_LENGTH,
),
);

cachedKeys = { cipherKey, hmacKey };
return cachedKeys;
}

function encryptWithIv(plaintext: string, iv: Buffer): string {
const { cipherKey } = deriveKeys();
const cipher = createCipheriv(ALGORITHM, cipherKey, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintext, "utf8"),
cipher.final(),
]);
const authTag = cipher.getAuthTag();

return Buffer.concat([iv, authTag, ciphertext]).toString("base64");
}

function decrypt(stored: string): string {
const { cipherKey } = deriveKeys();
const buffer = Buffer.from(stored, "base64");
const iv = buffer.subarray(0, IV_LENGTH);
const authTag = buffer.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const ciphertext = buffer.subarray(IV_LENGTH + AUTH_TAG_LENGTH);

const decipher = createDecipheriv(ALGORITHM, cipherKey, iv, {
authTagLength: AUTH_TAG_LENGTH,
});
decipher.setAuthTag(authTag);

return Buffer.concat([
decipher.update(ciphertext),
decipher.final(),
]).toString("utf8");
}

export function encryptField(value: string | null | undefined): string | null {
if (!value) return null;
return encryptWithIv(value, randomBytes(IV_LENGTH));
}

export function decryptField(stored: string | null | undefined): string | null {
if (!stored) return stored ?? null;

try {
return decrypt(stored);
} catch {
return stored;
}
}

export function encryptDescription(value: string): string {
const { hmacKey } = deriveKeys();
const iv = createHmac("sha256", hmacKey)
.update(value, "utf8")
.digest()
.subarray(0, IV_LENGTH);

return encryptWithIv(value, iv);
}

export const decryptDescription = decryptField;

export function isAlreadyEncrypted(stored: string | null | undefined): boolean {
if (!stored) return false;
return decryptField(stored) !== stored;
}
Loading
Loading