Add an account activation step in the signing request flow when needed

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-11 11:23:18 +04:00
parent 0a532f17c5
commit 355e6b81b8
10 changed files with 151 additions and 69 deletions

View File

@@ -0,0 +1,22 @@
import { useSearchParams } from "react-router";
export function useSafeContinueUrl(fallbackUrl?: URL): URL {
fallbackUrl = new URL(fallbackUrl ?? window.location.origin, window.location.origin);
const [searchParams] = useSearchParams();
const continueUrlParam = searchParams.get("continue");
let safeContinueUrl: URL;
if (continueUrlParam) {
let continueUrl: URL;
try {
continueUrl = new URL(continueUrlParam, window.location.origin);
} catch {
continueUrl = fallbackUrl;
}
safeContinueUrl = new URL(continueUrl.pathname + continueUrl.search, window.location.origin);
} else {
safeContinueUrl = fallbackUrl;
}
return safeContinueUrl;
}

View File

@@ -8,6 +8,7 @@ import { Link, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import type { ActivateAccountPageMutation$data, ActivateAccountPageMutation } from "#/__generated__/iam/ActivateAccountPageMutation.graphql";
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
const activateAccountMutation = graphql`
mutation ActivateAccountPageMutation(
@@ -22,9 +23,10 @@ const activateAccountMutation = graphql`
export default function ActivateAccountPage() {
const { __ } = useTranslate();
const { toast } = useToast();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const submittedRef = useRef<boolean>(false);
const safeContinueUrl = useSafeContinueUrl();
usePageTitle(__("Activate Account"));
@@ -44,6 +46,7 @@ export default function ActivateAccountPage() {
window.location.href = "/";
return;
}
// FIXME: If already activated redirect too
}
toast({
title: __("Activation failed"),
@@ -69,12 +72,22 @@ export default function ActivateAccountPage() {
}
if (activateAccount.createPasswordToken) {
const search = new URLSearchParams([
["token", activateAccount.createPasswordToken],
["continue", safeContinueUrl.toString()],
]);
void navigate(
{ pathname: "/auth/create-password", search: `?token=${activateAccount.createPasswordToken}` },
{
pathname: "/auth/create-password",
search: "?" + search.toString(),
},
{ replace: true },
);
} else {
void navigate("/", { replace: true });
void navigate({
pathname: safeContinueUrl.pathname,
search: safeContinueUrl.search,
}, { replace: true });
}
},
onError: (e) => {
@@ -85,7 +98,7 @@ export default function ActivateAccountPage() {
});
},
});
}, [__, toast, activateAccount, navigate]);
}, [__, toast, activateAccount, navigate, safeContinueUrl]);
useEffect(() => {
const token = searchParams.get("token");

View File

@@ -3,7 +3,7 @@ import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import { Button, Field, useToast } from "@probo/ui";
import { useMutation } from "react-relay";
import { Link, useNavigate, useSearchParams } from "react-router";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
import { graphql } from "relay-runtime";
import { z } from "zod";
@@ -27,6 +27,7 @@ export default function CreatePasswordPage() {
const { toast } = useToast();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const location = useLocation();
usePageTitle(__("Create Password"));
@@ -61,7 +62,12 @@ export default function CreatePasswordPage() {
description: __("Account created successfully"),
variant: "success",
});
void navigate("/auth/login", { replace: true });
void navigate({
pathname: "/auth/login",
search: location.search,
}, {
replace: true,
});
},
onError: (e) => {
toast({

View File

@@ -3,10 +3,11 @@ import { useTranslate } from "@probo/i18n";
import { Button, Field, IconChevronLeft, useToast } from "@probo/ui";
import type { FormEventHandler } from "react";
import { useMutation } from "react-relay";
import { Link, matchPath, useLocation, useSearchParams } from "react-router";
import { Link, matchPath, useLocation } from "react-router";
import { graphql } from "relay-runtime";
import type { PasswordSignInPageMutation } from "#/__generated__/iam/PasswordSignInPageMutation.graphql";
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
const signInMutation = graphql`
mutation PasswordSignInPageMutation($input: SignInInput!) {
@@ -20,7 +21,7 @@ const signInMutation = graphql`
export default function PasswordSignInPage() {
const location = useLocation();
const [searchParams] = useSearchParams();
const safeContinueUrl = useSafeContinueUrl();
const { __ } = useTranslate();
const { toast } = useToast();
@@ -36,20 +37,6 @@ export default function PasswordSignInPage() {
if (!emailValue || !passwordValue) return;
const continueUrlParam = searchParams.get("continue");
let safeContinueUrl: URL;
if (continueUrlParam) {
let continueUrl: URL;
try {
continueUrl = new URL(continueUrlParam, window.location.origin);
} catch {
continueUrl = new URL(window.location.origin);
}
safeContinueUrl = new URL(continueUrl.pathname + continueUrl.search, window.location.origin);
} else {
safeContinueUrl = new URL(window.location.origin);
}
const match = matchPath(
{ path: "/organizations/:organizationId", caseSensitive: false, end: false },
safeContinueUrl.pathname,

View File

@@ -8,6 +8,7 @@ import { graphql } from "relay-runtime";
import type { AssumePageMutation } from "#/__generated__/iam/AssumePageMutation.graphql";
import type { AssumePageQuery } from "#/__generated__/iam/AssumePageQuery.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { useSafeContinueUrl } from "#/hooks/useSafeContinueUrl";
import AuthLayout from "../auth/AuthLayout";
@@ -48,28 +49,23 @@ export function AssumePage(props: { queryRef: PreloadedQuery<AssumePageQuery> })
const [searchParams] = useSearchParams();
const { __ } = useTranslate();
const safeContinueUrl = useSafeContinueUrl(
new URL(window.location.origin + `/organizations/${organizationId}`),
);
const { viewer } = usePreloadedQuery<AssumePageQuery>(assumePageQuery, queryRef);
const [assumeOrganizationSession] = useMutation<AssumePageMutation>(assumeMutation);
const continueUrlParam = searchParams.get("continue");
let safeContinueUrl: string;
if (continueUrlParam) {
const continueUrl = new URL(continueUrlParam);
safeContinueUrl = window.location.origin + continueUrl.pathname + continueUrl.search;
} else {
safeContinueUrl = window.location.origin + `/organizations/${organizationId}`;
}
useEffect(() => {
assumeOrganizationSession({
variables: {
input: { organizationId, continue: safeContinueUrl },
input: { organizationId, continue: safeContinueUrl.toString() },
},
onError: (error) => {
if (error instanceof UnAuthenticatedError) {
const search = new URLSearchParams([
["organization-id", organizationId],
["continue", safeContinueUrl],
["continue", safeContinueUrl.toString()],
]);
void navigate({ pathname: "/auth/login", search: "?" + search.toString() });
@@ -88,7 +84,7 @@ export function AssumePage(props: { queryRef: PreloadedQuery<AssumePageQuery> })
switch (result.__typename) {
case "PasswordRequired":
search.set("organization-id", organizationId);
search.set("continue", safeContinueUrl);
search.set("continue", safeContinueUrl.toString());
void navigate({ pathname: "/auth/password-login", search: "?" + search.toString() });
break;
@@ -102,7 +98,7 @@ export function AssumePage(props: { queryRef: PreloadedQuery<AssumePageQuery> })
window.location.href = samlSSOLoginURL.toString();
break;
default:
window.location.href = safeContinueUrl;
window.location.href = safeContinueUrl.toString();
}
},
});

View File

@@ -23,6 +23,7 @@ import (
htmltemplate "html/template"
"io/fs"
"mime"
"net/url"
"path/filepath"
texttemplate "text/template"
"time"
@@ -338,14 +339,20 @@ func (p *Presenter) RenderInvitation(ctx context.Context, invitationURLPath stri
return fmt.Sprintf(subjectInvitation, organizationName), textBody, htmlBody, err
}
func (p *Presenter) RenderDocumentSigning(ctx context.Context, signinURLPath string, organizationName string) (subject string, textBody string, htmlBody *string, err error) {
func (p *Presenter) RenderDocumentSigning(
ctx context.Context,
signingURLPath string,
signingURLQuery url.Values,
organizationName string,
) (subject string, textBody string, htmlBody *string, err error) {
vars, err := p.getCommonVariables(ctx)
if err != nil {
return "", "", nil, fmt.Errorf("cannot get common variables: %w", err)
}
signingURL := baseurl.MustParse(vars.BaseURL).
AppendPath(signinURLPath).
AppendPath(signingURLPath).
WithQueryValues(signingURLQuery).
MustString()
data := struct {

View File

@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"regexp"
"strings"
@@ -16,20 +17,25 @@ import (
"go.gearno.de/crypto/uuid"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/packages/emails"
"go.probo.inc/probo/pkg/baseurl"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/docgen"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/html2pdf"
"go.probo.inc/probo/pkg/iam"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
"go.probo.inc/probo/pkg/statelesstoken"
"go.probo.inc/probo/pkg/validator"
"go.probo.inc/probo/pkg/watermarkpdf"
)
type (
DocumentService struct {
svc *TenantService
html2pdfConverter *html2pdf.Converter
svc *TenantService
html2pdfConverter *html2pdf.Converter
invitationTokenValidity time.Duration
tokenSecret string
}
ErrSignatureNotCancellable struct {
@@ -588,6 +594,8 @@ func (s *DocumentService) SendSigningNotifications(
ctx context.Context,
organizationID gid.GID,
) error {
now := time.Now()
err := s.svc.pg.WithTx(
ctx,
func(tx pg.Conn) error {
@@ -604,9 +612,46 @@ func (s *DocumentService) SendSigningNotifications(
for _, signatory := range signatories {
emailPresenter := emails.NewPresenter(s.svc.fileManager, s.svc.bucket, s.svc.baseURL, signatory.FullName)
var (
employeeDocumentsURLPath = "/organizations/" + organizationID.String() + "/employee"
emailLinkURLPath = employeeDocumentsURLPath
query url.Values
)
if signatory.State != coredata.ProfileStateActive {
if signatory.Source != coredata.ProfileSourceSCIM {
invitation := &coredata.Invitation{
ID: gid.New(organizationID.TenantID(), coredata.InvitationEntityType),
OrganizationID: organizationID,
UserID: signatory.ID,
Status: coredata.InvitationStatusPending,
ExpiresAt: now.Add(s.invitationTokenValidity),
CreatedAt: now,
}
if err := invitation.Insert(ctx, tx, coredata.NewScopeFromObjectID(organizationID)); err != nil {
return fmt.Errorf("cannot insert invitation: %w", err)
}
invitationToken, err := statelesstoken.NewToken(
s.tokenSecret,
iam.TokenTypeOrganizationInvitation,
s.invitationTokenValidity,
iam.InvitationTokenData{InvitationID: invitation.ID},
)
if err != nil {
return fmt.Errorf("cannot generate invitation token: %w", err)
}
emailLinkURLPath = "/auth/activate-account"
continueURL := baseurl.MustParse(s.svc.baseURL).AppendPath(employeeDocumentsURLPath).MustString()
query.Add("token", invitationToken)
query.Add("continue", continueURL)
}
}
subject, textBody, htmlBody, err := emailPresenter.RenderDocumentSigning(
ctx,
"/organizations/"+organizationID.String()+"/employee",
emailLinkURLPath,
query,
organization.Name,
)
if err != nil {

View File

@@ -49,19 +49,20 @@ type ExportService interface {
type (
Service struct {
pg *pg.Client
s3 *s3.Client
bucket string
encryptionKey cipher.EncryptionKey
baseURL string
tokenSecret string
agentConfig agents.Config
html2pdfConverter *html2pdf.Converter
acmeService *certmanager.ACMEService
fileManager *filemanager.Service
logger *log.Logger
slack *slack.Service
esign *esign.Service
pg *pg.Client
s3 *s3.Client
bucket string
encryptionKey cipher.EncryptionKey
baseURL string
tokenSecret string
agentConfig agents.Config
html2pdfConverter *html2pdf.Converter
acmeService *certmanager.ACMEService
fileManager *filemanager.Service
logger *log.Logger
slack *slack.Service
esign *esign.Service
invitationTokenValidity time.Duration
}
TenantService struct {
@@ -133,6 +134,7 @@ func NewService(
slackService *slack.Service,
iamService *iam.Service,
esignService *esign.Service,
invitationTokenValidity time.Duration,
) (*Service, error) {
if bucket == "" {
return nil, fmt.Errorf("bucket is required")
@@ -141,19 +143,20 @@ func NewService(
iamService.Authorizer.RegisterPolicySet(ProboPolicySet())
svc := &Service{
pg: pgClient,
s3: s3Client,
bucket: bucket,
encryptionKey: encryptionKey,
baseURL: baseURL,
tokenSecret: tokenSecret,
agentConfig: agentConfig,
html2pdfConverter: html2pdfConverter,
acmeService: acmeService,
fileManager: fileManagerService,
logger: logger,
slack: slackService,
esign: esignService,
pg: pgClient,
s3: s3Client,
bucket: bucket,
encryptionKey: encryptionKey,
baseURL: baseURL,
tokenSecret: tokenSecret,
agentConfig: agentConfig,
html2pdfConverter: html2pdfConverter,
acmeService: acmeService,
fileManager: fileManagerService,
logger: logger,
slack: slackService,
esign: esignService,
invitationTokenValidity: invitationTokenValidity,
}
return svc, nil
@@ -195,8 +198,10 @@ func (s *Service) WithTenant(tenantID gid.TenantID) *TenantService {
}
tenantService.Vendors = &VendorService{svc: tenantService}
tenantService.Documents = &DocumentService{
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
svc: tenantService,
html2pdfConverter: s.html2pdfConverter,
invitationTokenValidity: s.invitationTokenValidity,
tokenSecret: s.tokenSecret,
}
tenantService.Organizations = &OrganizationService{
svc: tenantService,

View File

@@ -451,6 +451,7 @@ func (impl *Implm) Run(
slackService,
iamService,
esignService,
time.Duration(impl.cfg.Auth.InvitationConfirmationTokenValidity)*time.Second,
)
if err != nil {
return fmt.Errorf("cannot create probo service: %w", err)

View File

@@ -351,7 +351,7 @@ func (r *mutationResolver) SignOut(ctx context.Context) (*types.SignOutPayload,
return &types.SignOutPayload{Success: true}, nil
}
// ActivateAccount is the resolver for the signUpFromInvitation field.
// ActivateAccount is the resolver for the activateAccount field.
func (r *mutationResolver) ActivateAccount(ctx context.Context, input types.ActivateAccountInput) (*types.ActivateAccountPayload, error) {
session := authn.SessionFromContext(ctx)