Skip to content

Commit

Permalink
Merge pull request #2579 from quantified-uncertainty/invite-link
Browse files Browse the repository at this point in the history
Reusable invite links
  • Loading branch information
berekuk authored Nov 24, 2023
2 parents 673f8fd + a8b65e4 commit 8e2eac0
Show file tree
Hide file tree
Showing 49 changed files with 1,123 additions and 360 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Group" ADD COLUMN "reusableInviteToken" TEXT;
2 changes: 2 additions & 0 deletions packages/hub/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ model Group {
asOwner Owner @relation(fields: [ownerId], references: [id])
ownerId String @unique
reusableInviteToken String?
}

model Owner {
Expand Down
57 changes: 57 additions & 0 deletions packages/hub/schema.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
type AcceptReusableGroupInviteTokenResult {
membership: UserGroupMembership!
}

type AdminUpdateModelVersionResult {
model: Model!
}
Expand Down Expand Up @@ -26,6 +30,10 @@ type CreateRelativeValuesDefinitionResult {
definition: RelativeValuesDefinition!
}

type CreateReusableGroupInviteTokenResult {
group: Group!
}

type CreateSquiggleSnippetModelResult {
model: Model!
}
Expand All @@ -47,6 +55,10 @@ type DeleteRelativeValuesDefinitionResult {
ok: Boolean!
}

type DeleteReusableGroupInviteTokenResult {
group: Group!
}

type EmailGroupInvite implements GroupInvite & Node {
email: String!
group: Group!
Expand All @@ -72,6 +84,7 @@ type Group implements Node & Owner {
memberships(after: String, before: String, first: Int, last: Int): UserGroupMembershipConnection!
models(after: String, before: String, first: Int, last: Int): ModelConnection!
myMembership: UserGroupMembership
reusableInviteToken: String
slug: String!
updatedAtTimestamp: Float!
}
Expand Down Expand Up @@ -206,17 +219,29 @@ type MoveModelResult {
}

type Mutation {
acceptReusableGroupInviteToken(input: MutationAcceptReusableGroupInviteTokenInput!): MutationAcceptReusableGroupInviteTokenResult!

"""Admin-only query for upgrading model versions"""
adminUpdateModelVersion(input: MutationAdminUpdateModelVersionInput!): MutationAdminUpdateModelVersionResult!
buildRelativeValuesCache(input: MutationBuildRelativeValuesCacheInput!): MutationBuildRelativeValuesCacheResult!
cancelGroupInvite(input: MutationCancelGroupInviteInput!): MutationCancelGroupInviteResult!
clearRelativeValuesCache(input: MutationClearRelativeValuesCacheInput!): MutationClearRelativeValuesCacheResult!
createGroup(input: MutationCreateGroupInput!): MutationCreateGroupResult!
createRelativeValuesDefinition(input: MutationCreateRelativeValuesDefinitionInput!): MutationCreateRelativeValuesDefinitionResult!

"""
Create or replace a reusable invite token for a group, available as `reusableInviteToken` field on group object.
You must be an admin of the group to call this mutation. Previous invite token, if it existed, will stop working.
"""
createReusableGroupInviteToken(input: MutationCreateReusableGroupInviteTokenInput!): MutationCreateReusableGroupInviteTokenResult!
createSquiggleSnippetModel(input: MutationCreateSquiggleSnippetModelInput!): MutationCreateSquiggleSnippetModelResult!
deleteMembership(input: MutationDeleteMembershipInput!): MutationDeleteMembershipResult!
deleteModel(input: MutationDeleteModelInput!): MutationDeleteModelResult!
deleteRelativeValuesDefinition(input: MutationDeleteRelativeValuesDefinitionInput!): MutationDeleteRelativeValuesDefinitionResult!

"""Disable a reusable invite token for a group."""
deleteReusableGroupInviteToken(input: MutationDeleteReusableGroupInviteTokenInput!): MutationDeleteReusableGroupInviteTokenResult!
inviteUserToGroup(input: MutationInviteUserToGroupInput!): MutationInviteUserToGroupResult!
moveModel(input: MutationMoveModelInput!): MutationMoveModelResult!
reactToGroupInvite(input: MutationReactToGroupInviteInput!): MutationReactToGroupInviteResult!
Expand All @@ -227,8 +252,16 @@ type Mutation {
updateModelSlug(input: MutationUpdateModelSlugInput!): MutationUpdateModelSlugResult!
updateRelativeValuesDefinition(input: MutationUpdateRelativeValuesDefinitionInput!): MutationUpdateRelativeValuesDefinitionResult!
updateSquiggleSnippetModel(input: MutationUpdateSquiggleSnippetModelInput!): MutationUpdateSquiggleSnippetModelResult!
validateReusableGroupInviteToken(input: MutationValidateReusableGroupInviteTokenInput!): MutationValidateReusableGroupInviteTokenResult!
}

input MutationAcceptReusableGroupInviteTokenInput {
groupSlug: String!
inviteToken: String!
}

union MutationAcceptReusableGroupInviteTokenResult = AcceptReusableGroupInviteTokenResult | BaseError

input MutationAdminUpdateModelVersionInput {
modelId: String!
version: String!
Expand Down Expand Up @@ -275,6 +308,12 @@ input MutationCreateRelativeValuesDefinitionInput {

union MutationCreateRelativeValuesDefinitionResult = BaseError | CreateRelativeValuesDefinitionResult | ValidationError

input MutationCreateReusableGroupInviteTokenInput {
slug: String!
}

union MutationCreateReusableGroupInviteTokenResult = BaseError | CreateReusableGroupInviteTokenResult

input MutationCreateSquiggleSnippetModelInput {
"""Squiggle source code"""
code: String!
Expand Down Expand Up @@ -311,6 +350,12 @@ input MutationDeleteRelativeValuesDefinitionInput {

union MutationDeleteRelativeValuesDefinitionResult = BaseError | DeleteRelativeValuesDefinitionResult

input MutationDeleteReusableGroupInviteTokenInput {
slug: String!
}

union MutationDeleteReusableGroupInviteTokenResult = BaseError | DeleteReusableGroupInviteTokenResult

input MutationInviteUserToGroupInput {
group: String!
role: MembershipRole!
Expand Down Expand Up @@ -389,6 +434,13 @@ input MutationUpdateSquiggleSnippetModelInput {

union MutationUpdateSquiggleSnippetModelResult = BaseError | UpdateSquiggleSnippetResult

input MutationValidateReusableGroupInviteTokenInput {
groupSlug: String!
inviteToken: String!
}

union MutationValidateReusableGroupInviteTokenResult = BaseError | ValidateReusableGroupInviteTokenResult

interface Node {
id: ID!
}
Expand Down Expand Up @@ -617,6 +669,7 @@ type UserGroupInvite implements GroupInvite & Node {
}

type UserGroupMembership implements Node {
group: Group!
id: ID!
role: MembershipRole!
user: User!
Expand All @@ -636,6 +689,10 @@ input UsersQueryInput {
usernameContains: String
}

type ValidateReusableGroupInviteTokenResult {
ok: Boolean!
}

type ValidationError implements Error {
issues: [ValidationErrorIssue!]!
message: String!
Expand Down
2 changes: 1 addition & 1 deletion packages/hub/src/app/groups/[slug]/GroupLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const NewButton: FC<{ group: string }> = ({ group }) => {

const router = useRouter();

if (segment === "members") {
if (segment === "members" || segment === "invite-link") {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"use client";
import { useSession } from "next-auth/react";
import { redirect, useRouter, useSearchParams } from "next/navigation";
import { FC, useEffect } from "react";
import { graphql } from "relay-runtime";

import { AcceptGroupInvitePageMutation } from "@/__generated__/AcceptGroupInvitePageMutation.graphql";
import { MutationButton } from "@/components/ui/MutationButton";
import { usePageQuery } from "@/relay/usePageQuery";
import { SerializablePreloadedQuery } from "@/relay/loadPageQuery";
import { AcceptGroupInvitePageQuery } from "@/__generated__/AcceptGroupInvitePageQuery.graphql";
import { useIsGroupMember } from "../hooks";
import { extractFromGraphqlErrorUnion } from "@/lib/graphqlHelpers";
import { groupRoute } from "@/routes";
import { useAsyncMutation } from "@/hooks/useAsyncMutation";
import { AcceptGroupInvitePage_ValidateMutation } from "@/__generated__/AcceptGroupInvitePage_ValidateMutation.graphql";
import { useToast } from "@quri/ui";

export const AcceptGroupInvitePage: FC<{
query: SerializablePreloadedQuery<AcceptGroupInvitePageQuery>;
}> = ({ query }) => {
useSession({ required: true });

const [{ result }] = usePageQuery(
graphql`
query AcceptGroupInvitePageQuery($slug: String!) {
result: group(slug: $slug) {
__typename
... on Group {
slug
...hooks_useIsGroupMember
}
}
}
`,
query
);
const group = extractFromGraphqlErrorUnion(result, "Group");
const isGroupMember = useIsGroupMember(group);

if (isGroupMember) {
redirect(groupRoute({ slug: group.slug }));
}

const params = useSearchParams();
const inviteToken = params.get("token");
if (!inviteToken) {
throw new Error("Token is missing");
}

const [validateMutation] = useAsyncMutation<
AcceptGroupInvitePage_ValidateMutation,
"ValidateReusableGroupInviteTokenResult"
>({
mutation: graphql`
mutation AcceptGroupInvitePage_ValidateMutation(
$input: MutationValidateReusableGroupInviteTokenInput!
) {
result: validateReusableGroupInviteToken(input: $input) {
__typename
... on BaseError {
message
}
... on ValidateReusableGroupInviteTokenResult {
ok
}
}
}
`,
expectedTypename: "ValidateReusableGroupInviteTokenResult",
});

const toast = useToast();
const router = useRouter();

useEffect(() => {
validateMutation({
variables: {
input: {
groupSlug: group.slug,
inviteToken,
},
},
onCompleted({ ok }) {
if (!ok) {
toast("Invalid token", "error");
router.replace(groupRoute({ slug: group.slug }));
}
},
});
}, []);

return (
<div>
<p className="mb-4">{`You've been invited to join ${group.slug} group.`}</p>
<MutationButton<
AcceptGroupInvitePageMutation,
"AcceptReusableGroupInviteTokenResult"
>
mutation={graphql`
mutation AcceptGroupInvitePageMutation(
$input: MutationAcceptReusableGroupInviteTokenInput!
) {
result: acceptReusableGroupInviteToken(input: $input) {
__typename
... on BaseError {
message
}
... on AcceptReusableGroupInviteTokenResult {
__typename
membership {
group {
id
slug
...hooks_useIsGroupMember
}
}
}
}
}
`}
expectedTypename="AcceptReusableGroupInviteTokenResult"
variables={{
input: {
groupSlug: group.slug,
inviteToken,
},
}}
title="Join this group"
theme="primary"
onCompleted={() => toast("Joined", "confirmation")}
/>
</div>
);
};
17 changes: 17 additions & 0 deletions packages/hub/src/app/groups/[slug]/invite-link/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { loadPageQuery } from "@/relay/loadPageQuery";
import { AcceptGroupInvitePage } from "./AcceptGroupInvitePage";
import QueryNode, {
AcceptGroupInvitePageQuery,
} from "@/__generated__/AcceptGroupInvitePageQuery.graphql";

type Props = {
params: { slug: string };
};

export default async function OuterAcceptGroupInvitePage({ params }: Props) {
const query = await loadPageQuery<AcceptGroupInvitePageQuery>(QueryNode, {
slug: params.slug,
});

return <AcceptGroupInvitePage query={query} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { CancelInviteAction } from "./CancelInviteAction";

export const GroupInviteCard: FC<{
inviteRef: GroupInviteCard$key;
groupRef: hooks_useIsGroupAdmin$key;
groupId: string;
}> = (props) => {
const invite = useFragment(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const fragment = graphql`
count: { type: "Int", defaultValue: 20 }
)
@refetchable(queryName: "GroupInviteListPaginationQuery") {
...hooks_useIsGroupAdmin
invites(first: $count, after: $cursor)
@connection(key: "GroupInviteList_invites") {
edges {
Expand Down Expand Up @@ -47,7 +46,6 @@ export const GroupInviteList: FC<Props> = ({ groupRef }) => {
{group.invites.edges.map(({ node: invite }) => (
<GroupInviteCard
inviteRef={invite}
groupRef={group}
groupId={group.id}
key={invite.id}
/>
Expand Down
13 changes: 10 additions & 3 deletions packages/hub/src/app/groups/[slug]/members/GroupMembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { usePageQuery } from "@/relay/usePageQuery";
import { GroupInviteList } from "./GroupInviteList";
import { GroupMemberList } from "./GroupMemberList";
import { useIsGroupAdmin } from "../hooks";
import { GroupReusableInviteSection } from "./GroupReusableInviteSection";

const Query = graphql`
query GroupMembersPageQuery($slug: String!) {
Expand All @@ -25,6 +26,7 @@ const Query = graphql`
id
...GroupMemberList
...GroupInviteList
...GroupReusableInviteSection
...hooks_useIsGroupAdmin
}
}
Expand All @@ -48,9 +50,14 @@ export const GroupMembersPage: FC<{
<GroupMemberList groupRef={group} />
</section>
{isAdmin && (
<section>
<GroupInviteList groupRef={group} />
</section>
<>
<section>
<GroupInviteList groupRef={group} />
</section>
<section>
<GroupReusableInviteSection groupRef={group} />
</section>
</>
)}
</div>
);
Expand Down
Loading

1 comment on commit 8e2eac0

@vercel
Copy link

@vercel vercel bot commented on 8e2eac0 Nov 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.