Implement continue on verify magic link

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-20 13:02:34 +04:00
parent 3c760bfc51
commit 316e81f938
11 changed files with 96 additions and 46 deletions

View File

@@ -8,13 +8,14 @@ import {
useMutation, useMutation,
usePreloadedQuery, usePreloadedQuery,
} from "react-relay"; } from "react-relay";
import { useSearchParams } from "react-router";
import { graphql } from "relay-runtime"; import { graphql } from "relay-runtime";
import { z } from "zod"; import { z } from "zod";
import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { getPathPrefix } from "#/utils/pathPrefix"; import { getPathPrefix } from "#/utils/pathPrefix";
import type { ConnectPageMutation } from "./__generated__/ConnectPageMutation.graphql"; import type { ConnectPageMutation, SendMagicLinkInput } from "./__generated__/ConnectPageMutation.graphql";
import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql"; import type { ConnectPageQuery } from "./__generated__/ConnectPageQuery.graphql";
export const connectPageQuery = graphql` export const connectPageQuery = graphql`
@@ -53,11 +54,22 @@ export function ConnectPage(props: {
const [magicLinkSent, setMagicLinkSent] = useState<boolean>(false); const [magicLinkSent, setMagicLinkSent] = useState<boolean>(false);
const interval = useRef<NodeJS.Timeout>(undefined); const interval = useRef<NodeJS.Timeout>(undefined);
const [timer, setTimer] = useState<number>(timerDurationSeconds); const [timer, setTimer] = useState<number>(timerDurationSeconds);
const [searchParams] = useSearchParams();
const { const {
currentTrustCenter: { organization }, currentTrustCenter: { organization },
} = usePreloadedQuery<ConnectPageQuery>(connectPageQuery, queryRef); } = usePreloadedQuery<ConnectPageQuery>(connectPageQuery, queryRef);
const continueUrlParam = searchParams.get("continue");
let safeContinueUrl: string;
if (continueUrlParam) {
const continueUrl = new URL(continueUrlParam);
safeContinueUrl = window.location.origin + continueUrl.pathname + continueUrl.search;
} else {
const pathPrefix = getPathPrefix();
safeContinueUrl = window.location.origin + pathPrefix ? getPathPrefix() : "/";
}
useEffect(() => { useEffect(() => {
if (!magicLinkSent && interval.current) { if (!magicLinkSent && interval.current) {
clearInterval(interval.current); clearInterval(interval.current);
@@ -92,14 +104,18 @@ export function ConnectPage(props: {
); );
const handleSubmit = handleSubmitWrapper(({ email }: FormData) => { const handleSubmit = handleSubmitWrapper(({ email }: FormData) => {
const input: SendMagicLinkInput = { email };
if (safeContinueUrl) {
input.continue = safeContinueUrl;
}
sendMagicLink({ sendMagicLink({
variables: { variables: {
input: { input: {
email, email,
continue: safeContinueUrl,
}, },
}, },
onCompleted: (data, errors: GraphQLError[] | null) => { onCompleted: (_, errors: GraphQLError[] | null) => {
console.log(data, errors);
if (errors) { if (errors) {
for (const err of errors) { for (const err of errors) {
if (err.extensions?.code === "ALREADY_AUTHENTICATED") { if (err.extensions?.code === "ALREADY_AUTHENTICATED") {

View File

@@ -14,7 +14,7 @@ import type { VerifyMagicLinkPageMutation } from "./__generated__/VerifyMagicLin
const verifyMagicLinkMutation = graphql` const verifyMagicLinkMutation = graphql`
mutation VerifyMagicLinkPageMutation($input: VerifyMagicLinkInput!) { mutation VerifyMagicLinkPageMutation($input: VerifyMagicLinkInput!) {
verifyMagicLink(input: $input) { verifyMagicLink(input: $input) {
success continue
} }
} }
`; `;
@@ -38,7 +38,7 @@ export default function VerifyMagicLinkPagePageMutation() {
variables: { variables: {
input: { token }, input: { token },
}, },
onCompleted: (_, errors: GraphQLError[] | null) => { onCompleted: (response, errors: GraphQLError[] | null) => {
if (errors) { if (errors) {
for (const err of errors) { for (const err of errors) {
if (err.extensions?.code === "ALREADY_AUTHENTICATED") { if (err.extensions?.code === "ALREADY_AUTHENTICATED") {
@@ -55,13 +55,21 @@ export default function VerifyMagicLinkPagePageMutation() {
return; return;
} }
const { verifyMagicLink } = response;
toast({ toast({
title: __("Success"), title: __("Success"),
description: __("Your have successfully signed in"), description: __("Your have successfully signed in"),
variant: "success", variant: "success",
}); });
const pathPrefix = getPathPrefix();
window.location.href = pathPrefix ? getPathPrefix() : "/"; if (verifyMagicLink?.continue) {
const continueUrl = new URL(verifyMagicLink.continue);
window.location.href = window.location.origin + continueUrl.pathname + continueUrl.search;
} else {
const pathPrefix = getPathPrefix();
window.location.href = pathPrefix ? getPathPrefix() : "/";
}
}, },
onError: (err) => { onError: (err) => {
toast({ toast({

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<bcbab3713eaca3141e6dfb8f54a54e7d>> * @generated SignedSource<<711ecaa392c23004a3bd1dfb24a5751f>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -10,6 +10,7 @@
import { ConcreteRequest } from 'relay-runtime'; import { ConcreteRequest } from 'relay-runtime';
export type SendMagicLinkInput = { export type SendMagicLinkInput = {
continue?: string | null | undefined;
email: any; email: any;
}; };
export type ConnectPageMutation$variables = { export type ConnectPageMutation$variables = {

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<5037d9722f3c6cc15c2a323ef954e3d6>> * @generated SignedSource<<5570f368d6cc3c3be80a32390e12c8f9>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -17,7 +17,7 @@ export type VerifyMagicLinkPageMutation$variables = {
}; };
export type VerifyMagicLinkPageMutation$data = { export type VerifyMagicLinkPageMutation$data = {
readonly verifyMagicLink: { readonly verifyMagicLink: {
readonly success: boolean; readonly continue: string | null | undefined;
} | null | undefined; } | null | undefined;
}; };
export type VerifyMagicLinkPageMutation = { export type VerifyMagicLinkPageMutation = {
@@ -52,7 +52,7 @@ v1 = [
"alias": null, "alias": null,
"args": null, "args": null,
"kind": "ScalarField", "kind": "ScalarField",
"name": "success", "name": "continue",
"storageKey": null "storageKey": null
} }
], ],
@@ -77,16 +77,16 @@ return {
"selections": (v1/*: any*/) "selections": (v1/*: any*/)
}, },
"params": { "params": {
"cacheID": "07cf89de3f37725d847cda46557467f5", "cacheID": "05d0e504b6f11ad7dd11ed84059a96ac",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "VerifyMagicLinkPageMutation", "name": "VerifyMagicLinkPageMutation",
"operationKind": "mutation", "operationKind": "mutation",
"text": "mutation VerifyMagicLinkPageMutation(\n $input: VerifyMagicLinkInput!\n) {\n verifyMagicLink(input: $input) {\n success\n }\n}\n" "text": "mutation VerifyMagicLinkPageMutation(\n $input: VerifyMagicLinkInput!\n) {\n verifyMagicLink(input: $input) {\n continue\n }\n}\n"
} }
}; };
})(); })();
(node as any).hash = "074415601c4d50f50d177c06dfab64ef"; (node as any).hash = "cc9e7f7886d9d61b95f13c5ba66c41ec";
export default node; export default node;

View File

@@ -64,6 +64,7 @@ type (
Email mail.Addr Email mail.Addr
URLPath string URLPath string
OrganizationID gid.GID OrganizationID gid.GID
Continue *string
// If users tries to connect to compliance page, we must brand the emails accordingly // If users tries to connect to compliance page, we must brand the emails accordingly
CompliancePageID *gid.GID CompliancePageID *gid.GID
} }
@@ -73,7 +74,8 @@ type (
} }
MagicLinkData struct { MagicLinkData struct {
Email mail.Addr `json:"email"` Email mail.Addr `json:"email"`
Continue *string `json:"continue"`
} }
) )
@@ -593,7 +595,8 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
TokenTypeMagicLink, TokenTypeMagicLink,
s.magicLinkTokenValidity, s.magicLinkTokenValidity,
MagicLinkData{ MagicLinkData{
Email: req.Email, Email: req.Email,
Continue: req.Continue,
}, },
) )
if err != nil { if err != nil {
@@ -670,16 +673,16 @@ func (s AuthService) SendMagicLink(ctx context.Context, req *SendMagicLinkReques
) )
} }
func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString string) (*coredata.Identity, *coredata.Session, error) { func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString string) (*coredata.Identity, *coredata.Session, *string, error) {
var ( var (
now = time.Now() now = time.Now()
identity = &coredata.Identity{}
session = &coredata.Session{} session = &coredata.Session{}
identity = &coredata.Identity{}
) )
payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, tokenString) payload, err := statelesstoken.ValidateToken[MagicLinkData](s.tokenSecret, TokenTypeMagicLink, tokenString)
if err != nil { if err != nil {
return nil, nil, NewInvalidTokenError() return nil, nil, nil, NewInvalidTokenError()
} }
if err := s.pg.WithTx( if err := s.pg.WithTx(
@@ -737,10 +740,10 @@ func (s AuthService) OpenSessionWithMagicLink(ctx context.Context, tokenString s
return nil return nil
}, },
); err != nil { ); err != nil {
return nil, nil, err return nil, nil, nil, err
} }
return identity, session, nil return identity, session, payload.Data.Continue, nil
} }
func (s *AuthService) UpdateIdentity(ctx context.Context, identityID gid.GID, fullName string) (*coredata.Identity, error) { func (s *AuthService) UpdateIdentity(ctx context.Context, identityID gid.GID, fullName string) (*coredata.Identity, error) {

View File

@@ -21,6 +21,7 @@ import (
"go.probo.inc/probo/pkg/baseurl" "go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/trust/v1/schema" "go.probo.inc/probo/pkg/server/api/trust/v1/schema"
@@ -38,6 +39,7 @@ func NewGraphQLHandler(iamSvc *iam.Service, trustSvc *trust.Service, esignSvc *e
logger: logger, logger: logger,
baseURL: baseURL, baseURL: baseURL,
sessionCookie: authn.NewCookie(&cookieConfig), sessionCookie: authn.NewCookie(&cookieConfig),
safeRedirect: &saferedirect.SafeRedirect{AllowedHost: baseURL.Host()},
}, },
Directives: schema.DirectiveRoot{ Directives: schema.DirectiveRoot{
Session: session.Directive, Session: session.Directive,

View File

@@ -26,6 +26,7 @@ import (
"go.probo.inc/probo/pkg/esign" "go.probo.inc/probo/pkg/esign"
"go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/saferedirect"
"go.probo.inc/probo/pkg/securecookie" "go.probo.inc/probo/pkg/securecookie"
"go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authn"
"go.probo.inc/probo/pkg/server/api/compliancepage" "go.probo.inc/probo/pkg/server/api/compliancepage"
@@ -52,6 +53,7 @@ type (
iam *iam.Service iam *iam.Service
sessionCookie *authn.Cookie sessionCookie *authn.Cookie
baseURL *baseurl.BaseURL baseURL *baseurl.BaseURL
safeRedirect *saferedirect.SafeRedirect
} }
) )

View File

@@ -553,6 +553,7 @@ type TrustCenterAccess implements Node {
input SendMagicLinkInput { input SendMagicLinkInput {
email: EmailAddr! email: EmailAddr!
continue: String
} }
type SendMagicLinkPayload { type SendMagicLinkPayload {
@@ -564,7 +565,7 @@ input VerifyMagicLinkInput {
} }
type VerifyMagicLinkPayload { type VerifyMagicLinkPayload {
success: Boolean! continue: String
} }
type RequestAccessesPayload { type RequestAccessesPayload {

View File

@@ -277,7 +277,7 @@ type ComplexityRoot struct {
} }
VerifyMagicLinkPayload struct { VerifyMagicLinkPayload struct {
Success func(childComplexity int) int Continue func(childComplexity int) int
} }
} }
@@ -1205,12 +1205,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin
return e.ComplexityRoot.VendorEdge.Node(childComplexity), true return e.ComplexityRoot.VendorEdge.Node(childComplexity), true
case "VerifyMagicLinkPayload.success": case "VerifyMagicLinkPayload.continue":
if e.ComplexityRoot.VerifyMagicLinkPayload.Success == nil { if e.ComplexityRoot.VerifyMagicLinkPayload.Continue == nil {
break break
} }
return e.ComplexityRoot.VerifyMagicLinkPayload.Success(childComplexity), true return e.ComplexityRoot.VerifyMagicLinkPayload.Continue(childComplexity), true
} }
return 0, false return 0, false
@@ -1860,6 +1860,7 @@ type TrustCenterAccess implements Node {
input SendMagicLinkInput { input SendMagicLinkInput {
email: EmailAddr! email: EmailAddr!
continue: String
} }
type SendMagicLinkPayload { type SendMagicLinkPayload {
@@ -1871,7 +1872,7 @@ input VerifyMagicLinkInput {
} }
type VerifyMagicLinkPayload { type VerifyMagicLinkPayload {
success: Boolean! continue: String
} }
type RequestAccessesPayload { type RequestAccessesPayload {
@@ -3796,8 +3797,8 @@ func (ec *executionContext) fieldContext_Mutation_verifyMagicLink(ctx context.Co
IsResolver: true, IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name { switch field.Name {
case "success": case "continue":
return ec.fieldContext_VerifyMagicLinkPayload_success(ctx, field) return ec.fieldContext_VerifyMagicLinkPayload_continue(ctx, field)
} }
return nil, fmt.Errorf("no field named %q was found under type VerifyMagicLinkPayload", field.Name) return nil, fmt.Errorf("no field named %q was found under type VerifyMagicLinkPayload", field.Name)
}, },
@@ -6855,30 +6856,30 @@ func (ec *executionContext) fieldContext_VendorEdge_node(_ context.Context, fiel
return fc, nil return fc, nil
} }
func (ec *executionContext) _VerifyMagicLinkPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.VerifyMagicLinkPayload) (ret graphql.Marshaler) { func (ec *executionContext) _VerifyMagicLinkPayload_continue(ctx context.Context, field graphql.CollectedField, obj *types.VerifyMagicLinkPayload) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
ec.OperationContext, ec.OperationContext,
field, field,
ec.fieldContext_VerifyMagicLinkPayload_success, ec.fieldContext_VerifyMagicLinkPayload_continue,
func(ctx context.Context) (any, error) { func(ctx context.Context) (any, error) {
return obj.Success, nil return obj.Continue, nil
}, },
nil, nil,
ec.marshalNBoolean2bool, ec.marshalOString2ᚖstring,
true,
true, true,
false,
) )
} }
func (ec *executionContext) fieldContext_VerifyMagicLinkPayload_success(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { func (ec *executionContext) fieldContext_VerifyMagicLinkPayload_continue(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{ fc = &graphql.FieldContext{
Object: "VerifyMagicLinkPayload", Object: "VerifyMagicLinkPayload",
Field: field, Field: field,
IsMethod: false, IsMethod: false,
IsResolver: false, IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Boolean does not have child fields") return nil, errors.New("field of type String does not have child fields")
}, },
} }
return fc, nil return fc, nil
@@ -8559,7 +8560,7 @@ func (ec *executionContext) unmarshalInputSendMagicLinkInput(ctx context.Context
asMap[k] = v asMap[k] = v
} }
fieldsInOrder := [...]string{"email"} fieldsInOrder := [...]string{"email", "continue"}
for _, k := range fieldsInOrder { for _, k := range fieldsInOrder {
v, ok := asMap[k] v, ok := asMap[k]
if !ok { if !ok {
@@ -8573,6 +8574,13 @@ func (ec *executionContext) unmarshalInputSendMagicLinkInput(ctx context.Context
return it, err return it, err
} }
it.Email = data it.Email = data
case "continue":
ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("continue"))
data, err := ec.unmarshalOString2ᚖstring(ctx, v)
if err != nil {
return it, err
}
it.Continue = data
} }
} }
return it, nil return it, nil
@@ -11243,11 +11251,8 @@ func (ec *executionContext) _VerifyMagicLinkPayload(ctx context.Context, sel ast
switch field.Name { switch field.Name {
case "__typename": case "__typename":
out.Values[i] = graphql.MarshalString("VerifyMagicLinkPayload") out.Values[i] = graphql.MarshalString("VerifyMagicLinkPayload")
case "success": case "continue":
out.Values[i] = ec._VerifyMagicLinkPayload_success(ctx, field, obj) out.Values[i] = ec._VerifyMagicLinkPayload_continue(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default: default:
panic("unknown field " + strconv.Quote(field.Name)) panic("unknown field " + strconv.Quote(field.Name))
} }

View File

@@ -194,7 +194,8 @@ type RequestTrustCenterFileAccessInput struct {
} }
type SendMagicLinkInput struct { type SendMagicLinkInput struct {
Email mail.Addr `json:"email"` Email mail.Addr `json:"email"`
Continue *string `json:"continue,omitempty"`
} }
type SendMagicLinkPayload struct { type SendMagicLinkPayload struct {
@@ -296,5 +297,5 @@ type VerifyMagicLinkInput struct {
} }
type VerifyMagicLinkPayload struct { type VerifyMagicLinkPayload struct {
Success bool `json:"success"` Continue *string `json:"continue,omitempty"`
} }

View File

@@ -157,11 +157,22 @@ func (r *frameworkResolver) DarkLogoURL(ctx context.Context, obj *types.Framewor
func (r *mutationResolver) SendMagicLink(ctx context.Context, input types.SendMagicLinkInput) (*types.SendMagicLinkPayload, error) { func (r *mutationResolver) SendMagicLink(ctx context.Context, input types.SendMagicLinkInput) (*types.SendMagicLinkPayload, error) {
trustCenter := compliancepage.CompliancePageFromContext(ctx) trustCenter := compliancepage.CompliancePageFromContext(ctx)
var continueURLString *string
if input.Continue != nil {
safeURL, ok := r.safeRedirect.Validate(*input.Continue)
if !ok {
return nil, gqlutils.Invalidf(ctx, "invalid continue URL")
}
continueURLString = &safeURL
}
req := &iam.SendMagicLinkRequest{ req := &iam.SendMagicLinkRequest{
Email: input.Email, Email: input.Email,
CompliancePageID: &trustCenter.ID, CompliancePageID: &trustCenter.ID,
OrganizationID: trustCenter.OrganizationID, OrganizationID: trustCenter.OrganizationID,
URLPath: "verify-magic-link", URLPath: "verify-magic-link",
Continue: continueURLString,
} }
if err := r.iam.AuthService.SendMagicLink(ctx, req); err != nil { if err := r.iam.AuthService.SendMagicLink(ctx, req); err != nil {
@@ -174,7 +185,7 @@ func (r *mutationResolver) SendMagicLink(ctx context.Context, input types.SendMa
// VerifyMagicLink is the resolver for the verifyMagicLink field. // VerifyMagicLink is the resolver for the verifyMagicLink field.
func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.VerifyMagicLinkInput) (*types.VerifyMagicLinkPayload, error) { func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.VerifyMagicLinkInput) (*types.VerifyMagicLinkPayload, error) {
identity, session, err := r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token) identity, session, continueURL, err := r.iam.AuthService.OpenSessionWithMagicLink(ctx, input.Token)
if err != nil { if err != nil {
var errInvalidToken *iam.ErrInvalidToken var errInvalidToken *iam.ErrInvalidToken
if errors.As(err, &errInvalidToken) { if errors.As(err, &errInvalidToken) {
@@ -196,7 +207,7 @@ func (r *mutationResolver) VerifyMagicLink(ctx context.Context, input types.Veri
r.sessionCookie.Set(w, session) r.sessionCookie.Set(w, session)
return &types.VerifyMagicLinkPayload{ return &types.VerifyMagicLinkPayload{
Success: true, Continue: continueURL,
}, nil }, nil
} }