Fix sign up and accept invite

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2026-01-02 18:15:10 +01:00
committed by Bryan Frimin
parent c3d4573091
commit 3564bd5495
14 changed files with 516 additions and 117 deletions

View File

@@ -0,0 +1,118 @@
/**
* @generated SignedSource<<8c8ece60966bd2f39668d4ee4459ae86>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type AcceptInvitationInput = {
invitationId: string;
};
export type InvitationCardMutation$variables = {
input: AcceptInvitationInput;
};
export type InvitationCardMutation$data = {
readonly acceptInvitation: {
readonly membershipEdge: {
readonly node: {
readonly id: string;
};
};
} | null | undefined;
};
export type InvitationCardMutation = {
response: InvitationCardMutation$data;
variables: InvitationCardMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "AcceptInvitationPayload",
"kind": "LinkedField",
"name": "acceptInvitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "MembershipEdge",
"kind": "LinkedField",
"name": "membershipEdge",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Membership",
"kind": "LinkedField",
"name": "node",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "InvitationCardMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "InvitationCardMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "592fad5ce23af0f457d7e0ccf5f7123f",
"id": null,
"metadata": {},
"name": "InvitationCardMutation",
"operationKind": "mutation",
"text": "mutation InvitationCardMutation(\n $input: AcceptInvitationInput!\n) {\n acceptInvitation(input: $input) {\n membershipEdge {\n node {\n id\n }\n }\n }\n}\n"
}
};
})();
(node as any).hash = "00a20b1db5dd8e98838c8c9659560bb9";
export default node;

View File

@@ -0,0 +1,107 @@
/**
* @generated SignedSource<<59df18257cc2bae52aefd576c20f03cf>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type SignUpFromInvitationInput = {
fullName: string;
password: string;
token: string;
};
export type SignUpFromInvitationPageMutation$variables = {
input: SignUpFromInvitationInput;
};
export type SignUpFromInvitationPageMutation$data = {
readonly signUpFromInvitation: {
readonly identity: {
readonly id: string;
} | null | undefined;
} | null | undefined;
};
export type SignUpFromInvitationPageMutation = {
response: SignUpFromInvitationPageMutation$data;
variables: SignUpFromInvitationPageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "SignUpFromInvitationPayload",
"kind": "LinkedField",
"name": "signUpFromInvitation",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "identity",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SignUpFromInvitationPageMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SignUpFromInvitationPageMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "9bdca3bd56cb86a2e9c0627379f7f58f",
"id": null,
"metadata": {},
"name": "SignUpFromInvitationPageMutation",
"operationKind": "mutation",
"text": "mutation SignUpFromInvitationPageMutation(\n $input: SignUpFromInvitationInput!\n) {\n signUpFromInvitation(input: $input) {\n identity {\n id\n }\n }\n}\n"
}
};
})();
(node as any).hash = "418fe53fbdc92e4ce5d2256062f08322";
export default node;

View File

@@ -0,0 +1,107 @@
/**
* @generated SignedSource<<450f7808784d82c4ee82e21f72c57465>>
* @lightSyntaxTransform
* @nogrep
*/
/* tslint:disable */
/* eslint-disable */
// @ts-nocheck
import { ConcreteRequest } from 'relay-runtime';
export type SignUpInput = {
email: string;
fullName: string;
password: string;
};
export type SignUpPageMutation$variables = {
input: SignUpInput;
};
export type SignUpPageMutation$data = {
readonly signUp: {
readonly identity: {
readonly id: string;
} | null | undefined;
} | null | undefined;
};
export type SignUpPageMutation = {
response: SignUpPageMutation$data;
variables: SignUpPageMutation$variables;
};
const node: ConcreteRequest = (function(){
var v0 = [
{
"defaultValue": null,
"kind": "LocalArgument",
"name": "input"
}
],
v1 = [
{
"alias": null,
"args": [
{
"kind": "Variable",
"name": "input",
"variableName": "input"
}
],
"concreteType": "SignUpPayload",
"kind": "LinkedField",
"name": "signUp",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"concreteType": "Identity",
"kind": "LinkedField",
"name": "identity",
"plural": false,
"selections": [
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "id",
"storageKey": null
}
],
"storageKey": null
}
],
"storageKey": null
}
];
return {
"fragment": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Fragment",
"metadata": null,
"name": "SignUpPageMutation",
"selections": (v1/*: any*/),
"type": "Mutation",
"abstractKey": null
},
"kind": "Request",
"operation": {
"argumentDefinitions": (v0/*: any*/),
"kind": "Operation",
"name": "SignUpPageMutation",
"selections": (v1/*: any*/)
},
"params": {
"cacheID": "266f2f87a14a72bb166348ae9142a885",
"id": null,
"metadata": {},
"name": "SignUpPageMutation",
"operationKind": "mutation",
"text": "mutation SignUpPageMutation(\n $input: SignUpInput!\n) {\n signUp(input: $input) {\n identity {\n id\n }\n }\n}\n"
}
};
})();
(node as any).hash = "b8ec213c1c715d5a7fbfa5896047785d";
export default node;

View File

@@ -1,45 +1,51 @@
import { Link, useNavigate, useSearchParams } from "react-router";
import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useEffect } from "react"; import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { Link, useNavigate, useSearchParams } from "react-router";
import z from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { graphql } from "relay-runtime";
import { useMutation } from "react-relay";
const signUpFromIvitationMutation = graphql`
mutation SignUpFromInvitationPageMutation(
$input: SignUpFromInvitationInput!
) {
signUpFromInvitation(input: $input) {
identity {
id
}
}
}
`;
const schema = z.object({ const schema = z.object({
fullName: z.string().min(2),
password: z.string().min(8), password: z.string().min(8),
fullName: z.string().min(2),
}); });
export default function SignupFromInvitationPage() { type FormData = z.infer<typeof schema>;
export default function SignUpFromInvitationPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const fullNameFromParams = searchParams.get("fullName") || "";
const { register, handleSubmit, formState, reset } = useFormWithSchema( usePageTitle(__("Sign up"));
schema,
{
defaultValues: {
fullName: "",
password: "",
},
}
);
useEffect(() => { const { register, handleSubmit, formState } = useFormWithSchema(schema, {
const fullNameFromParams = searchParams.get("fullName") || ""; defaultValues: {
if (fullNameFromParams) { password: "",
reset({ fullName: fullNameFromParams,
fullName: fullNameFromParams, },
password: "", });
});
}
}, [searchParams, reset]);
const onSubmit = handleSubmit(async (data) => { const [signUpFromInvitation] = useMutation(signUpFromIvitationMutation);
const onSubmit = (data: FormData) => {
const token = searchParams.get("token"); const token = searchParams.get("token");
if (!token) { if (!token) {
toast({ toast({
title: __("Signup failed"), title: __("Signup failed"),
@@ -49,40 +55,33 @@ export default function SignupFromInvitationPage() {
return; return;
} }
const response = await fetch("/connect/signup-from-invitation", { signUpFromInvitation({
method: "POST", variables: {
headers: { input: {
"Content-Type": "application/json", token,
password: data.password,
fullName: data.fullName,
},
},
onCompleted: () => {
toast({
title: __("Success"),
description: __(
"Account created successfully. Please accept your invitation to join the organization.",
),
variant: "success",
});
navigate("/", { replace: true });
},
onError: (errorData) => {
toast({
title: __("Signup failed"),
description: errorData.message || __("Signup failed"),
variant: "error",
});
}, },
credentials: "include",
body: JSON.stringify({
token: token,
password: data.password,
fullName: data.fullName,
}),
}); });
};
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
toast({
title: __("Signup failed"),
description: errorData.message || __("Signup failed"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __(
"Account created successfully. Please accept your invitation to join the organization."
),
variant: "success",
});
navigate("/", { replace: true });
});
usePageTitle(__("Create your account"));
return ( return (
<div className="space-y-6 w-full max-w-md mx-auto"> <div className="space-y-6 w-full max-w-md mx-auto">
@@ -93,7 +92,7 @@ export default function SignupFromInvitationPage() {
</p> </p>
</div> </div>
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field <Field
label={__("Full Name")} label={__("Full Name")}
type="text" type="text"

View File

@@ -1,9 +1,21 @@
import { Link, useNavigate } from "react-router";
import { Button, Field, useToast } from "@probo/ui";
import { useTranslate } from "@probo/i18n";
import { z } from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { usePageTitle } from "@probo/hooks"; import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { Link, useNavigate } from "react-router";
import z from "zod";
import { useFormWithSchema } from "/hooks/useFormWithSchema";
import { graphql } from "relay-runtime";
import { useMutation } from "react-relay";
const signUpMutation = graphql`
mutation SignUpPageMutation($input: SignUpInput!) {
signUp(input: $input) {
identity {
id
}
}
}
`;
const schema = z.object({ const schema = z.object({
email: z.string().email(), email: z.string().email(),
@@ -11,10 +23,15 @@ const schema = z.object({
fullName: z.string().min(2), fullName: z.string().min(2),
}); });
export default function RegisterPage() { type FormData = z.infer<typeof schema>;
export default function SignUpPage() {
const { __ } = useTranslate(); const { __ } = useTranslate();
const navigate = useNavigate();
const { toast } = useToast(); const { toast } = useToast();
const navigate = useNavigate();
usePageTitle(__("Sign up"));
const { register, handleSubmit, formState } = useFormWithSchema(schema, { const { register, handleSubmit, formState } = useFormWithSchema(schema, {
defaultValues: { defaultValues: {
email: "", email: "",
@@ -23,36 +40,32 @@ export default function RegisterPage() {
}, },
}); });
const onSubmit = handleSubmit(async (data) => { const [signUp] = useMutation(signUpMutation);
const response = await fetch("/connect/register", {
method: "POST", const onSubmit = (data: FormData) => {
headers: { signUp({
"Content-Type": "application/json", variables: {
email: data.email,
password: data.password,
fullName: data.fullName,
},
onCompleted: () => {
toast({
title: __("Success"),
description: __("Account created successfully"),
variant: "success",
});
navigate("/", { replace: true });
},
onError: (errorData) => {
toast({
title: __("Registration failed"),
description: errorData.message || __("Registration failed"),
variant: "error",
});
}, },
credentials: "include",
body: JSON.stringify(data),
}); });
};
// Registration failed
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
toast({
title: __("Registration failed"),
description: errorData.message || __("Registration failed"),
variant: "error",
});
return;
}
toast({
title: __("Success"),
description: __("Account created successfully"),
variant: "success",
});
navigate("/", { replace: true });
});
usePageTitle(__("Sign up"));
return ( return (
<div className="space-y-6 w-full max-w-md mx-auto"> <div className="space-y-6 w-full max-w-md mx-auto">
@@ -63,7 +76,7 @@ export default function RegisterPage() {
</p> </p>
</div> </div>
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Field <Field
label={__("Full Name")} label={__("Full Name")}
type="text" type="text"

View File

@@ -11,6 +11,7 @@ import { useMemo, useState } from "react";
import { InvitationCard } from "./_components/InvitationCard"; import { InvitationCard } from "./_components/InvitationCard";
import { MembershipCard } from "./_components/MembershipCard"; import { MembershipCard } from "./_components/MembershipCard";
import type { MembershipsPageQuery } from "/__generated__/iam/MembershipsPageQuery.graphql"; import type { MembershipsPageQuery } from "/__generated__/iam/MembershipsPageQuery.graphql";
import { usePageTitle } from "@probo/hooks";
export const membershipsPageQuery = graphql` export const membershipsPageQuery = graphql`
query MembershipsPageQuery { query MembershipsPageQuery {
@@ -48,6 +49,8 @@ export function MembershipsPage(props: {
const { __ } = useTranslate(); const { __ } = useTranslate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
usePageTitle(__("Select an organization"));
const { queryRef } = props; const { queryRef } = props;
const { const {
viewer: { viewer: {
@@ -78,12 +81,7 @@ export function MembershipsPage(props: {
{__("Pending invitations")} {__("Pending invitations")}
</h2> </h2>
{invitations.map(({ node }) => ( {invitations.map(({ node }) => (
<InvitationCard <InvitationCard key={node.id} fKey={node} />
key={node.id}
fKey={node}
// onAccept={handleAcceptInvitation}
// isAccepting={isAccepting}
/>
))} ))}
</div> </div>
)} )}

View File

@@ -1,9 +1,10 @@
import { formatDate } from "@probo/helpers"; import { formatDate } from "@probo/helpers";
import { useTranslate } from "@probo/i18n"; import { useTranslate } from "@probo/i18n";
import { Card } from "@probo/ui"; import { Button, Card } from "@probo/ui";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { useFragment } from "react-relay"; import { useFragment, useMutation } from "react-relay";
import type { InvitationCardFragment$key } from "/__generated__/iam/InvitationCardFragment.graphql"; import type { InvitationCardFragment$key } from "/__generated__/iam/InvitationCardFragment.graphql";
import { useNavigate } from "react-router";
const fragment = graphql` const fragment = graphql`
fragment InvitationCardFragment on Invitation { fragment InvitationCardFragment on Invitation {
@@ -17,19 +18,49 @@ const fragment = graphql`
} }
`; `;
const acceptMutation = graphql`
mutation InvitationCardMutation($input: AcceptInvitationInput!) {
acceptInvitation(input: $input) {
membershipEdge {
node {
id
}
}
}
}
`;
interface InvitationCardProps { interface InvitationCardProps {
fKey: InvitationCardFragment$key; fKey: InvitationCardFragment$key;
// onAccept: (invitationId: string, organizationId: string) => void;
// isAccepting: boolean;
} }
export function InvitationCard(props: InvitationCardProps) { export function InvitationCard(props: InvitationCardProps) {
const { fKey } = props; const { fKey } = props;
const navigate = useNavigate();
const { __ } = useTranslate(); const { __ } = useTranslate();
const invitation = useFragment<InvitationCardFragment$key>(fragment, fKey); const invitation = useFragment<InvitationCardFragment$key>(fragment, fKey);
const [acceptInvitation, isAccepting] = useMutation(acceptMutation);
const handleAccept = () => {
acceptInvitation({
variables: {
input: {
invitationId: invitation.id,
},
},
onCompleted: () => {
navigate(`/organizations/${invitation.organization.id}`);
},
onError: (err) => {
console.error("Failed to accept invitation:", err);
alert(__("Failed to accept invitation"));
},
});
};
return ( return (
<Card padded className="w-full"> <Card padded className="w-full">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@@ -44,12 +75,9 @@ export function InvitationCard(props: InvitationCardProps) {
{__("Invited on")} {formatDate(invitation.createdAt)} {__("Invited on")} {formatDate(invitation.createdAt)}
</p> </p>
</div> </div>
{/* <Button <Button onClick={handleAccept} disabled={isAccepting}>
onClick={() => onAccept(invitation.id, invitation.organization.id)}
disabled={isAccepting}
>
{isAccepting ? __("Accepting...") : __("Accept invitation")} {isAccepting ? __("Accepting...") : __("Accept invitation")}
</Button> */} </Button>
</div> </div>
</Card> </Card>
); );

View File

@@ -69,7 +69,7 @@ const routes = [
}, },
{ {
path: "register", path: "register",
Component: lazy(() => import("./pages/auth/RegisterPage")), Component: lazy(() => import("./pages/iam/auth/SignUpPage")),
}, },
// { // {
// path: "confirm-email", // path: "confirm-email",
@@ -77,7 +77,9 @@ const routes = [
// }, // },
{ {
path: "signup-from-invitation", path: "signup-from-invitation",
Component: lazy(() => import("./pages/auth/SignupFromInvitationPage")), Component: lazy(
() => import("./pages/iam/auth/SignUpFromInvitationPage"),
),
}, },
{ {
path: "forgot-password", path: "forgot-password",

View File

@@ -257,6 +257,19 @@ func (s *AccountService) AcceptInvitation(
return fmt.Errorf("cannot create membership: %w", err) return fmt.Errorf("cannot create membership: %w", err)
} }
profile := &coredata.MembershipProfile{
ID: gid.New(tenantID, coredata.MembershipProfileEntityType),
MembershipID: membership.ID,
FullName: identity.FullName,
CreatedAt: now,
UpdatedAt: now,
}
err = profile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert profile: %w", err)
}
invitation.AcceptedAt = &now invitation.AcceptedAt = &now
err = invitation.Update(ctx, tx, scope) err = invitation.Update(ctx, tx, scope)
if err != nil { if err != nil {

View File

@@ -425,7 +425,10 @@ func (s *OrganizationService) InviteMember(
return fmt.Errorf("cannot parse base URL: %w", err) return fmt.Errorf("cannot parse base URL: %w", err)
} }
invitationURL := baseurl.WithPath("/auth/signup-from-invitation").WithQuery("token", invitationToken).MustString() invitationURL := baseurl.WithPath("/auth/signup-from-invitation").
WithQuery("token", invitationToken).
WithQuery("fullName", invitation.FullName).
MustString()
subject, textBody, htmlBody, err := emails.RenderInvitation( subject, textBody, htmlBody, err := emails.RenderInvitation(
s.baseURL, s.baseURL,

View File

@@ -491,6 +491,7 @@ input SignUpInput {
input SignUpFromInvitationInput { input SignUpFromInvitationInput {
token: String! token: String!
password: String! password: String!
fullName: String!
} }
input ForgotPasswordInput { input ForgotPasswordInput {

View File

@@ -2455,6 +2455,7 @@ input SignUpInput {
input SignUpFromInvitationInput { input SignUpFromInvitationInput {
token: String! token: String!
password: String! password: String!
fullName: String!
} }
input ForgotPasswordInput { input ForgotPasswordInput {
@@ -12877,7 +12878,7 @@ func (ec *executionContext) unmarshalInputSignUpFromInvitationInput(ctx context.
asMap[k] = v asMap[k] = v
} }
fieldsInOrder := [...]string{"token", "password"} fieldsInOrder := [...]string{"token", "password", "fullName"}
for _, k := range fieldsInOrder { for _, k := range fieldsInOrder {
v, ok := asMap[k] v, ok := asMap[k]
if !ok { if !ok {
@@ -12898,6 +12899,13 @@ func (ec *executionContext) unmarshalInputSignUpFromInvitationInput(ctx context.
return it, err return it, err
} }
it.Password = data it.Password = data
case "fullName":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("fullName"))
data, err := ec.unmarshalNString2string(ctx, v)
if err != nil {
return it, err
}
it.FullName = data
} }
} }

View File

@@ -410,6 +410,7 @@ type SignOutPayload struct {
type SignUpFromInvitationInput struct { type SignUpFromInvitationInput struct {
Token string `json:"token"` Token string `json:"token"`
Password string `json:"password"` Password string `json:"password"`
FullName string `json:"fullName"`
} }
type SignUpFromInvitationPayload struct { type SignUpFromInvitationPayload struct {

View File

@@ -440,6 +440,7 @@ func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types
&iam.CreateIdentityFromInvitationRequest{ &iam.CreateIdentityFromInvitationRequest{
InvitationToken: input.Token, InvitationToken: input.Token,
Password: input.Password, Password: input.Password,
FullName: input.FullName,
}, },
) )
if err != nil { if err != nil {