Refactor Supabase server client and improve team management features

Refactored Supabase server component client to improve type safety. Improved team account management by adjusting role data providers to consider account IDs, and adjusted invite member functionality to use account IDs and slugs. Enhanced invitation update dialog by adding account parameter. Fixed database webhook URLs in seed.sql. Overall, these changes enhance the robustness and usability of the team management functionalities.
This commit is contained in:
giancarlo
2024-04-03 22:34:12 +08:00
parent 53afd10f32
commit 406739d96d
14 changed files with 1074 additions and 1000 deletions

View File

@@ -1,3 +1,5 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import {
BillingPortalCard,
CurrentSubscriptionCard,
@@ -39,6 +41,31 @@ async function TeamAccountBillingPage({ params }: Params) {
const canManageBilling =
workspace.account.permissions.includes('billing.manage');
const Checkout = () => {
if (!canManageBilling) {
return <CannotManageBillingAlert />;
}
return (
<TeamAccountCheckoutForm customerId={customerId} accountId={accountId} />
);
};
const BillingPortal = () => {
if (!canManageBilling || !customerId) {
return null;
}
return (
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={params.account} />
<BillingPortalCard />
</form>
);
};
return (
<>
<PageHeader
@@ -48,39 +75,25 @@ async function TeamAccountBillingPage({ params }: Params) {
<PageBody>
<div className={'mx-auto w-full'}>
<If condition={!canManageBilling}>
<CannotManageBillingAlert />
</If>
<div>
<div className={'flex flex-col space-y-6'}>
<If
condition={subscription}
fallback={
<If condition={canManageBilling}>
<TeamAccountCheckoutForm
customerId={customerId}
accountId={accountId}
/>
</If>
<>
<Checkout />
</>
}
>
{(data) => (
{(subscription) => (
<CurrentSubscriptionCard
subscription={data}
subscription={subscription}
config={billingConfig}
/>
)}
</If>
<If condition={customerId && canManageBilling}>
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={params.account} />
<BillingPortalCard />
</form>
</If>
<BillingPortal />
</div>
</div>
</div>
@@ -93,7 +106,9 @@ export default withI18n(TeamAccountBillingPage);
function CannotManageBillingAlert() {
return (
<Alert>
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'billing:cannotManageBillingAlertTitle'} />
</AlertTitle>

View File

@@ -131,10 +131,12 @@ async function TeamAccountMembersPage({ params }: Params) {
<If condition={canManageInvitations}>
<InviteMembersDialogContainer
userRoleHierarchy={currentUserRoleHierarchy}
account={account.slug}
accountId={account.id}
accountSlug={account.slug}
>
<Button size={'sm'}>
<PlusCircle className={'mr-2 w-4'} />
<span>
<Trans i18nKey={'teams:inviteMembersButton'} />
</span>

View File

@@ -197,6 +197,7 @@ function ActionsDropdown({
<UpdateInvitationDialog
isOpen
setIsOpen={setIsUpdatingRole}
account={invitation.account_id}
invitationId={invitation.id}
userRole={invitation.role}
userRoleHierarchy={permissions.currentUserRoleHierarchy}

View File

@@ -36,9 +36,17 @@ export const UpdateInvitationDialog: React.FC<{
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
invitationId: number;
account: string;
userRole: Role;
userRoleHierarchy: number;
}> = ({ isOpen, setIsOpen, invitationId, userRole, userRoleHierarchy }) => {
}> = ({
isOpen,
setIsOpen,
invitationId,
userRole,
userRoleHierarchy,
account,
}) => {
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
@@ -52,9 +60,13 @@ export const UpdateInvitationDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
<RolesDataProvider
accountId={account}
maxRoleHierarchy={userRoleHierarchy}
>
{(roles) => (
<UpdateInvitationForm
account={account}
invitationId={invitationId}
userRole={userRole}
userRoleHierarchy={roles.length}
@@ -68,11 +80,13 @@ export const UpdateInvitationDialog: React.FC<{
};
function UpdateInvitationForm({
account,
invitationId,
userRole,
userRoleHierarchy,
setIsOpen,
}: React.PropsWithChildren<{
account: string;
invitationId: number;
userRole: Role;
userRoleHierarchy: number;
@@ -136,7 +150,10 @@ function UpdateInvitationForm({
</FormLabel>
<FormControl>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
<RolesDataProvider
accountId={account}
maxRoleHierarchy={userRoleHierarchy}
>
{(roles) => (
<MembershipRoleSelector
roles={roles}

View File

@@ -42,11 +42,13 @@ type InviteModel = ReturnType<typeof createEmptyInviteModel>;
type Role = string;
export function InviteMembersDialogContainer({
account,
accountSlug,
accountId,
userRoleHierarchy,
children,
}: React.PropsWithChildren<{
account: string;
accountSlug: string;
accountId: string;
userRoleHierarchy: number;
}>) {
const [pending, startTransition] = useTransition();
@@ -67,7 +69,10 @@ export function InviteMembersDialogContainer({
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
<RolesDataProvider
accountId={accountId}
maxRoleHierarchy={userRoleHierarchy}
>
{(roles) => (
<InviteMembersForm
pending={pending}
@@ -75,7 +80,7 @@ export function InviteMembersDialogContainer({
onSubmit={(data) => {
startTransition(async () => {
await createInvitationsAction({
account,
accountSlug,
invitations: data.invitations,
});

View File

@@ -5,17 +5,15 @@ import { LoadingOverlay } from '@kit/ui/loading-overlay';
export function RolesDataProvider(props: {
maxRoleHierarchy: number;
accountId: string;
children: (roles: string[]) => React.ReactNode;
}) {
const rolesQuery = useFetchRoles({
maxRoleHierarchy: props.maxRoleHierarchy,
});
const rolesQuery = useFetchRoles(props);
if (rolesQuery.isLoading) {
return <LoadingOverlay fullPage={false} />;
}
// TODO handle error
if (rolesQuery.isError) {
return null;
}
@@ -23,7 +21,7 @@ export function RolesDataProvider(props: {
return <>{props.children(rolesQuery.data ?? [])}</>;
}
function useFetchRoles(props: { maxRoleHierarchy: number }) {
function useFetchRoles(props: { maxRoleHierarchy: number; accountId: string }) {
const supabase = useSupabase();
return useQuery({
@@ -33,6 +31,7 @@ function useFetchRoles(props: { maxRoleHierarchy: number }) {
.from('roles')
.select('name')
.gte('hierarchy_level', props.maxRoleHierarchy)
.or(`account_id.eq.${props.accountId}, account_id.is.null`)
.order('hierarchy_level', { ascending: true });
if (error) {

View File

@@ -60,7 +60,10 @@ export const UpdateMemberRoleDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
<RolesDataProvider
accountId={accountId}
maxRoleHierarchy={userRoleHierarchy}
>
{(data) => (
<UpdateMemberForm
setIsOpen={setIsOpen}

View File

@@ -22,7 +22,7 @@ import { AccountInvitationsService } from '../services/account-invitations.servi
* Creates invitations for inviting members.
*/
export async function createInvitationsAction(params: {
account: string;
accountSlug: string;
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
}) {
const client = getSupabaseServerActionClient();
@@ -35,7 +35,10 @@ export async function createInvitationsAction(params: {
const service = new AccountInvitationsService(client);
await service.sendInvitations({ invitations, account: params.account });
await service.sendInvitations({
invitations,
accountSlug: params.accountSlug,
});
revalidateMemberPage();

View File

@@ -69,21 +69,21 @@ export class AccountInvitationsService {
}
async sendInvitations({
account,
accountSlug,
invitations,
}: {
invitations: z.infer<typeof InviteMembersSchema>['invitations'];
account: string;
accountSlug: string;
}) {
Logger.info(
{ account, invitations, name: this.namespace },
{ account: accountSlug, invitations, name: this.namespace },
'Storing invitations',
);
const accountResponse = await this.client
.from('accounts')
.select('name')
.eq('slug', account)
.eq('slug', accountSlug)
.single();
if (!accountResponse.data) {
@@ -92,7 +92,7 @@ export class AccountInvitationsService {
const response = await this.client.rpc('add_invitations_to_account', {
invitations,
account_slug: account,
account_slug: accountSlug,
});
if (response.error) {
@@ -105,7 +105,7 @@ export class AccountInvitationsService {
Logger.info(
{
account,
account: accountSlug,
count: responseInvitations.length,
name: this.namespace,
},

View File

@@ -10,7 +10,7 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
* @name getSupabaseServerComponentClient
* @description Get a Supabase client for use in the Server Components
*/
export const getSupabaseServerComponentClient = <GenericSchema = Database>(
export const getSupabaseServerComponentClient = (
params = {
admin: false,
},
@@ -30,7 +30,7 @@ export const getSupabaseServerComponentClient = <GenericSchema = Database>(
throw new Error('Supabase Service Role Key not provided');
}
return createServerClient<GenericSchema>(keys.url, serviceRoleKey, {
return createServerClient<Database>(keys.url, serviceRoleKey, {
auth: {
persistSession: false,
},
@@ -38,7 +38,7 @@ export const getSupabaseServerComponentClient = <GenericSchema = Database>(
});
}
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
return createServerClient<Database>(keys.url, keys.anonKey, {
cookies: getCookiesStrategy(),
});
};

File diff suppressed because it is too large Load Diff

View File

@@ -463,7 +463,6 @@ create trigger "on_auth_user_updated"
after update of email on auth.users for each row
execute procedure kit.handle_update_user_email();
/*
* -------------------------------------------------------
* Section: Roles
@@ -475,13 +474,51 @@ create table if not exists public.roles(
name varchar(50) not null,
hierarchy_level int not null,
account_id uuid references public.accounts(id) on delete cascade,
is_custom boolean not null default false,
unique (hierarchy_level, account_id, is_custom),
unique(name, account_id),
primary key (name)
);
grant select on table public.roles to authenticated, service_role;
-- define the system role uuid as a static UUID to be used as a default
-- account_id for system roles when the account_id is null. Useful for constraints.
create or replace function kit.get_system_role_uuid()
returns uuid
as $$
begin
return 'fd4f287c-762e-42b7-8207-b1252f799670';
end; $$ language plpgsql immutable;
grant execute on function kit.get_system_role_uuid() to authenticated, service_role;
create unique index idx_unique_hierarchy_per_account
on public.roles (hierarchy_level, coalesce(account_id, kit.get_system_role_uuid()));
create unique index idx_unique_name_per_account
on public.roles (name, coalesce(account_id, kit.get_system_role_uuid()));
create or replace function kit.check_non_personal_account_roles()
returns trigger
as $$
begin
if new.account_id is not null and(
select
is_personal_account
from
public.accounts
where
id = new.account_id) then
raise exception 'Roles cannot be created for personal accounts';
end if;
return new;
end; $$ language plpgsql;
create constraint trigger tr_check_non_personal_account_roles
after insert or update on public.roles
for each row
execute procedure kit.check_non_personal_account_roles();
-- Seed the roles table with default roles 'owner' and
-- 'member'
insert into public.roles(
@@ -501,12 +538,6 @@ values (
-- RLS
alter table public.roles enable row level security;
-- SELECT: authenticated users can query roles
create policy roles_read on public.roles
for select to authenticated
using (true);
/*
* -------------------------------------------------------
* Section: Memberships
@@ -567,6 +598,7 @@ create or replace trigger prevent_account_owner_membership_delete_check
before delete on public.accounts_memberships for each row
execute function kit.prevent_account_owner_membership_delete();
-- Functions
create or replace function public.has_role_on_account(account_id
uuid, account_role varchar(50) default null)
returns boolean
@@ -590,6 +622,7 @@ $$;
grant execute on function public.has_role_on_account(uuid, varchar)
to authenticated;
-- Function to check if a user is a team member of an account or not
create or replace function public.is_team_member(account_id uuid,
user_id uuid)
returns boolean
@@ -611,7 +644,16 @@ $$;
grant execute on function public.is_team_member(uuid, uuid) to authenticated;
-- Functions
-- SELECT(roles): authenticated users can query roles if the role is public
-- or the user has a role on the account the role is for
create policy roles_read on public.roles
for select to authenticated
using (
account_id is null
or public.has_role_on_account(account_id)
);
-- Function to check if a user can remove a member from an account
create or replace function
kit.can_remove_account_member(target_team_account_id uuid,
@@ -716,7 +758,6 @@ create policy accounts_team_read on public.accounts
where
public.is_team_member(membership.account_id, id)));
/*
* -------------------------------------------------------
* Section: Account Roles

View File

@@ -6,7 +6,7 @@
create trigger "accounts_memberships_insert" after insert
on "public"."accounts_memberships" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/database/webhook',
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
@@ -17,7 +17,7 @@ execute function "supabase_functions"."http_request"(
create trigger "account_membership_delete" after delete
on "public"."accounts_memberships" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/database/webhook',
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
@@ -29,7 +29,7 @@ execute function "supabase_functions"."http_request"(
create trigger "account_delete" after delete
on "public"."subscriptions" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/database/webhook',
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
@@ -41,7 +41,7 @@ execute function "supabase_functions"."http_request"(
create trigger "invitations_insert" after insert
on "public"."invitations" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/database/webhook',
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',