B2B-88: add starter kit structure and elements
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { CreateTeamSchema } from '../../schema/create-team.schema';
|
||||
import { createCreateTeamAccountService } from '../services/create-team-account.service';
|
||||
|
||||
export const createTeamAccountAction = enhanceAction(
|
||||
async ({ name }, user) => {
|
||||
const logger = await getLogger();
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createCreateTeamAccountService(client);
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.create',
|
||||
userId: user.id,
|
||||
accountName: name,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Creating team account...`);
|
||||
|
||||
const { data, error } = await service.createNewOrganizationAccount({
|
||||
name,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, `Failed to create team account`);
|
||||
|
||||
return {
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
|
||||
logger.info(ctx, `Team account created`);
|
||||
|
||||
const accountHomePath = '/home/' + data.slug;
|
||||
|
||||
redirect(accountHomePath);
|
||||
},
|
||||
{
|
||||
schema: CreateTeamSchema,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,96 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import type { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { DeleteTeamAccountSchema } from '../../schema/delete-team-account.schema';
|
||||
import { createDeleteTeamAccountService } from '../services/delete-team-account.service';
|
||||
|
||||
const enableTeamAccountDeletion =
|
||||
process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true';
|
||||
|
||||
export const deleteTeamAccountAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const logger = await getLogger();
|
||||
|
||||
const params = DeleteTeamAccountSchema.parse(
|
||||
Object.fromEntries(formData.entries()),
|
||||
);
|
||||
|
||||
const otpService = createOtpApi(getSupabaseServerClient());
|
||||
|
||||
const otpResult = await otpService.verifyToken({
|
||||
purpose: `delete-team-account-${params.accountId}`,
|
||||
userId: user.id,
|
||||
token: params.otp,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
throw new Error('Invalid OTP code');
|
||||
}
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.delete',
|
||||
userId: user.id,
|
||||
accountId: params.accountId,
|
||||
};
|
||||
|
||||
if (!enableTeamAccountDeletion) {
|
||||
logger.warn(ctx, `Team account deletion is not enabled`);
|
||||
|
||||
throw new Error('Team account deletion is not enabled');
|
||||
}
|
||||
|
||||
logger.info(ctx, `Deleting team account...`);
|
||||
|
||||
await deleteTeamAccount({
|
||||
accountId: params.accountId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
logger.info(ctx, `Team account request successfully sent`);
|
||||
|
||||
return redirect('/home');
|
||||
},
|
||||
{
|
||||
auth: true,
|
||||
},
|
||||
);
|
||||
|
||||
async function deleteTeamAccount(params: {
|
||||
accountId: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createDeleteTeamAccountService();
|
||||
|
||||
// verify that the user has the necessary permissions to delete the team account
|
||||
await assertUserPermissionsToDeleteTeamAccount(client, params.accountId);
|
||||
|
||||
// delete the team account
|
||||
await service.deleteTeamAccount(client, params);
|
||||
}
|
||||
|
||||
async function assertUserPermissionsToDeleteTeamAccount(
|
||||
client: SupabaseClient<Database>,
|
||||
accountId: string,
|
||||
) {
|
||||
const { data: isOwner, error } = await client
|
||||
.rpc('is_account_owner', {
|
||||
account_id: accountId,
|
||||
})
|
||||
.single();
|
||||
|
||||
if (error || !isOwner) {
|
||||
throw new Error('You do not have permission to delete this account');
|
||||
}
|
||||
|
||||
return isOwner;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
|
||||
import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema';
|
||||
import { createLeaveTeamAccountService } from '../services/leave-team-account.service';
|
||||
|
||||
export const leaveTeamAccountAction = enhanceAction(
|
||||
async (formData: FormData, user) => {
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
const params = LeaveTeamAccountSchema.parse(body);
|
||||
|
||||
const service = createLeaveTeamAccountService(
|
||||
getSupabaseServerAdminClient(),
|
||||
);
|
||||
|
||||
await service.leaveTeamAccount({
|
||||
accountId: params.accountId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return redirect('/home');
|
||||
},
|
||||
{},
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
'use server';
|
||||
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema';
|
||||
|
||||
export const updateTeamAccountName = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
const { name, path, slug } = params;
|
||||
|
||||
const ctx = {
|
||||
name: 'team-accounts.update',
|
||||
accountName: name,
|
||||
};
|
||||
|
||||
logger.info(ctx, `Updating team name...`);
|
||||
|
||||
const { error, data } = await client
|
||||
.from('accounts')
|
||||
.update({
|
||||
name,
|
||||
slug,
|
||||
})
|
||||
.match({
|
||||
slug,
|
||||
})
|
||||
.select('slug')
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, `Failed to update team name`);
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const newSlug = data.slug;
|
||||
|
||||
logger.info(ctx, `Team name updated`);
|
||||
|
||||
if (newSlug) {
|
||||
const nextPath = path.replace('[account]', newSlug);
|
||||
|
||||
redirect(nextPath);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: UpdateTeamNameSchema,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,159 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { AcceptInvitationSchema } from '../../schema/accept-invitation.schema';
|
||||
import { DeleteInvitationSchema } from '../../schema/delete-invitation.schema';
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
|
||||
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
|
||||
import { createAccountInvitationsService } from '../services/account-invitations.service';
|
||||
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
|
||||
|
||||
/**
|
||||
* @name createInvitationsAction
|
||||
* @description Creates invitations for inviting members.
|
||||
*/
|
||||
export const createInvitationsAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
// Create the service
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// send invitations
|
||||
await service.sendInvitations(params);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: InviteMembersSchema.and(
|
||||
z.object({
|
||||
accountSlug: z.string().min(1),
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name deleteInvitationAction
|
||||
* @description Deletes an invitation specified by the invitation ID.
|
||||
*/
|
||||
export const deleteInvitationAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// Delete the invitation
|
||||
await service.deleteInvitation(data);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: DeleteInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name updateInvitationAction
|
||||
* @description Updates an invitation.
|
||||
*/
|
||||
export const updateInvitationAction = enhanceAction(
|
||||
async (invitation) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
await service.updateInvitation(invitation);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: UpdateInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name acceptInvitationAction
|
||||
* @description Accepts an invitation to join a team.
|
||||
*/
|
||||
export const acceptInvitationAction = enhanceAction(
|
||||
async (data: FormData, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
|
||||
const { inviteToken, nextPath } = AcceptInvitationSchema.parse(
|
||||
Object.fromEntries(data),
|
||||
);
|
||||
|
||||
// create the services
|
||||
const perSeatBillingService = createAccountPerSeatBillingService(client);
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// use admin client to accept invitation
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// Accept the invitation
|
||||
const accountId = await service.acceptInvitationToTeam(adminClient, {
|
||||
inviteToken,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// If the account ID is not present, throw an error
|
||||
if (!accountId) {
|
||||
throw new Error('Failed to accept invitation');
|
||||
}
|
||||
|
||||
// Increase the seats for the account
|
||||
await perSeatBillingService.increaseSeats(accountId);
|
||||
|
||||
return redirect(nextPath);
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name renewInvitationAction
|
||||
* @description Renews an invitation.
|
||||
*/
|
||||
export const renewInvitationAction = enhanceAction(
|
||||
async (params) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const { invitationId } = RenewInvitationSchema.parse(params);
|
||||
|
||||
const service = createAccountInvitationsService(client);
|
||||
|
||||
// Renew the invitation
|
||||
await service.renewInvitation(invitationId);
|
||||
|
||||
revalidateMemberPage();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: RenewInvitationSchema,
|
||||
},
|
||||
);
|
||||
|
||||
function revalidateMemberPage() {
|
||||
revalidatePath('/home/[account]/members', 'page');
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
'use server';
|
||||
|
||||
import { revalidatePath } from 'next/cache';
|
||||
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createOtpApi } from '@kit/otp';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import { RemoveMemberSchema } from '../../schema/remove-member.schema';
|
||||
import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-ownership-confirmation.schema';
|
||||
import { UpdateMemberRoleSchema } from '../../schema/update-member-role.schema';
|
||||
import { createAccountMembersService } from '../services/account-members.service';
|
||||
|
||||
/**
|
||||
* @name removeMemberFromAccountAction
|
||||
* @description Removes a member from an account.
|
||||
*/
|
||||
export const removeMemberFromAccountAction = enhanceAction(
|
||||
async ({ accountId, userId }) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountMembersService(client);
|
||||
|
||||
await service.removeMemberFromAccount({
|
||||
accountId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: RemoveMemberSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name updateMemberRoleAction
|
||||
* @description Updates the role of a member in an account.
|
||||
*/
|
||||
export const updateMemberRoleAction = enhanceAction(
|
||||
async (data) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const service = createAccountMembersService(client);
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// update the role of the member
|
||||
await service.updateMemberRole(data, adminClient);
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
{
|
||||
schema: UpdateMemberRoleSchema,
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @name transferOwnershipAction
|
||||
* @description Transfers the ownership of an account to another member.
|
||||
* Requires OTP verification for security.
|
||||
*/
|
||||
export const transferOwnershipAction = enhanceAction(
|
||||
async (data, user) => {
|
||||
const client = getSupabaseServerClient();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
name: 'teams.transferOwnership',
|
||||
userId: user.id,
|
||||
accountId: data.accountId,
|
||||
};
|
||||
|
||||
logger.info(ctx, 'Processing team ownership transfer request...');
|
||||
|
||||
// assert that the user is the owner of the account
|
||||
const { data: isOwner, error } = await client.rpc('is_account_owner', {
|
||||
account_id: data.accountId,
|
||||
});
|
||||
|
||||
if (error || !isOwner) {
|
||||
logger.error(ctx, 'User is not the owner of this account');
|
||||
|
||||
throw new Error(
|
||||
`You must be the owner of the account to transfer ownership`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify the OTP
|
||||
const otpApi = createOtpApi(client);
|
||||
|
||||
const otpResult = await otpApi.verifyToken({
|
||||
token: data.otp,
|
||||
userId: user.id,
|
||||
purpose: `transfer-team-ownership-${data.accountId}`,
|
||||
});
|
||||
|
||||
if (!otpResult.valid) {
|
||||
logger.error(ctx, 'Invalid OTP provided');
|
||||
throw new Error('Invalid OTP');
|
||||
}
|
||||
|
||||
// validate the user ID matches the nonce's user ID
|
||||
if (otpResult.user_id !== user.id) {
|
||||
logger.error(
|
||||
ctx,
|
||||
`This token was meant to be used by a different user. Exiting.`,
|
||||
);
|
||||
|
||||
throw new Error('Nonce mismatch');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
ctx,
|
||||
'OTP verification successful. Proceeding with ownership transfer...',
|
||||
);
|
||||
|
||||
const service = createAccountMembersService(client);
|
||||
|
||||
// at this point, the user is authenticated, is the owner of the account, and has verified via OTP
|
||||
// so we proceed with the transfer of ownership with admin privileges
|
||||
const adminClient = getSupabaseServerAdminClient();
|
||||
|
||||
// transfer the ownership of the account
|
||||
await service.transferOwnership(data, adminClient);
|
||||
|
||||
// revalidate all pages that depend on the account
|
||||
revalidatePath('/home/[account]', 'layout');
|
||||
|
||||
logger.info(ctx, 'Team ownership transferred successfully');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
{
|
||||
schema: TransferOwnershipConfirmationSchema,
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user