feat: add client auth tables

This commit is contained in:
Arthur Belleville 2026-04-30 18:33:31 +02:00
parent f3ea5ac76e
commit fda95d9ce4
3 changed files with 350 additions and 0 deletions

View file

@ -116,6 +116,24 @@ describe("Client Invites Endpoints", () => {
}
};
const cleanupClientAuthByEmail = async (email: string) => {
const normalizedEmail = email.trim().toLowerCase();
const { data: client } = await supabaseAdmin
.from("clients")
.select("id")
.eq("normalized_email", normalizedEmail)
.maybeSingle();
if (!client?.id) {
return;
}
await supabaseAdmin.from("client_magic_links").delete().eq("client_id", client.id);
await supabaseAdmin.from("client_access").delete().eq("client_id", client.id);
await supabaseAdmin.from("clients").delete().eq("id", client.id);
};
const createClientAccount = async (
email: string,
input?: { onboarded?: boolean; password?: string }
@ -160,6 +178,106 @@ describe("Client Invites Endpoints", () => {
beforeEach(async () => {
await cleanupInvitesByEmail(testEmail);
await cleanupInvitesByEmail(existingClientEmail);
await cleanupClientAuthByEmail(testEmail);
await cleanupClientAuthByEmail(existingClientEmail);
});
it("upserts one global client identity per normalized email", async () => {
const mixedCaseEmail = "Test_Client_Invite_New@Example.com";
const res = await postInvite(ownerUser, adminTabloId, mixedCaseEmail);
expect(res.status).toBe(200);
const { data: clients, error } = await supabaseAdmin
.from("clients")
.select("id, email, normalized_email")
.eq("normalized_email", mixedCaseEmail.toLowerCase());
expect(error).toBeNull();
expect(clients).toHaveLength(1);
expect(clients?.[0]?.email).toBe(mixedCaseEmail.toLowerCase());
expect(clients?.[0]?.normalized_email).toBe(mixedCaseEmail.toLowerCase());
});
it("creates a client_access grant for the invited tablo", async () => {
const res = await postInvite(ownerUser, adminTabloId, testEmail);
expect(res.status).toBe(200);
const { data: client } = await supabaseAdmin
.from("clients")
.select("id")
.eq("normalized_email", testEmail)
.single();
const { data: accessRows, error } = await supabaseAdmin
.from("client_access")
.select("id, tablo_id, client_id, revoked_at")
.eq("client_id", client.id)
.eq("tablo_id", adminTabloId);
expect(error).toBeNull();
expect(accessRows).toHaveLength(1);
expect(accessRows?.[0]?.revoked_at).toBeNull();
});
it("creates a one-time invite magic link row", async () => {
const res = await postInvite(ownerUser, adminTabloId, testEmail);
expect(res.status).toBe(200);
const { data: client } = await supabaseAdmin
.from("clients")
.select("id")
.eq("normalized_email", testEmail)
.single();
const { data: links, error } = await supabaseAdmin
.from("client_magic_links")
.select("id, client_id, purpose, consumed_at, expires_at, created_by")
.eq("client_id", client.id)
.eq("purpose", "invite");
expect(error).toBeNull();
expect(links).toHaveLength(1);
expect(links?.[0]?.consumed_at).toBeNull();
expect(links?.[0]?.expires_at).toBeTruthy();
expect(links?.[0]?.created_by).toBe(ownerUser.userId);
});
it("reuses the same client row when the same email is invited again", async () => {
const firstTabloId = adminTabloId;
const secondTabloId = "test_tablo_owner_shared";
const firstRes = await postInvite(ownerUser, firstTabloId, existingClientEmail);
expect(firstRes.status).toBe(200);
const { data: firstClient } = await supabaseAdmin
.from("clients")
.select("id")
.eq("normalized_email", existingClientEmail)
.single();
const secondRes = await postInvite(ownerUser, secondTabloId, existingClientEmail);
expect(secondRes.status).toBe(200);
const { data: secondClient } = await supabaseAdmin
.from("clients")
.select("id")
.eq("normalized_email", existingClientEmail)
.single();
const { data: accessRows, error } = await supabaseAdmin
.from("client_access")
.select("tablo_id")
.eq("client_id", firstClient.id);
expect(error).toBeNull();
expect(secondClient.id).toBe(firstClient.id);
expect(accessRows?.map((row) => row.tablo_id).sort()).toEqual(
[firstTabloId, secondTabloId].sort()
);
});
it("creates a setup token for a first-time client invite", async () => {

View file

@ -78,6 +78,165 @@ export type Database = {
},
];
};
client_access: {
Row: {
client_id: string;
created_at: string;
granted_at: string;
granted_by: string;
id: number;
revoked_at: string | null;
tablo_id: string;
};
Insert: {
client_id: string;
created_at?: string;
granted_at?: string;
granted_by: string;
id?: number;
revoked_at?: string | null;
tablo_id: string;
};
Update: {
client_id?: string;
created_at?: string;
granted_at?: string;
granted_by?: string;
id?: number;
revoked_at?: string | null;
tablo_id?: string;
};
Relationships: [
{
foreignKeyName: "client_access_client_id_fkey";
columns: ["client_id"];
isOneToOne: false;
referencedRelation: "clients";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_access_granted_by_fkey";
columns: ["granted_by"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_access_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "tablos";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_access_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "events_and_tablos";
referencedColumns: ["tablo_id"];
},
{
foreignKeyName: "client_access_tablo_id_fkey";
columns: ["tablo_id"];
isOneToOne: false;
referencedRelation: "user_tablos";
referencedColumns: ["id"];
},
];
};
client_magic_links: {
Row: {
client_id: string;
consumed_at: string | null;
created_at: string;
created_by: string | null;
email: string;
expires_at: string;
id: number;
jti: string | null;
purpose: string;
redirect_to: string | null;
token_hash: string | null;
};
Insert: {
client_id: string;
consumed_at?: string | null;
created_at?: string;
created_by?: string | null;
email: string;
expires_at: string;
id?: number;
jti?: string | null;
purpose: string;
redirect_to?: string | null;
token_hash?: string | null;
};
Update: {
client_id?: string;
consumed_at?: string | null;
created_at?: string;
created_by?: string | null;
email?: string;
expires_at?: string;
id?: number;
jti?: string | null;
purpose?: string;
redirect_to?: string | null;
token_hash?: string | null;
};
Relationships: [
{
foreignKeyName: "client_magic_links_client_id_fkey";
columns: ["client_id"];
isOneToOne: false;
referencedRelation: "clients";
referencedColumns: ["id"];
},
{
foreignKeyName: "client_magic_links_created_by_fkey";
columns: ["created_by"];
isOneToOne: false;
referencedRelation: "profiles";
referencedColumns: ["id"];
},
];
};
clients: {
Row: {
created_at: string;
email: string;
first_name: string | null;
id: string;
last_login_at: string | null;
last_name: string | null;
normalized_email: string;
phone: string | null;
updated_at: string;
};
Insert: {
created_at?: string;
email: string;
first_name?: string | null;
id?: string;
last_login_at?: string | null;
last_name?: string | null;
normalized_email: string;
phone?: string | null;
updated_at?: string;
};
Update: {
created_at?: string;
email?: string;
first_name?: string | null;
id?: string;
last_login_at?: string | null;
last_name?: string | null;
normalized_email?: string;
phone?: string | null;
updated_at?: string;
};
Relationships: [];
};
client_invites: {
Row: {
created_at: string;

View file

@ -0,0 +1,73 @@
create table if not exists public.clients (
id uuid primary key default gen_random_uuid(),
email text not null,
normalized_email text not null,
first_name text,
last_name text,
phone text,
last_login_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index if not exists clients_normalized_email_idx
on public.clients (normalized_email);
create table if not exists public.client_access (
id bigserial primary key,
client_id uuid not null references public.clients(id) on delete cascade,
tablo_id text not null references public.tablos(id) on delete cascade,
granted_by uuid not null references public.profiles(id),
granted_at timestamptz not null default now(),
revoked_at timestamptz,
created_at timestamptz not null default now()
);
create unique index if not exists client_access_active_unique_idx
on public.client_access (client_id, tablo_id)
where revoked_at is null;
create index if not exists client_access_client_id_idx
on public.client_access (client_id);
create index if not exists client_access_tablo_id_idx
on public.client_access (tablo_id);
create table if not exists public.client_magic_links (
id bigserial primary key,
client_id uuid not null references public.clients(id) on delete cascade,
email text not null,
purpose text not null check (purpose in ('invite', 'login')),
token_hash text,
jti text,
redirect_to text,
expires_at timestamptz not null,
consumed_at timestamptz,
created_by uuid references public.profiles(id),
created_at timestamptz not null default now(),
constraint client_magic_links_token_reference_check check (
token_hash is not null or jti is not null
)
);
create index if not exists client_magic_links_active_idx
on public.client_magic_links (client_id, purpose, expires_at)
where consumed_at is null;
create unique index if not exists client_magic_links_jti_unique_idx
on public.client_magic_links (jti)
where jti is not null;
create unique index if not exists client_magic_links_token_hash_unique_idx
on public.client_magic_links (token_hash)
where token_hash is not null;
alter table public.clients enable row level security;
alter table public.client_access enable row level security;
alter table public.client_magic_links enable row level security;
drop trigger if exists set_clients_updated_at on public.clients;
create trigger set_clients_updated_at
before update on public.clients
for each row
execute function public.set_updated_at();