feat: add client auth tables
This commit is contained in:
parent
f3ea5ac76e
commit
fda95d9ce4
3 changed files with 350 additions and 0 deletions
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Reference in a new issue