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:
committed by
GitHub
parent
195cf41680
commit
2e20d3e76f
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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!',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './account-webhooks.service';
|
||||
export * from './account-invitations-webhook.service';
|
||||
Reference in New Issue
Block a user