Skip to content

Commit

Permalink
feat(web): add member invite compatibility
Browse files Browse the repository at this point in the history
  • Loading branch information
IKatsuba committed May 6, 2024
1 parent ed73043 commit f78be60
Show file tree
Hide file tree
Showing 44 changed files with 873 additions and 143 deletions.
35 changes: 35 additions & 0 deletions .eslintrc.base.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
]
}
7 changes: 2 additions & 5 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
Expand All @@ -23,12 +21,10 @@
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"],
"rules": {}
},
{
Expand All @@ -38,5 +34,6 @@
},
"rules": {}
}
]
],
"extends": ["./.eslintrc.base.json"]
}
3 changes: 2 additions & 1 deletion apps/web/.eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
"plugin:@nx/react-typescript",
"next",
"next/core-web-vitals",
"../../.eslintrc.json"
"../../.eslintrc.json",
"../../.eslintrc.base.json"
],
"ignorePatterns": ["!**/*", ".next/**/*"],
"overrides": [
Expand Down
56 changes: 56 additions & 0 deletions apps/web/app/api/invites/[inviteId]/accept/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
});
51 changes: 51 additions & 0 deletions apps/web/app/api/invites/[inviteId]/decline/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
});
21 changes: 21 additions & 0 deletions apps/web/app/api/workspaces/[workspaceSlug]/invites/route.ts
Original file line number Diff line number Diff line change
@@ -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,
},
);
});
3 changes: 2 additions & 1 deletion apps/web/app/app/(auth)/auth/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 || '/'));
}
74 changes: 74 additions & 0 deletions apps/web/app/app/(auth)/invite/[id]/buttons.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Button
onClick={async function handleClick() {
const response = await fetch(`/api/invites/${id}/accept`, {
method: 'POST',
});

if (response.ok) {
router.push('/');

toast({
title: 'Success',
description: 'Invitation accepted.',
});
} else {
toast({
title: 'Error',
description: 'Failed to accept invitation.',
variant: 'destructive',
});
}
}}
>
Accept Invitation
</Button>
);
}

export function DeclineButton() {
const { id } = useParams();
const router = useRouter();
const { toast } = useToast();

return (
<Button
variant="outline"
onClick={async function handleClick() {
const response = await fetch(`/api/invites/${id}/decline`, {
method: 'POST',
});

if (response.ok) {
router.push('/');

toast({
title: 'Success',
description: 'Invitation declined.',
});
} else {
toast({
title: 'Error',
description: 'Failed to decline invitation.',
variant: 'destructive',
});
}
}}
>
Decline
</Button>
);
}
73 changes: 47 additions & 26 deletions apps/web/app/app/(auth)/invite/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -16,38 +29,46 @@ export default async function Component({ params }: { params: { id: string } })

if (!invite || error) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="max-w-md p-6 rounded-lg shadow shadow-muted border border-muted">
<div className="mb-6 text-center">
<h2 className="text-3xl font-bold">Invite Not Found</h2>
<p className="mt-2 text-gray-600 dark:text-gray-400">
The invite link is invalid or has expired.
</p>
<section className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="mx-auto max-w-lg space-y-8">
<div className="space-y-4 text-center">
<h1 className="text-3xl font-bold tracking-tighter sm:text-5xl">
Invitation Not Found
</h1>
<p className="max-w-[600px] text-gray-500 mx-auto md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
The invitation you are trying to access is not valid or has expired.
</p>
</div>
<div className="flex justify-center gap-4">
<Button variant="outline" asChild>
<Link href="/">Go to Home</Link>
</Button>
</div>
</div>
</div>
</div>
</section>
);
}

return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="max-w-md p-6 rounded-lg shadow shadow-muted border border-muted">
<div className="mb-6 text-center">
<h2 className="text-3xl font-bold">Join Workspace</h2>
<p className="mt-2 text-gray-600 dark:text-gray-400">
You&apos;ve been invited to collaborate on &quot{invite.workspaces?.name}&quot
workspace.
</p>
</div>
<div className="flex justify-center gap-4">
<Button className="px-6 py-3" variant="default">
Accept
</Button>
<Button className="px-6 py-3" variant="outline">
Decline
</Button>
<section className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="mx-auto max-w-lg space-y-8">
<div className="space-y-4 text-center">
<h1 className="text-3xl font-bold tracking-tighter sm:text-5xl">
Welcome to Acme Workspace
</h1>
<p className="max-w-[600px] text-gray-500 mx-auto md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
You have been invited to join our workspace.
</p>
</div>
<div className="flex justify-center gap-4">
<AcceptButton />
<DeclineButton />
</div>
</div>
</div>
</div>
</section>
);
}
Loading

0 comments on commit f78be60

Please sign in to comment.