Démo – données d’exemple · catalogue et entreprises fictifs

Architecture

La démonstration utilise la pile imposée. Seule la base change : SQLite remplace Supabase, avec les mêmes tables, et les règles d’accès vivent dans un seul module serveur dont chaque fonction correspond à une politique RLS.

Pile

TanStack Start (React 19, Vite)Rendu côté serveur de chaque page, routes fichiers, fonctions serveur pour les chargements.
TanStack Router + zodFiltres du catalogue dans l’URL, validés par validateSearch : un lien partagé ou rechargé redonne la même liste.
PuckÉditeur de blocs du back-office (/admin/pages) ; <Render> dans la route serveur de l’accueil.
SQLite (démo) → Supabase Postgres (cible)Même schéma de tables ; SQLite chargé en mémoire et réinitialisé à chaque redémarrage.
Module d’accès unique (src/server/acces.ts)Toutes les règles d’accès côté serveur, écrites pour correspondre une à une aux politiques RLS ci-dessous.
Cookie signé (HMAC)Profils de démonstration sans inscription ; remplacé par Supabase Auth en production.

Règles d’accès et politiques RLS équivalentes

peutLireDocument · documentsVisibles

Un document confidentiel n’est lisible que par un membre de son entreprise : l’admin toujours, les autres membres seulement sur les catégories accordées. Les FDS publiques sont lisibles par tous.

alter table public.documents enable row level security;

create or replace function public.a_acces_categorie(p_entreprise uuid, p_categorie text)
returns boolean
language sql stable security definer set search_path = public
as $$
  select exists (
    select 1 from memberships m
    where m.profile_id = auth.uid()
      and m.company_id = p_entreprise
      and (
        m.role = 'admin'
        or exists (
          select 1 from permissions p
          where p.profile_id = auth.uid()
            and p.company_id = p_entreprise
            and p.category_id = p_categorie
        )
      )
  );
$$;

create policy documents_lecture on public.documents
for select to anon, authenticated
using (
  (company_id is null and exists (
    select 1 from document_categories c where c.id = category_id and c.is_public
  ))
  or public.a_acces_categorie(company_id, category_id)
);

route /documents/$id (fichier PDF)

Le fichier suit la même règle que sa ligne : sans ligne visible, Storage refuse le téléchargement (403).

create policy documents_fichiers_lecture on storage.objects
for select to anon, authenticated
using (
  bucket_id = 'documents'
  and exists (select 1 from public.documents d where d.storage_path = name)
);

peutGererDroits · modifierDroit

Seul l’admin de l’entreprise accorde ou retire un droit, et seulement à un membre non admin de sa propre entreprise.

alter table public.permissions enable row level security;

create policy droits_gestion_admin on public.permissions
for all to authenticated
using (exists (
  select 1 from memberships m
  where m.profile_id = auth.uid() and m.company_id = permissions.company_id and m.role = 'admin'
))
with check (
  exists (
    select 1 from memberships m
    where m.profile_id = auth.uid() and m.company_id = permissions.company_id and m.role = 'admin'
  )
  and exists (
    select 1 from memberships cible
    where cible.profile_id = permissions.profile_id
      and cible.company_id = permissions.company_id
      and cible.role <> 'admin'
  )
);

peutEnvoyerDemandeDevis · peutLireDemandesDevis

Un membre crée une demande pour sa propre entreprise ; l’équipe interne lit toutes les demandes, un client seulement celles de son entreprise.

alter table public.quote_requests enable row level security;

create policy devis_creation_membre on public.quote_requests
for insert to authenticated
with check (
  profile_id = auth.uid()
  and exists (
    select 1 from memberships m
    where m.profile_id = auth.uid() and m.company_id = quote_requests.company_id
  )
);

create policy devis_lecture on public.quote_requests
for select to authenticated
using (
  exists (select 1 from profiles p where p.id = auth.uid() and p.is_staff)
  or exists (
    select 1 from memberships m
    where m.profile_id = auth.uid() and m.company_id = quote_requests.company_id
  )
);

peutPublierPage

Les pages publiées sont lisibles par tous ; seule l’équipe marketing publie.

alter table public.pages enable row level security;

create policy pages_lecture on public.pages for select using (true);

create policy pages_publication_marketing on public.pages
for all to authenticated
using (exists (select 1 from profiles p where p.id = auth.uid() and p.is_staff))
with check (exists (select 1 from profiles p where p.id = auth.uid() and p.is_staff));

Schéma cible Supabase (Postgres)

companies, profiles, memberships, document_categories, documents, permissions, products, quote_requests, quote_lines, pages.

create type member_role as enum ('admin', 'acheteur', 'technicien');

create table public.companies (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  siret text not null,
  city text not null,
  capacity_certificate_number text not null,
  capacity_certificate_valid_until date not null
);

create table public.profiles (
  id uuid primary key references auth.users(id) on delete cascade,
  full_name text not null,
  email text not null,
  is_staff boolean not null default false
);

create table public.memberships (
  profile_id uuid not null references public.profiles(id) on delete cascade,
  company_id uuid not null references public.companies(id) on delete cascade,
  role member_role not null,
  primary key (profile_id, company_id)
);

create table public.document_categories (
  id text primary key,
  label text not null,
  is_public boolean not null default false,
  sort_order int not null
);

create table public.documents (
  id uuid primary key default gen_random_uuid(),
  company_id uuid references public.companies(id),
  category_id text not null references public.document_categories(id),
  title text not null,
  storage_path text not null,
  created_at timestamptz not null default now()
);

create table public.permissions (
  company_id uuid not null references public.companies(id) on delete cascade,
  profile_id uuid not null references public.profiles(id) on delete cascade,
  category_id text not null references public.document_categories(id),
  granted_by uuid references public.profiles(id),
  granted_at timestamptz not null default now(),
  primary key (company_id, profile_id, category_id)
);

create table public.products (
  id uuid primary key default gen_random_uuid(),
  slug text not null unique,
  name text not null,
  kind text not null check (kind in ('retrofit', 'equipement', 'historique')),
  replaces text[] not null default '{}',
  gwp_ar4 int not null,
  safety_class text not null check (safety_class in ('A1', 'A2L', 'A3')),
  glide_k numeric(4, 1) not null,
  oils text[] not null,
  applications text[] not null,
  packagings text[] not null,
  composition text not null,
  regulatory_note text not null
);

create table public.quote_requests (
  id bigint generated always as identity primary key,
  reference text not null unique,
  company_id uuid not null references public.companies(id),
  profile_id uuid not null references public.profiles(id),
  company_siret text not null,
  capacity_certificate_number text not null,
  capacity_certificate_valid_until date not null,
  capacity_certificate_expired boolean not null,
  comment text not null default '',
  status text not null default 'reçue',
  created_at timestamptz not null default now()
);

create table public.quote_lines (
  id bigint generated always as identity primary key,
  quote_request_id bigint not null references public.quote_requests(id) on delete cascade,
  product_id uuid not null references public.products(id),
  product_name text not null,
  packaging text not null,
  quantity int not null check (quantity between 1 and 500)
);

create table public.pages (
  slug text primary key,
  published_data jsonb not null,
  published_at timestamptz not null default now(),
  published_by uuid references public.profiles(id)
);