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