Implement invitation renewal and optimize revalidation function
This commit adds a new function to renew team invitations and a central function for revalidating member page. It also removes redundant revalidations across different actions. The renew invitation function and UI elements are introduced including a new dialog for confirming the renewal action. Furthermore, function revalidateMemberPage() is added to abstract the revalidation path used multiple times in different functions. The code readability and maintainability have thus been improved.
This commit is contained in:
11
README.md
11
README.md
@@ -53,6 +53,8 @@ pnpm i
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This command will run both the web application and the Supabase container. If the Supabase container is already running, it will only start the web application.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
This project uses Turborepo to manage multiple packages in a single repository.
|
This project uses Turborepo to manage multiple packages in a single repository.
|
||||||
@@ -73,14 +75,12 @@ The main application defines the following:
|
|||||||
|
|
||||||
Below are the reusable packages that can be shared across multiple applications (or packages).
|
Below are the reusable packages that can be shared across multiple applications (or packages).
|
||||||
|
|
||||||
- **`@kit/ui`**: Shared UI components and styles (using Shadcn UI)
|
- **`@kit/ui`**: Shared UI components and styles (using Shadcn UI and some custom components)
|
||||||
- **`@kit/shared`**: Shared code and utilities
|
- **`@kit/shared`**: Shared code and utilities
|
||||||
- **`@kit/supabase`**: Supabase package that defines the schema and logic for managing Supabase
|
- **`@kit/supabase`**: Supabase package that defines the schema and logic for managing Supabase
|
||||||
- **`@kit/i18n`**: Internationalization package that defines utilities for managing translations
|
- **`@kit/i18n`**: Internationalization package that defines utilities for managing translations
|
||||||
- **`@kit/billing`**: Billing package that defines the schema and logic for managing subscriptions
|
- **`@kit/billing`**: Billing package that defines the schema and logic for managing subscriptions
|
||||||
- **`@kit/billing-gateway`**: Billing gateway package that defines the schema and logic for managing payment gateways
|
- **`@kit/billing-gateway`**: Billing gateway package that defines the schema and logic for managing payment gateways
|
||||||
- **`@kit/stripe`**: Stripe package that defines the schema and logic for managing Stripe. This is used by the `@kit/billing-gateway` package and abstracts the Stripe API.
|
|
||||||
- **`@kit/lemon-squeezy`**: Lemon Squeezy package that defines the schema and logic for managing Lemon Squeezy. This is used by the `@kit/billing-gateway` package and abstracts the Lemon Squeezy API.
|
|
||||||
- **`@kit/email-templates`**: Here we define the email templates using the `react.email` package.
|
- **`@kit/email-templates`**: Here we define the email templates using the `react.email` package.
|
||||||
- **`@kit/mailers`**: Mailer package that abstracts the email service provider (e.g., Resend, Cloudflare, SendGrid, Mailgun, etc.)
|
- **`@kit/mailers`**: Mailer package that abstracts the email service provider (e.g., Resend, Cloudflare, SendGrid, Mailgun, etc.)
|
||||||
|
|
||||||
@@ -90,6 +90,11 @@ And features that can be added to the application:
|
|||||||
- **`@kit/team-accounts`**: Package that defines components and logic for managing team
|
- **`@kit/team-accounts`**: Package that defines components and logic for managing team
|
||||||
- **`@kit/admin`**: Admin package that defines the schema and logic for managing users, subscriptions, and more.
|
- **`@kit/admin`**: Admin package that defines the schema and logic for managing users, subscriptions, and more.
|
||||||
|
|
||||||
|
And billing packages that can be added to the application:
|
||||||
|
- **`@kit/stripe`**: Stripe package that defines the schema and logic for managing Stripe. This is used by the `@kit/billing-gateway` package and abstracts the Stripe API.
|
||||||
|
- **`@kit/lemon-squeezy`**: Lemon Squeezy package that defines the schema and logic for managing Lemon Squeezy. This is used by the `@kit/billing-gateway` package and abstracts the Lemon Squeezy API. (Coming soon)
|
||||||
|
- **`@kit/paddle`**: Paddle package that defines the schema and logic for managing Paddle. This is used by the `@kit/billing-gateway` package and abstracts the Paddle API. (Coming soon
|
||||||
|
|
||||||
### Application Configuration
|
### Application Configuration
|
||||||
|
|
||||||
The configuration is defined in the `apps/web/config` folder. Here you can find the following configuration files:
|
The configuration is defined in the `apps/web/config` folder. Here you can find the following configuration files:
|
||||||
|
|||||||
@@ -2,11 +2,10 @@
|
|||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { Session } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
import { ArrowLeftCircle, ArrowRightCircle } from 'lucide-react';
|
import { ArrowLeftCircle, ArrowRightCircle } from 'lucide-react';
|
||||||
|
|
||||||
import { AccountSelector } from '@kit/accounts/account-selector';
|
import { AccountSelector } from '@kit/accounts/account-selector';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
|
import { Sidebar, SidebarContent } from '@kit/ui/sidebar';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -58,6 +57,7 @@ function SidebarContainer(props: {
|
|||||||
accounts: AccountModel[];
|
accounts: AccountModel[];
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
setCollapsed: (collapsed: boolean) => void;
|
setCollapsed: (collapsed: boolean) => void;
|
||||||
|
collapsible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { account, accounts } = props;
|
const { account, accounts } = props;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -88,10 +88,12 @@ function SidebarContainer(props: {
|
|||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<ProfileAccountDropdownContainer collapsed={props.collapsed} />
|
<ProfileAccountDropdownContainer collapsed={props.collapsed} />
|
||||||
|
|
||||||
|
<If condition={props.collapsible}>
|
||||||
<AppSidebarFooterMenu
|
<AppSidebarFooterMenu
|
||||||
collapsed={props.collapsed}
|
collapsed={props.collapsed}
|
||||||
setCollapsed={props.setCollapsed}
|
setCollapsed={props.setCollapsed}
|
||||||
/>
|
/>
|
||||||
|
</If>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,15 +10,18 @@ import pathsConfig from '~/config/paths.config';
|
|||||||
interface Params {
|
interface Params {
|
||||||
searchParams: {
|
searchParams: {
|
||||||
error: string;
|
error: string;
|
||||||
|
invite_token: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuthCallbackErrorPage({ searchParams }: Params) {
|
function AuthCallbackErrorPage({ searchParams }: Params) {
|
||||||
const { error } = searchParams;
|
const { error, invite_token } = searchParams;
|
||||||
|
const queryParam = invite_token ? `?invite_token=${invite_token}` : '';
|
||||||
|
const signInPath = pathsConfig.auth.signIn + queryParam;
|
||||||
|
|
||||||
// if there is no error, redirect the user to the sign-in page
|
// if there is no error, redirect the user to the sign-in page
|
||||||
if (!error) {
|
if (!error) {
|
||||||
redirect(pathsConfig.auth.signIn);
|
redirect(signInPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +39,7 @@ function AuthCallbackErrorPage({ searchParams }: Params) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button>
|
<Button>
|
||||||
<Link href={pathsConfig.auth.signIn}>
|
<Link href={signInPath}>
|
||||||
<Trans i18nKey={'auth:signIn'} />
|
<Trans i18nKey={'auth:signIn'} />
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import Link from 'next/link';
|
||||||
import { notFound, redirect } from 'next/navigation';
|
import { notFound, redirect } from 'next/navigation';
|
||||||
|
|
||||||
|
import { ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
import { Logger } from '@kit/shared/logger';
|
import { Logger } from '@kit/shared/logger';
|
||||||
import { requireAuth } from '@kit/supabase/require-auth';
|
import { requireAuth } from '@kit/supabase/require-auth';
|
||||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||||
import { AcceptInvitationContainer } from '@kit/team-accounts/components';
|
import { AcceptInvitationContainer } from '@kit/team-accounts/components';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { Heading } from '@kit/ui/heading';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
import pathsConfig from '~/config/paths.config';
|
import pathsConfig from '~/config/paths.config';
|
||||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||||
@@ -42,7 +48,7 @@ async function JoinTeamAccountPage({ searchParams }: Context) {
|
|||||||
const invitation = await getInviteDataFromInviteToken(token);
|
const invitation = await getInviteDataFromInviteToken(token);
|
||||||
|
|
||||||
if (!invitation) {
|
if (!invitation) {
|
||||||
notFound();
|
return <InviteNotFoundOrExpired />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// we need to verify the user isn't already in the account
|
// we need to verify the user isn't already in the account
|
||||||
@@ -124,13 +130,39 @@ async function getInviteDataFromInviteToken(token: string) {
|
|||||||
picture_url: string;
|
picture_url: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
>('id, account: account_id !inner (id, name, slug, picture_url)')
|
>(
|
||||||
|
'id, expires_at, account: account_id !inner (id, name, slug, picture_url)',
|
||||||
|
)
|
||||||
.eq('invite_token', token)
|
.eq('invite_token', token)
|
||||||
|
.rangeLt('expires_at', new Date().toISOString())
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
console.log(invitation, error);
|
||||||
|
|
||||||
if (!invitation ?? error) {
|
if (!invitation ?? error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return invitation;
|
return invitation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InviteNotFoundOrExpired() {
|
||||||
|
return (
|
||||||
|
<div className={'flex flex-col space-y-4'}>
|
||||||
|
<Heading level={6}>
|
||||||
|
<Trans i18nKey={'teams:inviteNotFoundOrExpired'} />
|
||||||
|
</Heading>
|
||||||
|
|
||||||
|
<p className={'text-sm text-muted-foreground'}>
|
||||||
|
<Trans i18nKey={'teams:inviteNotFoundOrExpiredDescription'} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link href={pathsConfig.app.home}>
|
||||||
|
<Button className={'w-full'} variant={'outline'}>
|
||||||
|
<ArrowLeft className={'mr-2 w-4'} />
|
||||||
|
<Trans i18nKey={'teams:backToHome'} />
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,6 +135,18 @@
|
|||||||
"updateInvitation": "Update Invitation",
|
"updateInvitation": "Update Invitation",
|
||||||
"removeInvitation": "Remove Invitation",
|
"removeInvitation": "Remove Invitation",
|
||||||
"acceptInvitation": "Accept Invitation",
|
"acceptInvitation": "Accept Invitation",
|
||||||
|
"renewInvitation": "Renew Invitation",
|
||||||
|
"resendInvitation": "Resend Invitation",
|
||||||
|
"expiresAtLabel": "Expires at",
|
||||||
|
"expired": "Expired",
|
||||||
|
"active": "Active",
|
||||||
|
"inviteStatus": "Status",
|
||||||
|
"inviteNotFoundOrExpired": "Invite not found or expired",
|
||||||
|
"inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the team owner to renew the invite.",
|
||||||
|
"backToHome": "Back to Home",
|
||||||
|
"renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the team.",
|
||||||
|
"renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.",
|
||||||
|
"renewInvitationErrorDescription": "We encountered an error renewing the invitation. Please try again.",
|
||||||
"signInWithDifferentAccount": "Sign in with a different account",
|
"signInWithDifferentAccount": "Sign in with a different account",
|
||||||
"signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.",
|
"signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.",
|
||||||
"acceptInvitationHeading": "Accept Invitation to join {{accountName}}",
|
"acceptInvitationHeading": "Accept Invitation to join {{accountName}}",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Ellipsis } from 'lucide-react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { Database } from '@kit/supabase/database';
|
import { Database } from '@kit/supabase/database';
|
||||||
|
import { Badge } from '@kit/ui/badge';
|
||||||
import { Button } from '@kit/ui/button';
|
import { Button } from '@kit/ui/button';
|
||||||
import { DataTable } from '@kit/ui/data-table';
|
import { DataTable } from '@kit/ui/data-table';
|
||||||
import {
|
import {
|
||||||
@@ -22,6 +23,7 @@ import { Trans } from '@kit/ui/trans';
|
|||||||
|
|
||||||
import { RoleBadge } from '../members/role-badge';
|
import { RoleBadge } from '../members/role-badge';
|
||||||
import { DeleteInvitationDialog } from './delete-invitation-dialog';
|
import { DeleteInvitationDialog } from './delete-invitation-dialog';
|
||||||
|
import { RenewInvitationDialog } from './renew-invitation-dialog';
|
||||||
import { UpdateInvitationDialog } from './update-invitation-dialog';
|
import { UpdateInvitationDialog } from './update-invitation-dialog';
|
||||||
|
|
||||||
type Invitations =
|
type Invitations =
|
||||||
@@ -107,6 +109,24 @@ function useGetColumns(permissions: {
|
|||||||
return new Date(row.original.created_at).toLocaleDateString();
|
return new Date(row.original.created_at).toLocaleDateString();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
header: t('expiresAtLabel'),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return new Date(row.original.expires_at).toLocaleDateString();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: t('inviteStatus'),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isExpired = getIsInviteExpired(row.original.expires_at);
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
return <Badge variant={'warning'}>{t('expired')}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Badge variant={'success'}>{t('active')}</Badge>;
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: '',
|
header: '',
|
||||||
id: 'actions',
|
id: 'actions',
|
||||||
@@ -131,6 +151,7 @@ function ActionsDropdown({
|
|||||||
}) {
|
}) {
|
||||||
const [isDeletingInvite, setIsDeletingInvite] = useState(false);
|
const [isDeletingInvite, setIsDeletingInvite] = useState(false);
|
||||||
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
const [isUpdatingRole, setIsUpdatingRole] = useState(false);
|
||||||
|
const [iRenewingInvite, setIsRenewingInvite] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -146,6 +167,12 @@ function ActionsDropdown({
|
|||||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||||
<Trans i18nKey={'teams:updateInvitation'} />
|
<Trans i18nKey={'teams:updateInvitation'} />
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<If condition={getIsInviteExpired(invitation.expires_at)}>
|
||||||
|
<DropdownMenuItem onClick={() => setIsRenewingInvite(true)}>
|
||||||
|
<Trans i18nKey={'teams:renewInvitation'} />
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</If>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
<If condition={permissions.canRemoveInvitation}>
|
<If condition={permissions.canRemoveInvitation}>
|
||||||
@@ -172,6 +199,24 @@ function ActionsDropdown({
|
|||||||
userRole={invitation.role}
|
userRole={invitation.role}
|
||||||
/>
|
/>
|
||||||
</If>
|
</If>
|
||||||
|
|
||||||
|
<If condition={iRenewingInvite}>
|
||||||
|
<RenewInvitationDialog
|
||||||
|
isOpen
|
||||||
|
setIsOpen={setIsRenewingInvite}
|
||||||
|
invitationId={invitation.id}
|
||||||
|
email={invitation.email}
|
||||||
|
/>
|
||||||
|
</If>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getIsInviteExpired(isoExpiresAt: string) {
|
||||||
|
const currentIsoTime = new Date().toISOString();
|
||||||
|
|
||||||
|
const isoExpiresAtDate = new Date(isoExpiresAt);
|
||||||
|
const currentIsoTimeDate = new Date(currentIsoTime);
|
||||||
|
|
||||||
|
return isoExpiresAtDate < currentIsoTimeDate;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { useState, useTransition } from 'react';
|
||||||
|
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@kit/ui/alert-dialog';
|
||||||
|
import { Button } from '@kit/ui/button';
|
||||||
|
import { If } from '@kit/ui/if';
|
||||||
|
import { Trans } from '@kit/ui/trans';
|
||||||
|
|
||||||
|
import { renewInvitationAction } from '../../server/actions/team-invitations-server-actions';
|
||||||
|
|
||||||
|
export const RenewInvitationDialog: React.FC<{
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
invitationId: number;
|
||||||
|
email: string;
|
||||||
|
}> = ({ isOpen, setIsOpen, invitationId, email }) => {
|
||||||
|
return (
|
||||||
|
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
<Trans i18nKey="team:renewInvitation" />
|
||||||
|
</AlertDialogTitle>
|
||||||
|
|
||||||
|
<AlertDialogDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="team:renewInvitationDialogDescription"
|
||||||
|
values={{ email }}
|
||||||
|
/>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
|
||||||
|
<RenewInvitationForm
|
||||||
|
setIsOpen={setIsOpen}
|
||||||
|
invitationId={invitationId}
|
||||||
|
/>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function RenewInvitationForm({
|
||||||
|
invitationId,
|
||||||
|
setIsOpen,
|
||||||
|
}: {
|
||||||
|
invitationId: number;
|
||||||
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const [isSubmitting, startTransition] = useTransition();
|
||||||
|
const [error, setError] = useState<boolean>();
|
||||||
|
|
||||||
|
const inInvitationRenewed = () => {
|
||||||
|
startTransition(async () => {
|
||||||
|
try {
|
||||||
|
await renewInvitationAction({ invitationId });
|
||||||
|
|
||||||
|
setIsOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
setError(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={inInvitationRenewed}>
|
||||||
|
<div className={'flex flex-col space-y-6'}>
|
||||||
|
<p className={'text-muted-foreground text-sm'}>
|
||||||
|
<Trans i18nKey={'common:modalConfirmationQuestion'} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<If condition={error}>
|
||||||
|
<RenewInvitationErrorAlert />
|
||||||
|
</If>
|
||||||
|
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
<Trans i18nKey={'common:cancel'} />
|
||||||
|
</AlertDialogCancel>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
data-test={'confirm-renew-invitation'}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<Trans i18nKey={'teams:renewInvitation'} />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenewInvitationErrorAlert() {
|
||||||
|
return (
|
||||||
|
<Alert variant={'destructive'}>
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans i18nKey={'teams:renewInvitationErrorTitle'} />
|
||||||
|
</AlertTitle>
|
||||||
|
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans i18nKey={'teams:renewInvitationErrorDescription'} />
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export async function createInvitationsAction(params: {
|
|||||||
|
|
||||||
await service.sendInvitations({ invitations, account: params.account });
|
await service.sendInvitations({ invitations, account: params.account });
|
||||||
|
|
||||||
revalidatePath('/home/[account]/members', 'page');
|
revalidateMemberPage();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
@@ -65,6 +65,8 @@ export async function deleteInvitationAction(
|
|||||||
|
|
||||||
await service.deleteInvitation(invitation);
|
await service.deleteInvitation(invitation);
|
||||||
|
|
||||||
|
revalidateMemberPage();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,6 +82,8 @@ export async function updateInvitationAction(
|
|||||||
|
|
||||||
await service.updateInvitation(invitation);
|
await service.updateInvitation(invitation);
|
||||||
|
|
||||||
|
revalidateMemberPage();
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,6 +107,21 @@ export async function acceptInvitationAction(data: FormData) {
|
|||||||
return redirect(nextPath);
|
return redirect(nextPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function renewInvitationAction(params: { invitationId: number }) {
|
||||||
|
const client = getSupabaseServerActionClient();
|
||||||
|
const { invitationId } = params;
|
||||||
|
|
||||||
|
await assertSession(client);
|
||||||
|
|
||||||
|
const service = new AccountInvitationsService(client);
|
||||||
|
|
||||||
|
await service.renewInvitation(invitationId);
|
||||||
|
|
||||||
|
revalidateMemberPage();
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
async function assertSession(client: SupabaseClient<Database>) {
|
async function assertSession(client: SupabaseClient<Database>) {
|
||||||
const { error, data } = await requireAuth(client);
|
const { error, data } = await requireAuth(client);
|
||||||
|
|
||||||
@@ -112,3 +131,7 @@ async function assertSession(client: SupabaseClient<Database>) {
|
|||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function revalidateMemberPage() {
|
||||||
|
revalidatePath('/home/[account]/members', 'page');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { SupabaseClient } from '@supabase/supabase-js';
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import { addDays, formatISO } from 'date-fns';
|
||||||
import 'server-only';
|
import 'server-only';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
@@ -227,6 +228,35 @@ export class AccountInvitationsService {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renewInvitation(invitationId: number) {
|
||||||
|
Logger.info('Renewing invitation', {
|
||||||
|
invitationId,
|
||||||
|
name: this.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sevenDaysFromNow = formatISO(addDays(new Date(), 7));
|
||||||
|
|
||||||
|
const { data, error } = await this.client
|
||||||
|
.from('invitations')
|
||||||
|
.update({
|
||||||
|
expires_at: sevenDaysFromNow,
|
||||||
|
})
|
||||||
|
.match({
|
||||||
|
id: invitationId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info('Invitation successfully renewed', {
|
||||||
|
invitationId,
|
||||||
|
name: this.namespace,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
private async getUser() {
|
private async getUser() {
|
||||||
const { data, error } = await requireAuth(this.client);
|
const { data, error } = await requireAuth(this.client);
|
||||||
|
|
||||||
|
|||||||
@@ -481,14 +481,7 @@ export type Database = {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
Functions: {
|
Functions: {
|
||||||
accept_invitation:
|
accept_invitation: {
|
||||||
| {
|
|
||||||
Args: {
|
|
||||||
invite_token: string;
|
|
||||||
};
|
|
||||||
Returns: undefined;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
Args: {
|
Args: {
|
||||||
token: string;
|
token: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -592,6 +585,7 @@ export type Database = {
|
|||||||
role: Database['public']['Enums']['account_role'];
|
role: Database['public']['Enums']['account_role'];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
expires_at: string;
|
||||||
inviter_name: string;
|
inviter_name: string;
|
||||||
inviter_email: string;
|
inviter_email: string;
|
||||||
}[];
|
}[];
|
||||||
|
|||||||
@@ -408,8 +408,8 @@ create table if not exists
|
|||||||
user_id uuid references auth.users on delete cascade not null,
|
user_id uuid references auth.users on delete cascade not null,
|
||||||
account_id uuid references public.accounts (id) on delete cascade not null,
|
account_id uuid references public.accounts (id) on delete cascade not null,
|
||||||
account_role public.account_role not null,
|
account_role public.account_role not null,
|
||||||
created_at timestamp default current_timestamp not null,
|
created_at timestamptz default current_timestamp not null,
|
||||||
updated_at timestamp default current_timestamp not null,
|
updated_at timestamptz default current_timestamp not null,
|
||||||
created_by uuid references auth.users,
|
created_by uuid references auth.users,
|
||||||
updated_by uuid references auth.users,
|
updated_by uuid references auth.users,
|
||||||
primary key (user_id, account_id)
|
primary key (user_id, account_id)
|
||||||
@@ -674,9 +674,9 @@ create table if not exists
|
|||||||
invited_by uuid references auth.users on delete cascade not null,
|
invited_by uuid references auth.users on delete cascade not null,
|
||||||
role public.account_role not null,
|
role public.account_role not null,
|
||||||
invite_token varchar(255) unique not null,
|
invite_token varchar(255) unique not null,
|
||||||
created_at timestamp default current_timestamp not null,
|
created_at timestamptz default current_timestamp not null,
|
||||||
updated_at timestamp default current_timestamp not null,
|
updated_at timestamptz default current_timestamp not null,
|
||||||
expires_at timestamp default current_timestamp + interval '7 days' not null
|
expires_at timestamptz default current_timestamp + interval '7 days' not null
|
||||||
);
|
);
|
||||||
|
|
||||||
comment on table public.invitations is 'The invitations for an account';
|
comment on table public.invitations is 'The invitations for an account';
|
||||||
@@ -735,6 +735,27 @@ insert
|
|||||||
has_role_on_account (account_id)
|
has_role_on_account (account_id)
|
||||||
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions));
|
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions));
|
||||||
|
|
||||||
|
-- UPDATE: Users can update invitations to users of an account they are a member of
|
||||||
|
-- and have the 'invites.manage' permission
|
||||||
|
create policy invitations_update on public.invitations for
|
||||||
|
update
|
||||||
|
to authenticated using (
|
||||||
|
has_role_on_account (account_id)
|
||||||
|
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
|
||||||
|
) with check (
|
||||||
|
has_role_on_account (account_id)
|
||||||
|
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- DELETE: Users can delete invitations to users of an account they are a member of
|
||||||
|
-- and have the 'invites.manage' permission
|
||||||
|
create policy invitations_delete on public.invitations for
|
||||||
|
delete
|
||||||
|
to authenticated using (
|
||||||
|
has_role_on_account (account_id)
|
||||||
|
and public.has_permission (auth.uid (), account_id, 'invites.manage'::app_permissions)
|
||||||
|
);
|
||||||
|
|
||||||
-- Functions
|
-- Functions
|
||||||
-- Function to accept an invitation to an account
|
-- Function to accept an invitation to an account
|
||||||
create or replace function accept_invitation(token text, user_id uuid) returns void as $$
|
create or replace function accept_invitation(token text, user_id uuid) returns void as $$
|
||||||
@@ -751,7 +772,12 @@ begin
|
|||||||
from
|
from
|
||||||
public.invitations
|
public.invitations
|
||||||
where
|
where
|
||||||
invite_token = token;
|
invite_token = token
|
||||||
|
and expires_at > now();
|
||||||
|
|
||||||
|
if not found then
|
||||||
|
raise exception 'Invalid or expired invitation token';
|
||||||
|
end if;
|
||||||
|
|
||||||
insert into
|
insert into
|
||||||
public.accounts_memberships(
|
public.accounts_memberships(
|
||||||
@@ -768,7 +794,7 @@ begin
|
|||||||
end;
|
end;
|
||||||
$$ language plpgsql;
|
$$ language plpgsql;
|
||||||
|
|
||||||
grant execute on function accept_invitation (uuid) to service_role;
|
grant execute on function accept_invitation (text, uuid) to service_role;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* -------------------------------------------------------
|
* -------------------------------------------------------
|
||||||
@@ -1303,8 +1329,8 @@ OR REPLACE FUNCTION public.get_account_members (account_slug text) RETURNS TABLE
|
|||||||
name varchar,
|
name varchar,
|
||||||
email varchar,
|
email varchar,
|
||||||
picture_url varchar,
|
picture_url varchar,
|
||||||
created_at timestamp,
|
created_at timestamptz,
|
||||||
updated_at timestamp
|
updated_at timestamptz
|
||||||
) LANGUAGE plpgsql AS $$
|
) LANGUAGE plpgsql AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
@@ -1326,8 +1352,9 @@ create or replace function public.get_account_invitations(account_slug text) ret
|
|||||||
account_id uuid,
|
account_id uuid,
|
||||||
invited_by uuid,
|
invited_by uuid,
|
||||||
role public.account_role,
|
role public.account_role,
|
||||||
created_at timestamp,
|
created_at timestamptz,
|
||||||
updated_at timestamp,
|
updated_at timestamptz,
|
||||||
|
expires_at timestamptz,
|
||||||
inviter_name varchar,
|
inviter_name varchar,
|
||||||
inviter_email varchar
|
inviter_email varchar
|
||||||
) as $$
|
) as $$
|
||||||
@@ -1341,6 +1368,7 @@ begin
|
|||||||
invitation.role,
|
invitation.role,
|
||||||
invitation.created_at,
|
invitation.created_at,
|
||||||
invitation.updated_at,
|
invitation.updated_at,
|
||||||
|
invitation.expires_at,
|
||||||
account.name,
|
account.name,
|
||||||
account.email
|
account.email
|
||||||
from
|
from
|
||||||
|
|||||||
Reference in New Issue
Block a user