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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -197,6 +197,7 @@ function ActionsDropdown({
|
||||
<UpdateInvitationDialog
|
||||
isOpen
|
||||
setIsOpen={setIsUpdatingRole}
|
||||
account={invitation.account_id}
|
||||
invitationId={invitation.id}
|
||||
userRole={invitation.role}
|
||||
userRoleHierarchy={permissions.currentUserRoleHierarchy}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -60,7 +60,10 @@ export const UpdateMemberRoleDialog: React.FC<{
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
<RolesDataProvider
|
||||
accountId={accountId}
|
||||
maxRoleHierarchy={userRoleHierarchy}
|
||||
>
|
||||
{(data) => (
|
||||
<UpdateMemberForm
|
||||
setIsOpen={setIsOpen}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"}',
|
||||
'{}',
|
||||
|
||||
Reference in New Issue
Block a user