feat: add user email support

Ayman Bagabas created

Change summary

pkg/backend/user.go                                   | 122 ++++++++++++
pkg/db/migrate/0004_create_orgs_teams_postgres.up.sql |   9 
pkg/db/migrate/0004_create_orgs_teams_sqlite.up.sql   |   5 
pkg/proto/user.go                                     |  17 +
pkg/ssh/cmd/org.go                                    |   2 
pkg/ssh/cmd/user.go                                   |  76 +++++++
pkg/store/database/org.go                             |   9 
pkg/store/database/user.go                            |  56 ++++-
pkg/store/user.go                                     |   6 
pkg/utils/utils.go                                    |  22 ++
testscript/script_test.go                             |   2 
testscript/testdata/user_management.txtar             |  42 ++++
12 files changed, 345 insertions(+), 23 deletions(-)

Detailed changes

pkg/backend/user.go 🔗

@@ -26,6 +26,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 	var m models.User
 	var pks []ssh.PublicKey
 	var hl models.Handle
+	var ems []proto.UserEmail
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		var err error
 		m, err = d.store.FindUserByUsername(ctx, tx, username)
@@ -38,6 +39,15 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 			return err
 		}
 
+		emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+		if err != nil {
+			return err
+		}
+
+		for _, e := range emails {
+			ems = append(ems, &userEmail{e})
+		}
+
 		hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
@@ -53,6 +63,7 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 		user:       m,
 		publicKeys: pks,
 		handle:     hl,
+		emails:     ems,
 	}, nil
 }
 
@@ -61,6 +72,7 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
 	var m models.User
 	var pks []ssh.PublicKey
 	var hl models.Handle
+	var ems []proto.UserEmail
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		var err error
 		m, err = d.store.GetUserByID(ctx, tx, id)
@@ -73,6 +85,15 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
 			return err
 		}
 
+		emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+		if err != nil {
+			return err
+		}
+
+		for _, e := range emails {
+			ems = append(ems, &userEmail{e})
+		}
+
 		hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
@@ -88,6 +109,7 @@ func (d *Backend) UserByID(ctx context.Context, id int64) (proto.User, error) {
 		user:       m,
 		publicKeys: pks,
 		handle:     hl,
+		emails:     ems,
 	}, nil
 }
 
@@ -98,6 +120,7 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 	var m models.User
 	var pks []ssh.PublicKey
 	var hl models.Handle
+	var ems []proto.UserEmail
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		var err error
 		m, err = d.store.FindUserByPublicKey(ctx, tx, pk)
@@ -110,6 +133,15 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 			return err
 		}
 
+		emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+		if err != nil {
+			return err
+		}
+
+		for _, e := range emails {
+			ems = append(ems, &userEmail{e})
+		}
+
 		hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
@@ -125,6 +157,7 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 		user:       m,
 		publicKeys: pks,
 		handle:     hl,
+		emails:     ems,
 	}, nil
 }
 
@@ -134,6 +167,7 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
 	var m models.User
 	var pks []ssh.PublicKey
 	var hl models.Handle
+	var ems []proto.UserEmail
 	token = HashToken(token)
 
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
@@ -156,6 +190,15 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
 			return err
 		}
 
+		emails, err := d.store.ListUserEmails(ctx, tx, m.ID)
+		if err != nil {
+			return err
+		}
+
+		for _, e := range emails {
+			ems = append(ems, &userEmail{e})
+		}
+
 		hl, err = d.store.GetHandleByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
@@ -171,6 +214,7 @@ func (d *Backend) UserByAccessToken(ctx context.Context, token string) (proto.Us
 		user:       m,
 		publicKeys: pks,
 		handle:     hl,
+		emails:     ems,
 	}, nil
 }
 
@@ -228,7 +272,7 @@ func (d *Backend) AddPublicKey(ctx context.Context, username string, pk ssh.Publ
 // It implements backend.Backend.
 func (d *Backend) CreateUser(ctx context.Context, username string, opts proto.UserOptions) (proto.User, error) {
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
-		return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys)
+		return d.store.CreateUser(ctx, tx, username, opts.Admin, opts.PublicKeys, opts.Emails)
 	}); err != nil {
 		return nil, db.WrapError(err)
 	}
@@ -335,10 +379,60 @@ func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword
 	)
 }
 
+// AddUserEmail adds an email to a user.
+func (d *Backend) AddUserEmail(ctx context.Context, user proto.User, email string) error {
+	return db.WrapError(
+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+			return d.store.AddUserEmail(ctx, tx, user.ID(), email, false)
+		}),
+	)
+}
+
+// ListUserEmails lists the emails of a user.
+func (d *Backend) ListUserEmails(ctx context.Context, user proto.User) ([]proto.UserEmail, error) {
+	var ems []proto.UserEmail
+	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+		emails, err := d.store.ListUserEmails(ctx, tx, user.ID())
+		if err != nil {
+			return err
+		}
+
+		for _, e := range emails {
+			ems = append(ems, &userEmail{e})
+		}
+
+		return nil
+	}); err != nil {
+		return nil, db.WrapError(err)
+	}
+
+	return ems, nil
+}
+
+// RemoveUserEmail deletes an email for a user.
+// The deleted email must not be the primary email.
+func (d *Backend) RemoveUserEmail(ctx context.Context, user proto.User, email string) error {
+	return db.WrapError(
+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+			return d.store.RemoveUserEmail(ctx, tx, user.ID(), email)
+		}),
+	)
+}
+
+// SetUserPrimaryEmail sets the primary email of a user.
+func (d *Backend) SetUserPrimaryEmail(ctx context.Context, user proto.User, email string) error {
+	return db.WrapError(
+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+			return d.store.SetUserPrimaryEmail(ctx, tx, user.ID(), email)
+		}),
+	)
+}
+
 type user struct {
 	user       models.User
 	publicKeys []ssh.PublicKey
 	handle     models.Handle
+	emails     []proto.UserEmail
 }
 
 var _ proto.User = (*user)(nil)
@@ -371,3 +465,29 @@ func (u *user) Password() string {
 
 	return ""
 }
+
+// Emails implements proto.User.
+func (u *user) Emails() []proto.UserEmail {
+	return u.emails
+}
+
+type userEmail struct {
+	email models.UserEmail
+}
+
+var _ proto.UserEmail = (*userEmail)(nil)
+
+// Email implements proto.UserEmail.
+func (e *userEmail) Email() string {
+	return e.email.Email
+}
+
+// ID implements proto.UserEmail.
+func (e *userEmail) ID() int64 {
+	return e.email.ID
+}
+
+// IsPrimary implements proto.UserEmail.
+func (e *userEmail) IsPrimary() bool {
+	return e.email.IsPrimary
+}

pkg/db/migrate/0004_create_orgs_teams_postgres.up.sql 🔗

@@ -69,7 +69,7 @@ CREATE TABLE IF NOT EXISTS user_emails (
   id SERIAL PRIMARY KEY,
   user_id INTEGER NOT NULL,
   email TEXT NOT NULL UNIQUE,
-  is_primary BOOLEAN NOT NULL,
+  is_primary BOOLEAN NOT NULL DEFAULT false,
   created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
   updated_at TIMESTAMP NOT NULL,
   CONSTRAINT user_id_fk
@@ -78,6 +78,9 @@ CREATE TABLE IF NOT EXISTS user_emails (
   ON UPDATE CASCADE
 );
 
+-- Create unique index for primary email
+CREATE UNIQUE INDEX user_emails_user_id_is_primary_idx ON user_emails (user_id) WHERE is_primary;
+
 -- Add name to users table
 ALTER TABLE users ADD COLUMN name TEXT;
 
@@ -112,7 +115,7 @@ ALTER TABLE repos ADD CONSTRAINT org_id_fk
 ALTER TABLE repos ALTER COLUMN user_id DROP NOT NULL;
 
 -- Check that both user_id and org_id can't be null
-ALTER TABLE repos ADD CONSTRAINT user_id_org_id_not_null CHECK (user_id IS NULL <> org_id IS NULL);
+ALTER TABLE repos ADD CONSTRAINT user_id_org_id_not_null CHECK ((user_id IS NULL) <> (org_id IS NULL));
 
 -- Add team_id to collabs table
 ALTER TABLE collabs ADD COLUMN team_id INTEGER;
@@ -125,7 +128,7 @@ ALTER TABLE collabs ADD CONSTRAINT team_id_fk
 ALTER TABLE collabs ALTER COLUMN user_id DROP NOT NULL;
 
 -- Check that both user_id and team_id can't be null
-ALTER TABLE collabs ADD CONSTRAINT user_id_team_id_not_null CHECK (user_id IS NULL <> team_id IS NULL);
+ALTER TABLE collabs ADD CONSTRAINT user_id_team_id_not_null CHECK ((user_id IS NULL) <> (team_id IS NULL));
 
 -- Alter unique constraint on collabs table
 ALTER TABLE collabs DROP CONSTRAINT collabs_user_id_repo_id_key;

pkg/db/migrate/0004_create_orgs_teams_sqlite.up.sql 🔗

@@ -71,7 +71,7 @@ CREATE TABLE IF NOT EXISTS user_emails (
   id INTEGER PRIMARY KEY AUTOINCREMENT,
   user_id INTEGER NOT NULL,
   email TEXT NOT NULL UNIQUE,
-  is_primary BOOLEAN NOT NULL,
+  is_primary BOOLEAN NOT NULL DEFAULT false,
   created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
   updated_at TIMESTAMP NOT NULL,
   CONSTRAINT user_id_fk
@@ -80,6 +80,9 @@ CREATE TABLE IF NOT EXISTS user_emails (
   ON UPDATE CASCADE
 );
 
+-- Create unique index for primary email
+CREATE UNIQUE INDEX user_emails_user_id_is_primary_idx ON user_emails (user_id) WHERE is_primary;
+
 ALTER TABLE users RENAME TO _users_old;
 
 CREATE TABLE IF NOT EXISTS users (

pkg/proto/user.go 🔗

@@ -14,6 +14,8 @@ type User interface {
 	PublicKeys() []ssh.PublicKey
 	// Password returns the user's password hash.
 	Password() string
+	// Emails returns the user's emails.
+	Emails() []UserEmail
 }
 
 // UserOptions are options for creating a user.
@@ -22,4 +24,19 @@ type UserOptions struct {
 	Admin bool
 	// PublicKeys are the user's public keys.
 	PublicKeys []ssh.PublicKey
+	// Emails are the user's emails.
+	// The first email in the slice will be set as the user's primary email.
+	Emails []string
+}
+
+// UserEmail represents a user's email address.
+type UserEmail interface {
+	// ID returns the email's ID.
+	ID() int64
+
+	// Email returns the email address.
+	Email() string
+
+	// IsPrimary returns whether the email is the user's primary email.
+	IsPrimary() bool
 }

pkg/ssh/cmd/org.go 🔗

@@ -33,7 +33,7 @@ func OrgCommand() *cobra.Command {
 		Use:   "list",
 		Short: "List organizations",
 		Args:  cobra.NoArgs,
-		RunE: func(cmd *cobra.Command, args []string) error {
+		RunE: func(cmd *cobra.Command, _ []string) error {
 			ctx := cmd.Context()
 			be := backend.FromContext(ctx)
 			user := proto.UserFromContext(ctx)

pkg/ssh/cmd/user.go 🔗

@@ -22,9 +22,9 @@ func UserCommand() *cobra.Command {
 	var admin bool
 	var key string
 	userCreateCommand := &cobra.Command{
-		Use:               "create USERNAME",
+		Use:               "create USERNAME [EMAIL]",
 		Short:             "Create a new user",
-		Args:              cobra.ExactArgs(1),
+		Args:              cobra.MinimumNArgs(1),
 		PersistentPreRunE: checkIfAdmin,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			var pubkeys []ssh.PublicKey
@@ -45,6 +45,10 @@ func UserCommand() *cobra.Command {
 				PublicKeys: pubkeys,
 			}
 
+			if len(args) > 1 {
+				opts.Emails = append(opts.Emails, args[1])
+			}
+
 			_, err := be.CreateUser(ctx, username, opts)
 			return err
 		},
@@ -166,6 +170,14 @@ func UserCommand() *cobra.Command {
 				cmd.Printf("  %s\n", sshutils.MarshalAuthorizedKey(pk))
 			}
 
+			emails := user.Emails()
+			if len(emails) > 0 {
+				cmd.Printf("Emails:\n")
+				for _, e := range emails {
+					cmd.Printf("  %s (primary: %v)\n", e.Email(), e.IsPrimary())
+				}
+			}
+
 			return nil
 		},
 	}
@@ -185,6 +197,63 @@ func UserCommand() *cobra.Command {
 		},
 	}
 
+	userAddEmailCommand := &cobra.Command{
+		Use:               "add-email USERNAME EMAIL",
+		Short:             "Add an email to a user",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			username := args[0]
+			email := args[1]
+			u, err := be.User(ctx, username)
+			if err != nil {
+				return err
+			}
+
+			return be.AddUserEmail(ctx, u, email)
+		},
+	}
+
+	userRemoveEmailCommand := &cobra.Command{
+		Use:               "remove-email USERNAME EMAIL",
+		Short:             "Remove an email from a user",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			username := args[0]
+			email := args[1]
+			u, err := be.User(ctx, username)
+			if err != nil {
+				return err
+			}
+
+			return be.RemoveUserEmail(ctx, u, email)
+		},
+	}
+
+	userSetPrimaryEmailCommand := &cobra.Command{
+		Use:               "set-primary-email USERNAME EMAIL",
+		Short:             "Set a user's primary email",
+		Args:              cobra.ExactArgs(2),
+		PersistentPreRunE: checkIfAdmin,
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			be := backend.FromContext(ctx)
+			username := args[0]
+			email := args[1]
+			u, err := be.User(ctx, username)
+			if err != nil {
+				return err
+			}
+
+			return be.SetUserPrimaryEmail(ctx, u, email)
+		},
+	}
+
 	cmd.AddCommand(
 		userCreateCommand,
 		userAddPubkeyCommand,
@@ -194,6 +263,9 @@ func UserCommand() *cobra.Command {
 		userRemovePubkeyCommand,
 		userSetAdminCommand,
 		userSetUsernameCommand,
+		userAddEmailCommand,
+		userRemoveEmailCommand,
+		userSetPrimaryEmailCommand,
 	)
 
 	return cmd

pkg/store/database/org.go 🔗

@@ -7,6 +7,7 @@ import (
 	"github.com/charmbracelet/soft-serve/pkg/db"
 	"github.com/charmbracelet/soft-serve/pkg/db/models"
 	"github.com/charmbracelet/soft-serve/pkg/store"
+	"github.com/charmbracelet/soft-serve/pkg/utils"
 )
 
 var _ store.OrgStore = (*orgStore)(nil)
@@ -15,6 +16,10 @@ type orgStore struct{ *handleStore }
 
 // UpdateOrgContactEmail implements store.OrgStore.
 func (*orgStore) UpdateOrgContactEmail(ctx context.Context, h db.Handler, org int64, email string) error {
+	if err := utils.ValidateEmail(email); err != nil {
+		return err
+	}
+
 	query := h.Rebind(`
 		UPDATE organizations
 		SET
@@ -58,6 +63,10 @@ func (s *orgStore) DeleteOrgByID(ctx context.Context, h db.Handler, user, id int
 
 // Create implements store.OrgStore.
 func (s *orgStore) CreateOrg(ctx context.Context, h db.Handler, user int64, name, email string) (models.Organization, error) {
+	if err := utils.ValidateEmail(email); err != nil {
+		return models.Organization{}, err
+	}
+
 	handle, err := s.CreateHandle(ctx, h, name)
 	if err != nil {
 		return models.Organization{}, err

pkg/store/database/user.go 🔗

@@ -2,6 +2,7 @@ package database
 
 import (
 	"context"
+	"fmt"
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/pkg/db"
@@ -39,7 +40,7 @@ func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, use
 }
 
 // CreateUser implements store.UserStore.
-func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error {
+func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey, emails []string) error {
 	handleID, err := s.CreateHandle(ctx, tx, username)
 	if err != nil {
 		return err
@@ -71,6 +72,12 @@ func (s *userStore) CreateUser(ctx context.Context, tx db.Handler, username stri
 		}
 	}
 
+	for i, e := range emails {
+		if err := s.AddUserEmail(ctx, tx, userID, e, i == 0); err != nil {
+			return err
+		}
+	}
+
 	return nil
 }
 
@@ -255,6 +262,9 @@ func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler,
 
 // AddUserEmail implements store.UserStore.
 func (*userStore) AddUserEmail(ctx context.Context, tx db.Handler, userID int64, email string, isPrimary bool) error {
+	if err := utils.ValidateEmail(email); err != nil {
+		return err
+	}
 	query := tx.Rebind(`INSERT INTO user_emails (user_id, email, is_primary, updated_at)
 			VALUES (?, ?, ?, CURRENT_TIMESTAMP);`)
 	_, err := tx.ExecContext(ctx, query, userID, email, isPrimary)
@@ -269,16 +279,40 @@ func (*userStore) ListUserEmails(ctx context.Context, tx db.Handler, userID int6
 	return ms, err
 }
 
-// UpdateUserEmail implements store.UserStore.
-func (*userStore) UpdateUserEmail(ctx context.Context, tx db.Handler, userID int64, oldEmail string, newEmail string, isPrimary bool) error {
-	query := tx.Rebind(`UPDATE user_emails SET email = ?, is_primary = ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ? AND email = ?;`)
-	_, err := tx.ExecContext(ctx, query, newEmail, isPrimary, userID, oldEmail)
-	return err
+// RemoveUserEmail implements store.UserStore.
+func (*userStore) RemoveUserEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
+	var e models.UserEmail
+	query := tx.Rebind(`DELETE FROM user_emails WHERE user_id = ? AND email = ? RETURNING *;`)
+	if err := tx.GetContext(ctx, &e, query, userID, email); err != nil {
+		return err
+	}
+
+	if e.IsPrimary {
+		return fmt.Errorf("cannot remove primary email")
+	} else if e.ID == 0 {
+		return db.ErrRecordNotFound
+	}
+
+	return nil
 }
 
-// DeleteUserEmail implements store.UserStore.
-func (*userStore) DeleteUserEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
-	query := tx.Rebind(`DELETE FROM user_emails WHERE user_id = ? AND email = ?;`)
-	_, err := tx.ExecContext(ctx, query, userID, email)
-	return err
+// SetUserPrimaryEmail implements store.UserStore.
+func (*userStore) SetUserPrimaryEmail(ctx context.Context, tx db.Handler, userID int64, email string) error {
+	query := tx.Rebind(`UPDATE user_emails SET is_primary = FALSE WHERE user_id = ?;`)
+	_, err := tx.ExecContext(ctx, query, userID)
+	if err != nil {
+		return err
+	}
+
+	var emailID int64
+	query = tx.Rebind(`UPDATE user_emails SET is_primary = TRUE WHERE user_id = ? AND email = ? RETURNING id;`)
+	if err := tx.GetContext(ctx, &emailID, query, userID, email); err != nil {
+		return err
+	}
+
+	if emailID == 0 {
+		return db.ErrRecordNotFound
+	}
+
+	return nil
 }

pkg/store/user.go 🔗

@@ -15,7 +15,7 @@ type UserStore interface {
 	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
 	FindUserByAccessToken(ctx context.Context, h db.Handler, token string) (models.User, error)
 	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
-	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
+	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey, emails []string) error
 	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
 	SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
 	SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
@@ -28,6 +28,6 @@ type UserStore interface {
 
 	AddUserEmail(ctx context.Context, h db.Handler, userID int64, email string, isPrimary bool) error
 	ListUserEmails(ctx context.Context, h db.Handler, userID int64) ([]models.UserEmail, error)
-	UpdateUserEmail(ctx context.Context, h db.Handler, userID int64, oldEmail string, newEmail string, isPrimary bool) error
-	DeleteUserEmail(ctx context.Context, h db.Handler, userID int64, email string) error
+	RemoveUserEmail(ctx context.Context, h db.Handler, userID int64, email string) error
+	SetUserPrimaryEmail(ctx context.Context, h db.Handler, userID int64, email string) error
 }

pkg/utils/utils.go 🔗

@@ -1,12 +1,20 @@
 package utils
 
 import (
+	"errors"
 	"fmt"
+	"net/mail"
 	"path"
 	"strings"
 	"unicode"
 )
 
+var (
+
+	// ErrInvalidEmail indicates that an email address is invalid.
+	ErrInvalidEmail = errors.New("invalid email address")
+)
+
 // SanitizeRepo returns a sanitized version of the given repository name.
 func SanitizeRepo(repo string) string {
 	repo = strings.TrimPrefix(repo, "/")
@@ -50,3 +58,17 @@ func ValidateRepo(repo string) error {
 
 	return nil
 }
+
+// ValidateEmail returns an error if the given email address is invalid.
+func ValidateEmail(email string) error {
+	if strings.ContainsAny(email, " <>") {
+		return ErrInvalidEmail
+	}
+
+	_, err := mail.ParseAddress(email)
+	if err != nil {
+		return fmt.Errorf("%w: %s", ErrInvalidEmail, err)
+	}
+
+	return nil
+}

testscript/script_test.go 🔗

@@ -76,6 +76,7 @@ func TestScript(t *testing.T) {
 	key, admin1 := mkkey("admin1")
 	_, admin2 := mkkey("admin2")
 	_, user1 := mkkey("user1")
+	_, user2 := mkkey("user2")
 
 	testscript.Run(t, testscript.Params{
 		Dir:                 "./testdata/",
@@ -117,6 +118,7 @@ func TestScript(t *testing.T) {
 			e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
 			e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
 			e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
+			e.Setenv("USER2_AUTHORIZED_KEY", user2.AuthorizedKey())
 			e.Setenv("SSH_KNOWN_HOSTS_FILE", filepath.Join(t.TempDir(), "known_hosts"))
 			e.Setenv("SSH_KNOWN_CONFIG_FILE", filepath.Join(t.TempDir(), "config"))
 

testscript/testdata/user_management.txtar 🔗

@@ -1,7 +1,7 @@
 # vi: set ft=conf
 
 # convert crlf to lf on windows
-[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt
+[windows] dos2unix info.txt admin_key_list1.txt admin_key_list2.txt list1.txt list2.txt foo_info1.txt foo_info2.txt foo_info3.txt foo_info4.txt foo_info5.txt bar_info.txt
 
 # start soft serve
 exec soft serve &
@@ -68,6 +68,38 @@ soft user delete foo2
 soft user list
 cmpenv stdout list1.txt
 
+# create a new user with an invalid email
+! soft user create bar --key "$USER2_AUTHORIZED_KEY" "foobar"
+stderr 'invalid email address.*'
+
+# create a new user with a valid email
+soft user create bar --key "$USER2_AUTHORIZED_KEY" "foo@bar.baz"
+! stdout .
+# add email to existing user
+soft user add-email bar "foobar@fubar.baz"
+! stdout .
+# add existing email
+! soft user add-email bar "foobar@fubar.baz"
+stderr 'duplicate key.*'
+
+# get new user info
+soft user info bar
+cmpenv stdout bar_info.txt
+
+# remove primary email from user
+! soft user remove-email bar "foo@bar.baz"
+stderr 'cannot remove primary email.*'
+
+# set primary email that doesn't exist
+! soft user set-primary-email bar "foobar@foofoo.foo"
+stderr 'no rows in result set.*'
+# set primary email
+soft user set-primary-email bar "foobar@fubar.baz"
+! stdout .
+# remove other email
+soft user remove-email bar "foo@bar.baz"
+! stdout .
+
 # stop the server
 [windows] stopserver
 [windows] ! stderr .
@@ -112,3 +144,11 @@ $ADMIN1_AUTHORIZED_KEY
 $ADMIN2_AUTHORIZED_KEY
 -- admin_key_list2.txt --
 $ADMIN1_AUTHORIZED_KEY
+-- bar_info.txt --
+Username: bar
+Admin: false
+Public keys:
+  $USER2_AUTHORIZED_KEY
+Emails:
+  foo@bar.baz (primary: true)
+  foobar@fubar.baz (primary: false)