# Authentication Setup · Member + Partner Login

How to wire **real login** into the static PETVITY site without rebuilding everything. Recommendation: **Supabase**. Free tier, EU-hosted, drop-in.

---

## TL;DR — Why Supabase

- **EU servers** (Frankfurt) — matches your Swiss/EU data-residency story
- **Free tier**: 50,000 monthly active users · 500MB DB · 1GB file storage
- **Built-in**: email/password, OAuth (Google, Apple, Facebook), magic link, OTP
- **Row-Level Security** (RLS) — keeps members and partners separate at the database layer
- **Drop-in** — works with vanilla HTML + JS, no framework needed

Cost projection:
- 0–50k members: **CHF 0/mo** (free tier)
- 50k–100k members: **CHF 25/mo** (Pro tier)

---

## What you'll have at the end

| | Today (demo) | After this guide |
|---|---|---|
| Member login | localStorage UI shell | Real email/password + Google/Apple |
| Partner login | UI shell | Same, with separate role |
| Pet profiles | localStorage | Cloud-synced, persistent across devices |
| Share with expert | Demo link | Real signed URL with expiry |
| Course progress | Demo | Tracked per user |
| Order history | Demo | Synced from Shopify |

---

## Step 1 · Create your Supabase project (5 min)

1. <https://supabase.com> → Sign up (free, GitHub or email)
2. **New project** · name: `petvity` · region: **Frankfurt (eu-central-1)**
3. Set a strong DB password — save it (you won't see it again)
4. Wait ~2 minutes for provisioning
5. From the dashboard, copy two values you'll need later:
   - **Project URL**: `https://abcd1234.supabase.co`
   - **anon (public) API key**: `eyJhbGciOiJI...` (long token, starts with `eyJ`)

---

## Step 2 · Define the data model (10 min)

In the Supabase Dashboard → **SQL Editor** → **New query**, paste and run:

```sql
-- profiles · extends Supabase auth.users with our app data
create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  role text not null default 'member' check (role in ('member','partner','admin')),
  first_name text,
  last_name text,
  country text,
  membership_tier text default 'free',
  membership_renewal_at timestamptz,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- pets · belongs to a profile
create table public.pets (
  id uuid primary key default gen_random_uuid(),
  owner_id uuid not null references public.profiles(id) on delete cascade,
  name text not null,
  species text not null check (species in ('dog','cat','horse','other')),
  breed text,
  birth_year int,
  weight_kg numeric,
  sex text,
  data jsonb,                            -- lifestyle, goals, concerns, harmony score
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now()
);

-- pet_assets · vet records, photos, videos uploaded for each pet
create table public.pet_assets (
  id uuid primary key default gen_random_uuid(),
  pet_id uuid not null references public.pets(id) on delete cascade,
  kind text not null,                    -- 'vet_record','photo','video','note'
  storage_path text,                     -- key in Supabase Storage
  filename text,
  size_bytes int,
  mime_type text,
  notes text,
  created_at timestamptz not null default now()
);

-- partner_applications · for B2B partner registrations
create table public.partner_applications (
  id uuid primary key default gen_random_uuid(),
  email text not null,
  category text not null,                -- 'vet','sitter','coach','brand','hotel','company'
  company_name text,
  country text,
  payload jsonb,                         -- full Tally answers
  status text not null default 'pending' check (status in ('pending','approved','rejected')),
  reviewed_by uuid references public.profiles(id),
  created_at timestamptz not null default now()
);

-- shared_pet_profiles · for "share with expert" links
create table public.shared_pet_profiles (
  id uuid primary key default gen_random_uuid(),
  pet_id uuid not null references public.pets(id) on delete cascade,
  shared_by uuid not null references public.profiles(id),
  shared_with_email text,                -- expert email (or partner_id later)
  expires_at timestamptz not null default (now() + interval '14 days'),
  view_count int default 0,
  created_at timestamptz not null default now()
);

-- INDEXES
create index on public.pets(owner_id);
create index on public.pet_assets(pet_id);
create index on public.partner_applications(email);
create index on public.shared_pet_profiles(pet_id);
```

---

## Step 3 · Row-Level Security (10 min)

This is the magic that keeps member data private. Run in SQL Editor:

```sql
-- Enable RLS on all tables
alter table public.profiles enable row level security;
alter table public.pets enable row level security;
alter table public.pet_assets enable row level security;
alter table public.partner_applications enable row level security;
alter table public.shared_pet_profiles enable row level security;

-- profiles: users see only their own
create policy "users see own profile" on public.profiles
  for select using (auth.uid() = id);
create policy "users update own profile" on public.profiles
  for update using (auth.uid() = id);

-- pets: only owner can see/modify
create policy "owners see their pets" on public.pets
  for select using (auth.uid() = owner_id);
create policy "owners insert pets" on public.pets
  for insert with check (auth.uid() = owner_id);
create policy "owners update pets" on public.pets
  for update using (auth.uid() = owner_id);
create policy "owners delete pets" on public.pets
  for delete using (auth.uid() = owner_id);

-- pet_assets: only via owned pets
create policy "owners see their assets" on public.pet_assets
  for select using (
    pet_id in (select id from public.pets where owner_id = auth.uid())
  );

-- partner_applications: anyone can insert, only admins can read all
create policy "anyone applies" on public.partner_applications
  for insert with check (true);
create policy "admins see all applications" on public.partner_applications
  for select using (
    exists (select 1 from public.profiles where id = auth.uid() and role = 'admin')
  );

-- Auto-create profile on signup
create or replace function public.handle_new_user()
returns trigger as $$
begin
  insert into public.profiles (id, role)
  values (new.id, 'member');
  return new;
end;
$$ language plpgsql security definer;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();
```

---

## Step 4 · Configure auth providers (5 min)

In Supabase Dashboard → **Authentication** → **Providers**:

1. **Email** — already on. Enable **Confirm email** if you want verification emails
2. **Google OAuth** — toggle on, paste Google Client ID + Secret (get them from <https://console.cloud.google.com>)
3. **Apple Sign-in** — toggle on (needs Apple Developer account)

In **Authentication** → **URL Configuration**:
- **Site URL**: `https://petvity.com` (or your Netlify URL while testing)
- **Redirect URLs**: add both `http://localhost:8000` and `https://petvity.com`

---

## Step 5 · Wire up the website (15 min)

Add Supabase to the site. Create `assets/auth.js`:

```js
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';

const SUPABASE_URL = 'https://YOUR_PROJECT.supabase.co';
const SUPABASE_ANON_KEY = 'YOUR_ANON_KEY';

export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);

// Sign up
export async function signUp(email, password, firstName, country) {
  const { data, error } = await supabase.auth.signUp({
    email, password,
    options: { data: { first_name: firstName, country } }
  });
  return { data, error };
}

// Sign in
export async function signIn(email, password) {
  return await supabase.auth.signInWithPassword({ email, password });
}

// Sign in with Google
export async function signInWithGoogle() {
  return await supabase.auth.signInWithOAuth({
    provider: 'google',
    options: { redirectTo: window.location.origin + '/dashboard.html' }
  });
}

// Get current user
export async function getCurrentUser() {
  const { data: { user } } = await supabase.auth.getUser();
  return user;
}

// Sign out
export async function signOut() {
  return await supabase.auth.signOut();
}

// Save pet
export async function savePet(petData) {
  const user = await getCurrentUser();
  if (!user) throw new Error('Not signed in');
  return await supabase.from('pets').insert({ owner_id: user.id, ...petData });
}

// Load all pets for current user
export async function loadPets() {
  const { data } = await supabase.from('pets').select('*').order('created_at', { ascending: false });
  return data || [];
}

// Upload pet asset
export async function uploadAsset(petId, file, kind = 'photo') {
  const user = await getCurrentUser();
  if (!user) throw new Error('Not signed in');
  const path = `${user.id}/${petId}/${Date.now()}-${file.name}`;
  const { error: upErr } = await supabase.storage.from('pet-assets').upload(path, file);
  if (upErr) throw upErr;
  return await supabase.from('pet_assets').insert({
    pet_id: petId, kind, storage_path: path, filename: file.name,
    size_bytes: file.size, mime_type: file.type,
  });
}

// Generate signed share link for a pet (valid 14 days)
export async function generateShareLink(petId, expertEmail) {
  return await supabase.from('shared_pet_profiles').insert({
    pet_id: petId, shared_with_email: expertEmail
  }).select().single();
}
```

In `login.html`, replace the demo `demoSignIn()` with:

```html
<script type="module">
  import { signIn, signInWithGoogle } from './assets/auth.js';

  document.querySelector('form').addEventListener('submit', async (e) => {
    e.preventDefault();
    const email = document.getElementById('emailInput').value;
    const password = document.querySelector('input[type=password]').value;
    const { error } = await signIn(email, password);
    if (error) return alert(error.message);
    window.location.href = 'dashboard.html';
  });

  document.querySelector('.oauth button:first-child').onclick = () => signInWithGoogle();
</script>
```

---

## Step 6 · Storage bucket for uploads (3 min)

Supabase Dashboard → **Storage** → **Create bucket** → name: `pet-assets` · public: **off** · file size limit: 50 MB

Add storage RLS policy:

```sql
create policy "users upload to own folder"
on storage.objects for insert
with check (auth.uid()::text = (storage.foldername(name))[1]);

create policy "users see own files"
on storage.objects for select
using (auth.uid()::text = (storage.foldername(name))[1]);
```

---

## Step 7 · Migrate existing localStorage data (one-time, optional)

When users first sign in after the upgrade, migrate their localStorage profiles:

```js
const localProfiles = JSON.parse(localStorage.getItem('petvity-profiles') || '[]');
for (const profile of localProfiles) {
  await supabase.from('pets').insert({
    owner_id: user.id,
    name: profile.name,
    species: profile.species,
    breed: profile.breed,
    weight_kg: profile.weight,
    data: profile,
  });
}
localStorage.removeItem('petvity-profiles');
```

---

## Partner login (separate role)

The same Supabase Auth handles partners. After they apply via Tally → admin reviews → admin sets `role = 'partner'` in their `profiles` row → they can sign in via `partner-login.html`.

In `partner-login.html` after sign-in:

```js
const { data: profile } = await supabase.from('profiles').select('role').eq('id', user.id).single();
if (profile.role !== 'partner') {
  await supabase.auth.signOut();
  return alert('This account is not a partner. Apply at /professionals.html');
}
window.location.href = 'partner-dashboard.html';
```

(Build `partner-dashboard.html` later — same pattern as member dashboard.)

---

## Total setup time

| Task | Time |
|---|---|
| Create project | 5 min |
| SQL schema | 10 min |
| RLS policies | 10 min |
| Auth providers | 5 min |
| Wire login.html + dashboard.html | 15 min |
| Storage bucket | 3 min |
| **Total** | **~50 min** |

---

## Going further

- **Stripe sync** — sync membership tier to `profiles.membership_tier` via Stripe webhook → Supabase Edge Function
- **Shopify sync** — pull order history into the dashboard via Shopify API + Edge Function
- **Magic-link login** — quieter, no password — built-in: `supabase.auth.signInWithOtp({ email })`
- **2FA** — Supabase Auth supports TOTP

---

## Cost summary at scale

| Tier | Members | Storage | Monthly |
|---|---|---|---|
| Free | <50k MAU | 1 GB | CHF 0 |
| Pro | 50k–100k MAU | 100 GB | CHF 25 |
| Team | enterprise | unlimited | CHF 599+ |

---

*Last updated: 9 May 2026*
