diff --git a/apps/console/src/hooks/useSafeContinueUrl.ts b/apps/console/src/hooks/useSafeContinueUrl.ts new file mode 100644 index 000000000..808a94cbe --- /dev/null +++ b/apps/console/src/hooks/useSafeContinueUrl.ts @@ -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; +} diff --git a/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx b/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx index 8f29cf178..a2217db75 100644 --- a/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx +++ b/apps/console/src/pages/iam/auth/ActivateAccountPage.tsx @@ -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(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"); diff --git a/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx b/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx index 67e9aa649..c03918b5e 100644 --- a/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx +++ b/apps/console/src/pages/iam/auth/CreatePasswordPage.tsx @@ -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({ diff --git a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx index 651ff8333..69ecc5c09 100644 --- a/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx +++ b/apps/console/src/pages/iam/auth/sign-in/PasswordSignInPage.tsx @@ -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, diff --git a/apps/console/src/pages/iam/organizations/AssumePage.tsx b/apps/console/src/pages/iam/organizations/AssumePage.tsx index c2c99d08d..057facb47 100644 --- a/apps/console/src/pages/iam/organizations/AssumePage.tsx +++ b/apps/console/src/pages/iam/organizations/AssumePage.tsx @@ -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 }) const [searchParams] = useSearchParams(); const { __ } = useTranslate(); + const safeContinueUrl = useSafeContinueUrl( + new URL(window.location.origin + `/organizations/${organizationId}`), + ); + const { viewer } = usePreloadedQuery(assumePageQuery, queryRef); const [assumeOrganizationSession] = useMutation(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 }) 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 }) window.location.href = samlSSOLoginURL.toString(); break; default: - window.location.href = safeContinueUrl; + window.location.href = safeContinueUrl.toString(); } }, }); diff --git a/packages/emails/emails.go b/packages/emails/emails.go index 5ec381d67..a6ca7e3e7 100644 --- a/packages/emails/emails.go +++ b/packages/emails/emails.go @@ -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 { diff --git a/pkg/probo/document_service.go b/pkg/probo/document_service.go index 722063a2f..6405f65ea 100644 --- a/pkg/probo/document_service.go +++ b/pkg/probo/document_service.go @@ -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 { diff --git a/pkg/probo/service.go b/pkg/probo/service.go index 4e0303e91..2793fa726 100644 --- a/pkg/probo/service.go +++ b/pkg/probo/service.go @@ -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, diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 79ed9cc57..b09414f6d 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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) diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index d9a8ec2a5..c91d1323f 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -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)