From f78be607608fc243eb97e7e6f97c639c9c54b579 Mon Sep 17 00:00:00 2001 From: Igor Katsuba Date: Mon, 6 May 2024 17:49:48 +0300 Subject: [PATCH] feat(web): add member invite compatibility --- .eslintrc.base.json | 35 +++++ .eslintrc.json | 7 +- apps/web/.eslintrc.json | 3 +- .../api/invites/[inviteId]/accept/route.ts | 56 +++++++ .../api/invites/[inviteId]/decline/route.ts | 51 +++++++ .../[workspaceSlug]/invites/route.ts | 21 +++ .../web/app/app/(auth)/auth/callback/route.ts | 3 +- .../app/app/(auth)/invite/[id]/buttons.tsx | 74 ++++++++++ apps/web/app/app/(auth)/invite/[id]/page.tsx | 73 +++++---- .../web/app/app/(auth)/signin/[view]/page.tsx | 12 +- .../app/(auth)/signin/[view]/signin-form.tsx | 4 +- .../settings/members/add-member-dialog.tsx | 139 ++++++++++++++++++ .../settings/members/invite-table.tsx | 40 +++++ .../settings/members/member-table.tsx | 48 +++--- .../[workspaceSlug]/settings/members/page.tsx | 83 ++++++----- nx.json | 25 +++- package-lock.json | 21 ++- package.json | 3 +- packages/api/.eslintrc.json | 2 +- packages/components/.eslintrc.json | 2 +- packages/crud/invites/.eslintrc.json | 18 +++ packages/crud/invites/README.md | 7 + packages/crud/invites/project.json | 9 ++ packages/crud/invites/src/index.ts | 1 + packages/crud/invites/src/lib/create.ts | 34 +++++ packages/crud/invites/src/lib/schemas.ts | 10 ++ packages/crud/invites/src/server.ts | 2 + packages/crud/invites/tsconfig.json | 17 +++ packages/crud/invites/tsconfig.lib.json | 25 ++++ packages/crud/tokens/.eslintrc.json | 2 +- packages/crud/workspace-users/.eslintrc.json | 2 +- packages/crud/workspaces/.eslintrc.json | 2 +- packages/resend/.eslintrc.json | 2 +- packages/stripe/.eslintrc.json | 2 +- packages/supabase/.eslintrc.json | 2 +- packages/supabase/src/lib/db-types.ts | 139 +++++++++++++++++- packages/supabase/src/lib/supabase.module.css | 7 - packages/supabase/src/lib/supabase.tsx | 14 -- packages/ui/.eslintrc.json | 2 +- packages/utils/.eslintrc.json | 2 +- supabase/migrations/20240503153727_roles.sql | 6 + .../20240506132736_invite_email_unique.sql | 1 + .../20240506143357_invite_status.sql | 6 + tsconfig.base.json | 2 + 44 files changed, 873 insertions(+), 143 deletions(-) create mode 100644 .eslintrc.base.json create mode 100644 apps/web/app/api/invites/[inviteId]/accept/route.ts create mode 100644 apps/web/app/api/invites/[inviteId]/decline/route.ts create mode 100644 apps/web/app/api/workspaces/[workspaceSlug]/invites/route.ts create mode 100644 apps/web/app/app/(auth)/invite/[id]/buttons.tsx create mode 100644 apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/add-member-dialog.tsx create mode 100644 apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/invite-table.tsx create mode 100644 packages/crud/invites/.eslintrc.json create mode 100644 packages/crud/invites/README.md create mode 100644 packages/crud/invites/project.json create mode 100644 packages/crud/invites/src/index.ts create mode 100644 packages/crud/invites/src/lib/create.ts create mode 100644 packages/crud/invites/src/lib/schemas.ts create mode 100644 packages/crud/invites/src/server.ts create mode 100644 packages/crud/invites/tsconfig.json create mode 100644 packages/crud/invites/tsconfig.lib.json delete mode 100644 packages/supabase/src/lib/supabase.module.css delete mode 100644 packages/supabase/src/lib/supabase.tsx create mode 100644 supabase/migrations/20240503153727_roles.sql create mode 100644 supabase/migrations/20240506132736_invite_email_unique.sql create mode 100644 supabase/migrations/20240506143357_invite_status.sql diff --git a/.eslintrc.base.json b/.eslintrc.base.json new file mode 100644 index 0000000..9ca2e83 --- /dev/null +++ b/.eslintrc.base.json @@ -0,0 +1,35 @@ +{ + "root": true, + "ignorePatterns": ["**/*"], + "plugins": ["@nx"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": { + "@nx/enforce-module-boundaries": [ + "error", + { + "enforceBuildableLibDependency": true, + "allow": [], + "depConstraints": [ + { + "sourceTag": "*", + "onlyDependOnLibsWithTags": ["*"] + } + ] + } + ] + } + }, + { + "files": ["*.ts", "*.tsx"], + "extends": ["plugin:@nx/typescript"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "extends": ["plugin:@nx/javascript"], + "rules": {} + } + ] +} diff --git a/.eslintrc.json b/.eslintrc.json index 0be733b..1f84846 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,7 +1,5 @@ { - "root": true, "ignorePatterns": ["**/*"], - "plugins": ["@nx"], "overrides": [ { "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], @@ -23,12 +21,10 @@ }, { "files": ["*.ts", "*.tsx"], - "extends": ["plugin:@nx/typescript"], "rules": {} }, { "files": ["*.js", "*.jsx"], - "extends": ["plugin:@nx/javascript"], "rules": {} }, { @@ -38,5 +34,6 @@ }, "rules": {} } - ] + ], + "extends": ["./.eslintrc.base.json"] } diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json index a427812..7fcf582 100644 --- a/apps/web/.eslintrc.json +++ b/apps/web/.eslintrc.json @@ -3,7 +3,8 @@ "plugin:@nx/react-typescript", "next", "next/core-web-vitals", - "../../.eslintrc.json" + "../../.eslintrc.json", + "../../.eslintrc.base.json" ], "ignorePatterns": ["!**/*", ".next/**/*"], "overrides": [ diff --git a/apps/web/app/api/invites/[inviteId]/accept/route.ts b/apps/web/app/api/invites/[inviteId]/accept/route.ts new file mode 100644 index 0000000..bc89ec6 --- /dev/null +++ b/apps/web/app/api/invites/[inviteId]/accept/route.ts @@ -0,0 +1,56 @@ +import { withUser } from '@saasfy/api/server'; +import { createWorkspaceUser } from '@saasfy/crud/workspace-users/server'; +import { createAdminClient } from '@saasfy/supabase/server'; + +export const POST = withUser<{ inviteId: string }>(async ({ req, user, params }) => { + const supabase = createAdminClient(); + + const { data: invite, error } = await supabase + .from('workspace_invites') + .select('*, workspaces(*)') + .eq('id', params.inviteId) + .eq('email', user.email!) + .single(); + + if (!invite || error) { + return Response.json( + { + errors: ['Invitation not found'], + invite: null, + }, + { + status: 400, + }, + ); + } + + if (!invite.workspaces) { + return Response.json( + { + errors: ['Invalid invitation'], + invite: null, + }, + { + status: 400, + }, + ); + } + + const { errors, workspaceUser } = await createWorkspaceUser({ + workspace_id: invite.workspaces.id, + user_id: user.id, + role: invite.role, + }); + + await supabase.from('workspace_invites').update({ status: 'accepted' }).eq('id', params.inviteId); + + return Response.json( + { + errors, + workspaceUser, + }, + { + status: errors ? 400 : 200, + }, + ); +}); diff --git a/apps/web/app/api/invites/[inviteId]/decline/route.ts b/apps/web/app/api/invites/[inviteId]/decline/route.ts new file mode 100644 index 0000000..399386d --- /dev/null +++ b/apps/web/app/api/invites/[inviteId]/decline/route.ts @@ -0,0 +1,51 @@ +import { withUser } from '@saasfy/api/server'; +import { createAdminClient } from '@saasfy/supabase/server'; + +export const POST = withUser<{ inviteId: string }>(async ({ req, user, params }) => { + const supabase = createAdminClient(); + + const { data: invite, error } = await supabase + .from('workspace_invites') + .select('*, workspaces(*)') + .eq('id', params.inviteId) + .eq('email', user.email!) + .single(); + + if (!invite || error) { + return Response.json( + { + errors: ['Invitation not found'], + invite: null, + }, + { + status: 400, + }, + ); + } + + if (!invite.workspaces) { + return Response.json( + { + errors: ['Invalid invitation'], + invite: null, + }, + { + status: 400, + }, + ); + } + + const { error: deleteError } = await supabase + .from('workspace_invites') + .update({ status: 'declined' }) + .eq('id', params.inviteId); + + return Response.json( + { + errors: deleteError ? [deleteError.message] : null, + }, + { + status: deleteError ? 400 : 200, + }, + ); +}); diff --git a/apps/web/app/api/workspaces/[workspaceSlug]/invites/route.ts b/apps/web/app/api/workspaces/[workspaceSlug]/invites/route.ts new file mode 100644 index 0000000..f39b2f1 --- /dev/null +++ b/apps/web/app/api/workspaces/[workspaceSlug]/invites/route.ts @@ -0,0 +1,21 @@ +import { withWorkspaceOwner } from '@saasfy/api/server'; +import { createInvite } from '@saasfy/invites/server'; + +export const POST = withWorkspaceOwner(async ({ req, workspace, user }) => { + const data = await req.json(); + + const { errors, invite } = await createInvite({ + ...data, + workspace_id: workspace.id, + }); + + return Response.json( + { + errors, + invite, + }, + { + status: errors ? 400 : 200, + }, + ); +}); diff --git a/apps/web/app/app/(auth)/auth/callback/route.ts b/apps/web/app/app/(auth)/auth/callback/route.ts index af2685d..5d260f6 100644 --- a/apps/web/app/app/(auth)/auth/callback/route.ts +++ b/apps/web/app/app/(auth)/auth/callback/route.ts @@ -6,6 +6,7 @@ export async function GET(request: Request) { // by the `@supabase/ssr` package. It exchanges an auth code for the user's session. const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get('code'); + const redirect = requestUrl.searchParams.get('redirect'); if (code) { const auth = createAuthClient(); @@ -18,5 +19,5 @@ export async function GET(request: Request) { } // URL to redirect to after sign in process completes - return Response.redirect(getUrl(request, '/')); + return Response.redirect(getUrl(request, redirect || '/')); } diff --git a/apps/web/app/app/(auth)/invite/[id]/buttons.tsx b/apps/web/app/app/(auth)/invite/[id]/buttons.tsx new file mode 100644 index 0000000..fdac12f --- /dev/null +++ b/apps/web/app/app/(auth)/invite/[id]/buttons.tsx @@ -0,0 +1,74 @@ +'use client'; + +import * as React from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +import { Button } from '@saasfy/ui/button'; +import { useToast } from '@saasfy/ui/use-toast'; + +export function AcceptButton() { + const { id } = useParams(); + const router = useRouter(); + const { toast } = useToast(); + + return ( + + ); +} + +export function DeclineButton() { + const { id } = useParams(); + const router = useRouter(); + const { toast } = useToast(); + + return ( + + ); +} diff --git a/apps/web/app/app/(auth)/invite/[id]/page.tsx b/apps/web/app/app/(auth)/invite/[id]/page.tsx index 58ffe1f..b03c625 100644 --- a/apps/web/app/app/(auth)/invite/[id]/page.tsx +++ b/apps/web/app/app/(auth)/invite/[id]/page.tsx @@ -1,13 +1,26 @@ -import { createAdminClient } from '@saasfy/supabase/server'; +import Link from 'next/link'; +import { redirect } from 'next/navigation'; + +import { createAdminClient, getUser } from '@saasfy/supabase/server'; import { Button } from '@saasfy/ui/button'; +import { AcceptButton, DeclineButton } from './buttons'; + export default async function Component({ params }: { params: { id: string } }) { + const user = await getUser(); + + if (!user) { + return redirect(`/signin/signin?redirect=${encodeURIComponent(`/invite/${params.id}`)}`); + } + const supabase = createAdminClient(); const { data: invite, error } = await supabase .from('workspace_invites') .select('*, workspaces(*)') .eq('id', params.id) + .eq('email', user.email!) + .eq('status', 'pending') .single(); if (error) { @@ -16,38 +29,46 @@ export default async function Component({ params }: { params: { id: string } }) if (!invite || error) { return ( -
-
-
-

Invite Not Found

-

- The invite link is invalid or has expired. -

+
+
+
+
+

+ Invitation Not Found +

+

+ The invitation you are trying to access is not valid or has expired. +

+
+
+ +
-
+ ); } return ( -
-
-
-

Join Workspace

-

- You've been invited to collaborate on "{invite.workspaces?.name}" - workspace. -

-
-
- - +
+
+
+
+

+ Welcome to Acme Workspace +

+

+ You have been invited to join our workspace. +

+
+
+ + +
-
+ ); } diff --git a/apps/web/app/app/(auth)/signin/[view]/page.tsx b/apps/web/app/app/(auth)/signin/[view]/page.tsx index 0d8725c..dfe66e1 100644 --- a/apps/web/app/app/(auth)/signin/[view]/page.tsx +++ b/apps/web/app/app/(auth)/signin/[view]/page.tsx @@ -6,7 +6,13 @@ import { SignInForm, type SignInViews } from './signin-form'; const views: SignInViews[] = ['signin', 'signup', 'forgot-password']; -export default async function SignInViews({ params }: { params: { view: SignInViews } }) { +export default async function SignInViews({ + params, + searchParams, +}: { + params: { view: SignInViews }; + searchParams: { redirect?: string }; +}) { let { view = 'signin' } = params; if (!views.includes(view)) { @@ -16,12 +22,12 @@ export default async function SignInViews({ params }: { params: { view: SignInVi const user = await getUser(); if (user) { - return redirect('/'); + return redirect(searchParams.redirect || '/'); } return (
- +
); } diff --git a/apps/web/app/app/(auth)/signin/[view]/signin-form.tsx b/apps/web/app/app/(auth)/signin/[view]/signin-form.tsx index e161547..afb57fb 100644 --- a/apps/web/app/app/(auth)/signin/[view]/signin-form.tsx +++ b/apps/web/app/app/(auth)/signin/[view]/signin-form.tsx @@ -13,7 +13,7 @@ import { forgotPassword, login, signup } from './signin-actions'; export type SignInViews = 'signin' | 'signup' | 'forgot-password'; -export function SignInForm({ view }: { view: SignInViews }) { +export function SignInForm({ view, redirect }: { view: SignInViews; redirect?: string }) { const { toast } = useToast(); const supabase = createClient(); @@ -108,7 +108,7 @@ export function SignInForm({ view }: { view: SignInViews }) { supabase.auth.signInWithOAuth({ provider: 'github', options: { - redirectTo: `${window.location.origin}/auth/callback`, + redirectTo: `${window.location.origin}/auth/callback?redirect=${redirect || '/'}`, }, }); }} diff --git a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/add-member-dialog.tsx b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/add-member-dialog.tsx new file mode 100644 index 0000000..ec05508 --- /dev/null +++ b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/add-member-dialog.tsx @@ -0,0 +1,139 @@ +'use client'; + +import React, { useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; + +import { useForm } from 'react-hook-form'; + +import { Button } from '@saasfy/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@saasfy/ui/dialog'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@saasfy/ui/form'; +import { Input } from '@saasfy/ui/input'; +import { Label } from '@saasfy/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@saasfy/ui/select'; +import { useToast } from '@saasfy/ui/use-toast'; + +export function AddMemberDialog({ children }: { children: React.ReactNode }) { + const params = useParams(); + + const form = useForm({ + defaultValues: { + email: '', + role: 'member', + }, + }); + + const { toast } = useToast(); + const router = useRouter(); + + const [dialogOpen, setDialogOpen] = useState(false); + + return ( + + {children} + + + Invite Member + + Enter the email address of the person you want to invite. + + +
+ { + const response = await fetch(`/api/workspaces/${params.workspaceSlug}/invites`, { + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + }, + }); + + const { invite, errors } = await response.json(); + + if (errors) { + toast({ + title: 'Error', + description: errors.join(', '), + variant: 'destructive', + }); + setDialogOpen(false); + return; + } + + toast({ + title: 'Success', + description: `Invitation sent to ${invite.email}`, + }); + + navigator.clipboard.writeText(`${window.location.origin}/invite/${invite.id}`); + + router.refresh(); + setDialogOpen(false); + })} + > + ( + +
+ + + + +
+ +
+ )} + name="email" + /> + ( +
+ + + +
+ )} + name="role" + /> + + + + + +
+
+ ); +} diff --git a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/invite-table.tsx b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/invite-table.tsx new file mode 100644 index 0000000..37bdd3b --- /dev/null +++ b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/invite-table.tsx @@ -0,0 +1,40 @@ +import { TrashIcon } from 'lucide-react'; + +import { Tables } from '@saasfy/supabase'; +import { Button } from '@saasfy/ui/button'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@saasfy/ui/table'; + +export function InviteTable({ invites }: { invites: Tables<'workspace_invites'>[] | null }) { + if (!invites?.length) { + return
No invites found.
; + } + + return ( + + + + Email + Role + Expires + + Actions + + + + + {invites.map((invite) => ( + + {invite.email} + {invite.role} + {invite.expires} + + + + + ))} + +
+ ); +} diff --git a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/member-table.tsx b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/member-table.tsx index 05c4489..4c37753 100644 --- a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/member-table.tsx +++ b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/member-table.tsx @@ -1,47 +1,41 @@ import { TrashIcon } from 'lucide-react'; -import { Badge } from '@saasfy/ui/badge'; +import { Tables } from '@saasfy/supabase'; import { Button } from '@saasfy/ui/button'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@saasfy/ui/table'; -export function MemberTable() { +export function MemberTable({ + members, +}: { + members: (Tables<'workspace_users'> & { email: string })[] | null; +}) { + if (!members?.length) { + return
No members found.
; + } + return ( Email Role - Status Actions - - olivia.davis@vercel.com - Admin - - Active - - - - - - - michael.johnson@vercel.com - Member - - Inactive - - - - - + {members.map((member) => ( + + {member.email} + {member.role} + + + + + ))}
); diff --git a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/page.tsx b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/page.tsx index ed440c4..45c54af 100644 --- a/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/page.tsx +++ b/apps/web/app/app/(dashboard)/[workspaceSlug]/settings/members/page.tsx @@ -1,15 +1,11 @@ +import React from 'react'; +import { redirect } from 'next/navigation'; + import { FilterIcon, PlusIcon } from 'lucide-react'; +import postgres from 'postgres'; +import { createAdminClient, Tables } from '@saasfy/supabase/server'; import { Button } from '@saasfy/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from '@saasfy/ui/dialog'; import { DropdownMenu, DropdownMenuContent, @@ -21,9 +17,38 @@ import { } from '@saasfy/ui/dropdown-menu'; import { Input } from '@saasfy/ui/input'; +import { AddMemberDialog } from './add-member-dialog'; +import { InviteTable } from './invite-table'; import { MemberTable } from './member-table'; -export default function Component() { +export default async function Component({ params }: { params: { workspaceSlug: string } }) { + const supabase = createAdminClient(); + + const { data: workspace } = await supabase + .from('workspaces') + .select('*') + .eq('slug', params.workspaceSlug) + .single(); + + if (!workspace) { + return redirect('/not-found'); + } + + const sql = postgres(process.env.POSTGRES_URL!); + + const workspaceMembers = await sql<(Tables<'workspace_users'> & { email: string })[]>` + SELECT workspace_users.*, users.email + FROM public.workspace_users + JOIN auth.users on public.workspace_users.user_id = auth.users.id + WHERE public.workspace_users.workspace_id = ${workspace.id} + `; + + const { data: invites } = await supabase + .from('workspace_invites') + .select('*') + .eq('status', 'pending') + .eq('workspace_id', workspace.id); + return (
@@ -58,34 +83,22 @@ export default function Component() {
- - - - - - - Invite Member - - Enter the email address of the person you want to invite. - - -
-
- -
-
- - - -
-
+ + +
- + +
+
+

Invitations

+
+ +
); diff --git a/nx.json b/nx.json index 79f2120..f73d808 100644 --- a/nx.json +++ b/nx.json @@ -24,12 +24,29 @@ "serveStaticTargetName": "serve-static" } }, - { "plugin": "@nx/jest/plugin", "options": { "targetName": "test" } }, - { "plugin": "@nx/eslint/plugin", "options": { "targetName": "lint" } } + { + "plugin": "@nx/jest/plugin", + "options": { + "targetName": "test" + } + }, + { + "plugin": "@nx/eslint/plugin", + "options": { + "targetName": "lint" + } + } ], "generators": { - "@nx/next": { "application": { "style": "tailwind", "linter": "eslint" } }, - "@nx/react": { "library": {} } + "@nx/next": { + "application": { + "style": "tailwind", + "linter": "eslint" + } + }, + "@nx/react": { + "library": {} + } }, "useDaemonProcess": false } diff --git a/package-lock.json b/package-lock.json index 2a412b7..9911c8c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,7 @@ "next": "^14.1.4", "next-themes": "^0.3.0", "pg": "^8.11.5", + "postgres": "^3.4.4", "react": "18.2.0", "react-day-picker": "^8.10.0", "react-dom": "18.2.0", @@ -117,7 +118,7 @@ "prettier": "^3.2.5", "prettier-plugin-packagejson": "^2.5.0", "prettier-plugin-tailwindcss": "^0.5.14", - "supabase": "^1.162.4", + "supabase": "^1.165.0", "tailwindcss": "^3.4.1", "ts-jest": "^29.1.0", "ts-node": "10.9.1", @@ -20881,6 +20882,18 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/postgres": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.4.tgz", + "integrity": "sha512-IbyN+9KslkqcXa8AO9fxpk97PA4pzewvpi2B3Dwy9u4zpV32QicaEdgmF3eSQUzdRk7ttDHQejNgAEr4XoeH4A==", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -23252,9 +23265,9 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" }, "node_modules/supabase": { - "version": "1.162.4", - "resolved": "https://registry.npmjs.org/supabase/-/supabase-1.162.4.tgz", - "integrity": "sha512-oNTn2eyvDLcKr4wRDs/QxN9lVcO6hMzKUjMLtDMU700WuyOpBZ6YBsAZkPlFWyodwmH/jUX2jqTMpcHL2XRv0g==", + "version": "1.165.0", + "resolved": "https://registry.npmjs.org/supabase/-/supabase-1.165.0.tgz", + "integrity": "sha512-bN1TSR6p4POxCQqb3OsO6vo2H9yKIUB2HW44SiLAV9leBIjdm4AsrJJ1hmc/YecqjtuBooAr7RXz/uGKQEQbEQ==", "dev": true, "hasInstallScript": true, "dependencies": { diff --git a/package.json b/package.json index f770300..35f95bc 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "next": "^14.1.4", "next-themes": "^0.3.0", "pg": "^8.11.5", + "postgres": "^3.4.4", "react": "18.2.0", "react-day-picker": "^8.10.0", "react-dom": "18.2.0", @@ -119,7 +120,7 @@ "prettier": "^3.2.5", "prettier-plugin-packagejson": "^2.5.0", "prettier-plugin-tailwindcss": "^0.5.14", - "supabase": "^1.162.4", + "supabase": "^1.165.0", "tailwindcss": "^3.4.1", "ts-jest": "^29.1.0", "ts-node": "10.9.1", diff --git a/packages/api/.eslintrc.json b/packages/api/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/api/.eslintrc.json +++ b/packages/api/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/components/.eslintrc.json b/packages/components/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/components/.eslintrc.json +++ b/packages/components/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/crud/invites/.eslintrc.json b/packages/crud/invites/.eslintrc.json new file mode 100644 index 0000000..cbfb331 --- /dev/null +++ b/packages/crud/invites/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../../.eslintrc.base.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/crud/invites/README.md b/packages/crud/invites/README.md new file mode 100644 index 0000000..8e8d7bb --- /dev/null +++ b/packages/crud/invites/README.md @@ -0,0 +1,7 @@ +# invites + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test invites` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/packages/crud/invites/project.json b/packages/crud/invites/project.json new file mode 100644 index 0000000..8a610ea --- /dev/null +++ b/packages/crud/invites/project.json @@ -0,0 +1,9 @@ +{ + "name": "invites", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/crud/invites/src", + "projectType": "library", + "tags": [], + "// targets": "to see all targets run: nx show project invites --web", + "targets": {} +} diff --git a/packages/crud/invites/src/index.ts b/packages/crud/invites/src/index.ts new file mode 100644 index 0000000..8259b5c --- /dev/null +++ b/packages/crud/invites/src/index.ts @@ -0,0 +1 @@ +export * from './lib/schemas'; diff --git a/packages/crud/invites/src/lib/create.ts b/packages/crud/invites/src/lib/create.ts new file mode 100644 index 0000000..144f0ae --- /dev/null +++ b/packages/crud/invites/src/lib/create.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +import { createAdminClient } from '@saasfy/supabase/server'; + +import { CreateInviteSchema } from './schemas'; + +export async function createInvite(data: z.infer) { + const supabase = createAdminClient(); + + const parsed = CreateInviteSchema.safeParse(data); + + if (!parsed.success) { + return { + errors: parsed.error.errors.map((err) => err.message), + invite: null, + }; + } + + const { data: invite, error } = await supabase + .from('workspace_invites') + .insert({ + email: data.email, + workspace_id: data.workspace_id, + expires: data.expires?.toString() ?? new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + role: data.role, + }) + .select('*') + .single(); + + return { + errors: error ? [error.message] : null, + invite, + }; +} diff --git a/packages/crud/invites/src/lib/schemas.ts b/packages/crud/invites/src/lib/schemas.ts new file mode 100644 index 0000000..b014163 --- /dev/null +++ b/packages/crud/invites/src/lib/schemas.ts @@ -0,0 +1,10 @@ +import { z, ZodType } from 'zod'; + +import { TablesInsert } from '@saasfy/supabase'; + +export const CreateInviteSchema = z.object({ + email: z.string().email('Invalid email address'), + expires: z.coerce.date().default(() => new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)), + workspace_id: z.string().min(1, 'Workspace ID is required'), + role: z.enum(['owner', 'member']).default('member' as const), +}) satisfies ZodType, 'expires'>>; diff --git a/packages/crud/invites/src/server.ts b/packages/crud/invites/src/server.ts new file mode 100644 index 0000000..3518745 --- /dev/null +++ b/packages/crud/invites/src/server.ts @@ -0,0 +1,2 @@ +export * from './lib/create'; +export * from './lib/schemas'; diff --git a/packages/crud/invites/tsconfig.json b/packages/crud/invites/tsconfig.json new file mode 100644 index 0000000..89f8ac0 --- /dev/null +++ b/packages/crud/invites/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../../tsconfig.base.json" +} diff --git a/packages/crud/invites/tsconfig.lib.json b/packages/crud/invites/tsconfig.lib.json new file mode 100644 index 0000000..d9f9db4 --- /dev/null +++ b/packages/crud/invites/tsconfig.lib.json @@ -0,0 +1,25 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "next", + "@nx/next/typings/image.d.ts" + ] + }, + "exclude": [ + "jest.config.ts", + "src/**/*.spec.ts", + "src/**/*.test.ts", + "src/**/*.spec.tsx", + "src/**/*.test.tsx", + "src/**/*.spec.js", + "src/**/*.test.js", + "src/**/*.spec.jsx", + "src/**/*.test.jsx" + ], + "include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"] +} diff --git a/packages/crud/tokens/.eslintrc.json b/packages/crud/tokens/.eslintrc.json index 75b8507..0c10a54 100644 --- a/packages/crud/tokens/.eslintrc.json +++ b/packages/crud/tokens/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../../.eslintrc.json", "../../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/crud/workspace-users/.eslintrc.json b/packages/crud/workspace-users/.eslintrc.json index 75b8507..0c10a54 100644 --- a/packages/crud/workspace-users/.eslintrc.json +++ b/packages/crud/workspace-users/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../../.eslintrc.json", "../../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/crud/workspaces/.eslintrc.json b/packages/crud/workspaces/.eslintrc.json index 75b8507..0c10a54 100644 --- a/packages/crud/workspaces/.eslintrc.json +++ b/packages/crud/workspaces/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../../.eslintrc.json", "../../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/resend/.eslintrc.json b/packages/resend/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/resend/.eslintrc.json +++ b/packages/resend/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/stripe/.eslintrc.json b/packages/stripe/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/stripe/.eslintrc.json +++ b/packages/stripe/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/supabase/.eslintrc.json b/packages/supabase/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/supabase/.eslintrc.json +++ b/packages/supabase/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/supabase/src/lib/db-types.ts b/packages/supabase/src/lib/db-types.ts index 8ee9eb6..1cb1de4 100644 --- a/packages/supabase/src/lib/db-types.ts +++ b/packages/supabase/src/lib/db-types.ts @@ -240,6 +240,8 @@ export type Database = { email: string; expires: string; id: string; + role: Database['public']['Enums']['Role']; + status: Database['public']['Enums']['InviteStatus']; updated_at: string; workspace_id: string; }; @@ -248,6 +250,8 @@ export type Database = { email: string; expires: string; id?: string; + role?: Database['public']['Enums']['Role']; + status?: Database['public']['Enums']['InviteStatus']; updated_at?: string; workspace_id: string; }; @@ -256,6 +260,8 @@ export type Database = { email?: string; expires?: string; id?: string; + role?: Database['public']['Enums']['Role']; + status?: Database['public']['Enums']['InviteStatus']; updated_at?: string; workspace_id?: string; }; @@ -273,7 +279,7 @@ export type Database = { Row: { created_at: string; id: string; - role: Database['public']['Enums']['Role'] | null; + role: Database['public']['Enums']['Role']; updated_at: string; user_id: string | null; workspace_id: string | null; @@ -281,7 +287,7 @@ export type Database = { Insert: { created_at?: string; id?: string; - role?: Database['public']['Enums']['Role'] | null; + role?: Database['public']['Enums']['Role']; updated_at?: string; user_id?: string | null; workspace_id?: string | null; @@ -289,7 +295,7 @@ export type Database = { Update: { created_at?: string; id?: string; - role?: Database['public']['Enums']['Role'] | null; + role?: Database['public']['Enums']['Role']; updated_at?: string; user_id?: string | null; workspace_id?: string | null; @@ -369,6 +375,7 @@ export type Database = { [_ in never]: never; }; Enums: { + InviteStatus: 'pending' | 'accepted' | 'declined'; PlanStatus: 'active' | 'inactive'; PriceInterval: 'month' | 'year'; PriceStatus: 'active' | 'inactive'; @@ -500,6 +507,101 @@ export type Database = { }, ]; }; + s3_multipart_uploads: { + Row: { + bucket_id: string; + created_at: string; + id: string; + in_progress_size: number; + key: string; + owner_id: string | null; + upload_signature: string; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + id: string; + in_progress_size?: number; + key: string; + owner_id?: string | null; + upload_signature: string; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + id?: string; + in_progress_size?: number; + key?: string; + owner_id?: string | null; + upload_signature?: string; + version?: string; + }; + Relationships: [ + { + foreignKeyName: 's3_multipart_uploads_bucket_id_fkey'; + columns: ['bucket_id']; + isOneToOne: false; + referencedRelation: 'buckets'; + referencedColumns: ['id']; + }, + ]; + }; + s3_multipart_uploads_parts: { + Row: { + bucket_id: string; + created_at: string; + etag: string; + id: string; + key: string; + owner_id: string | null; + part_number: number; + size: number; + upload_id: string; + version: string; + }; + Insert: { + bucket_id: string; + created_at?: string; + etag: string; + id?: string; + key: string; + owner_id?: string | null; + part_number: number; + size?: number; + upload_id: string; + version: string; + }; + Update: { + bucket_id?: string; + created_at?: string; + etag?: string; + id?: string; + key?: string; + owner_id?: string | null; + part_number?: number; + size?: number; + upload_id?: string; + version?: string; + }; + Relationships: [ + { + foreignKeyName: 's3_multipart_uploads_parts_bucket_id_fkey'; + columns: ['bucket_id']; + isOneToOne: false; + referencedRelation: 'buckets'; + referencedColumns: ['id']; + }, + { + foreignKeyName: 's3_multipart_uploads_parts_upload_id_fkey'; + columns: ['upload_id']; + isOneToOne: false; + referencedRelation: 's3_multipart_uploads'; + referencedColumns: ['id']; + }, + ]; + }; }; Views: { [_ in never]: never; @@ -539,6 +641,37 @@ export type Database = { bucket_id: string; }[]; }; + list_multipart_uploads_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + next_key_token?: string; + next_upload_token?: string; + }; + Returns: { + key: string; + id: string; + created_at: string; + }[]; + }; + list_objects_with_delimiter: { + Args: { + bucket_id: string; + prefix_param: string; + delimiter_param: string; + max_keys?: number; + start_after?: string; + next_token?: string; + }; + Returns: { + name: string; + id: string; + metadata: Json; + updated_at: string; + }[]; + }; search: { Args: { prefix: string; diff --git a/packages/supabase/src/lib/supabase.module.css b/packages/supabase/src/lib/supabase.module.css deleted file mode 100644 index 45c2aa4..0000000 --- a/packages/supabase/src/lib/supabase.module.css +++ /dev/null @@ -1,7 +0,0 @@ -/* - * Replace this with your own classes - * - * e.g. - * .container { - * } -*/ diff --git a/packages/supabase/src/lib/supabase.tsx b/packages/supabase/src/lib/supabase.tsx deleted file mode 100644 index 74e06a0..0000000 --- a/packages/supabase/src/lib/supabase.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import styles from './supabase.module.css'; - -/* eslint-disable-next-line */ -export interface SupabaseProps {} - -export function Supabase(props: SupabaseProps) { - return ( -
-

Welcome to Supabase!

-
- ); -} - -export default Supabase; diff --git a/packages/ui/.eslintrc.json b/packages/ui/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/ui/.eslintrc.json +++ b/packages/ui/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/packages/utils/.eslintrc.json b/packages/utils/.eslintrc.json index a39ac5d..4011e82 100644 --- a/packages/utils/.eslintrc.json +++ b/packages/utils/.eslintrc.json @@ -1,5 +1,5 @@ { - "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "extends": ["plugin:@nx/react", "../../.eslintrc.json", "../../.eslintrc.base.json"], "ignorePatterns": ["!**/*"], "overrides": [ { diff --git a/supabase/migrations/20240503153727_roles.sql b/supabase/migrations/20240503153727_roles.sql new file mode 100644 index 0000000..94a7710 --- /dev/null +++ b/supabase/migrations/20240503153727_roles.sql @@ -0,0 +1,6 @@ +alter table "public"."workspace_invites" add column "role" "Role" not null default 'member'::"Role"; + +alter table "public"."workspace_users" alter column "role" set not null; + + + diff --git a/supabase/migrations/20240506132736_invite_email_unique.sql b/supabase/migrations/20240506132736_invite_email_unique.sql new file mode 100644 index 0000000..496311b --- /dev/null +++ b/supabase/migrations/20240506132736_invite_email_unique.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_invites ADD CONSTRAINT unique_email_per_workspace UNIQUE (email, workspace_id); diff --git a/supabase/migrations/20240506143357_invite_status.sql b/supabase/migrations/20240506143357_invite_status.sql new file mode 100644 index 0000000..2e99a65 --- /dev/null +++ b/supabase/migrations/20240506143357_invite_status.sql @@ -0,0 +1,6 @@ +create type "public"."InviteStatus" as enum ('pending', 'accepted', 'declined'); + +alter table "public"."workspace_invites" add column "status" "InviteStatus" not null default 'pending'::"InviteStatus"; + + + diff --git a/tsconfig.base.json b/tsconfig.base.json index 509037c..a2e678a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,6 +25,8 @@ "@saasfy/crud/workspace-users/server": ["packages/crud/workspace-users/src/server.ts"], "@saasfy/crud/workspaces": ["packages/crud/workspaces/src/index.ts"], "@saasfy/crud/workspaces/server": ["packages/crud/workspaces/src/server.ts"], + "@saasfy/invites": ["packages/crud/invites/src/index.ts"], + "@saasfy/invites/server": ["packages/crud/invites/src/server.ts"], "@saasfy/resend": ["packages/resend/src/index.ts"], "@saasfy/resend/server": ["packages/resend/src/server.ts"], "@saasfy/stripe": ["packages/stripe/src/index.ts"],