Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/deploy-cloudflare.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
(github.event.workflow_run.head_branch == 'main' ||
github.event.workflow_run.head_branch == 'develop')
permissions:
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/deploy-supabase-functions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,4 @@ jobs:
supabase functions deploy platform-access
supabase functions deploy delete-account
supabase functions deploy waitlist-welcome --no-verify-jwt
supabase functions deploy sync-contacts
18 changes: 9 additions & 9 deletions .github/workflows/ensure-pr-source-develop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,15 @@ jobs:

- name: Read inputs
id: check
env:
ALLOWED: ${{ secrets.ALLOWED_MERGERS }}
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
TARGET_BRANCH: ${{ github.event.pull_request.base.ref }}
run: |
# Allowed mergers come from a repo secret, comma-separated.
# Set this secret in your repo settings: Settings -> Secrets -> Actions -> New repository secret
# Example value: ayoub,my-org-release-bot
ALLOWED="${{ secrets.ALLOWED_MERGERS }}"

# PR metadata from github context
HEAD_REF="${{ github.event.pull_request.head.ref }}"
PR_AUTHOR="${{ github.event.pull_request.user.login }}"
TARGET_BRANCH="${{ github.event.pull_request.base.ref }}"

echo "HEAD_REF=$HEAD_REF"
echo "PR_AUTHOR=$PR_AUTHOR"
Expand Down Expand Up @@ -68,10 +67,11 @@ jobs:

- name: Fail if not allowed
if: steps.check.outputs.allowed != 'true'
env:
HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
run: |
head_ref="${{ github.event.pull_request.head.ref }}"
author="${{ github.event.pull_request.user.login }}"
echo "ERROR: PR targeting 'main' is not allowed. Head branch is '$head_ref' and PR author is '$author'."
echo "ERROR: PR targeting 'main' is not allowed. Head branch is '$HEAD_REF' and PR author is '$PR_AUTHOR'."
echo "Only PRs whose head branch is 'develop' or PRs authored by an allowed merger (repo secret ALLOWED_MERGERS) may target 'main'."
exit 1

Expand Down
12 changes: 9 additions & 3 deletions .github/workflows/keep-supabase-alive.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@ on:
- cron: "0 0 */3 * *"
workflow_dispatch:

permissions:
contents: read

jobs:
ping:
runs-on: ubuntu-latest
steps:
- name: Ping Supabase REST API
env:
SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }}
SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
run: |
curl -sf "${{ secrets.VITE_SUPABASE_URL }}/rest/v1/keepalive?select=id&limit=1" \
-H "apikey: ${{ secrets.VITE_SUPABASE_ANON_KEY }}" \
-H "Authorization: Bearer ${{ secrets.VITE_SUPABASE_ANON_KEY }}"
curl -sf "${SUPABASE_URL}/rest/v1/keepalive?select=id&limit=1" \
-H "apikey: ${SUPABASE_ANON_KEY}" \
-H "Authorization: Bearer ${SUPABASE_ANON_KEY}"
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ See reference doc for full checklists (JS, Python, pseudocode, sound, insight, t
- **OAuth UX** — Google Identity Services (PKCE popup on `/auth/google/callback`); web uses `signInWithIdToken`, not `signInWithOAuth`; `googleIdentity.js` manages GIS script loading, nonce creation, and popup flow
- **Service layer** — `src/services/authService.js`, `profileService.js`, `entitlementService.js`, `accessService.js`, `googleTokenExchange.js`; components use `AuthContext` / `useAuth`, never import Supabase directly
- **Platform access** — signed-in users pass through `checkPlatformAccess()` → Supabase Edge Function `platform-access` (account ban gate); **fail-open** on transport/invoke errors, **fail-closed** only when `allowed: false` + `reason: account_banned`; signup path uses `before-signup` / `post-signup` hooks (ban logic fail-closed)
- **Postgres-portable schema** — `profiles` keyed to `auth.users`; RLS on public tables; client-writable columns: `display_name`, `avatar_preference` only; `avatar_url` is OAuth/trigger-populated (not client-writable); `plan` and future `referral_*` / `pro_*` columns are service role / webhook only
- **Postgres-portable schema** — `profiles` keyed to `auth.users`; RLS on public tables; client-writable columns: `display_name`, `avatar_preference` only; `avatar_url` is OAuth/trigger-populated (not client-writable); `plan` is service role only. Through 0.5.0: **no** LemonSqueezy, subscriptions, referrals, or `user_sessions` tables in tree
- **Pro through 0.5.0** — waitlist demand only (`/pro` + `waitlist`); no checkout or Pro entitlements beyond waitlist capture
- **Analytics (PostHog)** — `src/services/analytics.js` + `analyticsEvents.js`; SPA pageviews via `capture_pageview: 'history_change'`; `disable_surveys: true`; session replay sampled (`sampleRate: 0.2`). Growth events: `waitlist_joined`, `upgrade_limit_hit` (plus existing `sign_in_completed`). Anonymous viz limit stays hardcoded at `12` (no feature-flag A/B)
- **Email (Resend)** — `waitlist-welcome` (one styled transactional email per waitlist join); `sync-contacts` on `SIGNED_IN` upserts the contact **and** sends a one-time Free-account welcome (`profiles.welcome_email_sent_at`). No broadcasts, digests, referral, or Pro-nudge sends in 0.5.0. Stay under Resend free daily cap (100/day)
- **Profile settings** — private route `/settings/profile` (`RequireAuth`); `updateProfile()` in `profileService.js`; security boundary = RLS row scope + `REVOKE UPDATE` + `GRANT UPDATE (display_name, avatar_preference)`; tabbed UI with profile/notifications/connections tabs; DiceBear notionists avatar fallback
- **Session** — `getSession()`, `onAuthStateChange()`; `AuthProvider` in `src/main.jsx`; request-dedup via `requestRef`
- **Tiered access model** — Anonymous (no account) gets limited access to drive sign-in conversion; Free account (Google sign-in) unlocks the full platform
Expand Down
7 changes: 6 additions & 1 deletion docs/AGENTS_REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@
- `supabase/functions/post-signup/` — post-signup side effects
- `supabase/functions/platform-access/` — signed-in ban check (`accessService.checkPlatformAccess()`; fail-open on transport)
- `supabase/functions/waitlist-welcome/` — Pro waitlist confirmation email via Resend (fail-open; invoked after client insert)
- `supabase/functions/sync-contacts/` — JWT-authenticated Resend contact upsert + one-time Free welcome email on sign-in (fail-open; identity from JWT only; atomic claim on `profiles.welcome_email_sent_at`; Resend segment via `RESEND_SEGMENT_ID` or legacy `RESEND_AUDIENCE_ID`)
- `supabase/functions/delete-account/` — self-service account deletion
- Shared email HTML: `supabase/functions/_shared/transactionalEmails.ts`
- **Not in 0.5.0:** LemonSqueezy webhook, subscriptions / usage_events / referrals / user_sessions tables (dropped via `20260720180000_drop_premature_saas_scaffolding.sql`); no viz-limit email, weekly digest, Pro nudge, or referral invite sends (Pro nudge + referral templates may exist in Resend as parked drafts for later)
- Analytics: `src/services/analytics.js`, `src/services/analyticsEvents.js` — PostHog SPA pageviews + growth events `waitlist_joined`, `upgrade_limit_hit`; surveys disabled in SDK
- Context: `src/contexts/AuthProvider.jsx`, `src/hooks/useAuth.js`
- Avatar resolution: `src/utils/resolveUserAvatar.js` (`resolveUserAvatar`, `resolveDisplayName`, DiceBear notionists style)
- Components: `src/components/UserMenu.jsx`, `src/components/UserAvatar.jsx`, `src/components/RequireAuth.jsx`
Expand All @@ -117,8 +121,9 @@
| `display_name` | yes | Editable on profile settings page |
| `avatar_url` | no | OAuth / trigger-populated HTTPS URL |
| `avatar_preference` | yes | `google` (default) \| `generated` |
| `welcome_email_sent_at` | no | Set atomically by `sync-contacts` when claiming the one-time welcome send |

Future (v0.6.0, not shipped): `username` (unique, set-once RLS), public `/u/:username` route; referral/billing columns (`referral_code`, `referred_by`, `referral_count`, `pro_months_earned`, `pro_expires_at`) — service role only.
Future (post-0.5.0, not shipped): `username` (unique, set-once RLS), public `/u/:username` route; any referral/billing columns — new migrations then, not restore of dropped scaffolding. Through 0.5.0 Pro remains waitlist-only.

### Personal learning tables

Expand Down
7 changes: 7 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,13 @@
}
})();
</script>

<!-- Cloudflare Turnstile — invisible CAPTCHA for bot signup prevention -->
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit"
async
defer
></script>
</head>
<body>
<div id="root"></div>
Expand Down
26 changes: 26 additions & 0 deletions src/contexts/AuthProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,29 @@
import { AuthContext } from './AuthContextDefinition';
import { identifyUser, resetUser } from '../services/analytics';
import { trackSignInCompleted } from '../services/analyticsEvents';
import { getSupabaseClient } from '@/lib/supabaseClient';

/** @typedef {'account_banned' | null} AccessBlockReason */

/**
* Sync signed-in user to Resend (fire-and-forget).
* Identity is taken from the JWT inside the edge function — body is metadata only.
* @param {object} [profile]
*/
function syncContactToResend(profile) {
const supabase = getSupabaseClient();
if (!supabase) return;

void supabase.functions.invoke('sync-contacts', {
method: 'POST',
body: {
plan: profile?.plan || 'free',
displayName: profile?.displayName || '',
language: document.documentElement.lang || 'en',
},
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

/**
* @param {import('@supabase/supabase-js').User | null} user
* @param {Awaited<ReturnType<typeof getProfile>>} profileRow
Expand Down Expand Up @@ -194,6 +214,12 @@
plan: null,
});
trackSignInCompleted();
syncContactToResend({
plan: null,
displayName:
nextSession.user.user_metadata?.full_name ||
nextSession.user.user_metadata?.name,

Check warning on line 221 in src/contexts/AuthProvider.jsx

View check run for this annotation

Codecov / codecov/patch

src/contexts/AuthProvider.jsx#L221

Added line #L221 was not covered by tests
});
}

if (event === 'SIGNED_OUT') {
Expand Down
10 changes: 10 additions & 0 deletions src/contexts/AuthProvider.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
resetSupabaseMocks,
supabaseAuthMock,
supabaseFromMock,
supabaseFunctionsInvokeMock,
authStateChangeCallbackRef,
} from '../test/supabaseMock.js';

Expand Down Expand Up @@ -339,6 +340,15 @@ describe('AuthProvider', () => {
});
expect(screen.getByTestId('display-name')).toHaveTextContent('New User');
expect(resetAllSessionCounters).toHaveBeenCalledTimes(1);
expect(supabaseFunctionsInvokeMock).toHaveBeenCalledWith(
'sync-contacts',
expect.objectContaining({
method: 'POST',
body: expect.objectContaining({
displayName: 'New User',
}),
})
);
});

it('does not reset session counters on INITIAL_SESSION hydrate', async () => {
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/ar/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
"dangerZoneTitle": "منطقة الخطر",
"dangerZoneDescription": "يحذف حسابك نهائياً مع جميع المفضلات وملاحظات الدراسة. لا يمكن التراجع عن هذا الإجراء.",
"deleteAccount": "حذف الحساب",
"deleteAccountConfirmLabel": "اكتب DELETE للتأكيد",
"deleteAccountConfirmPlaceholder": "DELETE",
"deleteAccountConfirmLabel": "اكتب بريدك الإلكتروني للتأكيد",
"deleteAccountConfirmPlaceholder": "بريدك@الإلكتروني.com",
"deleteAccountConfirmWord": "DELETE",
"deleteAccountInProgress": "جارٍ حذف الحساب…",
"deleteAccountSuccess": "تم حذف حسابك.",
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
"dangerZoneTitle": "Danger zone",
"dangerZoneDescription": "Permanently delete your account and all saved favorites and study notes. This cannot be undone.",
"deleteAccount": "Delete account",
"deleteAccountConfirmLabel": "Type DELETE to confirm",
"deleteAccountConfirmPlaceholder": "DELETE",
"deleteAccountConfirmLabel": "Type your email to confirm deletion",
"deleteAccountConfirmPlaceholder": "your@email.com",
"deleteAccountConfirmWord": "DELETE",
"deleteAccountInProgress": "Deleting account…",
"deleteAccountSuccess": "Your account has been deleted.",
Expand Down
4 changes: 2 additions & 2 deletions src/i18n/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,8 @@
"dangerZoneTitle": "Zone de danger",
"dangerZoneDescription": "Supprime définitivement votre compte ainsi que vos favoris et notes d'étude. Cette action est irréversible.",
"deleteAccount": "Supprimer le compte",
"deleteAccountConfirmLabel": "Tapez DELETE pour confirmer",
"deleteAccountConfirmPlaceholder": "DELETE",
"deleteAccountConfirmLabel": "Tapez votre email pour confirmer la suppression",
"deleteAccountConfirmPlaceholder": "votre@email.com",
"deleteAccountConfirmWord": "DELETE",
"deleteAccountInProgress": "Suppression du compte…",
"deleteAccountSuccess": "Votre compte a été supprimé.",
Expand Down
2 changes: 2 additions & 0 deletions src/pages/ProComingSoonPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
joinWaitlist,
readStoredWaitlistEmail,
} from '@/services/waitlistService';
import { trackWaitlistJoined } from '@/services/analyticsEvents';

const FEATURE_ITEMS = [
{ key: 'customInput', Icon: Sliders },
Expand Down Expand Up @@ -69,7 +70,7 @@
const [email, setEmail] = useState(defaultEmail);
const emailEditedRef = useRef(false);
const [submitState, setSubmitState] = useState('idle');
const [position, setPosition] = useState(null);

Check warning on line 73 in src/pages/ProComingSoonPage.jsx

View workflow job for this annotation

GitHub Actions / Upload PR preview

'position' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]|^motion$/u

Check warning on line 73 in src/pages/ProComingSoonPage.jsx

View workflow job for this annotation

GitHub Actions / Code Quality

'position' is assigned a value but never used. Allowed unused vars must match /^[A-Z_]|^motion$/u
const [errorKey, setErrorKey] = useState(null);
const [waitlistCount, setWaitlistCount] = useState(0);

Expand Down Expand Up @@ -104,6 +105,7 @@
if (result.status === 'joined') {
setPosition(result.position ?? null);
setSubmitState('success');
trackWaitlistJoined(source, result.position);
return;
}

Expand Down
13 changes: 3 additions & 10 deletions src/pages/ProfileSettingsPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,11 +189,7 @@ function ProfileSettingsPage() {
};

const handleDeleteAccount = async () => {
if (
!user ||
isDeletingAccount ||
deleteConfirmText !== t('profile.deleteAccountConfirmWord')
) {
if (!user || isDeletingAccount || deleteConfirmText.trim() !== user.email) {
return;
}

Expand Down Expand Up @@ -523,9 +519,7 @@ function ProfileSettingsPage() {
onChange={event =>
setDeleteConfirmText(event.target.value)
}
placeholder={t(
'profile.deleteAccountConfirmPlaceholder'
)}
placeholder={user.email}
className="w-full rounded-lg border border-red-200 dark:border-red-800 bg-surface px-4 py-3 text-sm text-text-primary placeholder:text-text-secondary focus:outline-none focus:ring-2 focus:ring-red-400/40 mb-6"
autoComplete="off"
/>
Expand All @@ -546,8 +540,7 @@ function ProfileSettingsPage() {
onClick={handleDeleteAccount}
disabled={
isDeletingAccount ||
deleteConfirmText !==
t('profile.deleteAccountConfirmWord')
deleteConfirmText.trim() !== user.email
}
className="flex-1 inline-flex items-center justify-center rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-950/40 px-5 py-3 text-sm font-semibold text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-950/60 disabled:opacity-50 disabled:cursor-not-allowed min-h-11"
>
Expand Down
6 changes: 3 additions & 3 deletions src/pages/ProfileSettingsPage.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,7 @@ describe('ProfileSettingsPage', () => {
});
});

it('deletes account after typing DELETE confirmation via modal', async () => {
it('deletes account after typing email confirmation via modal', async () => {
getProfileMock.mockResolvedValue({
display_name: 'Ada',
avatar_url: null,
Expand All @@ -242,9 +242,9 @@ describe('ProfileSettingsPage', () => {
const dialog = await screen.findByRole('dialog');

const confirmInput = within(dialog).getByLabelText(
/type delete to confirm/i
/type your email to confirm/i
);
fireEvent.change(confirmInput, { target: { value: 'DELETE' } });
fireEvent.change(confirmInput, { target: { value: 'ada@example.com' } });

const deleteButton = within(dialog).getByRole('button', {
name: /delete account/i,
Expand Down
2 changes: 2 additions & 0 deletions src/pages/VisualizerApp.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ import {
trackInsightPanelOpened,
trackVideoExportStarted,
trackCategoryChanged,
trackUpgradeLimitHit,
} from '../services/analyticsEvents';

const SOUND_PREFERENCE_STORAGE_KEY = 'bayan-flow:sound-enabled';
Expand Down Expand Up @@ -418,6 +419,7 @@ function App() {
/** Play handler with session limit check for anonymous users */
const handlePlay = () => {
if (!canRunVisualization(user)) {
trackUpgradeLimitHit(ANONYMOUS_VISUALIZATION_LIMIT);
openGatedFeature('session_limit', {
limit: ANONYMOUS_VISUALIZATION_LIMIT,
});
Expand Down
2 changes: 1 addition & 1 deletion src/security/cspHeaders.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, vi, beforeEach } from 'vitest';
import {
assertAnalyticsCspDirectives,
assertAuthCspDirectives,
Expand Down
3 changes: 3 additions & 0 deletions src/services/analytics.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ export function initPostHog() {
api_host: host,
ui_host: 'https://us.posthog.com',
defaults: '2026-05-30',
capture_pageview: 'history_change',
autocapture: false,
disable_surveys: true,
capture_performance: true,
capture_dead_clicks: true,
rageclick: true,
person_profiles: 'identified_only',
session_recording: {
// Sample to stretch the free-tier 5k recordings/month.
sampleRate: 0.2,
maskTextSelector: '.ph-no-capture, [data-sensitive]',
maskAllInputs: true,
maskAllMedia: true,
Expand Down
39 changes: 39 additions & 0 deletions src/services/analyticsEvents.growth.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Copyright (c) 2025 Bayan Flow
* Licensed under Elastic License 2.0 OR Commercial
* See LICENSE for details.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
trackWaitlistJoined,
trackUpgradeLimitHit,
WAITLIST_JOINED,
UPGRADE_LIMIT_HIT,
} from './analyticsEvents.js';
import { captureEvent } from './analytics.js';

vi.mock('./analytics.js', () => ({
captureEvent: vi.fn(),
}));

describe('analyticsEvents growth conversions', () => {
beforeEach(() => {
vi.mocked(captureEvent).mockClear();
});

it('tracks waitlist_joined with source and position', () => {
trackWaitlistJoined('pro_page', 3);
expect(captureEvent).toHaveBeenCalledWith(WAITLIST_JOINED, {
source: 'pro_page',
position: 3,
});
});

it('tracks upgrade_limit_hit with limit', () => {
trackUpgradeLimitHit(12);
expect(captureEvent).toHaveBeenCalledWith(UPGRADE_LIMIT_HIT, {
limit: 12,
});
});
});
Loading
Loading