Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
25 changes: 22 additions & 3 deletions admin/public/confirmed.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Supabase may return a short-lived session in the URL fragment after
// confirmation. This informational page never needs it.
if (location.hash || location.search) history.replaceState(null, '', location.pathname);
// Supabase verifies the link before redirecting here. The page must not claim
// success on a direct visit, and it never needs to retain a returned session.
const hash = new URLSearchParams(location.hash.slice(1));
const query = new URLSearchParams(location.search);
const error = hash.get('error_description') || query.get('error_description') ||
hash.get('error') || query.get('error');
if (hash.get('type') === 'recovery' && hash.get('access_token') && !error) {
const reset = new URL('/reset-password', location.origin);
reset.hash = location.hash;
location.replace(reset.toString());
} else {
history.replaceState(null, '', location.pathname);
const heading = document.getElementById('heading');
const message = document.getElementById('message');
if (error) {
heading.textContent = 'Confirmation did not finish';
message.textContent = error;
} else if (hash.get('access_token')) {
heading.textContent = 'Email confirmed ✓';
message.textContent = "You're all set. Open the One Concept app and sign in to get today's concept.";
}
}
6 changes: 3 additions & 3 deletions admin/public/confirmed/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>One Concept — email confirmed</title>
<title>One Concept — email confirmation</title>
<link rel="stylesheet" href="/public-info.css">
<script defer src="/confirmed.js"></script>
</head>
<body>
<main>
<h1>Email confirmed</h1>
<p>Open the One Concept app on your phone and sign in to get today's concept.</p>
<h1 id="heading">Check your email link</h1>
<p id="message">Open the confirmation link from your email to finish setting up your account.</p>
</main>
</body>
</html>
103 changes: 103 additions & 0 deletions admin/tests/auth-landing.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, test, type Page } from "@playwright/test";
import { publicPage } from "../../supabase/functions/api/public_pages.ts";

const origin = "https://one-concept.supabase.co";
const base = `${origin}/functions/v1/api`;
const config = { supabaseUrl: origin, anonKey: "public-anon-key" };

async function serveAuthPages(page: Page) {
await page.route(`${base}/**`, async route => {
const url = new URL(route.request().url());
const response = publicPage(url.pathname.slice("/functions/v1/api".length), "GET", config, new Headers());
if (!response) return route.fulfill({ status: 404, body: "Not found" });
return route.fulfill({
status: response.status,
headers: Object.fromEntries(response.headers),
body: await response.text(),
});
});
}

test("direct confirmation visit does not claim that an email was verified", async ({ page }) => {
await serveAuthPages(page);
await page.goto(`${base}/confirmed`);
await expect(page.getByRole("heading", { name: "Check your email link" })).toBeVisible();
await expect(page.getByText("Email confirmed ✓")).toHaveCount(0);
await expect(page).toHaveTitle("One Concept — email confirmation");
});

test("verified confirmation link reports success and removes its session fragment", async ({ page }) => {
await serveAuthPages(page);
await page.goto(`${base}/confirmed#access_token=confirmed-token&type=signup`);
await expect(page.getByRole("heading", { name: "Email confirmed ✓" })).toBeVisible();
await expect(page).toHaveURL(`${base}/confirmed`);
});

test("expired confirmation link reports the error without claiming success", async ({ page }) => {
await serveAuthPages(page);
await page.goto(`${base}/confirmed#error=access_denied&error_description=Email%20link%20expired`);
await expect(page.getByRole("heading", { name: "Confirmation did not finish" })).toBeVisible();
await expect(page.getByText("Email link expired")).toBeVisible();
await expect(page).toHaveURL(`${base}/confirmed`);
});

test("recovery link reaches the reset form and updates the password with its token", async ({ page }) => {
await serveAuthPages(page);
let authRequest: { method: string; authorization: string | undefined; apikey: string | undefined; password: string } | undefined;
await page.route(`${origin}/auth/v1/user`, async route => {
authRequest = {
method: route.request().method(),
authorization: route.request().headers().authorization,
apikey: route.request().headers().apikey,
password: route.request().postDataJSON().password,
};
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});

await page.goto(`${base}/confirmed#access_token=recovery-token&type=recovery`);
await expect(page).toHaveURL(`${base}/reset-password`);
await page.getByLabel("New password", { exact: true }).fill("safe-new-password");
await page.getByLabel("Confirm new password").fill("safe-new-password");
await page.getByRole("button", { name: "Update password" }).click();
await expect(page.getByRole("alert")).toContainText("Password updated");
expect(authRequest).toEqual({
method: "PUT",
authorization: "Bearer recovery-token",
apikey: "public-anon-key",
password: "safe-new-password",
});
});

test("expired recovery link shows an error and never calls Auth", async ({ page }) => {
await serveAuthPages(page);
let called = false;
await page.route(`${origin}/auth/v1/user`, async route => {
called = true;
await route.fulfill({ status: 500, body: "Unexpected request" });
});
await page.goto(`${base}/reset-password#error=access_denied&error_description=Email%20link%20expired`);
await expect(page.getByRole("alert")).toHaveText("Email link expired");
await expect(page.getByRole("button", { name: "Update password" })).toBeHidden();
await expect(page).toHaveURL(`${base}/reset-password`);
expect(called).toBe(false);
});

test("password update rejects Auth redirects without forwarding the recovery token", async ({ page }) => {
await serveAuthPages(page);
let redirected = false;
await page.route(`${origin}/auth/v1/user`, route => route.fulfill({
status: 302,
headers: { location: `${origin}/auth/v1/redirect-target` },
}));
await page.route(`${origin}/auth/v1/redirect-target`, async route => {
redirected = true;
await route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
});

await page.goto(`${base}/reset-password#access_token=recovery-token&type=recovery`);
await page.getByLabel("New password", { exact: true }).fill("safe-new-password");
await page.getByLabel("Confirm new password").fill("safe-new-password");
await page.getByRole("button", { name: "Update password" }).click();
await expect(page.getByRole("alert")).toContainText("Could not reset your password");
expect(redirected).toBe(false);
});
38 changes: 38 additions & 0 deletions admin/tests/public-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,44 @@ test.beforeEach(async ({ page }) => {
}));
});

test("review privacy policy opens the published learner privacy page", async ({ page }) => {
const html = await readFile(resolve("public/privacy/index.html"), "utf8");
await page.route("**/privacy", route => route.fulfill({ contentType: "text/html", body: html }));
await page.goto("/privacy.html");
await page.getByRole("link", { name: "One Concept learning app privacy policy" }).click();
await expect(page).toHaveURL(/\/privacy\/?$/);
await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible();
});

test("Netlify confirmation stays neutral until an Auth redirect and clears its session", async ({ page }) => {
const html = await readFile(resolve("public/confirmed/index.html"), "utf8");
await page.route("**/confirmed", route => route.fulfill({ contentType: "text/html", body: html }));
await page.goto("/confirmed");
await expect(page.getByRole("heading", { name: "Check your email link" })).toBeVisible();
await expect(page).toHaveTitle("One Concept — email confirmation");

await page.goto("/");
await page.goto("/confirmed#access_token=confirmed-token&type=signup");
await expect(page.getByRole("heading", { name: "Email confirmed ✓" })).toBeVisible();
await expect(page).toHaveURL(/\/confirmed$/);

await page.goto("/");
await page.goto("/confirmed#error=access_denied&error_description=Email%20link%20expired");
await expect(page.getByRole("heading", { name: "Confirmation did not finish" })).toBeVisible();
await expect(page.locator("#message")).toHaveText("Email link expired");
await expect(page).toHaveURL(/\/confirmed$/);
});

test("Netlify confirmation forwards a recovery session to its reset form", async ({ page }) => {
const html = await readFile(resolve("public/confirmed/index.html"), "utf8");
const reset = await readFile(resolve("public/reset-password/index.html"), "utf8");
await page.route("**/confirmed", route => route.fulfill({ contentType: "text/html", body: html }));
await page.route("**/reset-password", route => route.fulfill({ contentType: "text/html", body: reset }));
await page.goto("/confirmed#access_token=recovery-token&type=recovery");
await expect(page).toHaveURL(/\/reset-password$/);
await expect(page.locator("#reset-form")).toBeVisible();
});

test("shared profile reads only public JSON and hides a revoked profile", async ({ page }) => {
const html = await readFile(resolve("public/p/index.html"), "utf8");
await page.route(`**/p/${token}`, (route) => route.fulfill({ contentType: "text/html", body: html }));
Expand Down
144 changes: 54 additions & 90 deletions docs/AUTH_REDIRECTS.md
Original file line number Diff line number Diff line change
@@ -1,94 +1,58 @@
# Authentication email redirects

Supabase verifies email addresses itself. After verification, it redirects the
browser to a permitted HTTPS URL. One Concept uses its FastAPI public pages for
that final browser step:

- `GET /confirmed` displays the One Concept confirmation page and asks the
person to return to the app.
- `GET /reset-password` displays the One Concept password-reset form.

These pages are product pages served by the API. Railway is only the current
hosting provider; its name, error page, or dashboard must never be part of the
learner experience.

The mobile app supplies `emailRedirectTo` for new sign-ups and `redirectTo` for
password recovery from `EXPO_PUBLIC_API_BASE_URL`. This keeps both flows on the
same deployed API origin instead of relying only on a stale Supabase Site URL.
Supabase must allow those destinations before it will honor them.

## Owner checklist

Do these **before merging the PR**. They are safe while the PR is open and do
not require a mobile build, database migration, secret, or payment.

1. In Railway, open the production **api** service, then **Settings →
Networking**. Copy the active public HTTPS domain exactly. Do not copy a
deployment ID, internal service URL, or an old removed domain.
2. In a private browser window, open `https://YOUR-API-DOMAIN/confirmed`.
Continue only when it displays the One Concept **“Email confirmed”** page.
If Railway shows “The train has not arrived at the station”, wait for the
deployment/domain to become active or create and attach a new Railway domain
to the current api service. Do not place that broken address in Supabase.
3. Open Supabase **Authentication → URL Configuration** for the production
project. Set **Site URL** to `https://YOUR-API-DOMAIN`. This remains the
fallback for old app versions and any request that lacks a redirect value.
4. Under **Redirect URLs**, add these exact entries:

```text
https://YOUR-API-DOMAIN/confirmed
https://YOUR-API-DOMAIN/reset-password
```

Keep any still-working previous recovery URL until links already sent to
users have expired. Do not add wildcards, credentials, a Railway dashboard
URL, `localhost`, or a URL with a query string.
5. In Expo/EAS, inspect the **production** value of
`EXPO_PUBLIC_API_BASE_URL`. It must equal `https://YOUR-API-DOMAIN` with no
trailing path, query, fragment, username, or password. The value is public
configuration; never put Supabase service keys or database passwords there.
6. Save the dashboard settings and capture the exact domain in the release
record. No source-code configuration file should contain the live domain if
EAS already owns that public setting.

## After this PR merges to `develop`

1. Wait for the normal preview EAS update workflow to complete, then install or
open the staging/preview app. A merge to `develop` does **not** update the
production app.
2. Create a fresh test account with an inbox you control. Open the confirmation
email and check this sequence:
# Authentication email redirects after the Supabase cutover

Supabase Auth verifies email links before redirecting the browser to an allowed
HTTPS landing URL. The production mobile release on `main` sends new sign-up and
recovery links to the Netlify browser pages built from
`EXPO_PUBLIC_WEB_BASE_URL`. Those pages remain the current explicit redirect
destinations; their `/confirmed` and `/reset-password` flows are tested in
`admin/tests/public-pages.spec.ts`.

The Supabase `api` Edge function also serves browser pages at:

- `/functions/v1/api/confirmed`: shows a neutral message on direct visits and a
confirmation message after an Auth redirect. A recovery session sent to this
page is forwarded to the reset page with its URL fragment.
- `/functions/v1/api/reset-password`: accepts a recovery session in the URL
fragment, removes it from the address bar, and updates the password through
Supabase Auth. A missing or expired link displays an error.
- `/functions/v1/api/privacy`: shows the learner privacy policy.

Once configured as the Site URL, the Edge pages provide a fallback for new
links that omit an explicit redirect. They also support the API-based redirect
configuration planned on `develop`. They contain only the
Supabase project URL and publishable key; no service credential is sent to the
browser. Their route and browser behavior are covered in
`supabase/functions/api/handler.test.mjs` and
`admin/tests/auth-landing.spec.ts`.

## Production deployment and verification

1. Deploy the reviewed `api` Edge function revision through the protected
production deployment workflow. Confirm `/health` and each public page over
HTTPS. A direct visit to `/confirmed` must ask the visitor to open an email
link; a direct visit to `/reset-password` must report an invalid link.
2. In the matching Supabase project, allow the exact `/confirmed` and
`/reset-password` Edge URLs under **Authentication → URL Configuration**.
Keep the working Netlify redirect URLs while the released mobile app still
uses them. Do not add a wildcard or a retired host.

```text
Supabase verifies the address → One Concept confirmation page → sign in succeeds
https://egdszjoesxlccauahmsx.supabase.co/functions/v1/api/confirmed
https://egdszjoesxlccauahmsx.supabase.co/functions/v1/api/reset-password
```

The browser must not show a Railway 404 page. Test password recovery too;
it must open the One Concept reset-password page and permit sign-in with the
new password.
3. If the test fails, keep the PR merged but stop the production release. Check
the exact generated email URL, the Railway domain and the Supabase allowlist
rather than changing templates or exposing a credential.

## Before production users receive the fix

This is a JavaScript mobile change. After the preview test passes, include it in
a normal `develop` → `main` release PR. After that release PR is merged, verify
the deployed API health and manually run the repository’s **Release** workflow
on the verified `main` commit. That publishes the production EAS OTA update to
compatible installed builds. A production user receives it on a later app
launch; an update is not instantaneous.

Changing the Supabase Site URL in the checklist is still worthwhile before the
OTA: it repairs the fallback redirect used by older app versions. Do not claim
production confirmation is fixed until a new controlled account has completed
the real email flow.

## Future own-domain upgrade

When One Concept has a domain, point a subdomain such as
`app.oneconcept.example` at the API service, wait for HTTPS provisioning, then
repeat the checklist using that domain. A true browser-to-app open button later
requires Android App Links (and iOS Universal Links) hosted on that domain. It
is separate from email verification and should be introduced with its own
native release, association-file validation, and device testing.
3. Keep the **Site URL** fallback on a working page. Change it to the Edge
`/confirmed` page only after that page is deployed and its recovery
forwarding is verified. The Site URL is used when a client supplies no
explicit redirect.
4. Check Expo's production `EXPO_PUBLIC_API_BASE_URL`,
`EXPO_PUBLIC_SUPABASE_URL`, and `EXPO_PUBLIC_WEB_BASE_URL` against the
production Supabase project and current Netlify site. The publishable key
must belong to the same project.
5. With an inbox and account you control, request a fresh signup confirmation
and password recovery email. Check that confirmation permits sign-in, and
that recovery accepts a new password and permits sign-in with it. Do not
share email links, URL fragments, tokens, or passwords in logs or chat.

A `200` response from a landing page proves only that the page is served; it
does not prove that Supabase verified the email or changed the password.
2 changes: 1 addition & 1 deletion docs/CODEBASE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ learned history, streaks, likes, saved concepts, and push reminders.
| Area | Entry points and purpose |
| --- | --- |
| Mobile | `mobile/README.md` is the local developer on-ramp; `mobile/index.ts` registers `mobile/App.tsx`; Expo SDK 57, React Native 0.86, React 19, TypeScript. `mobile/src/screens/AboutScreen.tsx` links learners to the public review explanation. |
| Review website and public pages | `admin/README.md`, `admin/src/main.tsx`, `App.tsx`; static editorial workspace using Supabase Auth and the configured API. `admin/public/confirmed/`, `reset-password/`, `privacy/`, and `p/` host browser-only pages outside Supabase Edge Functions. `admin/vite.config.ts` emits publishable-only page config and `admin/src/hosting.ts` emits security headers. `admin/public/_redirects` maps public paths and SPA links on Netlify. |
| Review website and public pages | `admin/README.md`, `admin/src/main.tsx`, `App.tsx`; static editorial workspace using Supabase Auth and the configured API. `admin/public/confirmed/`, `reset-password/`, `privacy/`, and `p/` host browser-only pages on Netlify. `supabase/functions/api/public_pages.ts` provides equivalent Edge confirmation, recovery, and learner privacy pages. `admin/vite.config.ts` emits publishable-only page config and `admin/src/hosting.ts` emits security headers. `admin/public/_redirects` maps public paths and SPA links on Netlify. |
| Backend | `backend/app/main.py`; FastAPI, async SQLAlchemy/asyncpg, Pydantic settings, ES256 JWT verification. Docker uses Python 3.12. |
| Database | `backend/migrations/`; Supabase PostgreSQL schema, RLS, seeds, and incremental migrations. |
| Content lifecycle | `docs/CONTENT_ARCHITECTURE.md`, `docs/CONTENT_OPERATIONS.md`; portable subject/curriculum imports, durable refill, reviewed publication, daily review, protected health report. |
Expand Down
Loading
Loading