2.18.0: New Invitation flow, refactored Database Webhooks, new ShadCN UI Components (#384)

* Streamlined invitations flow
* Removed web hooks in favor of handling logic directly in server actions
* Added new Shadcn UI Components
This commit is contained in:
Giancarlo Buomprisco
2025-10-05 17:54:16 +08:00
committed by GitHub
parent 195cf41680
commit 2e20d3e76f
60 changed files with 3760 additions and 1009 deletions

View File

@@ -0,0 +1,253 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database, Tables } from '@kit/supabase/database';
type Invitation = Tables<'invitations'>;
const invitePath = '/join';
const authTokenCallbackPath = '/auth/confirm';
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
const emailSender = process.env.EMAIL_SENDER;
const env = z
.object({
invitePath: z
.string({
required_error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
invitePath,
siteURL,
productName,
emailSender,
});
export function createAccountInvitationsDispatchService(
client: SupabaseClient<Database>,
) {
return new AccountInvitationsDispatchService(client);
}
class AccountInvitationsDispatchService {
private namespace = 'accounts.invitations.webhook';
constructor(private readonly adminClient: SupabaseClient<Database>) {}
/**
* @name sendInvitationEmail
* @description Sends an invitation email to the invited user
* @param invitation - The invitation to send
* @returns
*/
async sendInvitationEmail({
invitation,
link,
}: {
invitation: Invitation;
link: string;
}) {
const logger = await getLogger();
logger.info(
{
invitationId: invitation.id,
name: this.namespace,
},
'Handling invitation email dispatch...',
);
// retrieve the inviter details
const inviter = await this.getInviterDetails(invitation);
if (inviter.error) {
logger.error(
{
error: inviter.error,
name: this.namespace,
},
'Failed to fetch inviter details',
);
throw inviter.error;
}
// retrieve the team details
const team = await this.getTeamDetails(invitation.account_id);
if (team.error) {
logger.error(
{
error: team.error,
name: this.namespace,
},
'Failed to fetch team details',
);
throw team.error;
}
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
try {
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
// send the invitation email
await this.sendEmail({
invitation,
link,
inviter: inviter.data,
team: team.data,
});
return {
success: true,
};
} catch (error) {
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
return {
error,
success: false,
};
}
}
/**
* @name getInvitationLink
* @description Generates an invitation link for the given token and email
* @param token - The token to use for the invitation
*/
getInvitationLink(token: string) {
const siteUrl = env.siteURL;
const url = new URL(env.invitePath, siteUrl);
url.searchParams.set('invite_token', token);
return url.href;
}
/**
* @name sendEmail
* @description Sends an invitation email to the invited user
* @param invitation - The invitation to send
* @param link - The link to the invitation
* @param inviter - The inviter details
* @param team - The team details
* @returns
*/
private async sendEmail({
invitation,
link,
inviter,
team,
}: {
invitation: Invitation;
link: string;
inviter: { name: string; email: string | null };
team: { name: string };
}) {
const logger = await getLogger();
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
const { renderInviteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderInviteEmail({
link,
invitedUserEmail: invitation.email,
inviter: inviter.name ?? inviter.email ?? '',
productName: env.productName,
teamName: team.name,
});
return mailer
.sendEmail({
from: env.emailSender,
to: invitation.email,
subject,
html,
})
.then(() => {
logger.info(ctx, 'Invitation email successfully sent!');
})
.catch((error) => {
console.error(error);
logger.error({ error, ...ctx }, 'Failed to send invitation email');
});
}
/**
* @name getAuthCallbackUrl
* @description Generates an auth token callback url. This redirects the user to a page where the user can sign in with a token.
* @param nextLink - The next link to redirect the user to
* @returns
*/
getAuthCallbackUrl(nextLink: string) {
const url = new URL(authTokenCallbackPath, env.siteURL);
url.searchParams.set('next', nextLink);
return url;
}
/**
* @name getInviterDetails
* @description Fetches the inviter details for the given invitation
* @param invitation
* @returns
*/
private getInviterDetails(invitation: Invitation) {
return this.adminClient
.from('accounts')
.select('email, name')
.eq('id', invitation.invited_by)
.single();
}
/**
* @name getTeamDetails
* @description Fetches the team details for the given account ID
* @param accountId
* @returns
*/
private getTeamDetails(accountId: string) {
return this.adminClient
.from('accounts')
.select('name')
.eq('id', accountId)
.single();
}
}

View File

@@ -7,10 +7,12 @@ import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
import type { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
import type { InviteMembersSchema } from '../../schema/invite-members.schema';
import type { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsDispatchService } from './account-invitations-dispatcher.service';
/**
*
@@ -212,6 +214,8 @@ class AccountInvitationsService {
},
'Invitations added to account',
);
await this.dispatchInvitationEmails(ctx, responseInvitations);
}
/**
@@ -222,10 +226,12 @@ class AccountInvitationsService {
adminClient: SupabaseClient<Database>,
params: {
userId: string;
userEmail: string;
inviteToken: string;
},
) {
const logger = await getLogger();
const ctx = {
name: this.namespace,
...params,
@@ -233,6 +239,30 @@ class AccountInvitationsService {
logger.info(ctx, 'Accepting invitation to team');
const invitation = await adminClient
.from('invitations')
.select('email')
.eq('invite_token', params.inviteToken)
.single();
if (invitation.error) {
logger.error(
{
...ctx,
error: invitation.error,
},
'Failed to get invitation',
);
}
// if the invitation email does not match the user email, throw an error
if (invitation.data?.email !== params.userEmail) {
logger.error({
...ctx,
error: 'Invitation email does not match user email',
});
}
const { error, data } = await adminClient.rpc('accept_invitation', {
token: params.inviteToken,
user_id: params.userId,
@@ -297,4 +327,128 @@ class AccountInvitationsService {
return data;
}
/**
* @name dispatchInvitationEmails
* @description Dispatches invitation emails to the invited users.
* @param ctx
* @param invitations
* @returns
*/
private async dispatchInvitationEmails(
ctx: { accountSlug: string; name: string },
invitations: Database['public']['Tables']['invitations']['Row'][],
) {
if (!invitations.length) {
return;
}
const logger = await getLogger();
const adminClient = getSupabaseServerAdminClient();
const service = createAccountInvitationsDispatchService(this.client);
const results = await Promise.allSettled(
invitations.map(async (invitation) => {
const joinTeamLink = service.getInvitationLink(invitation.invite_token);
const authCallbackUrl = service.getAuthCallbackUrl(joinTeamLink);
const getEmailLinkType = async () => {
const user = await adminClient
.from('accounts')
.select('*')
.eq('email', invitation.email)
.single();
// if the user is not found, return the invite type
// this link allows the user to register to the platform
if (user.error || !user.data) {
return 'invite';
}
// if the user is found, return the email link type to sign in
return 'magiclink';
};
const emailLinkType = await getEmailLinkType();
// generate an invitation link with Supabase admin client
// use the "redirectTo" parameter to redirect the user to the invitation page after the link is clicked
const generateLinkResponse = await adminClient.auth.admin.generateLink({
email: invitation.email,
type: emailLinkType,
});
// if the link generation fails, throw an error
if (generateLinkResponse.error) {
logger.error(
{
...ctx,
error: generateLinkResponse.error,
},
'Failed to generate link',
);
throw generateLinkResponse.error;
}
// get the link from the response
const verifyLink = generateLinkResponse.data.properties?.action_link;
// extract token
const token = new URL(verifyLink).searchParams.get('token');
if (!token) {
// return error
throw new Error(
'Token in verify link from Supabase Auth was not found',
);
}
// add search params to be consumed by /auth/confirm route
authCallbackUrl.searchParams.set('token_hash', token);
authCallbackUrl.searchParams.set('type', emailLinkType);
const link = authCallbackUrl.href;
// send the invitation email
const data = await service.sendInvitationEmail({
invitation,
link,
});
// return the result
return {
id: invitation.id,
data,
};
}),
);
for (const result of results) {
if (result.status !== 'fulfilled' || !result.value.data.success) {
logger.error(
{
...ctx,
invitationId:
result.status === 'fulfilled' ? result.value.id : result.reason,
},
'Failed to send invitation email',
);
}
}
const succeeded = results.filter(
(result) => result.status === 'fulfilled' && result.value.data.success,
);
if (succeeded.length) {
logger.info(
{
...ctx,
count: succeeded.length,
},
'Invitation emails successfully sent!',
);
}
}
}

View File

@@ -1,175 +0,0 @@
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database, Tables } from '@kit/supabase/database';
type Invitation = Tables<'invitations'>;
const invitePath = '/join';
const siteURL = process.env.NEXT_PUBLIC_SITE_URL;
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME ?? '';
const emailSender = process.env.EMAIL_SENDER;
const env = z
.object({
invitePath: z
.string({
required_error: 'The property invitePath is required',
})
.min(1),
siteURL: z
.string({
required_error: 'NEXT_PUBLIC_SITE_URL is required',
})
.min(1),
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
emailSender: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
invitePath,
siteURL,
productName,
emailSender,
});
export function createAccountInvitationsWebhookService(
client: SupabaseClient<Database>,
) {
return new AccountInvitationsWebhookService(client);
}
class AccountInvitationsWebhookService {
private namespace = 'accounts.invitations.webhook';
constructor(private readonly adminClient: SupabaseClient<Database>) {}
/**
* @name handleInvitationWebhook
* @description Handles the webhook event for invitations
* @param invitation
*/
async handleInvitationWebhook(invitation: Invitation) {
return this.dispatchInvitationEmail(invitation);
}
private async dispatchInvitationEmail(invitation: Invitation) {
const logger = await getLogger();
logger.info(
{ invitation, name: this.namespace },
'Handling invitation webhook event...',
);
const inviter = await this.adminClient
.from('accounts')
.select('email, name')
.eq('id', invitation.invited_by)
.single();
if (inviter.error) {
logger.error(
{
error: inviter.error,
name: this.namespace,
},
'Failed to fetch inviter details',
);
throw inviter.error;
}
const team = await this.adminClient
.from('accounts')
.select('name')
.eq('id', invitation.account_id)
.single();
if (team.error) {
logger.error(
{
error: team.error,
name: this.namespace,
},
'Failed to fetch team details',
);
throw team.error;
}
const ctx = {
invitationId: invitation.id,
name: this.namespace,
};
logger.info(ctx, 'Invite retrieved. Sending invitation email...');
try {
const { renderInviteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const link = this.getInvitationLink(
invitation.invite_token,
invitation.email,
);
const { html, subject } = await renderInviteEmail({
link,
invitedUserEmail: invitation.email,
inviter: inviter.data.name ?? inviter.data.email ?? '',
productName: env.productName,
teamName: team.data.name,
});
await mailer
.sendEmail({
from: env.emailSender,
to: invitation.email,
subject,
html,
})
.then(() => {
logger.info(ctx, 'Invitation email successfully sent!');
})
.catch((error) => {
console.error(error);
logger.error({ error, ...ctx }, 'Failed to send invitation email');
});
return {
success: true,
};
} catch (error) {
console.error(error);
logger.warn({ error, ...ctx }, 'Failed to invite user to team');
return {
error,
success: false,
};
}
}
private getInvitationLink(token: string, email: string) {
const searchParams = new URLSearchParams({
invite_token: token,
email,
}).toString();
const href = new URL(env.invitePath, env.siteURL).href;
return `${href}?${searchParams}`;
}
}

View File

@@ -1,90 +0,0 @@
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Tables } from '@kit/supabase/database';
type Account = Tables<'accounts'>;
export function createAccountWebhooksService() {
return new AccountWebhooksService();
}
class AccountWebhooksService {
private readonly namespace = 'accounts.webhooks';
async handleAccountDeletedWebhook(account: Account) {
const logger = await getLogger();
const ctx = {
accountId: account.id,
namespace: this.namespace,
};
logger.info(ctx, 'Received account deleted webhook. Processing...');
if (account.is_personal_account) {
logger.info(ctx, `Account is personal. We send an email to the user.`);
await this.sendDeleteAccountEmail(account);
}
}
private async sendDeleteAccountEmail(account: Account) {
const userEmail = account.email;
const userDisplayName = account.name ?? userEmail;
const emailSettings = this.getEmailSettings();
if (userEmail) {
await this.sendAccountDeletionEmail({
fromEmail: emailSettings.fromEmail,
productName: emailSettings.productName,
userDisplayName,
userEmail,
});
}
}
private async sendAccountDeletionEmail(params: {
fromEmail: string;
userEmail: string;
userDisplayName: string;
productName: string;
}) {
const { renderAccountDeleteEmail } = await import('@kit/email-templates');
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const { html, subject } = await renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
return mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject,
html,
});
}
private getEmailSettings() {
const productName = process.env.NEXT_PUBLIC_PRODUCT_NAME;
const fromEmail = process.env.EMAIL_SENDER;
return z
.object({
productName: z.string(),
fromEmail: z
.string({
required_error: 'EMAIL_SENDER is required',
})
.min(1),
})
.parse({
productName,
fromEmail,
});
}
}

View File

@@ -1,2 +0,0 @@
export * from './account-webhooks.service';
export * from './account-invitations-webhook.service';