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 (
|
const createClientAccount = async (
|
||||||
email: string,
|
email: string,
|
||||||
input?: { onboarded?: boolean; password?: string }
|
input?: { onboarded?: boolean; password?: string }
|
||||||
|
|
@ -160,6 +178,106 @@ describe("Client Invites Endpoints", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await cleanupInvitesByEmail(testEmail);
|
await cleanupInvitesByEmail(testEmail);
|
||||||
await cleanupInvitesByEmail(existingClientEmail);
|
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 () => {
|
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: {
|
client_invites: {
|
||||||
Row: {
|
Row: {
|
||||||
created_at: string;
|
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