Skip to content

Commit

Permalink
Rework invite system with invite emails
Browse files Browse the repository at this point in the history
  • Loading branch information
kearfy committed Jan 2, 2024
1 parent 7d01b72 commit ba7471b
Show file tree
Hide file tree
Showing 31 changed files with 1,125 additions and 169 deletions.
34 changes: 20 additions & 14 deletions src/app/(api)/api/auth/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';

export async function POST(req: NextRequest) {
const { identifier, scope } = z
const { identifier, scope, followup } = z
.object({
identifier: z.string().email(),
scope: z.union([z.literal('admin'), z.literal('user')]),
followup: z.string().optional(),
})
.parse(await req.json());

Expand Down Expand Up @@ -49,18 +50,20 @@ export async function POST(req: NextRequest) {
from: '[email protected]',
to: email,
subject: 'PlayrBase signin link',
text: render(AuthMagicLinkEmail({ token }), {
text: render(AuthMagicLinkEmail({ token, followup }), {
plainText: true,
}),
html: render(AuthMagicLinkEmail({ token })),
html: render(AuthMagicLinkEmail({ token, followup })),
});
}

return NextResponse.json({ success: true });
}

export async function GET(req: NextRequest) {
const token = z.string().parse(new URL(req.url).searchParams.get('token'));
const searchParams = new URL(req.url).searchParams;
const token = z.string().parse(searchParams.get('token'));
const followup = z.string().parse(searchParams.get('followup'));
const decoded = await verifyEmailVerificationToken(token);

if (!decoded) {
Expand All @@ -72,9 +75,9 @@ export async function GET(req: NextRequest) {

const isEmail = z.string().email().safeParse(decoded.subject).success;
if (isEmail) {
const url = `/account/create-profile?${new URLSearchParams({
token,
})}`;
const params = new URLSearchParams({ token });
if (followup) params.set('followup', followup);
const url = `/account/create-profile?${params}`;

return new NextResponse(`Success! Redirecting to ${url}`, {
status: 302,
Expand Down Expand Up @@ -105,13 +108,16 @@ export async function GET(req: NextRequest) {
secure: req.nextUrl.protocol !== 'http:',
});

return new NextResponse('Success! Redirecting to /account', {
status: 302,
headers: {
Location: '/account',
'Set-Cookie': header,
},
});
return new NextResponse(
`Success! Redirecting to ${followup || '/account'}`,
{
status: 302,
headers: {
Location: followup || '/account',
'Set-Cookie': header,
},
}
);
}

export async function PUT(req: NextRequest) {
Expand Down
153 changes: 153 additions & 0 deletions src/app/(api)/api/invite/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import { surreal } from '@/app/(api)/lib/surreal';
import { extractUserTokenFromRequest } from '@/app/(api)/lib/token';
import OrganisationInviteEmail from '@/emails/organisation-invite';
import TeamInviteEmail from '@/emails/team-invite';
import { Invite } from '@/schema/resources/invite';
import { Organisation } from '@/schema/resources/organisation';
import { Team } from '@/schema/resources/team';
import { User } from '@/schema/resources/user';
import { render } from '@react-email/components';
import { NextRequest, NextResponse } from 'next/server';
import { z } from 'zod';
import { sendEmail } from '../../lib/email';

const Body = Invite.pick({
origin: true,
target: true,
role: true,
});

type Body = z.infer<typeof Body>;

async function createInvite({
user,
origin,
target,
role,
}: Body & {
user: User['id'];
}) {
const [res, target_email, fetched_user, fetched_target] = await surreal
.query<[Invite, string, User, Team | Organisation]>(
/* surrealql */ `
RETURN IF meta::tb(<record> $target) == "team" {
RETURN IF $user IN $target.players {
RETURN CREATE ONLY invite CONTENT {
origin: $origin,
target: $target,
invited_by: $user,
}
}
} ELSE IF meta::tb(<record> $target) == "organisation" {
RETURN IF $user IN $target.managers[WHERE role = "owner" OR (role = "administrator" AND org != NONE)].user {
RETURN IF type::is::string($role) {
RETURN CREATE ONLY invite CONTENT {
origin: $origin,
target: $target,
invited_by: $user,
role: $role,
}
}
}
};
$origin.email OR $origin;
$user.*;
$target.*;
`,
{
user,
origin,
target,
role,
}
)
.catch(() => []);

console.log(res);

const invite = Invite.safeParse(res);
if (invite.success) {
return {
invite: invite.data,
target_email,
fetched_user,
fetched_target,
};
} else {
console.log(invite.error);
}
}

export async function POST(req: NextRequest) {
const res = extractUserTokenFromRequest(req);
if (!res.success) return NextResponse.json(res);

if (!res.decoded?.ID)
return NextResponse.json({
success: false,
error: 'id_lookup_failed',
});

const user = User.shape.id.parse(res.decoded.ID);
const body = Body.safeParse(await req.json());
if (!body.success)
return NextResponse.json(
{ success: false, error: 'invalid_body' },
{ status: 400 }
);

const { origin, target, role } = body.data;
const invite = await createInvite({
user,
origin,
target,
role,
});

if (!invite)
return NextResponse.json(
{ success: false, error: 'invite_creation_failed' },
{ status: 400 }
);

if (invite.fetched_target.type == 'team') {
const renderProps = {
invite_id: invite.invite.id.slice(7),
invited_by: invite.fetched_user.name,
team: invite.fetched_target.name,
email: invite.target_email,
};

await sendEmail({
from: '[email protected]',
to: invite.target_email,
subject: 'PlayrBase Team invite',
text: render(TeamInviteEmail(renderProps), {
plainText: true,
}),
html: render(TeamInviteEmail(renderProps)),
});
} else if (invite.fetched_target.type == 'organisation') {
const renderProps = {
invite_id: invite.invite.id.slice(7),
invited_by: invite.fetched_user.name,
organisation: invite.fetched_target.name,
email: invite.target_email,
};

await sendEmail({
from: '[email protected]',
to: invite.target_email,
subject: 'PlayrBase Team invite',
text: render(OrganisationInviteEmail(renderProps), {
plainText: true,
}),
html: render(OrganisationInviteEmail(renderProps)),
});
}

return NextResponse.json({
success: true,
});
}
88 changes: 76 additions & 12 deletions src/app/[locale]/(console)/account/organisations/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
'use client';

import { Profile } from '@/components/cards/profile';
import { OrganisationTable } from '@/components/data/organisations/table';
import {
OrganisationSelector,
useOrganisationSelector,
} from '@/components/logic/OrganisationSelector';
import { DD, DDContent, DDFooter, DDTrigger } from '@/components/ui-custom/dd';
import {
DD,
DDContent,
DDDescription,
DDFooter,
DDHeader,
DDTitle,
DDTrigger,
} from '@/components/ui-custom/dd';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
Expand All @@ -19,6 +28,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Plus } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useSearchParams } from 'next/navigation';
import React, { useState } from 'react';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
Expand All @@ -27,21 +37,29 @@ import { z } from 'zod';
export default function Account() {
const { data: organisations, isPending, refetch } = useData();
const t = useTranslations('pages.console.account.organisations');
const searchParams = useSearchParams();
const invite_id = searchParams.get('invite_id');
const invite_popup =
(!!invite_id && organisations?.unconfirmed?.[`invite:${invite_id}`]) ||
undefined;

function findUnconfirmedEdge(id: Organisation['id']) {
function findInvite(id: Organisation['id']) {
const obj = organisations?.unconfirmed ?? {};
return Object.keys(obj).find((k) => obj[k].id === id);
}

const surreal = useSurreal();
const { mutateAsync: acceptInvitation } = useMutation({
mutationKey: ['manages', 'accept-invite'],
async mutationFn(id: Organisation['id']) {
async mutationFn(organisation: Organisation['id']) {
await toast.promise(
async () => {
const edge = findUnconfirmedEdge(id);
if (!edge) throw new Error(t('errors.no-unconfirmed-edge'));
await surreal.merge(edge, { confirmed: true });
await surreal.query(
/* surrealql */ `
RELATE $auth->manages->$organisation;
`,
{ organisation }
);
await refetch();
},
{
Expand All @@ -59,7 +77,7 @@ export default function Account() {
async mutationFn(id: Organisation['id']) {
await toast.promise(
async () => {
const edge = findUnconfirmedEdge(id);
const edge = findInvite(id);
if (!edge) throw new Error(t('errors.no-unconfirmed-edge'));
await surreal.delete(edge);
await refetch();
Expand Down Expand Up @@ -95,10 +113,57 @@ export default function Account() {
) : undefined
}
/>
{invite_popup && (
<InvitePopup
organisation={invite_popup}
acceptInvitation={acceptInvitation}
denyInvitation={denyInvitation}
/>
)}
</div>
);
}

function InvitePopup({
organisation,
acceptInvitation,
denyInvitation,
}: {
organisation: OrganisationSafeParse;
acceptInvitation: (id: Organisation['id']) => Promise<unknown>;
denyInvitation: (id: Organisation['id']) => Promise<unknown>;
}) {
const [open, setOpen] = useState(true);
const t = useTranslations(
'pages.console.account.organisations.invite-popup'
);

return (
<DD open={open} onOpenChange={setOpen}>
<DDContent>
<DDHeader>
<DDTitle>{t('title')}</DDTitle>
<DDDescription>{t('description')}</DDDescription>
</DDHeader>
<div className="mt-4 rounded-lg border p-3">
<Profile profile={organisation} />
</div>
<DDFooter closeText={t('close')}>
<Button onClick={() => acceptInvitation(organisation.id)}>
{t('accept')}
</Button>
<Button
onClick={() => denyInvitation(organisation.id)}
variant="destructive"
>
{t('deny')}
</Button>
</DDFooter>
</DDContent>
</DD>
);
}

function CreateOrganisation({ refetch }: { refetch: () => unknown }) {
const surreal = useSurreal();
const router = useRouter();
Expand Down Expand Up @@ -244,15 +309,14 @@ function useData() {
>(/* surql */ `
object::from_entries((
SELECT VALUE [<string> id, out.*]
FROM $auth->manages[?confirmed]
FROM $auth->manages
FETCH organisation.part_of.*
));
object::from_entries((
SELECT VALUE [<string> id, out.*]
FROM $auth->manages[?!confirmed]
FETCH organisation.part_of.*
));
SELECT VALUE [<string> id, target.*]
FROM invite WHERE origin = $auth AND meta::tb(target) == 'organisation'
));
`);

if (!result?.[0] || !result?.[1]) return null;
Expand Down
Loading

0 comments on commit ba7471b

Please sign in to comment.