feat(API): user invitations and emails
This commit is contained in:
1 parent
7aae704d0a
commit
acdef18b37
17 files changed
+1687
-7
No files matched your search
@@ -0,0 +1,41 @@
|
||||
package invite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrEmailHasAccount = errors.New("email already has an account")
|
||||
var ErrPendingInvite = errors.New("pending invite already exists")
|
||||
var ErrInviteNotFound = errors.New("invite not found")
|
||||
var ErrInviteEmailNotSent = errors.New("invite email not sent")
|
||||
|
||||
const Lifetime = 3 * 24 * time.Hour
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPending Status = "pending"
|
||||
StatusExpired Status = "expired"
|
||||
StatusAccepted Status = "accepted"
|
||||
)
|
||||
|
||||
type Invite struct {
|
||||
ID uint32 `json:"id"`
|
||||
Email string `json:"email"`
|
||||
HashedCode string `json:"hashed_code"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
InvitedBy uint32 `json:"invited_by"`
|
||||
AcceptedAt *time.Time `json:"accepted_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
func (i Invite) StatusAt(now time.Time) Status {
|
||||
if i.AcceptedAt != nil {
|
||||
return StatusAccepted
|
||||
}
|
||||
if i.ExpiresAt.After(now) {
|
||||
return StatusPending
|
||||
}
|
||||
return StatusExpired
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package invite
|
||||
|
||||
type Repository interface {
|
||||
Create(invite Invite) (Invite, error)
|
||||
GetAll() []Invite
|
||||
Delete(id uint32) error
|
||||
}
|
||||
@@ -24,14 +24,25 @@ func NewMailRepository(smtp config.SmtpSettings) *MailRepository {
|
||||
|
||||
const defaultSubject = "RenewCMS | Action required"
|
||||
|
||||
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
|
||||
subject := defaultSubject
|
||||
type subjectProvider interface {
|
||||
Subject() string
|
||||
}
|
||||
|
||||
func subjectFor(templateName string, data any) string {
|
||||
if provider, ok := data.(subjectProvider); ok {
|
||||
return provider.Subject()
|
||||
}
|
||||
switch templateName {
|
||||
case "verification":
|
||||
subject = "Verify your RenewCMS account"
|
||||
return "Verify your RenewCMS account"
|
||||
case "password-reset":
|
||||
subject = "Reset your RenewCMS password"
|
||||
return "Reset your RenewCMS password"
|
||||
}
|
||||
return defaultSubject
|
||||
}
|
||||
|
||||
func (m MailRepository) Render(templateName string, data any) (string, string, error) {
|
||||
subject := subjectFor(templateName, data)
|
||||
|
||||
tmpl, err := template.ParseFS(mailTemplateFiles, "templates/"+templateName+".html")
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>You're invited to RenewCMS</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; width: 100%; -webkit-text-size-adjust: 100%; }
|
||||
table { border-collapse: collapse; }
|
||||
a { color: #111111; }
|
||||
@media (max-width: 620px) {
|
||||
.rc-card { padding: 28px 20px !important; }
|
||||
.rc-btn a { display: block !important; }
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.rc-bg { background-color: #0a0a0a !important; }
|
||||
.rc-card { background-color: #111111 !important; border-color: #333333 !important; }
|
||||
.rc-h1, .rc-word { color: #fafafa !important; }
|
||||
.rc-text { color: #e6e4e0 !important; }
|
||||
.rc-muted { color: #999999 !important; }
|
||||
.rc-mark { border: 1px solid #444444 !important; }
|
||||
.rc-btn td { background-color: #fafafa !important; }
|
||||
.rc-btn a { background-color: #fafafa !important; color: #111111 !important; border-color: #fafafa !important; }
|
||||
.rc-url { color: #e6e4e0 !important; background-color: #1a1a1a !important; }
|
||||
.rc-rule { border-color: #333333 !important; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="rc-bg" style="margin:0;padding:0;background-color:#fafafa;">
|
||||
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;">{{.InvitedBy}} invited you to work on articles in RenewCMS. The invite expires in 3 days.</div>
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" class="rc-bg" style="background-color:#fafafa;">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 12px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:600px;">
|
||||
<tr>
|
||||
<td style="padding:0 0 20px 0;">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td class="rc-mark" width="32" height="32" align="center" valign="middle" bgcolor="#111111" style="width:32px;height:32px;background-color:#111111;color:#ffffff;font-family:Arial,Helvetica,sans-serif;font-size:19px;font-weight:bold;line-height:32px;">R</td>
|
||||
<td class="rc-word" style="padding-left:10px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:17px;font-weight:600;color:#111111;">RenewCMS</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-card" style="background-color:#ffffff;border:1px solid #e6e4e0;padding:40px;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;">
|
||||
<h1 class="rc-h1" style="margin:0 0 16px 0;font-size:24px;line-height:32px;font-weight:600;color:#111111;">You're invited to RenewCMS</h1>
|
||||
<p class="rc-text" style="margin:0 0 28px 0;font-size:16px;line-height:26px;color:#222222;">{{.InvitedBy}} invited you to write and publish articles in RenewCMS at {{.Host}}. Accept to choose a username and password. You'll sign in with your own account.</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" class="rc-btn">
|
||||
<tr>
|
||||
<td bgcolor="#111111" style="background-color:#111111;">
|
||||
<a href="{{.Host}}/invite/accept?c={{.VerificationCode}}" target="_blank" style="display:inline-block;padding:14px 24px;border:1px solid #111111;background-color:#111111;color:#ffffff;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:16px;font-weight:600;line-height:20px;text-decoration:none;text-align:center;">Accept invite</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="rc-muted" style="margin:16px 0 0 0;font-size:14px;line-height:22px;color:#666666;">This invite was sent to {{.Email}}. It expires in 3 days and works once.</p>
|
||||
<p class="rc-muted" style="margin:28px 0 8px 0;font-size:14px;line-height:22px;color:#666666;">If the button doesn't work, copy this address into your browser:</p>
|
||||
<p class="rc-url" style="margin:0;padding:10px 12px;background-color:#f3f2f0;font-family:'IBM Plex Mono',Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#222222;word-break:break-all;">{{.Host}}/invite/accept?c={{.VerificationCode}}</p>
|
||||
<p class="rc-text rc-rule" style="margin:28px 0 0 0;padding-top:20px;border-top:1px solid #e6e4e0;font-size:14px;line-height:22px;color:#222222;">Not expecting this? Ignore this email and no account will be created. If the invite has expired, ask {{.InvitedBy}} to send a new one.</p>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="rc-muted" style="padding:20px 0 0 0;font-family:'IBM Plex Sans',Arial,Helvetica,sans-serif;font-size:12px;line-height:18px;color:#666666;">RenewCMS · {{.Host}}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
package mappers
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
)
|
||||
|
||||
func InviteToDomain(m models.Invite) invite.Invite {
|
||||
return invite.Invite{
|
||||
ID: m.ID,
|
||||
Email: m.Email,
|
||||
HashedCode: m.HashedCode,
|
||||
ExpiresAt: m.ExpiresAt,
|
||||
InvitedBy: m.InvitedBy,
|
||||
AcceptedAt: m.AcceptedAt,
|
||||
CreatedAt: m.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func InviteToModel(i invite.Invite) models.Invite {
|
||||
return models.Invite{
|
||||
ID: i.ID,
|
||||
Email: i.Email,
|
||||
HashedCode: i.HashedCode,
|
||||
ExpiresAt: i.ExpiresAt,
|
||||
InvitedBy: i.InvitedBy,
|
||||
AcceptedAt: i.AcceptedAt,
|
||||
CreatedAt: i.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type Invite struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Email string `gorm:"index;not null"`
|
||||
HashedCode string `gorm:"not null"`
|
||||
ExpiresAt time.Time
|
||||
InvitedBy uint32 `gorm:"not null"`
|
||||
AcceptedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package persistence
|
||||
|
||||
import (
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/mappers"
|
||||
entity "RenewCMS/internal/infrastructure/persistence/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type InviteRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewInviteRepository(db *gorm.DB) *InviteRepository {
|
||||
return &InviteRepository{db}
|
||||
}
|
||||
|
||||
func (i *InviteRepository) Create(invite domainInvite.Invite) (domainInvite.Invite, error) {
|
||||
model := mappers.InviteToModel(invite)
|
||||
model.ID = 0
|
||||
if err := i.db.Create(&model).Error; err != nil {
|
||||
return domainInvite.Invite{}, err
|
||||
}
|
||||
|
||||
return mappers.InviteToDomain(model), nil
|
||||
}
|
||||
|
||||
func (i *InviteRepository) GetAll() []domainInvite.Invite {
|
||||
var invites []entity.Invite
|
||||
i.db.Model(&entity.Invite{}).Find(&invites)
|
||||
|
||||
var domainInvites []domainInvite.Invite
|
||||
for _, localInvite := range invites {
|
||||
domainInvites = append(domainInvites, mappers.InviteToDomain(localInvite))
|
||||
}
|
||||
|
||||
return domainInvites
|
||||
}
|
||||
|
||||
func (i *InviteRepository) Delete(id uint32) error {
|
||||
result := i.db.Delete(&entity.Invite{}, id)
|
||||
return deleteOrNotFound(result, domainInvite.ErrInviteNotFound)
|
||||
}
|
||||
|
||||
var _ domainInvite.Repository = &InviteRepository{}
|
||||
@@ -0,0 +1,128 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/hash"
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/mail"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const inviteTemplateName = "invitation"
|
||||
|
||||
type CreateInviteUseCase struct {
|
||||
userRepository user.Repository
|
||||
inviteRepository invite.Repository
|
||||
hasher hash.Hasher
|
||||
mailRepository mail.Repository
|
||||
publicURL string
|
||||
}
|
||||
|
||||
type CreateInviteCommand struct {
|
||||
Email string
|
||||
InvitedBy uint32
|
||||
}
|
||||
|
||||
type MailInviteData struct {
|
||||
Host string
|
||||
VerificationCode string
|
||||
Email string
|
||||
InvitedBy string
|
||||
}
|
||||
|
||||
func (d MailInviteData) Subject() string {
|
||||
inviter := strings.NewReplacer("\r", " ", "\n", " ").Replace(d.InvitedBy)
|
||||
return inviter + " invited you to RenewCMS"
|
||||
}
|
||||
|
||||
func NewCreateInviteUseCase(userRepository user.Repository, inviteRepository invite.Repository, hasher hash.Hasher, mailRepository mail.Repository, publicURL string) *CreateInviteUseCase {
|
||||
return &CreateInviteUseCase{userRepository, inviteRepository, hasher, mailRepository, publicURL}
|
||||
}
|
||||
|
||||
func (g *CreateInviteUseCase) CreateInvite(command CreateInviteCommand) (invite.Invite, error) {
|
||||
email := strings.ToLower(command.Email)
|
||||
now := time.Now()
|
||||
|
||||
inviter, err := g.userRepository.Get(command.InvitedBy)
|
||||
if err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
if g.emailHasAccount(email) {
|
||||
return invite.Invite{}, invite.ErrEmailHasAccount
|
||||
}
|
||||
|
||||
if err := g.discardOpenInvite(email, now); err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
rawCode := uuid.NewString()
|
||||
hashedCode, err := g.hasher.Hash(rawCode)
|
||||
if err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
createdInvite, err := g.inviteRepository.Create(invite.Invite{
|
||||
Email: email,
|
||||
HashedCode: hashedCode,
|
||||
ExpiresAt: now.Add(invite.Lifetime),
|
||||
InvitedBy: command.InvitedBy,
|
||||
CreatedAt: now,
|
||||
})
|
||||
if err != nil {
|
||||
return invite.Invite{}, err
|
||||
}
|
||||
|
||||
if err := g.mailRepository.Send(email, inviteTemplateName, MailInviteData{
|
||||
Host: g.publicURL,
|
||||
VerificationCode: rawCode,
|
||||
Email: email,
|
||||
InvitedBy: inviterName(inviter),
|
||||
}); err != nil {
|
||||
sendErr := fmt.Errorf("%w: %w", invite.ErrInviteEmailNotSent, err)
|
||||
if deleteErr := g.inviteRepository.Delete(createdInvite.ID); deleteErr != nil {
|
||||
return invite.Invite{}, errors.Join(sendErr, deleteErr)
|
||||
}
|
||||
return invite.Invite{}, sendErr
|
||||
}
|
||||
|
||||
return createdInvite, nil
|
||||
}
|
||||
|
||||
func (g *CreateInviteUseCase) emailHasAccount(email string) bool {
|
||||
for _, existingUser := range g.userRepository.GetAll() {
|
||||
if strings.EqualFold(existingUser.Email, email) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *CreateInviteUseCase) discardOpenInvite(email string, now time.Time) error {
|
||||
for _, existing := range g.inviteRepository.GetAll() {
|
||||
if existing.Email != email {
|
||||
continue
|
||||
}
|
||||
switch existing.StatusAt(now) {
|
||||
case invite.StatusPending:
|
||||
return invite.ErrPendingInvite
|
||||
case invite.StatusExpired:
|
||||
if err := g.inviteRepository.Delete(existing.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inviterName(inviter user.User) string {
|
||||
if inviter.DisplayName != "" {
|
||||
return inviter.DisplayName
|
||||
}
|
||||
return inviter.Username
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package useCases
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/domain/user"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
EntryKindUser = "user"
|
||||
EntryKindInvite = "invite"
|
||||
)
|
||||
|
||||
type ListUsersAndInvitesUseCase struct {
|
||||
userRepository user.Repository
|
||||
inviteRepository invite.Repository
|
||||
}
|
||||
|
||||
type DirectoryEntry struct {
|
||||
ID uint32
|
||||
Kind string
|
||||
Username string
|
||||
DisplayName string
|
||||
Email string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewListUsersAndInvitesUseCase(userRepository user.Repository, inviteRepository invite.Repository) *ListUsersAndInvitesUseCase {
|
||||
return &ListUsersAndInvitesUseCase{userRepository, inviteRepository}
|
||||
}
|
||||
|
||||
func (g *ListUsersAndInvitesUseCase) ListUsersAndInvites() []DirectoryEntry {
|
||||
now := time.Now()
|
||||
entries := []DirectoryEntry{}
|
||||
|
||||
for _, existingUser := range g.userRepository.GetAll() {
|
||||
entries = append(entries, DirectoryEntry{
|
||||
ID: existingUser.ID,
|
||||
Kind: EntryKindUser,
|
||||
Username: existingUser.Username,
|
||||
DisplayName: existingUser.DisplayName,
|
||||
Status: string(invite.StatusAccepted),
|
||||
CreatedAt: existingUser.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
for _, existingInvite := range g.inviteRepository.GetAll() {
|
||||
status := existingInvite.StatusAt(now)
|
||||
if status == invite.StatusAccepted {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, DirectoryEntry{
|
||||
ID: existingInvite.ID,
|
||||
Kind: EntryKindInvite,
|
||||
Email: existingInvite.Email,
|
||||
Status: string(status),
|
||||
CreatedAt: existingInvite.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
return entries[i].CreatedAt.After(entries[j].CreatedAt)
|
||||
})
|
||||
|
||||
return entries
|
||||
}
|
||||
Reference in new issue
Block a user