Included product surfaces
Understand the shipped browser routes, settings flows, and zero-key product behavior.
Included product surfaces
The browser application is a neutral authenticated SaaS shell. It includes complete auth and settings paths plus a starter dashboard that must be replaced with the buyer's product outcome. React Router owns navigation, TanStack Query owns server state, and Better Auth owns the session.
Browser routes
| Route | Access | Included behavior |
|---|---|---|
/auth/login | public | magic link, password sign-in, optional Google |
/auth/signup | public | password signup and verification |
/auth/forgot-password | public | password-reset request |
/auth/reset-password | public token | password replacement and session revocation |
/auth/verify | public token | email verification callback |
/dashboard | authenticated | neutral starter state; replace it with the product home |
/users | authenticated | paginated, filterable user table with a mobile card list |
/organization | authenticated | active organization: members, invitations, and organization settings |
/invitations/:token | authenticated | invitation preview and acceptance |
/users/:userId | authenticated | one user's profile, account state, and linked sign-in providers |
/settings/profile | authenticated | name, phone number, and optional public profile image |
/settings/account | authenticated | linked provider state, Google linking when configured, and deletion |
/settings/billing | authenticated | trial, plans, checkout, portal, and provider-disabled states |
/settings/notifications | authenticated | email and in-app preferences |
/notifications | authenticated | inbox, unread state, mark-read, and delete |
Organizations and tenancy
Every account owns one personal organization, created by a database trigger when the user row is
inserted, so a caller always has an acting organization. organizationContext resolves it from
users.active_organization_id after session resolution and falls back to the personal organization
when the stored one is missing or no longer a membership.
Product data is scoped to organization_id; personal account data — profile, profile image,
notification preferences, sessions, and subscription — stays scoped to users.id. The /files
library therefore belongs to the active organization: every member sees it, a member may delete a
file they uploaded, and an admin or owner may delete any of them.
Roles are owner, admin, and member, and the whole policy is the can(role, permission)
function in packages/contracts/src/organizations.ts. The server enforces it through
requirePermission; GET /organizations/current returns the caller's permission list so the UI can
hide affordances it must not offer. Postgres enforces exactly one owner per organization, so
ownership moves only through POST /organizations/current/transfer-ownership.
A personal organization cannot be renamed, deleted, left, or given a second member. Deleting a team organization removes its members, invitations, and files, and returns every affected member to their personal organization. An organization never outlives its last member.
Invitations are single-use tokens delivered by email. Only the SHA-256 hash is stored, they expire after seven days, and acceptance requires the signed-in account's email to match the invited address. A revoked, expired, already-accepted, and unknown token all return the same not-found answer.
The inbox has three entry points sharing one NotificationInbox implementation: the navbar bell
opens a popover on desktop and a bottom sheet on mobile for triage, its "View all" opens the full
inbox in a modal, and /notifications renders the same inbox as a page for deep links, the command
palette, and links sent by email. The bell is the only permanent affordance; notifications are an
account-level utility and deliberately hold no primary sidebar slot.
Setting the optional VITE_MARKETING_URL links the brand lockup on every authentication screen to
the product's public marketing site and adds a "Website" entry to the account menu. Both render
without a link when it is unset, so a deployment with no separate marketing site is unchanged.
Every protected route passes through PrivateRoute. A missing session redirects to login; a session
resolution failure remains visible instead of rendering protected content from stale client state.
Profile and account ownership
GET /users/me returns the current user's public application profile. PUT /users/profile accepts
the shared UpdateProfileRequestSchema; the caller cannot choose another user ID. The profile-image
flow is separately entitlement-gated and provider-backed as described in
Object storage.
GET /users is a deployment-wide directory, not an organization roster: use
GET /organizations/current/members for the members of the acting organization. GET /users lists
non-deleted users for the table, and GET /users/:user_id returns one user's detail record for
/users/:userId. Both require a session, return no credential material, and never expose a deleted
account. Add an authorization rule before exposing either surface to a role that must not see the
whole directory.
Both surfaces also skip any user whose users.directory_visible is false. The column defaults to
true, so every account appears in the directory unless you opt it out. Set it to false — per row, or
by changing the column default on one deployment — for accounts that must use the product normally
while staying out of the directory other users can see, such as visitors on a public demo instance.
A hidden user keeps full access to their own GET /users/me profile and their own data; only the
cross-user directory stops listing them.
Email changes are not included. Add them only through a Better Auth-compatible flow with new-address verification, existing-session policy, notification, and recovery behavior. Do not mutate the email column through the generic profile update.
Account deletion uses DELETE /users/profile/account. It is blocked while a provider subscription
can renew or paid access remains. See Authentication for the terminal
deletion state and billing boundary.
Zero-key states
With only Postgres configured:
- password, verification, reset, and magic-link flows work through the local email log;
- Google controls are hidden;
- billing renders an actionable unavailable state without a checkout button;
- profile text fields work, while profile-image upload returns the documented storage-unavailable response;
- notifications work in-app when application events create them;
- organizations, members, roles, and invitations work; the invitation link is readable in the local email log, so a second account can accept an invitation with no email provider configured;
- the dashboard clearly identifies itself as starter content.
An unavailable optional provider is not an empty success. Keep the current disabled state visible or remove the complete subsystem using Removing subsystems.
Replacing the dashboard
Replace apps/web/src/modules/dashboard/pages/DashboardPage.tsx with the first product-owned route.
Keep the route lazy, use shared contracts and a service-owned TanStack Query hook, and cover
loading, empty, error, unauthorized, narrow, keyboard, and reduced-motion states in proportion to
the feature. The maintained implementation path is Build your first feature.
Verification
vp run --filter @app/web test
vp run --filter @app/web typecheck
vp run --filter @app/web buildProtected-route behavior, zero-key auth, 404 handling, protected API responses, and cross-origin rejection are covered by the server and contract suites rather than by a browser. Click through the changed surfaces with:
vp run --filter @app/web devProvider configuration still requires the provider-specific smoke paths.