MODULES / FOUNDATION

Profiles

Free · MITv0.1.0

Sign-in, sign-up and account management on Supabase Auth, with a profile per user.

terminalnpx ajolla add profiles

What you get

  • Sign in with password, magic link or OAuth (Google, GitHub and more)
  • Sign up with email confirmation, password reset and safe ?next= redirects
  • Account page: name, bio, language, time zone and avatar upload
  • Change email or password, download my data, delete my account
  • Admin user directory with search, pagination and user details
  • Profile row per user kept in sync with Supabase Auth by a trigger
  • Row-level security for owners and admins; avatars in a locked-down bucket
  • Hosted REST API to store profiles for apps with their own auth

Routes it adds

/account/admin/users/admin/users/[userId]/auth/callback/auth/confirm/auth/update-password/forgot-password/sign-in/sign-up

/api/ajolla/profiles/export

Plus 2 Supabase migrations with row-level security, and an AGENTS.md your coding agent reads after install.

Installs with

Kernel (added automatically).

Configuration

AJOLLA_SITE_URL
Your app's public URL (e.g. https://example.com), used for email and OAuth links. Defaults to the request's host.
NEXT_PUBLIC_AJOLLA_OAUTH_PROVIDERS
Comma-separated OAuth providers to offer on sign-in/sign-up, e.g. "google,github". Enable them in Supabase too.

Events

Other modules (and your own code) can react to these:

profiles.updatedprofiles.account_deleted

Use it from any stack

Ajolla Hosted runs the same code as a multi-tenant API. Create an app in your dashboard, enable Profiles, and call /api/m/profiles/v1 with your app key.

OpenAPI document — feed it to Ajolla to generate SDKs in 7 languages and an MCP server.

AGENTS.md

Read the instructions installed with this module
markdown# Ajolla Profiles

Sign-in, sign-up and account management on Supabase Auth, plus one profile row per user.

## What was installed

### Routes
| Route | What it does |
|---|---|
| `/sign-in` | Email + password, magic link, and OAuth buttons. Honors `?next=`. |
| `/sign-up` | Email, password and display name. Shows "check your email" when confirmation is on. |
| `/forgot-password` | Emails a password reset link. |
| `/auth/update-password` | Set a new password after following a reset link. |
| `/auth/callback` | PKCE callback (`exchangeCodeForSession`) for OAuth and default email links. |
| `/auth/confirm` | `verifyOtp` for `token_hash` email links (works across browsers). |
| `/account` | Profile (name, bio, language, time zone), avatar, email, password, data download, account deletion. |
| `/api/ajolla/profiles/export` | `GET`: the signed-in user's data as a JSON download. |
| `/admin/users`, `/admin/users/[userId]` | User directory with search and pagination (needs `profiles.manage`). |

Every `?next=` is reduced to a same-site relative path (`lib/ajolla/profiles/auth.ts` → `safeRedirectPath`), so these routes can't be used as open redirects. Signed-out users are sent to the kernel's `SIGN_IN_PATH` (`AJOLLA_SIGN_IN_PATH`, default `/sign-in`).

### Files
- `lib/ajolla/profiles/core.ts`: profile reads/writes and validators (`canonicalTimeZone`, `canonicalLocale`, `normalizeSearch`). It is framework-free and shared with Ajolla Hosted.
- `lib/ajolla/profiles/auth.ts`: auth form schemas, `safeRedirectPath`, OAuth provider parsing and friendly Supabase error messages.
- `lib/ajolla/profiles/avatar.ts`: avatar limits and file-signature checks.
- `lib/ajolla/profiles/server.ts`: `siteOrigin()` and `authCallbackUrl()` (server-only).
- `components/ajolla/profiles/`: `<SignOutButton />`, `<Avatar />`, `<OAuthButtons />`, and the sign-out / OAuth server actions.
- `lib/ajolla/admin/profiles.ts`: admin page registration.

### Database
- `ajolla_profiles_profiles` has one row per `auth.users` row, with `user_id`, `email`, `display_name`, `avatar_url`, `bio`, `locale`, `timezone`, `metadata`, `created_at` and `updated_at`. A trigger creates it at sign-up (seeding the name and avatar from sign-up or OAuth metadata) and keeps `email` in sync. Existing users are backfilled.
- RLS: users read their own row, and holders of `profiles.manage` read every row. Users may update only `display_name`, `avatar_url`, `bio`, `locale` and `timezone`, because `email` belongs to Supabase Auth and `metadata` belongs to your server code (write it with the secret key). Anonymous visitors see nothing.
- Storage bucket `ajolla-avatars` is public-read, takes PNG/JPEG/WebP up to 1 MB, and each user can write only under `<their user id>/`.

### Events
- `profiles.updated`: `{ userId, fields: string[] }` after a profile or avatar change.
- `profiles.account_deleted`: `{ userId, email }`. It is emitted **before** the auth user is deleted, so subscribers can still act on it.

### Env
- `AJOLLA_SITE_URL` (recommended in production): the public origin used to build email and OAuth links. Without it, the request host is used.
- `NEXT_PUBLIC_AJOLLA_OAUTH_PROVIDERS`: for example `google,github`. Only these providers get buttons, and the server action rejects anything else.
- Account deletion and the admin user view use the kernel's `SUPABASE_SECRET_KEY`.

## Supabase dashboard setup
1. **Authentication → URL Configuration**
   - Site URL: your production URL (the same as `AJOLLA_SITE_URL`).
   - Redirect URLs: add `https://your-domain/auth/callback`, `https://your-domain/auth/confirm` and the local equivalents (`http://localhost:3000/auth/callback`, `http://localhost:3000/auth/confirm`). For preview deployments, add a wildcard such as `https://*-your-team.vercel.app/**`.
2. **Authentication → Email Templates** (recommended). Use `token_hash` links so they work even when opened in another browser or on another device:
   - Confirm signup: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next=/account`
   - Magic link: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=magiclink&next=/account`
   - Reset password: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=recovery&next=/auth/update-password`
   - Change email address: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email_change&next=/account?notice=email-updated`
   - Invite user: `{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=invite&next=/auth/update-password`

   If you keep the default templates, links go through `/auth/callback`, which only works in the browser that started the flow.
3. **Authentication → Providers**: enable each OAuth provider listed in `NEXT_PUBLIC_AJOLLA_OAUTH_PROVIDERS`, and set its callback to Supabase's own URL.
4. **Authentication → Sign In / Providers → Email**: choose whether "Confirm email" is on. `/sign-up` handles both cases. Leave "Secure email change" on.
5. Apply migrations with `supabase db push`. This also creates the `ajolla-avatars` bucket.

## Customising
- **Where users land after sign-in:** change `DEFAULT_AFTER_SIGN_IN` in `lib/ajolla/profiles/auth.ts`.
- **Header:** render `<SignOutButton />` and `<Avatar src={profile.avatar_url} name={profile.display_name} />`, using `getProfile(await getAjollaScope(), user.id)`.
- **More profile fields:** add a column in a new migration, grant `update (column)` to `authenticated` if users should edit it, then extend `profileInput`, `accountProfileInput` and `ProfileForm`.
- **Server-managed data** (plan, onboarding flags): write `metadata` with `updateProfile({ db: createAjollaAdminClient(), appId: null }, userId, { metadata })`.
- **Admins:** until Roles is installed, admins are users with `app_metadata.role = "admin"` (see the kernel's AGENTS.md).
- **Account deletion:** handle `profiles.account_deleted` with an event subscriber (`defineSubscribers` from `@ajolla/kernel`, registered alongside the generated `lib/ajolla/subscribers/index.ts`) to clean up data outside Ajolla, such as Stripe customers.