Add reject button + email on trust center access request

Signed-off-by: Émile Ré <nemile.re@gmail.com>
This commit is contained in:
Émile Ré
2025-12-05 20:40:42 +04:00
parent 306675cd07
commit c8471cd48d
14 changed files with 3963 additions and 58 deletions

3477
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -40,20 +40,22 @@ const (
)
var (
confirmEmailHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/confirm-email.html.tmpl"))
confirmEmailTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/confirm-email.txt.tmpl"))
passwordResetHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/password-reset.html.tmpl"))
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.tmpl"))
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.tmpl"))
documentExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-export.txt.tmpl"))
frameworkExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/framework-export.html.tmpl"))
frameworkExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/framework-export.txt.tmpl"))
trustCenterAccessHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/trust-center-access.html.tmpl"))
trustCenterAccessTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-access.txt.tmpl"))
confirmEmailHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/confirm-email.html.tmpl"))
confirmEmailTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/confirm-email.txt.tmpl"))
passwordResetHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/password-reset.html.tmpl"))
passwordResetTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/password-reset.txt.tmpl"))
invitationHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/invitation.html.tmpl"))
invitationTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/invitation.txt.tmpl"))
documentSigningHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-signing.html.tmpl"))
documentSigningTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-signing.txt.tmpl"))
documentExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/document-export.html.tmpl"))
documentExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/document-export.txt.tmpl"))
frameworkExportHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/framework-export.html.tmpl"))
frameworkExportTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/framework-export.txt.tmpl"))
trustCenterAccessHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/trust-center-access.html.tmpl"))
trustCenterAccessTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-access.txt.tmpl"))
trustCenterDocumentAccessRejectedHTMLTemplate = htmltemplate.Must(htmltemplate.ParseFS(Templates, "dist/trust-center-document-access-rejected.html.tmpl"))
trustCenterDocumentAccessRejectedTextTemplate = texttemplate.Must(texttemplate.ParseFS(Templates, "dist/trust-center-document-access-rejected.txt.tmpl"))
)
func RenderConfirmEmail(baseURL, fullName, confirmationUrl string) (subject string, textBody string, htmlBody *string, err error) {
@@ -171,6 +173,28 @@ func RenderTrustCenterAccess(baseURL, fullName, organizationName, accessUrl stri
return fmt.Sprintf(subjectTrustCenterAccess, organizationName), textBody, htmlBody, err
}
func RenderTrustCenterDocumentAccessRejected(
baseURL string,
fullName string,
organizationName string,
fileNames []string,
) (subject string, textBody string, htmlBody *string, err error) {
data := struct {
FullName string
OrganizationName string
LogoURL string
FileNames []string
}{
FullName: fullName,
OrganizationName: organizationName,
LogoURL: baseURL + logoURLPath,
FileNames: fileNames,
}
textBody, htmlBody, err = renderEmail(trustCenterDocumentAccessRejectedTextTemplate, trustCenterDocumentAccessRejectedHTMLTemplate, data)
return fmt.Sprintf(subjectTrustCenterAccess, organizationName), textBody, htmlBody, err
}
func renderEmail(textTemplate *texttemplate.Template, htmlTemplate *htmltemplate.Template, data any) (textBody string, htmlBody *string, err error) {
var textBuf bytes.Buffer
if err := textTemplate.Execute(&textBuf, data); err != nil {

View File

@@ -14,6 +14,7 @@
"react-email": "^4.3.0"
},
"devDependencies": {
"@react-email/preview-server": "^4.3.2",
"@types/node": "^22.10.7",
"@types/react": "^19.0.6",
"tsx": "^4.19.2",

View File

@@ -1,7 +1,7 @@
import { render } from '@react-email/components';
import { copyFile, mkdir, writeFile } from 'fs/promises';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { copyFile, mkdir, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as React from 'react';
import ConfirmEmail from '../src/ConfirmEmail';
@@ -11,6 +11,7 @@ import FrameworkExport from '../src/FrameworkExport';
import Invitation from '../src/Invitation';
import PasswordReset from '../src/PasswordReset';
import TrustCenterAccess from '../src/TrustCenterAccess';
import TrustCenterDocumentAccessRejected from '../src/TrustCenterDocumentAccessRejected';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -49,6 +50,10 @@ const templates: TemplateConfig[] = [
name: 'trust-center-access',
render: () => TrustCenterAccess()
},
{
name: 'trust-center-document-access-rejected',
render: () => TrustCenterDocumentAccessRejected()
},
];
async function build() {

View File

@@ -0,0 +1,21 @@
import { Column, Row, Text } from '@react-email/components';
import * as React from 'react';
import EmailLayout, { bodyText, footerText } from './components/EmailLayout';
export const TrustCenterDocumentAccessRejected = () => {
return (
<EmailLayout subject={`Trust Center Access Invitation - ${'{{.OrganizationName}}'}`} organizationName={'{{.OrganizationName}}'}>
<Text style={bodyText}>
Your access request to the following files in <strong>{'{{.OrganizationName}}'}</strong>'s Trust Center has been rejected:
</Text>
<Text style={bodyText}>
{'{{range .FileNames}}'}
• {'{{.}}'}<br/>
{'{{end}}'}
</Text>
</EmailLayout>
);
};
export default TrustCenterDocumentAccessRejected;

View File

@@ -0,0 +1,11 @@
Probo
Hi {{.FullName}},
Your access request to the following files in {{.OrganizationName}}'s Trust Center has been rejected:
{{range .FileNames}}
- {{.}}
{{end}}
Probo Inc, 490 Post St, STE 640, San Francisco, CA, 94102, US

View File

@@ -7,6 +7,6 @@
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["node"]
},
"include": ["src/**/*"],
"include": ["src/**/*", "scripts/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -132,23 +132,23 @@ func (p *Document) LoadByIDWithFilter(
filter *DocumentFilter,
) error {
q := `
SELECT
id,
organization_id,
owner_id,
title,
document_type,
classification,
current_published_version,
trust_center_visibility,
created_at,
updated_at
FROM
documents
WHERE
%s
AND deleted_at IS NULL
AND id = @document_id
SELECT
id,
organization_id,
owner_id,
title,
document_type,
classification,
current_published_version,
trust_center_visibility,
created_at,
updated_at
FROM
documents
WHERE
%s
AND deleted_at IS NULL
AND id = @document_id
AND %s
LIMIT 1;
`
@@ -178,6 +178,52 @@ LIMIT 1;
return nil
}
func (p *Documents) LoadByIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
documentIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
owner_id,
title,
document_type,
classification,
current_published_version,
trust_center_visibility,
created_at,
updated_at
FROM
documents
WHERE
%s
AND deleted_at IS NULL
AND id IN (SELECT id FROM UNNEST(@document_ids::text[]) AS t(id));;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"document_ids": documentIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query documents: %w", err)
}
documents, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Document])
if err != nil {
return fmt.Errorf("cannot collect documents: %w", err)
}
*p = documents
return nil
}
func (p *Documents) CountByOrganizationID(
ctx context.Context,
conn pg.Conn,

View File

@@ -111,6 +111,53 @@ LIMIT 1;
return nil
}
func (f *Files) LoadByIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
fileIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
bucket_name,
mime_type,
file_name,
file_key,
file_size,
created_at,
updated_at,
deleted_at
FROM
files
WHERE
%s
AND id (SELECT id FROM UNNEST(@file_ids::text[]) AS t(id))
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"file_ids": fileIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query file: %w", err)
}
defer rows.Close()
files, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[File])
if err != nil {
return fmt.Errorf("cannot collect file: %w", err)
}
*f = files
return nil
}
func (f File) Insert(
ctx context.Context,
conn pg.Conn,

View File

@@ -16,6 +16,7 @@ package coredata
import (
"context"
"errors"
"fmt"
"maps"
"time"
@@ -77,6 +78,10 @@ LIMIT 1;
report, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Report])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrDocumentNotFound{Identifier: reportID.String()}
}
return fmt.Errorf("cannot collect report: %w", err)
}
@@ -85,6 +90,50 @@ LIMIT 1;
return nil
}
func (r *Reports) LoadByIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
reportIDs []gid.GID,
) error {
q := `
SELECT
id,
organization_id,
object_key,
mime_type,
filename,
size,
created_at,
updated_at
FROM
reports
WHERE
%s
AND id IN (SELECT id FROM UNNEST(@report_ids::text[]) AS t(id))
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"report_ids": reportIDs}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query report: %w", err)
}
reports, err := pgx.CollectRows(rows, pgx.RowToAddrOfStructByName[Report])
if err != nil {
return fmt.Errorf("cannot collect reports: %w", err)
}
*r = reports
return nil
}
func (r *Report) Insert(
ctx context.Context,
conn pg.Conn,

View File

@@ -688,6 +688,37 @@ WHERE
return nil
}
func DeleteByDocumentIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
documentIDs []gid.GID,
) error {
q := `
DELETE FROM trust_center_document_accesses
WHERE
%s
AND trust_center_access_id = @trust_center_access_id
AND document_id = ANY(@document_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"trust_center_access_id": trustCenterAccessID,
"document_ids": documentIDs,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete trust center document accesses by document IDs: %w", err)
}
return nil
}
func ActivateByReportIDs(
ctx context.Context,
conn pg.Conn,
@@ -722,6 +753,37 @@ WHERE
return nil
}
func DeleteByReportIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
reportIDs []gid.GID,
) error {
q := `
DELETE FROM trust_center_document_accesses
WHERE
%s
AND trust_center_access_id = @trust_center_access_id
AND report_id = ANY(@report_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"trust_center_access_id": trustCenterAccessID,
"report_ids": reportIDs,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete trust center document accesses by report IDs: %w", err)
}
return nil
}
func (tcdas TrustCenterDocumentAccesses) BulkInsertDocumentAccesses(
ctx context.Context,
conn pg.Conn,
@@ -914,6 +976,37 @@ WHERE
return nil
}
func DeleteByTrustCenterFileIDs(
ctx context.Context,
conn pg.Conn,
scope Scoper,
trustCenterAccessID gid.GID,
trustCenterFileIDs []gid.GID,
) error {
q := `
DELETE FROM trust_center_document_accesses
WHERE
%s
AND trust_center_access_id = @trust_center_access_id
AND trust_center_file_id = ANY(@trust_center_file_ids)
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"trust_center_access_id": trustCenterAccessID,
"trust_center_file_ids": trustCenterFileIDs,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete trust center document accesses by trust center file IDs: %w", err)
}
return nil
}
func (tcdas TrustCenterDocumentAccesses) BulkInsertTrustCenterFileAccesses(
ctx context.Context,
conn pg.Conn,

View File

@@ -338,14 +338,8 @@ func (s TrustCenterAccessService) Update(
return fmt.Errorf("cannot update trust center access: %w", err)
}
if err := s.upsertDocumentAccesses(ctx, tx, access.ID, access.OrganizationID, req.DocumentIDs, req.ReportIDs, req.TrustCenterFileIDs, now); err != nil {
return fmt.Errorf("cannot upsert document accesses: %w", err)
}
if req.ReportIDs != nil {
if err := coredata.ActivateByReportIDs(ctx, tx, s.svc.scope, access.ID, req.ReportIDs, now); err != nil {
return fmt.Errorf("cannot activate report accesses: %w", err)
}
if err := s.upsertDocumentAccesses(ctx, tx, access.ID, access.OrganizationID, req.DocumentIDs, req.ReportIDs, req.TrustCenterFileIDs, now); err != nil {
return fmt.Errorf("cannot upsert document accesses: %w", err)
}
if shouldSendEmail {

View File

@@ -23,11 +23,11 @@ import (
"strings"
"time"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/slack"
"go.probo.inc/probo/pkg/trust"
"go.gearno.de/kit/httpserver"
"go.gearno.de/kit/log"
)
type (
@@ -151,7 +151,7 @@ func slackHandler(trustSvc *trust.Service, slackSigningSecret string, logger *lo
var fileIDs []gid.GID
switch action.ActionID {
case "accept_all":
case "accept_all", "reject_all":
currentMessageId, err := gid.ParseGID(action.Value)
if err != nil {
httpserver.RenderJSON(w, http.StatusBadRequest, SlackInteractiveResponse{Success: false, Message: "invalid message ID"})
@@ -164,7 +164,6 @@ func slackHandler(trustSvc *trust.Service, slackSigningSecret string, logger *lo
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
case "accept_document":
docID, err := gid.ParseGID(action.Value)
if err != nil {
@@ -194,17 +193,34 @@ func slackHandler(trustSvc *trust.Service, slackSigningSecret string, logger *lo
return
}
if err := tenantSvc.TrustCenterAccesses.AcceptByIDs(
ctx,
initialSlackMessage.OrganizationID,
requesterEmail,
documentIDs,
reportIDs,
fileIDs,
); err != nil {
logger.ErrorCtx(ctx, "cannot grant access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
if strings.HasPrefix(action.ActionID, "accept_") {
if err := tenantSvc.TrustCenterAccesses.AcceptByIDs(
ctx,
initialSlackMessage.OrganizationID,
requesterEmail,
documentIDs,
reportIDs,
fileIDs,
); err != nil {
logger.ErrorCtx(ctx, "cannot grant access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
}
if strings.HasPrefix(action.ActionID, "reject_") {
if err := tenantSvc.TrustCenterAccesses.RejectByIDs(
ctx,
initialSlackMessage.OrganizationID,
requesterEmail,
documentIDs,
reportIDs,
fileIDs,
); err != nil {
logger.ErrorCtx(ctx, "cannot reject access", log.Error(err))
httpserver.RenderJSON(w, http.StatusInternalServerError, SlackInteractiveResponse{Success: false, Message: "internal server error"})
return
}
}
if err := tenantSvc.SlackMessages.UpdateSlackAccessMessage(

View File

@@ -549,6 +549,127 @@ func (s *TrustCenterAccessService) sendTrustCenterAccessEmail(
return nil
}
func (s *TrustCenterAccessService) RejectByIDs(
ctx context.Context,
organizationID gid.GID,
email string,
documentIDs []gid.GID,
reportIDs []gid.GID,
fileIDs []gid.GID,
) error {
return s.svc.pg.WithTx(ctx, func(tx pg.Conn) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByOrganizationID(ctx, tx, s.svc.scope, organizationID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
access := &coredata.TrustCenterAccess{}
if err := access.LoadByTrustCenterIDAndEmail(ctx, tx, s.svc.scope, trustCenter.ID, email); err != nil {
return fmt.Errorf("cannot load trust center access: %w", err)
}
shouldSendEmail := false
if len(documentIDs) > 0 {
shouldSendEmail = true
if err := coredata.DeleteByDocumentIDs(ctx, tx, s.svc.scope, access.ID, documentIDs); err != nil {
return fmt.Errorf("cannot delete document accesses: %w", err)
}
}
if len(reportIDs) > 0 {
shouldSendEmail = true
if err := coredata.DeleteByReportIDs(ctx, tx, s.svc.scope, access.ID, reportIDs); err != nil {
return fmt.Errorf("cannot delete report accesses: %w", err)
}
}
if len(fileIDs) > 0 {
shouldSendEmail = true
if err := coredata.DeleteByTrustCenterFileIDs(ctx, tx, s.svc.scope, access.ID, fileIDs); err != nil {
return fmt.Errorf("cannot delete trust center file accesses: %w", err)
}
}
if shouldSendEmail {
if err := s.sendDocumentAccessRejectedEmail(ctx, tx, access, documentIDs, reportIDs, fileIDs); err != nil {
return fmt.Errorf("cannot send access email: %w", err)
}
}
return nil
})
}
func (s *TrustCenterAccessService) sendDocumentAccessRejectedEmail(
ctx context.Context,
tx pg.Conn,
access *coredata.TrustCenterAccess,
documentIDs []gid.GID,
reportIDs []gid.GID,
fileIDs []gid.GID,
) error {
trustCenter := &coredata.TrustCenter{}
if err := trustCenter.LoadByID(ctx, tx, s.svc.scope, access.TrustCenterID); err != nil {
return fmt.Errorf("cannot load trust center: %w", err)
}
organization := &coredata.Organization{}
if err := organization.LoadByID(ctx, tx, s.svc.scope, trustCenter.OrganizationID); err != nil {
return fmt.Errorf("cannot load organization: %w", err)
}
var fileNames []string
var documents coredata.Documents
if len(documentIDs) > 0 {
if err := documents.LoadByIDs(ctx, tx, s.svc.scope, documentIDs); err != nil {
return fmt.Errorf("cannot load documents by IDs: %w", err)
}
for _, d := range documents {
fileNames = append(fileNames, d.Title)
}
}
var reports coredata.Reports
if len(reportIDs) > 0 {
if err := reports.LoadByIDs(ctx, tx, s.svc.scope, reportIDs); err != nil {
return fmt.Errorf("cannot load reports by IDs: %w", err)
}
for _, r := range reports {
fileNames = append(fileNames, r.Filename)
}
}
var files coredata.Files
if len(fileIDs) > 0 {
if err := files.LoadByIDs(ctx, tx, s.svc.scope, fileIDs); err != nil {
return fmt.Errorf("cannot load files by IDs: %w", err)
}
for _, f := range files {
fileNames = append(fileNames, f.FileName)
}
}
subject, textBody, htmlBody, err := emails.RenderTrustCenterDocumentAccessRejected(
s.svc.baseURL,
access.Name,
organization.Name,
fileNames,
)
if err != nil {
return fmt.Errorf("cannot render trust center documents access rejected email: %w", err)
}
accessEmail := coredata.NewEmail(
access.Name,
access.Email,
subject,
textBody,
htmlBody,
)
if err := accessEmail.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert access email: %w", err)
}
return nil
}
func extractExistingIDs(accesses coredata.TrustCenterDocumentAccesses) ([]gid.GID, []gid.GID, []gid.GID) {
var documentIDs []gid.GID
var reportIDs []gid.GID