diff --git a/apps/console/src/__generated__/iam/InvitationCardMutation.graphql.ts b/apps/console/src/__generated__/iam/InvitationCardMutation.graphql.ts new file mode 100644 index 000000000..246dcbd9c --- /dev/null +++ b/apps/console/src/__generated__/iam/InvitationCardMutation.graphql.ts @@ -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; diff --git a/apps/console/src/__generated__/iam/SignUpFromInvitationPageMutation.graphql.ts b/apps/console/src/__generated__/iam/SignUpFromInvitationPageMutation.graphql.ts new file mode 100644 index 000000000..e814bbf38 --- /dev/null +++ b/apps/console/src/__generated__/iam/SignUpFromInvitationPageMutation.graphql.ts @@ -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; diff --git a/apps/console/src/__generated__/iam/SignUpPageMutation.graphql.ts b/apps/console/src/__generated__/iam/SignUpPageMutation.graphql.ts new file mode 100644 index 000000000..a7ff47518 --- /dev/null +++ b/apps/console/src/__generated__/iam/SignUpPageMutation.graphql.ts @@ -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; diff --git a/apps/console/src/pages/auth/SignupFromInvitationPage.tsx b/apps/console/src/pages/iam/auth/SignUpFromInvitationPage.tsx similarity index 58% rename from apps/console/src/pages/auth/SignupFromInvitationPage.tsx rename to apps/console/src/pages/iam/auth/SignUpFromInvitationPage.tsx index 45d1f4f4d..9342dd825 100644 --- a/apps/console/src/pages/auth/SignupFromInvitationPage.tsx +++ b/apps/console/src/pages/iam/auth/SignUpFromInvitationPage.tsx @@ -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 { 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({ - fullName: z.string().min(2), password: z.string().min(8), + fullName: z.string().min(2), }); -export default function SignupFromInvitationPage() { +type FormData = z.infer; + +export default function SignUpFromInvitationPage() { const { __ } = useTranslate(); - const navigate = useNavigate(); const { toast } = useToast(); + const navigate = useNavigate(); const [searchParams] = useSearchParams(); + const fullNameFromParams = searchParams.get("fullName") || ""; - const { register, handleSubmit, formState, reset } = useFormWithSchema( - schema, - { - defaultValues: { - fullName: "", - password: "", - }, - } - ); + usePageTitle(__("Sign up")); - useEffect(() => { - const fullNameFromParams = searchParams.get("fullName") || ""; - if (fullNameFromParams) { - reset({ - fullName: fullNameFromParams, - password: "", - }); - } - }, [searchParams, reset]); + const { register, handleSubmit, formState } = useFormWithSchema(schema, { + defaultValues: { + password: "", + fullName: fullNameFromParams, + }, + }); - const onSubmit = handleSubmit(async (data) => { + const [signUpFromInvitation] = useMutation(signUpFromIvitationMutation); + + const onSubmit = (data: FormData) => { const token = searchParams.get("token"); - if (!token) { toast({ title: __("Signup failed"), @@ -49,40 +55,33 @@ export default function SignupFromInvitationPage() { return; } - const response = await fetch("/connect/signup-from-invitation", { - method: "POST", - headers: { - "Content-Type": "application/json", + signUpFromInvitation({ + variables: { + input: { + 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 (
@@ -93,7 +92,7 @@ export default function SignupFromInvitationPage() {

-
+ ; + +export default function SignUpPage() { const { __ } = useTranslate(); - const navigate = useNavigate(); const { toast } = useToast(); + const navigate = useNavigate(); + + usePageTitle(__("Sign up")); + const { register, handleSubmit, formState } = useFormWithSchema(schema, { defaultValues: { email: "", @@ -23,36 +40,32 @@ export default function RegisterPage() { }, }); - const onSubmit = handleSubmit(async (data) => { - const response = await fetch("/connect/register", { - method: "POST", - headers: { - "Content-Type": "application/json", + const [signUp] = useMutation(signUpMutation); + + const onSubmit = (data: FormData) => { + signUp({ + 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 (
@@ -63,7 +76,7 @@ export default function RegisterPage() {

- + {invitations.map(({ node }) => ( - + ))} )} diff --git a/apps/console/src/pages/iam/memberships/_components/InvitationCard.tsx b/apps/console/src/pages/iam/memberships/_components/InvitationCard.tsx index e0b131f55..28c18517d 100644 --- a/apps/console/src/pages/iam/memberships/_components/InvitationCard.tsx +++ b/apps/console/src/pages/iam/memberships/_components/InvitationCard.tsx @@ -1,9 +1,10 @@ import { formatDate } from "@probo/helpers"; import { useTranslate } from "@probo/i18n"; -import { Card } from "@probo/ui"; +import { Button, Card } from "@probo/ui"; 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 { useNavigate } from "react-router"; const fragment = graphql` 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 { fKey: InvitationCardFragment$key; - // onAccept: (invitationId: string, organizationId: string) => void; - // isAccepting: boolean; } export function InvitationCard(props: InvitationCardProps) { const { fKey } = props; + const navigate = useNavigate(); const { __ } = useTranslate(); const invitation = useFragment(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 (
@@ -44,12 +75,9 @@ export function InvitationCard(props: InvitationCardProps) { {__("Invited on")} {formatDate(invitation.createdAt)}

- {/* */} +
); diff --git a/apps/console/src/routes.tsx b/apps/console/src/routes.tsx index fca4074c0..841fa62b4 100644 --- a/apps/console/src/routes.tsx +++ b/apps/console/src/routes.tsx @@ -69,7 +69,7 @@ const routes = [ }, { path: "register", - Component: lazy(() => import("./pages/auth/RegisterPage")), + Component: lazy(() => import("./pages/iam/auth/SignUpPage")), }, // { // path: "confirm-email", @@ -77,7 +77,9 @@ const routes = [ // }, { path: "signup-from-invitation", - Component: lazy(() => import("./pages/auth/SignupFromInvitationPage")), + Component: lazy( + () => import("./pages/iam/auth/SignUpFromInvitationPage"), + ), }, { path: "forgot-password", diff --git a/pkg/iam/account_service.go b/pkg/iam/account_service.go index 043ae39cd..14c513bed 100644 --- a/pkg/iam/account_service.go +++ b/pkg/iam/account_service.go @@ -257,6 +257,19 @@ func (s *AccountService) AcceptInvitation( 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 err = invitation.Update(ctx, tx, scope) if err != nil { diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 709451f33..72c6b00d2 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -425,7 +425,10 @@ func (s *OrganizationService) InviteMember( 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( s.baseURL, diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 86560b30f..4469c6165 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -491,6 +491,7 @@ input SignUpInput { input SignUpFromInvitationInput { token: String! password: String! + fullName: String! } input ForgotPasswordInput { diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index f964bfedd..b1f0f3bac 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -2455,6 +2455,7 @@ input SignUpInput { input SignUpFromInvitationInput { token: String! password: String! + fullName: String! } input ForgotPasswordInput { @@ -12877,7 +12878,7 @@ func (ec *executionContext) unmarshalInputSignUpFromInvitationInput(ctx context. asMap[k] = v } - fieldsInOrder := [...]string{"token", "password"} + fieldsInOrder := [...]string{"token", "password", "fullName"} for _, k := range fieldsInOrder { v, ok := asMap[k] if !ok { @@ -12898,6 +12899,13 @@ func (ec *executionContext) unmarshalInputSignUpFromInvitationInput(ctx context. return it, err } 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 } } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index aa01503de..9434ef58a 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -410,6 +410,7 @@ type SignOutPayload struct { type SignUpFromInvitationInput struct { Token string `json:"token"` Password string `json:"password"` + FullName string `json:"fullName"` } type SignUpFromInvitationPayload struct { diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 4118eebc1..35a27af17 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -440,6 +440,7 @@ func (r *mutationResolver) SignUpFromInvitation(ctx context.Context, input types &iam.CreateIdentityFromInvitationRequest{ InvitationToken: input.Token, Password: input.Password, + FullName: input.FullName, }, ) if err != nil {