diff --git a/src/app/(api)/api/auth/magic-link/route.ts b/src/app/(api)/api/auth/magic-link/route.ts index 140b0ba..12fe846 100644 --- a/src/app/(api)/api/auth/magic-link/route.ts +++ b/src/app/(api)/api/auth/magic-link/route.ts @@ -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()); @@ -49,10 +50,10 @@ export async function POST(req: NextRequest) { from: 'noreply@playrbase.app', 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 })), }); } @@ -60,7 +61,9 @@ export async function POST(req: NextRequest) { } 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) { @@ -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, @@ -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) { diff --git a/src/app/(api)/api/invite/route.ts b/src/app/(api)/api/invite/route.ts new file mode 100644 index 0000000..b908347 --- /dev/null +++ b/src/app/(api)/api/invite/route.ts @@ -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; + +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( $target) == "team" { + RETURN IF $user IN $target.players { + RETURN CREATE ONLY invite CONTENT { + origin: $origin, + target: $target, + invited_by: $user, + } + } + } ELSE IF meta::tb( $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: 'noreply@playrbase.app', + 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: 'noreply@playrbase.app', + to: invite.target_email, + subject: 'PlayrBase Team invite', + text: render(OrganisationInviteEmail(renderProps), { + plainText: true, + }), + html: render(OrganisationInviteEmail(renderProps)), + }); + } + + return NextResponse.json({ + success: true, + }); +} diff --git a/src/app/[locale]/(console)/account/organisations/page.tsx b/src/app/[locale]/(console)/account/organisations/page.tsx index b25849b..b673377 100644 --- a/src/app/[locale]/(console)/account/organisations/page.tsx +++ b/src/app/[locale]/(console)/account/organisations/page.tsx @@ -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'; @@ -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'; @@ -27,8 +37,13 @@ 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); } @@ -36,12 +51,15 @@ export default function Account() { 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(); }, { @@ -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(); @@ -95,10 +113,57 @@ export default function Account() { ) : undefined } /> + {invite_popup && ( + + )} ); } +function InvitePopup({ + organisation, + acceptInvitation, + denyInvitation, +}: { + organisation: OrganisationSafeParse; + acceptInvitation: (id: Organisation['id']) => Promise; + denyInvitation: (id: Organisation['id']) => Promise; +}) { + const [open, setOpen] = useState(true); + const t = useTranslations( + 'pages.console.account.organisations.invite-popup' + ); + + return ( +
+ + + {t('title')} + {t('description')} + +
+ +
+ + + + +
+
+ ); +} + function CreateOrganisation({ refetch }: { refetch: () => unknown }) { const surreal = useSurreal(); const router = useRouter(); @@ -244,15 +309,14 @@ function useData() { >(/* surql */ ` object::from_entries(( SELECT VALUE [ id, out.*] - FROM $auth->manages[?confirmed] + FROM $auth->manages FETCH organisation.part_of.* )); object::from_entries(( - SELECT VALUE [ id, out.*] - FROM $auth->manages[?!confirmed] - FETCH organisation.part_of.* - )); + SELECT VALUE [ id, target.*] + FROM invite WHERE origin = $auth AND meta::tb(target) == 'organisation' + )); `); if (!result?.[0] || !result?.[1]) return null; diff --git a/src/app/[locale]/(console)/account/teams/page.tsx b/src/app/[locale]/(console)/account/teams/page.tsx index 722d839..e7215c4 100644 --- a/src/app/[locale]/(console)/account/teams/page.tsx +++ b/src/app/[locale]/(console)/account/teams/page.tsx @@ -1,7 +1,16 @@ 'use client'; +import { Profile } from '@/components/cards/profile'; import { TeamTable } from '@/components/data/teams/table'; -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'; @@ -12,6 +21,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'; @@ -20,8 +30,13 @@ import { z } from 'zod'; export default function Teams() { const { data: teams, isPending, refetch } = useData(); const t = useTranslations('pages.console.account.teams'); + const searchParams = useSearchParams(); + const invite_id = searchParams.get('invite_id'); + const invite_popup = + (!!invite_id && teams?.unconfirmed?.[`invite:${invite_id}`]) || + undefined; - function findUnconfirmedEdge(id: Team['id']) { + function findInvite(id: Team['id']) { const obj = teams?.unconfirmed ?? {}; return Object.keys(obj).find((k) => obj[k].id === id); } @@ -29,12 +44,15 @@ export default function Teams() { const surreal = useSurreal(); const { mutateAsync: acceptInvitation } = useMutation({ mutationKey: ['plays_in', 'accept-invite'], - async mutationFn(id: Team['id']) { + async mutationFn(team: Team['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->plays_in->$team; + `, + { team } + ); await refetch(); }, { @@ -52,9 +70,10 @@ export default function Teams() { async mutationFn(id: Team['id']) { await toast.promise( async () => { - const edge = findUnconfirmedEdge(id); - if (!edge) throw new Error(t('errors.no-unconfirmed-edge')); - await surreal.delete(edge); + const invite = findInvite(id); + if (!invite) + throw new Error(t('errors.no-unconfirmed-edge')); + await surreal.delete(invite); await refetch(); }, { @@ -88,10 +107,54 @@ export default function Teams() { ) : undefined } /> + {invite_popup && ( + + )} ); } +function InvitePopup({ + team, + acceptInvitation, + denyInvitation, +}: { + team: TeamAnonymous; + acceptInvitation: (id: Team['id']) => Promise; + denyInvitation: (id: Team['id']) => Promise; +}) { + const [open, setOpen] = useState(true); + const t = useTranslations('pages.console.account.teams.invite-popup'); + return ( +
+ + + {t('title')} + {t('description')} + +
+ +
+ + + + +
+
+ ); +} + function CreateTeam({ refetch }: { refetch: () => unknown }) { const surreal = useSurreal(); const router = useRouter(); @@ -203,12 +266,12 @@ function useData() { >(/* surql */ ` object::from_entries(( SELECT VALUE [ id, out.*] - FROM $auth->plays_in[?confirmed] + FROM $auth->plays_in )); object::from_entries(( - SELECT VALUE [ id, out.*] - FROM $auth->plays_in[?!confirmed] + SELECT VALUE [ id, target.*] + FROM invite WHERE origin = $auth AND meta::tb(target) == 'team' )); `); diff --git a/src/app/[locale]/(console)/organisation/[organisation]/(container)/members/page.tsx b/src/app/[locale]/(console)/organisation/[organisation]/(container)/members/page.tsx index 22894e6..396b7aa 100644 --- a/src/app/[locale]/(console)/organisation/[organisation]/(container)/members/page.tsx +++ b/src/app/[locale]/(console)/organisation/[organisation]/(container)/members/page.tsx @@ -1,10 +1,13 @@ 'use client'; import { Avatar } from '@/components/cards/avatar'; -import { Profile } from '@/components/cards/profile'; +import { Profile, ProfileName } from '@/components/cards/profile'; import Container from '@/components/layout/Container'; import { NotFoundScreen } from '@/components/layout/NotFoundScreen'; -import { UserSelector, useUserSelector } from '@/components/logic/UserSelector'; +import { + UserEmailSelector, + useUserEmailSelector, +} from '@/components/logic/UserEmailSelector'; import { RoleName, SelectRole } from '@/components/miscellaneous/Role'; import { DD, DDContent, DDFooter, DDTrigger } from '@/components/ui-custom/dd'; import { Badge } from '@/components/ui/badge'; @@ -22,9 +25,11 @@ import { } from '@/components/ui/table'; import { useSurreal } from '@/lib/Surreal'; import { Role } from '@/lib/role'; -import { record } from '@/lib/zod'; +import { record, role } from '@/lib/zod'; import { Link } from '@/locales/navigation'; +import { Invite } from '@/schema/resources/invite'; import { Organisation } from '@/schema/resources/organisation'; +import { Profile as TProfile } from '@/schema/resources/profile'; import { User, UserAnonymous } from '@/schema/resources/user'; import { useMutation, useQuery } from '@tanstack/react-query'; import _ from 'lodash'; @@ -144,7 +149,7 @@ function ListManagers({ {invites?.map((invite) => ( toast.promise( async () => { - await surreal.merge(edge, { - role, - }); + await surreal.query( + /* surrealql */ ` + UPDATE type::thing('invite', $id) SET role = $role; + `, + { + id, + role, + } + ); await refresh(); }, @@ -355,11 +366,11 @@ function InvitedManager({ }); const { mutate: revokeInvite, isPending: isRevokingInvite } = useMutation({ - mutationKey: ['organisation', 'revoke-invite', edge], + mutationKey: ['organisation', 'revoke-invite', id], mutationFn: async () => toast.promise( async () => { - await surreal.delete(edge); + await surreal.delete(id); await refresh(); }, { @@ -371,13 +382,16 @@ function InvitedManager({ ), }); + const profile: TProfile = + typeof origin == 'string' ? { email: origin, type: 'email' } : origin; + return ( - + - {user.name} + {t('pending-invite')} @@ -416,8 +430,7 @@ function AddMember({ organisation: Organisation['id']; refresh: () => unknown; }) { - const surreal = useSurreal(); - const [user, setUser] = useUserSelector(); + const [user, setUser] = useUserEmailSelector(); const [role, setRole] = useState('event_viewer'); const [open, setOpen] = useState(false); const t = useTranslations('pages.console.organisation.members.add_member'); @@ -427,12 +440,17 @@ function AddMember({ async mutationFn() { toast.promise( async () => { - await surreal.query<[string[]]>( - /* surql */ ` - RELATE $user->manages->$organisation SET role = $role - `, - { user, role, organisation } - ); + await fetch('/api/invite', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + origin: user, + target: organisation, + role, + }), + }); await refresh(); }, { @@ -456,7 +474,7 @@ function AddMember({

{t('title')}

- ; -const Invited = z.object({ - user: UserAnonymous, - edge: record('manages'), - role: z.union([ - z.literal('owner'), - z.literal('administrator'), - z.literal('event_manager'), - z.literal('event_viewer'), - ]), +const Invited = Invite.extend({ + origin: z.union([UserAnonymous, z.string().email()]), + role, }); type Invited = z.infer; @@ -549,8 +556,10 @@ function useData(slug: Organisation['slug']) { managers.*.user.*, managers.*.org.name; - SELECT in.* as user, id as edge, role - FROM $org.id<-manages[?!confirmed]; + SELECT + *, + (origin.* ?? origin) as origin + FROM invite WHERE target = $org.id; $org; `, diff --git a/src/app/[locale]/(console)/organisation/[organisation]/(container)/overview/page.tsx b/src/app/[locale]/(console)/organisation/[organisation]/(container)/overview/page.tsx index 15947f9..5a77ee4 100644 --- a/src/app/[locale]/(console)/organisation/[organisation]/(container)/overview/page.tsx +++ b/src/app/[locale]/(console)/organisation/[organisation]/(container)/overview/page.tsx @@ -9,6 +9,7 @@ import { RoleName } from '@/components/miscellaneous/Role'; import { buttonVariants } from '@/components/ui/button'; import { useSurreal } from '@/lib/Surreal'; import { sort_roles } from '@/lib/role'; +import { role } from '@/lib/zod'; import { Link } from '@/locales/navigation'; import { Event } from '@/schema/resources/event'; import { @@ -175,12 +176,7 @@ export default function Account() { } const OrgCanManage = Organisation.extend({ - role: z.union([ - z.literal('owner'), - z.literal('administrator'), - z.literal('event_manager'), - z.literal('event_viewer'), - ]), + role, }); type OrgCanManage = z.infer; @@ -190,14 +186,7 @@ const ListedManager = User.pick({ name: true, profile_picture: true, }).extend({ - roles: z.array( - z.union([ - z.literal('owner'), - z.literal('administrator'), - z.literal('event_manager'), - z.literal('event_viewer'), - ]) - ), + roles: z.array(role), }); type ListedManager = z.infer; diff --git a/src/app/[locale]/(console)/team/[team]/(container)/members/page.tsx b/src/app/[locale]/(console)/team/[team]/(container)/members/page.tsx index 0738987..c90c02a 100644 --- a/src/app/[locale]/(console)/team/[team]/(container)/members/page.tsx +++ b/src/app/[locale]/(console)/team/[team]/(container)/members/page.tsx @@ -1,10 +1,13 @@ 'use client'; import { Avatar } from '@/components/cards/avatar'; -import { Profile } from '@/components/cards/profile'; +import { Profile, ProfileName } from '@/components/cards/profile'; import Container from '@/components/layout/Container'; import { NotFoundScreen } from '@/components/layout/NotFoundScreen'; -import { UserSelector, useUserSelector } from '@/components/logic/UserSelector'; +import { + UserEmailSelector, + useUserEmailSelector, +} from '@/components/logic/UserEmailSelector'; import { DD, DDContent, DDFooter, DDTrigger } from '@/components/ui-custom/dd'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -19,13 +22,10 @@ import { TableRow, } from '@/components/ui/table'; import { useSurreal } from '@/lib/Surreal'; -import { record } from '@/lib/zod'; +import { Invite } from '@/schema/resources/invite'; +import { Profile as TProfile } from '@/schema/resources/profile'; import { Team } from '@/schema/resources/team'; -import { - User, - UserAnonymous, - UserAsRelatedUser, -} from '@/schema/resources/user'; +import { UserAnonymous, UserAsRelatedUser } from '@/schema/resources/user'; import { useMutation, useQuery } from '@tanstack/react-query'; import { Loader2, Mail, MailX, Plus, Trash2 } from 'lucide-react'; import { useTranslations } from 'next-intl'; @@ -92,14 +92,13 @@ function ListPlayers({ {t('table.columns.name')} {t('table.columns.email')} - {t('table.columns.role')} {invites?.map((invite) => ( @@ -197,7 +196,7 @@ function ListPlayer({ } function InvitedPlayer({ - invite: { user, edge }, + invite: { origin, id }, refresh, }: { invite: Invited; @@ -209,20 +208,23 @@ function InvitedPlayer({ ); const { mutate: revokeInvite, isPending: isRevokingInvite } = useMutation({ - mutationKey: ['team', 'revoke-invite', edge], + mutationKey: ['team', 'revoke-invite', id], mutationFn: async () => { - await surreal.delete(edge); + await surreal.delete(id); await refresh(); }, }); + const profile: TProfile = + typeof origin == 'string' ? { email: origin, type: 'email' } : origin; + return ( - + - {user.name} + {t('pending-invite')} @@ -248,21 +250,24 @@ function AddMember({ team: Team['id']; refresh: () => unknown; }) { - const surreal = useSurreal(); - const [user, setUser] = useUserSelector(); + const [user, setUser] = useUserEmailSelector(); const [open, setOpen] = useState(false); const t = useTranslations('pages.console.organisation.members.add_member'); const { mutateAsync, error } = useMutation({ - mutationKey: ['plays_in', 'invite'], + mutationKey: ['team', 'invite-member'], async mutationFn() { - // TODO set to correct type, not important for the moment - await surreal.query<[string[]]>( - /* surql */ ` - RELATE $user->plays_in->$team - `, - { user, team } - ); + await fetch('/api/invite', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + origin: user, + target: team, + }), + }); + refresh(); }, }); @@ -278,7 +283,7 @@ function AddMember({

{t('title')}

- ; -const Invited = z.object({ - user: UserAnonymous, - edge: record('plays_in'), +const Invited = Invite.extend({ + origin: z.union([UserAnonymous, z.string().email()]), }); type Invited = z.infer; @@ -333,8 +337,10 @@ function useData(slug: Team['slug']) { FETCH players.*; - SELECT in.* as user, id as edge - FROM $team.id<-plays_in[?!confirmed]; + SELECT + *, + (origin.* ?? origin) as origin + FROM invite WHERE target = $team.id; $team; `, diff --git a/src/app/[locale]/(console)/team/[team]/(container)/overview/page.tsx b/src/app/[locale]/(console)/team/[team]/(container)/overview/page.tsx index 5fc76bc..6c3a982 100644 --- a/src/app/[locale]/(console)/team/[team]/(container)/overview/page.tsx +++ b/src/app/[locale]/(console)/team/[team]/(container)/overview/page.tsx @@ -141,8 +141,6 @@ function useData({ slug }: { slug: Team['slug'] }) { } ); - console.log(result); - if ( !result?.[1] || !result?.[2] || diff --git a/src/app/[locale]/(public)/account/create-passkey/page.tsx b/src/app/[locale]/(public)/account/create-passkey/page.tsx index 40cbab4..3a6c398 100644 --- a/src/app/[locale]/(public)/account/create-passkey/page.tsx +++ b/src/app/[locale]/(public)/account/create-passkey/page.tsx @@ -31,6 +31,7 @@ export default function Page() { const { loading, register, passkey } = useRegisterPasskey(); const searchParams = useSearchParams(); const signup = [...searchParams.keys()].includes('signup'); + const followup = signup ? searchParams.get('followup') : undefined; const [featureFlags] = useFeatureFlags(); const t = useTranslations('pages.account.create-passkey'); useAuth({ authRequired: true }); @@ -49,6 +50,7 @@ export default function Page() { ) : ( , 'loading' | 'register'> & { + followup?: string; signup?: boolean; }) { const [autoPoke, setAutoPoke] = useAutoPoke(); @@ -84,7 +88,7 @@ function CreatePasskey({ {t('trigger')} ; export default function CreateProfile() { const surreal = useSurreal(); const router = useRouter(); - const token = z.string().parse(useSearchParams().get('token')); + const searchParams = useSearchParams(); + const token = z.string().parse(searchParams.get('token')); + const followup = z.string().optional().parse(searchParams.get('followup')); const { refreshUser } = useAuth(); const decoded = jwt.decode(token); const webAuthnAvailable = useWebAuthnAvailable(); @@ -96,10 +98,14 @@ export default function CreateProfile() { .authenticate(res.token) .then(() => { refreshUser(); + const params = new URLSearchParams(); + params.set('signup', ''); + if (followup) params.set('followup', followup); + router.push( featureFlags.passkeys && webAuthnAvailable - ? '/account/create-passkey?signup' - : '/account' + ? `/account/create-passkey?${params}` + : followup ?? '/account' ); }) .catch((e) => { diff --git a/src/app/[locale]/(public)/account/signin/page.tsx b/src/app/[locale]/(public)/account/signin/page.tsx index 46a36d9..77768cf 100644 --- a/src/app/[locale]/(public)/account/signin/page.tsx +++ b/src/app/[locale]/(public)/account/signin/page.tsx @@ -1,6 +1,7 @@ 'use client'; import Container from '@/components/layout/Container'; +import { LoaderOverlay } from '@/components/layout/LoaderOverlay'; import { DropdownMenuOptionalBoolean } from '@/components/logic/DropdownMenuOptionalBoolean'; import { Button } from '@/components/ui/button'; import { @@ -22,6 +23,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { useAuth } from '@/lib/auth'; import { useFeatureFlags } from '@/lib/featureFlags'; import { cn } from '@/lib/utils'; import { useAutoPoke, usePasskeyAuthentication } from '@/lib/webauthn'; @@ -36,6 +38,7 @@ import { XCircle, } from 'lucide-react'; import { useTranslations } from 'next-intl'; +import { useSearchParams } from 'next/navigation'; import React, { useEffect, useState } from 'react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -63,9 +66,18 @@ export default function Signin() { } = usePasskeyAuthentication({ autoPoke }); const [featureFlags] = useFeatureFlags(); const router = useRouter(); + const searchParams = useSearchParams(); + const defaultEmail = searchParams.get('email'); + const followup = (() => { + const raw = searchParams.get('followup'); + if (!raw) return undefined; + return raw.startsWith('/') ? raw : `/${raw}`; + })(); + const [navigating, setNavigating] = useState(false); const t = useTranslations('pages.account.signin'); const [scope, setScope] = useState(defaultScope); + const { user, loading: userLoading } = useAuth(); const [status, setStatus] = useState<{ error?: boolean; message?: string; @@ -102,10 +114,21 @@ export default function Signin() { if (!navigating && passkey) { setNavigating(true); setTimeout(() => { - router.push('/account'); + router.push(followup ?? '/account'); }, 1000); } - }, [navigating, passkey, router]); + }, [navigating, passkey, router, followup]); + + useEffect(() => { + if ( + defaultEmail && + followup && + !userLoading && + user?.email == defaultEmail + ) { + router.push(followup); + } + }); const handler = handleSubmit(async ({ identifier }) => { setStatus({ message: 'Loading', loading: true }); @@ -115,7 +138,7 @@ export default function Signin() { headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ identifier, scope }), + body: JSON.stringify({ identifier, scope, followup }), }); const res = await raw.json().catch((_e) => ({ @@ -135,6 +158,7 @@ export default function Signin() { return ( <> + {userLoading && }
diff --git a/src/app/[locale]/providers.tsx b/src/app/[locale]/providers.tsx index 5ee4738..c7fa9a5 100644 --- a/src/app/[locale]/providers.tsx +++ b/src/app/[locale]/providers.tsx @@ -15,6 +15,7 @@ const queryClient = new QueryClient({ queries: { retry: false, throwOnError, + refetchInterval: false, }, mutations: { throwOnError, diff --git a/src/components/cards/avatar.tsx b/src/components/cards/avatar.tsx index 18065fb..cd90832 100644 --- a/src/components/cards/avatar.tsx +++ b/src/components/cards/avatar.tsx @@ -1,6 +1,6 @@ import { cn } from '@/lib/utils'; import { Profile, unknownProfile } from '@/schema/resources/profile'; -import { Building, Users } from 'lucide-react'; +import { Building, MailPlus, Users } from 'lucide-react'; import Image from 'next/image'; import React, { ReactNode } from 'react'; import { @@ -30,7 +30,9 @@ export function Avatar({ className?: string; }) { profile = profile ?? unknownProfile; - const avatarFallback = avatarFallbackByName(profile.name); + const avatarFallback = avatarFallbackByName( + profile.type == 'email' ? profile.email : profile.name + ); const avatarSize = { 'extra-tiny': 'h-6 w-6 text-xs', tiny: 'h-8 w-8 text-md', @@ -122,6 +124,11 @@ export function Avatar({ text="Team" icon={} /> + ) : profile.type == 'email' ? ( + } + /> ) : undefined ) : undefined}
diff --git a/src/components/cards/profile.tsx b/src/components/cards/profile.tsx index a5df444..4f742ff 100644 --- a/src/components/cards/profile.tsx +++ b/src/components/cards/profile.tsx @@ -21,7 +21,9 @@ export function Profile({ customSub?: ReactNode | string; }) { profile = profile ?? unknownProfile; - const sub = customSub || ('email' in profile && profile.email); + const sub = ( + + ); return (
- {!noSub && } + {sub && } ) : ( <> - {!noSub && } + {sub && } )}
@@ -67,9 +69,9 @@ export function Profile({ noSub ? 'font-semibold' : 'font-bold' )} > - {profile.name} + - {!noSub && sub && ( + {sub && (

{sub}

)}
@@ -77,3 +79,25 @@ export function Profile({ ); } + +export function ProfileName({ profile }: { profile?: TProfile | null }) { + profile = profile ?? unknownProfile; + return profile.type == 'email' ? profile.email : profile.name; +} + +export function ProfileSub({ + profile, + noSub, + customSub, +}: { + profile?: TProfile | null; + noSub?: boolean; + customSub?: ReactNode | string; +}) { + profile = profile ?? unknownProfile; + if (noSub) return; + return ( + customSub || + (profile.type != 'email' && 'email' in profile && profile.email) + ); +} diff --git a/src/components/layout/navbar.tsx b/src/components/layout/navbar.tsx index 30122ed..881aa0e 100644 --- a/src/components/layout/navbar.tsx +++ b/src/components/layout/navbar.tsx @@ -325,8 +325,8 @@ const AccountOptions = ({ actor }: { actor?: Actor }) => { const [orgs, teams] = await surreal.query< [Organisation[], Team[]] >(/* surrealql */ ` - SELECT * FROM $auth->manages[?confirmed]->organisation WHERE part_of = NONE LIMIT 3; - SELECT * FROM $auth->plays_in[?confirmed]->team LIMIT 3; + SELECT * FROM $auth->manages->organisation WHERE part_of = NONE LIMIT 3; + SELECT * FROM $auth->plays_in->team LIMIT 3; `); const data = { diff --git a/src/components/logic/UserEmailSelector.tsx b/src/components/logic/UserEmailSelector.tsx new file mode 100644 index 0000000..9d02cb0 --- /dev/null +++ b/src/components/logic/UserEmailSelector.tsx @@ -0,0 +1,146 @@ +'use client'; + +import { useSurreal } from '@/lib/Surreal'; +import { EmailProfile } from '@/schema/resources/profile'; +import { User } from '@/schema/resources/user'; +import { useQuery } from '@tanstack/react-query'; +import { useTranslations } from 'next-intl'; +import React, { + Dispatch, + ReactNode, + SetStateAction, + useEffect, + useState, +} from 'react'; +import { z } from 'zod'; +import { Profile } from '../cards/profile'; +import { Button } from '../ui/button'; +import { Input } from '../ui/input'; +import { Label } from '../ui/label'; + +export function useUserEmailSelector() { + return useState(); +} + +export function UserEmailSelector({ + user, + setUser, + autoFocus, + autoComplete, + limit = 5, + children, +}: { + user?: string; + setUser: Dispatch>; + autoFocus?: boolean; + autoComplete?: string; + limit?: number; + children?: ReactNode; +}) { + const surreal = useSurreal(); + const [input, setInput] = useState(''); + const [matches, setMatches] = useState<(User | EmailProfile)[]>([]); + const t = useTranslations('components.logic.user-selector'); + + const isEmail = (inp: string) => z.string().email().safeParse(inp).success; + + const { data: profile } = useQuery({ + queryKey: ['user', user], + async queryFn() { + if (!user) return null; + if (isEmail(user)) + return EmailProfile.parse({ + email: user, + type: 'email', + }); + + const [res] = await surreal.select(user); + return res ?? null; + }, + }); + + useEffect(() => { + const timeOutId = setTimeout(() => { + surreal + .query<[(User | EmailProfile)[]]>( + /* surql */ ` + SELECT * FROM user WHERE id != $auth && $email && email ~ $email LIMIT $limit + `, + { email: input, limit } + ) + .then(([result]) => { + if ( + isEmail(input) && + !result.find( + ({ email }) => + email.toLowerCase() == input.toLowerCase() + ) + ) { + result.unshift( + EmailProfile.parse({ + email: input, + type: 'email', + }) + ); + } + + setMatches(result ?? []); + }); + }, 300); + + return () => clearTimeout(timeOutId); + }, [input, limit, surreal]); + + useEffect(() => { + if (user && input) setInput(''); + }, [user, input, setInput]); + + return user ? ( +
+ + +
+ ) : ( +
+ + setInput(e.currentTarget.value)} + autoFocus={autoFocus} + autoComplete={autoComplete} + /> + {matches && ( +
+ {matches.map((user) => ( +
+ + +
+ ))} +
+ )} +
+ ); +} diff --git a/src/emails/auth-magic-link.tsx b/src/emails/auth-magic-link.tsx index fd37670..4df60c6 100644 --- a/src/emails/auth-magic-link.tsx +++ b/src/emails/auth-magic-link.tsx @@ -17,16 +17,19 @@ import * as React from 'react'; interface AuthMagicLinkEmailProps { token: string; + followup?: string; } const baseUrl = process.env.PLAYRBASE_ENV_ORIGIN ?? 'http://localhost:13000'; export const AuthMagicLinkEmail = ({ token = 'this-is-a-demo-token', + followup, }: AuthMagicLinkEmailProps) => { - const url = new URL( - baseUrl + `/api/auth/magic-link?` + new URLSearchParams({ token }) - ).toString(); + const params = new URLSearchParams({ token }); + if (followup) params.set('followup', followup); + const url = new URL(baseUrl + `/api/auth/magic-link?` + params).toString(); + return ( diff --git a/src/emails/organisation-invite.tsx b/src/emails/organisation-invite.tsx new file mode 100644 index 0000000..69709c6 --- /dev/null +++ b/src/emails/organisation-invite.tsx @@ -0,0 +1,160 @@ +import { + Body, + Button, + Container, + Font, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text, +} from '@react-email/components'; +import * as React from 'react'; + +interface OrganisationInviteEmailProps { + invite_id: string; + invited_by: string; + organisation: string; + email: string; +} + +const baseUrl = process.env.PLAYRBASE_ENV_ORIGIN ?? 'http://localhost:13000'; + +export const OrganisationInviteEmail = ({ + invite_id = 'this-is-a-demo-invite-id', + invited_by = 'John Doe', + organisation = 'Some Demo Organisation', + email = 'john@doe.org', +}: OrganisationInviteEmailProps) => { + const followup = + `/account/organisations?` + new URLSearchParams({ invite_id }); + const url = new URL( + baseUrl + `/account/signin?` + new URLSearchParams({ email, followup }) + ).toString(); + return ( + + + Playrbase Organisation invite + + + + {invited_by} invited you to join their organisation on + Playrbase! + + + + PlayrBase + + Playrbase Organisation invite + + + {invited_by} invited you to join the {organisation}{' '} + organisation on Playrbase. + +
+ +
+
+
+ + If you have issues with the link above, please + follow the below url: + + + {url} + +
+
+ + + ); +}; + +export default OrganisationInviteEmail; + +// Styles + +const main = { + backgroundColor: '#030712', +}; + +const container = { + margin: '40px auto 0', + padding: '40px 50px', + width: '560px', + border: '1px solid #1d283a', + borderRadius: '0.5rem', +}; + +const logo = { + borderRadius: 21, + height: 50, + margin: '20px auto 40px', +}; + +const heading = { + fontSize: '1.5rem', + letterSpacing: '-0.5px', + lineHeight: '1.3', + fontWeight: '400', + color: '#ffffff', + padding: '17px 0 0', +}; + +const paragraph = { + margin: '0 0 10px', + fontSize: '15px', + lineHeight: '1.4', + color: '#7f8ea3', +}; + +const buttonContainer = { + padding: '27px 0 27px', +}; + +const button = { + backgroundColor: '#f8fafc', + borderRadius: 'calc(0.5rem - 2px)', + fontWeight: '500', + color: '#020205', + textDecoration: 'none', + textAlign: 'center' as const, + display: 'block', + fontSize: '0.875rem', + lineHeight: '1.25rem', + padding: '0.75rem 1rem', +}; + +const line = { + borderColor: '#1d283a', +}; + +const fallback = { + paddingTop: '27px', +}; + +const fallbackLink = { + fontSize: '14px', + color: '#535969', + wordBreak: 'break-all', +} as const; diff --git a/src/emails/team-invite.tsx b/src/emails/team-invite.tsx new file mode 100644 index 0000000..d8b3faa --- /dev/null +++ b/src/emails/team-invite.tsx @@ -0,0 +1,156 @@ +import { + Body, + Button, + Container, + Font, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Text, +} from '@react-email/components'; +import * as React from 'react'; + +interface TeamInviteEmailProps { + invite_id: string; + invited_by: string; + team: string; + email: string; +} + +const baseUrl = process.env.PLAYRBASE_ENV_ORIGIN ?? 'http://localhost:13000'; + +export const TeamInviteEmail = ({ + invite_id = 'this-is-a-demo-invite-id', + invited_by = 'John Doe', + team = 'Some Demo Team', + email = 'john@doe.org', +}: TeamInviteEmailProps) => { + const followup = `/account/teams?` + new URLSearchParams({ invite_id }); + const url = new URL( + baseUrl + `/account/signin?` + new URLSearchParams({ email, followup }) + ).toString(); + return ( + + + Playrbase Team invite + + + + {invited_by} invited you to join their team on Playrbase! + + + + PlayrBase + Playrbase Team invite + + {invited_by} invited you to join the {team} team on + Playrbase. + +
+ +
+
+
+ + If you have issues with the link above, please + follow the below url: + + + {url} + +
+
+ + + ); +}; + +export default TeamInviteEmail; + +// Styles + +const main = { + backgroundColor: '#030712', +}; + +const container = { + margin: '40px auto 0', + padding: '40px 50px', + width: '560px', + border: '1px solid #1d283a', + borderRadius: '0.5rem', +}; + +const logo = { + borderRadius: 21, + height: 50, + margin: '20px auto 40px', +}; + +const heading = { + fontSize: '1.5rem', + letterSpacing: '-0.5px', + lineHeight: '1.3', + fontWeight: '400', + color: '#ffffff', + padding: '17px 0 0', +}; + +const paragraph = { + margin: '0 0 10px', + fontSize: '15px', + lineHeight: '1.4', + color: '#7f8ea3', +}; + +const buttonContainer = { + padding: '27px 0 27px', +}; + +const button = { + backgroundColor: '#f8fafc', + borderRadius: 'calc(0.5rem - 2px)', + fontWeight: '500', + color: '#020205', + textDecoration: 'none', + textAlign: 'center' as const, + display: 'block', + fontSize: '0.875rem', + lineHeight: '1.25rem', + padding: '0.75rem 1rem', +}; + +const line = { + borderColor: '#1d283a', +}; + +const fallback = { + paddingTop: '27px', +}; + +const fallbackLink = { + fontSize: '14px', + color: '#535969', + wordBreak: 'break-all', +} as const; diff --git a/src/lib/zod.ts b/src/lib/zod.ts index 1615dd8..0afcdc2 100644 --- a/src/lib/zod.ts +++ b/src/lib/zod.ts @@ -42,3 +42,10 @@ export function fullname() { } ); } + +export const role = z.union([ + z.literal('owner'), + z.literal('administrator'), + z.literal('event_manager'), + z.literal('event_viewer'), +]); diff --git a/src/locales/en/pages.json b/src/locales/en/pages.json index 8364250..7ff5dd2 100644 --- a/src/locales/en/pages.json +++ b/src/locales/en/pages.json @@ -82,6 +82,13 @@ "confirmed": "Confirmed" } }, + "invite-popup": { + "title": "Organisation invitation", + "description": "You have been invited to an organisation", + "accept": "Accept Invitation", + "deny": "Deny Invitation", + "close": "Close" + }, "new": { "trigger": "New organisation", "title": "New organisation", @@ -137,6 +144,13 @@ "confirmed": "Confirmed" } }, + "invite-popup": { + "title": "Team invitation", + "description": "You have been invited to a team", + "accept": "Accept Invitation", + "deny": "Deny Invitation", + "close": "Close" + }, "new": { "trigger": "New team", "title": "New team", @@ -386,11 +400,11 @@ "trigger": "Add member", "title": "Invite user", "toast": { - "adding-member": "Adding member", - "added-member": "Added member" + "adding-member": "Sending invite", + "added-member": "Invited member" }, "errors": { - "failed-add-member": "Failed to add member: {error}" + "failed-add-member": "Failed to invite member: {error}" }, "role": { "label": "Role", diff --git a/src/locales/nl/pages.json b/src/locales/nl/pages.json index 5b0074f..3551d47 100644 --- a/src/locales/nl/pages.json +++ b/src/locales/nl/pages.json @@ -72,6 +72,13 @@ "accepting-invite": "Uitnodiging aan het accepteren", "denied-invite": "Uitnodiging geweigerd", "denying-invite": "Uitnodiging aan het weigeren" + }, + "invite-popup": { + "accept": "Uitnodiging accepteren", + "close": "Sluiten", + "deny": "Uitnodiging weigeren", + "description": "Je bent uitgenodigd tot een organisatie", + "title": "Uitnodiging organisatie" } }, "passkeys": { @@ -220,6 +227,13 @@ "accepting-invite": "Uitnodiging aan het accepteren", "denied-invite": "Uitnodiging geweigerd", "denying-invite": "Uitnodiging aan het weigeren" + }, + "invite-popup": { + "accept": "Uitnodiging accepteren", + "close": "Sluiten", + "deny": "Uitnodiging weigeren", + "description": "Je bent uitgenodigd tot een team", + "title": "Uitnodiging team" } } }, @@ -265,11 +279,11 @@ "title": "Gebruiker uitnodigen", "trigger": "Lid toevoegen", "errors": { - "failed-add-member": "Kon lid niet toevoegen: {error}" + "failed-add-member": "Kon lid niet uitnodigen: {error}" }, "toast": { - "added-member": "Lid toegevoegd", - "adding-member": "Lid aan het toevoegen" + "added-member": "Lid uitgenodigd", + "adding-member": "Uitnodiging aan het versturen" } }, "invited_manager": { diff --git a/src/schema/index.ts b/src/schema/index.ts index ef5c7a7..b7dd3b3 100644 --- a/src/schema/index.ts +++ b/src/schema/index.ts @@ -8,6 +8,7 @@ export { default as auth } from './resources/auth.ts'; export { default as challenge } from './resources/challenge.ts'; export { default as credential } from './resources/credential.ts'; export { default as event } from './resources/event.ts'; +export { default as invite } from './resources/invite.ts'; export { default as log } from './resources/log.ts'; export { default as organisation } from './resources/organisation.ts'; export { default as team } from './resources/team.ts'; diff --git a/src/schema/relations/manages.ts b/src/schema/relations/manages.ts index 263a967..0a37230 100644 --- a/src/schema/relations/manages.ts +++ b/src/schema/relations/manages.ts @@ -5,7 +5,7 @@ const manages = /* surrealql */ ` // - They are an owner at any level // - They are an administrator, except for top-level. FOR create - WHERE $auth.id IN out.managers[WHERE role = "owner" OR (role = "administrator" AND org != NONE)].user + WHERE (SELECT VALUE id FROM invite WHERE origin = $auth.id AND $auth.id = $parent.in AND $parent.out = target).id FOR update, delete WHERE $auth.id = in.id OR $auth.id IN out.managers[WHERE role = "owner" OR (role = "administrator" AND org != NONE)].user @@ -17,10 +17,16 @@ const manages = /* surrealql */ ` DEFINE FIELD in ON manages TYPE record; DEFINE FIELD out ON manages TYPE record; - DEFINE FIELD confirmed ON manages TYPE bool DEFAULT false VALUE $before || IF !$auth OR in.id == $auth.id { $value } ELSE { false }; DEFINE FIELD public ON manages TYPE bool DEFAULT false; DEFINE FIELD role ON manages TYPE string ASSERT $value IN ['owner', 'administrator', 'event_manager', 'event_viewer'] + DEFAULT (SELECT VALUE role FROM ONLY invite WHERE origin = $parent.in AND target = $parent.out LIMIT 1) + VALUE + IF !$before { + RETURN SELECT VALUE role FROM ONLY invite WHERE origin = $parent.in AND target = $parent.out LIMIT 1; + } ELSE { + RETURN $value; + } PERMISSIONS FOR update WHERE $auth.id IN out.managers[WHERE role = "owner" OR (role = "administrator" AND org != NONE)].user; @@ -45,6 +51,15 @@ const verify_nonempty_organisation_after_deletion = /* surrealql */ ` }; `; -export default [manages, log, verify_nonempty_organisation_after_deletion].join( - '\n\n' -); +const cleanup_invite = /* surrealql */ ` + DEFINE EVENT cleanup_invite ON manages WHEN $event = "CREATE" THEN { + DELETE invite WHERE origin = $value.in AND target = $value.out; + } +`; + +export default [ + manages, + log, + verify_nonempty_organisation_after_deletion, + cleanup_invite, +].join('\n\n'); diff --git a/src/schema/relations/plays_in.ts b/src/schema/relations/plays_in.ts index f2e1b3c..40bd0b7 100644 --- a/src/schema/relations/plays_in.ts +++ b/src/schema/relations/plays_in.ts @@ -8,7 +8,7 @@ const plays_in = /* surrealql */ ` // - They are an owner at any level // - They are an administrator, except for top-level. FOR create - WHERE $auth.id IN out.players.* + WHERE (SELECT VALUE id FROM invite WHERE origin = $auth.id AND $parent.in = $auth.id AND $parent.out = target).id FOR update, delete WHERE $auth.id = in.id OR $auth.id IN out.players.* @@ -20,8 +20,6 @@ const plays_in = /* surrealql */ ` DEFINE FIELD in ON plays_in TYPE record; DEFINE FIELD out ON plays_in TYPE record; - DEFINE FIELD confirmed ON plays_in TYPE bool DEFAULT false VALUE $before || IF !$auth OR in.id == $auth.id { $value } ELSE { false }; - DEFINE FIELD created ON plays_in TYPE datetime VALUE $before OR time::now() DEFAULT time::now(); DEFINE FIELD updated ON plays_in TYPE datetime VALUE time::now() DEFAULT time::now(); @@ -32,7 +30,6 @@ export const PlaysIn = z.object({ id: record('plays_in'), in: record('user'), out: record('team'), - confirmed: z.boolean(), created: z.coerce.date(), updated: z.coerce.date(), }); @@ -60,9 +57,16 @@ const verify_nonempty_team_after_deletion = /* surrealql */ ` }; `; +const cleanup_invite = /* surrealql */ ` + DEFINE EVENT cleanup_invite ON plays_in WHEN $event = "CREATE" THEN { + DELETE invite WHERE origin = $value.in AND target = $value.out; + } +`; + export default [ plays_in, log, verify_registrations_after_deletion, verify_nonempty_team_after_deletion, + cleanup_invite, ].join('\n\n'); diff --git a/src/schema/resources/event.ts b/src/schema/resources/event.ts index 5bfb3b3..7f979c2 100644 --- a/src/schema/resources/event.ts +++ b/src/schema/resources/event.ts @@ -39,6 +39,8 @@ const event = /* surrealql */ ` DEFINE FIELD options.max_age ON event TYPE option; DEFINE FIELD options.manual_approval ON event TYPE option; + DEFINE FIELD type ON event VALUE meta::tb(id) DEFAULT meta::tb(id); + DEFINE FIELD created ON event TYPE datetime VALUE $before OR time::now() DEFAULT time::now(); DEFINE FIELD updated ON event TYPE datetime VALUE time::now() DEFAULT time::now(); `; @@ -66,6 +68,8 @@ export const Event = z.object({ manual_approval: z.boolean().optional(), }), + type: z.literal('event').default('event'), + created: z.coerce.date(), updated: z.coerce.date(), }); diff --git a/src/schema/resources/invite.ts b/src/schema/resources/invite.ts new file mode 100644 index 0000000..8fc4879 --- /dev/null +++ b/src/schema/resources/invite.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; +import { record, role } from '../../lib/zod.ts'; + +const user = /* surrealql */ ` + DEFINE TABLE invite SCHEMAFULL + PERMISSIONS + FOR create NONE + FOR update WHERE $scope = 'user' AND $auth IN target.managers[?role = "owner" OR (role = "administrator" AND org != NONE)].*.user + FOR select, delete + WHERE $scope = 'user' AND ( + origin IN [$auth, $auth.email] + OR $auth IN target.players + OR $auth IN target.managers[?role = "owner" OR (role = "administrator" AND org != NONE)].*.user + ); + + DEFINE FIELD origin ON invite TYPE string | record + VALUE (SELECT VALUE id FROM ONLY user WHERE email = $value LIMIT 1) OR $before OR $value + ASSERT type::is::record($value) OR string::is::email($value); + DEFINE FIELD target ON invite TYPE record | record + VALUE $before OR $value; + + DEFINE FIELD role ON invite TYPE option + ASSERT IF $value != NONE THEN $value IN ['owner', 'administrator', 'event_manager', 'event_viewer'] ELSE true END; + + DEFINE FIELD invited_by ON invite TYPE record + VALUE $before OR $value; + + DEFINE FIELD created ON invite TYPE datetime VALUE $before OR time::now() DEFAULT time::now(); + DEFINE FIELD updated ON invite TYPE datetime VALUE time::now() DEFAULT time::now(); + + DEFINE INDEX unique_invite ON invite COLUMNS origin, target UNIQUE; +`; + +export const Invite = z.object({ + id: record('invite'), + origin: z.union([z.string().email(), record('user')]), + target: z.union([record('team'), record('organisation')]), + role: role.optional(), + invited_by: record('user'), + created: z.coerce.date(), + updated: z.coerce.date(), +}); + +export type Invite = z.infer; + +/* Events */ + +const enfore_unique_index = /* surrealql */ ` + DEFINE EVENT enfore_unique_index ON invite THEN { + IF type::is::record(origin) { + UPDATE invite WHERE origin = $value.origin.email; + }; + }; +`; + +const prevent_duplicate_relation = /* surrealql */ ` + DEFINE EVENT prevent_duplicate_relation ON invite THEN { + LET $edge = SELECT VALUE id FROM ONLY $value.origin->manages, $value.origin->plays_in WHERE out = $value.target LIMIT 1; + IF $edge { + THROW "Relation which this invite represents already exists."; + }; + }; +`; + +export default [user, enfore_unique_index, prevent_duplicate_relation].join( + '\n\n' +); diff --git a/src/schema/resources/organisation.ts b/src/schema/resources/organisation.ts index 0f5f5b0..98e32a0 100644 --- a/src/schema/resources/organisation.ts +++ b/src/schema/resources/organisation.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { record } from '../../lib/zod.ts'; +import { record, role } from '../../lib/zod.ts'; const organisation = /* surrealql */ ` DEFINE TABLE organisation SCHEMAFULL @@ -63,7 +63,7 @@ const organisation = /* surrealql */ ` DEFINE FIELD managers ON organisation VALUE { -- Find all confirmed managers of this org - LET $local = SELECT <-manages[?confirmed] AS managers FROM ONLY $parent.id; + LET $local = SELECT <-manages AS managers FROM ONLY $parent.id; -- Grab the role and user ID LET $local = SELECT role, in AS user, id as edge FROM $local.managers; @@ -108,12 +108,7 @@ export const Organisation = z.object({ managers: z.array( z.object({ user: record('user'), - role: z.union([ - z.literal('owner'), - z.literal('administrator'), - z.literal('event_manager'), - z.literal('event_viewer'), - ]), + role, edge: record('manages'), org: record('organisation').optional(), }) diff --git a/src/schema/resources/profile.ts b/src/schema/resources/profile.ts index bedaccf..0c9c8f3 100644 --- a/src/schema/resources/profile.ts +++ b/src/schema/resources/profile.ts @@ -5,6 +5,13 @@ import { Organisation, OrganisationSafeParse } from './organisation'; import { Team, TeamAnonymous } from './team'; import { User, UserAnonymous, UserAsRelatedUser } from './user'; +export const EmailProfile = z.object({ + email: z.string().email(), + type: z.literal('email').default('email'), +}); + +export type EmailProfile = z.infer; + export const FakeProfile = z.object({ id: z.undefined(), name: z.literal('Unknown Profile'), @@ -28,6 +35,7 @@ export const Profile = z.union([ TeamAnonymous, Event, FakeProfile, + EmailProfile, ]); export type Profile = z.infer; diff --git a/src/schema/resources/team.ts b/src/schema/resources/team.ts index 89a3d0e..391d003 100644 --- a/src/schema/resources/team.ts +++ b/src/schema/resources/team.ts @@ -14,7 +14,7 @@ const team = /* surrealql */ ` DEFINE FIELD players ON team VALUE { -- Find all confirmed players of this team - LET $players = SELECT <-plays_in[?confirmed] AS players FROM ONLY $parent.id; + LET $players = SELECT <-plays_in AS players FROM ONLY $parent.id; RETURN SELECT VALUE players.*.in FROM ONLY $players; }; diff --git a/src/schema/resources/user.ts b/src/schema/resources/user.ts index ad9e2c7..c00c136 100644 --- a/src/schema/resources/user.ts +++ b/src/schema/resources/user.ts @@ -82,7 +82,14 @@ const log = /* surrealql */ ` const removal_cleanup = /* surrealql */ ` DEFINE EVENT removal_cleanup ON user WHEN $event = "DELETE" THEN { DELETE $before->plays_in, $before->attends, $before->manages; + DELETE invite WHERE origin = $before.id; }; `; -export default [user, log, removal_cleanup].join('\n\n'); +const convert_invites = /* surrealql */ ` + DEFINE EVENT convert_invites ON user WHEN $event = "CREATE" THEN { + UPDATE invite WHERE origin = $after.email; + }; +`; + +export default [user, log, removal_cleanup, convert_invites].join('\n\n');