diff --git a/README.md b/README.md index 0a57fcb4a..ad9c631e5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ pnpm i 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 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). -- **`@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/supabase`**: Supabase package that defines the schema and logic for managing Supabase - **`@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-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/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/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 The configuration is defined in the `apps/web/config` folder. Here you can find the following configuration files: diff --git a/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx b/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx index 5fafcafc0..c94e43b4a 100644 --- a/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx +++ b/apps/web/app/(dashboard)/home/[account]/_components/app-sidebar.tsx @@ -2,11 +2,10 @@ import { useRouter } from 'next/navigation'; -import type { Session } from '@supabase/supabase-js'; - import { ArrowLeftCircle, ArrowRightCircle } from 'lucide-react'; import { AccountSelector } from '@kit/accounts/account-selector'; +import { If } from '@kit/ui/if'; import { Sidebar, SidebarContent } from '@kit/ui/sidebar'; import { Tooltip, @@ -58,6 +57,7 @@ function SidebarContainer(props: { accounts: AccountModel[]; collapsed: boolean; setCollapsed: (collapsed: boolean) => void; + collapsible?: boolean; }) { const { account, accounts } = props; const router = useRouter(); @@ -88,10 +88,12 @@ function SidebarContainer(props: { - + + + diff --git a/apps/web/app/auth/callback/error/page.tsx b/apps/web/app/auth/callback/error/page.tsx index 779de670b..730feec62 100644 --- a/apps/web/app/auth/callback/error/page.tsx +++ b/apps/web/app/auth/callback/error/page.tsx @@ -10,15 +10,18 @@ import pathsConfig from '~/config/paths.config'; interface Params { searchParams: { error: string; + invite_token: string; }; } 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 (!error) { - redirect(pathsConfig.auth.signIn); + redirect(signInPath); } return ( @@ -36,7 +39,7 @@ function AuthCallbackErrorPage({ searchParams }: Params) { diff --git a/apps/web/app/join/page.tsx b/apps/web/app/join/page.tsx index e47ed93d0..132400c90 100644 --- a/apps/web/app/join/page.tsx +++ b/apps/web/app/join/page.tsx @@ -1,9 +1,15 @@ +import Link from 'next/link'; import { notFound, redirect } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; + import { Logger } from '@kit/shared/logger'; import { requireAuth } from '@kit/supabase/require-auth'; import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client'; 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 { withI18n } from '~/lib/i18n/with-i18n'; @@ -42,7 +48,7 @@ async function JoinTeamAccountPage({ searchParams }: Context) { const invitation = await getInviteDataFromInviteToken(token); if (!invitation) { - notFound(); + return ; } // we need to verify the user isn't already in the account @@ -124,13 +130,39 @@ async function getInviteDataFromInviteToken(token: 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) + .rangeLt('expires_at', new Date().toISOString()) .single(); + console.log(invitation, error); + if (!invitation ?? error) { return null; } return invitation; } + +function InviteNotFoundOrExpired() { + return ( +
+ + + + +

+ +

+ + + + +
+ ); +} diff --git a/apps/web/public/locales/en/teams.json b/apps/web/public/locales/en/teams.json index c29c50620..5f77f57f7 100644 --- a/apps/web/public/locales/en/teams.json +++ b/apps/web/public/locales/en/teams.json @@ -135,6 +135,18 @@ "updateInvitation": "Update Invitation", "removeInvitation": "Remove 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", "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}}", diff --git a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx index 470b187c8..ee48f5261 100644 --- a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx +++ b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx @@ -7,6 +7,7 @@ import { Ellipsis } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Database } from '@kit/supabase/database'; +import { Badge } from '@kit/ui/badge'; import { Button } from '@kit/ui/button'; import { DataTable } from '@kit/ui/data-table'; import { @@ -22,6 +23,7 @@ import { Trans } from '@kit/ui/trans'; import { RoleBadge } from '../members/role-badge'; import { DeleteInvitationDialog } from './delete-invitation-dialog'; +import { RenewInvitationDialog } from './renew-invitation-dialog'; import { UpdateInvitationDialog } from './update-invitation-dialog'; type Invitations = @@ -107,6 +109,24 @@ function useGetColumns(permissions: { 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 {t('expired')}; + } + + return {t('active')}; + }, + }, { header: '', id: 'actions', @@ -131,6 +151,7 @@ function ActionsDropdown({ }) { const [isDeletingInvite, setIsDeletingInvite] = useState(false); const [isUpdatingRole, setIsUpdatingRole] = useState(false); + const [iRenewingInvite, setIsRenewingInvite] = useState(false); return ( <> @@ -146,6 +167,12 @@ function ActionsDropdown({ setIsUpdatingRole(true)}> + + + setIsRenewingInvite(true)}> + + + @@ -172,6 +199,24 @@ function ActionsDropdown({ userRole={invitation.role} /> + + + + ); } + +function getIsInviteExpired(isoExpiresAt: string) { + const currentIsoTime = new Date().toISOString(); + + const isoExpiresAtDate = new Date(isoExpiresAt); + const currentIsoTimeDate = new Date(currentIsoTime); + + return isoExpiresAtDate < currentIsoTimeDate; +} diff --git a/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx new file mode 100644 index 000000000..11985a346 --- /dev/null +++ b/packages/features/team-accounts/src/components/invitations/renew-invitation-dialog.tsx @@ -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 ( + + + + + + + + + + + + + + + + ); +}; + +function RenewInvitationForm({ + invitationId, + setIsOpen, +}: { + invitationId: number; + setIsOpen: (isOpen: boolean) => void; +}) { + const [isSubmitting, startTransition] = useTransition(); + const [error, setError] = useState(); + + const inInvitationRenewed = () => { + startTransition(async () => { + try { + await renewInvitationAction({ invitationId }); + + setIsOpen(false); + } catch (e) { + setError(true); + } + }); + }; + + return ( +
+
+

+ +

+ + + + + + + + + + + + +
+
+ ); +} + +function RenewInvitationErrorAlert() { + return ( + + + + + + + + + + ); +} diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 331d08f3a..d06c4edbe 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -36,7 +36,7 @@ export async function createInvitationsAction(params: { await service.sendInvitations({ invitations, account: params.account }); - revalidatePath('/home/[account]/members', 'page'); + revalidateMemberPage(); return { success: true }; } @@ -65,6 +65,8 @@ export async function deleteInvitationAction( await service.deleteInvitation(invitation); + revalidateMemberPage(); + return { success: true }; } @@ -80,6 +82,8 @@ export async function updateInvitationAction( await service.updateInvitation(invitation); + revalidateMemberPage(); + return { success: true }; } @@ -103,6 +107,21 @@ export async function acceptInvitationAction(data: FormData) { 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) { const { error, data } = await requireAuth(client); @@ -112,3 +131,7 @@ async function assertSession(client: SupabaseClient) { return data; } + +function revalidateMemberPage() { + revalidatePath('/home/[account]/members', 'page'); +} diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index a9dde6141..765dd8a78 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -1,5 +1,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; +import { addDays, formatISO } from 'date-fns'; import 'server-only'; import { z } from 'zod'; @@ -227,6 +228,35 @@ export class AccountInvitationsService { 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() { const { data, error } = await requireAuth(this.client); diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index 7767f9fa4..0b2a72f1c 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -481,20 +481,13 @@ export type Database = { }; }; Functions: { - accept_invitation: - | { - Args: { - invite_token: string; - }; - Returns: undefined; - } - | { - Args: { - token: string; - user_id: string; - }; - Returns: undefined; - }; + accept_invitation: { + Args: { + token: string; + user_id: string; + }; + Returns: undefined; + }; add_invitations_to_account: { Args: { account_slug: string; @@ -592,6 +585,7 @@ export type Database = { role: Database['public']['Enums']['account_role']; created_at: string; updated_at: string; + expires_at: string; inviter_name: string; inviter_email: string; }[]; diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index a47c6f862..7e34782df 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -408,8 +408,8 @@ create table if not exists user_id uuid references auth.users on delete cascade not null, account_id uuid references public.accounts (id) on delete cascade not null, account_role public.account_role not null, - created_at timestamp default current_timestamp not null, - updated_at timestamp default current_timestamp not null, + created_at timestamptz default current_timestamp not null, + updated_at timestamptz default current_timestamp not null, created_by uuid references auth.users, updated_by uuid references auth.users, 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, role public.account_role not null, invite_token varchar(255) unique not null, - created_at timestamp default current_timestamp not null, - updated_at timestamp default current_timestamp not null, - expires_at timestamp default current_timestamp + interval '7 days' not null + created_at timestamptz default current_timestamp not null, + updated_at timestamptz default current_timestamp not null, + expires_at timestamptz default current_timestamp + interval '7 days' not null ); comment on table public.invitations is 'The invitations for an account'; @@ -735,6 +735,27 @@ insert has_role_on_account (account_id) 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 -- Function to accept an invitation to an account create or replace function accept_invitation(token text, user_id uuid) returns void as $$ @@ -751,7 +772,12 @@ begin from public.invitations 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 public.accounts_memberships( @@ -768,7 +794,7 @@ begin end; $$ 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, email varchar, picture_url varchar, - created_at timestamp, - updated_at timestamp + created_at timestamptz, + updated_at timestamptz ) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY @@ -1326,8 +1352,9 @@ create or replace function public.get_account_invitations(account_slug text) ret account_id uuid, invited_by uuid, role public.account_role, - created_at timestamp, - updated_at timestamp, + created_at timestamptz, + updated_at timestamptz, + expires_at timestamptz, inviter_name varchar, inviter_email varchar ) as $$ @@ -1341,6 +1368,7 @@ begin invitation.role, invitation.created_at, invitation.updated_at, + invitation.expires_at, account.name, account.email from