Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,15 @@ test-e2e-server: ## Run all E2E tests
test-e2e-ui: ## Run all E2E tests
npx cucumber-js e2e/features/ui/**/*.feature --require-module ts-node/register --require 'e2e/**/*.ts' --publish-quiet

test-e2e-ui-headed: ## Run all E2E tests with visible browser
HEADED=true npx cucumber-js e2e/features/ui/**/*.feature --require-module ts-node/register --require 'e2e/**/*.ts' --publish-quiet

test-e2e-ui-scenario: ## Run specific E2E test scenario (use NAME="scenario name")
npx cucumber-js e2e/features/ui/**/*.feature --require-module ts-node/register --require 'e2e/**/*.ts' --publish-quiet --name "$(NAME)"

test-e2e-ui-scenario-headed: ## Run specific E2E test scenario with visible browser (use NAME="scenario name")
HEADED=true npx cucumber-js e2e/features/ui/**/*.feature --require-module ts-node/register --require 'e2e/**/*.ts' --publish-quiet --name "$(NAME)"



##@ Running (Watch Mode)
Expand Down
105 changes: 102 additions & 3 deletions app/actions/signin.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ import (

// SignInByEmail happens when user request to sign in by email
type SignInByEmail struct {
Email string `json:"email" format:"lower"`
VerificationKey string
Email string `json:"email" format:"lower"`
VerificationCode string
}

func NewSignInByEmail() *SignInByEmail {
return &SignInByEmail{
VerificationKey: entity.GenerateEmailVerificationKey(),
VerificationCode: entity.GenerateEmailVerificationCode(),
}
}

Expand Down Expand Up @@ -74,6 +74,105 @@ func (action *SignInByEmail) GetKind() enum.EmailVerificationKind {
return enum.EmailVerificationKindSignIn
}

// VerifySignInCode happens when user enters the verification code received via email
type VerifySignInCode struct {
Email string `json:"email" format:"lower"`
Code string `json:"code"`
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *VerifySignInCode) IsAuthorized(ctx context.Context, user *entity.User) bool {
return true
}

// Validate if current model is valid
func (action *VerifySignInCode) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()

if action.Email == "" {
result.AddFieldFailure("email", propertyIsRequired(ctx, "email"))
} else {
messages := validate.Email(ctx, action.Email)
result.AddFieldFailure("email", messages...)
}

if action.Code == "" {
result.AddFieldFailure("code", propertyIsRequired(ctx, "code"))
} else if len(action.Code) != 6 {
result.AddFieldFailure("code", "Verification code must be 6 digits")
} else {
// Validate that code contains only digits
for _, char := range action.Code {
if char < '0' || char > '9' {
result.AddFieldFailure("code", "Verification code must contain only digits")
break
}
}
}

return result
}

// SignInByEmailWithName happens when a new user (without account) requests to sign in by email
type SignInByEmailWithName struct {
Email string `json:"email" format:"lower"`
Name string `json:"name"`
VerificationCode string
}

func NewSignInByEmailWithName() *SignInByEmailWithName {
return &SignInByEmailWithName{
VerificationCode: entity.GenerateEmailVerificationCode(),
}
}

// IsAuthorized returns true if current user is authorized to perform this action
func (action *SignInByEmailWithName) IsAuthorized(ctx context.Context, user *entity.User) bool {
tenant := ctx.Value(app.TenantCtxKey).(*entity.Tenant)
// New users can only sign in if tenant allows email auth or is not private
return tenant.IsEmailAuthAllowed || !tenant.IsPrivate
}

// Validate if current model is valid
func (action *SignInByEmailWithName) Validate(ctx context.Context, user *entity.User) *validate.Result {
result := validate.Success()

if action.Email == "" {
result.AddFieldFailure("email", propertyIsRequired(ctx, "email"))
} else {
messages := validate.Email(ctx, action.Email)
result.AddFieldFailure("email", messages...)
}

if action.Name == "" {
result.AddFieldFailure("name", propertyIsRequired(ctx, "name"))
} else if len(action.Name) > 100 {
result.AddFieldFailure("name", propertyMaxStringLen(ctx, "name", 100))
}

return result
}

// GetEmail returns the email being verified
func (action *SignInByEmailWithName) GetEmail() string {
return action.Email
}

// GetName returns the name provided by the user
func (action *SignInByEmailWithName) GetName() string {
return action.Name
}

// GetUser returns the current user performing this action
func (action *SignInByEmailWithName) GetUser() *entity.User {
return nil
}

// GetKind returns EmailVerificationKindSignIn
func (action *SignInByEmailWithName) GetKind() enum.EmailVerificationKind {
return enum.EmailVerificationKindSignIn
}

// CompleteProfile happens when users completes their profile during first time sign in
type CompleteProfile struct {
Kind enum.EmailVerificationKind `json:"kind"`
Expand Down
102 changes: 101 additions & 1 deletion app/actions/signin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ func TestSignInByEmail_ShouldHaveVerificationKey(t *testing.T) {

result := action.Validate(context.Background(), nil)
ExpectSuccess(result)
Expect(action.VerificationKey).IsNotEmpty()
Expect(action.VerificationCode).IsNotEmpty()
Expect(len(action.VerificationCode)).Equals(6)
}

func TestCompleteProfile_EmptyNameAndKey(t *testing.T) {
Expand All @@ -52,3 +53,102 @@ func TestCompleteProfile_LongName(t *testing.T) {
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "name", "key")
}

func TestVerifySignInCode_EmptyEmail(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "", Code: "123456"}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "email")
}

func TestVerifySignInCode_InvalidEmail(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "invalid", Code: "123456"}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "email")
}

func TestVerifySignInCode_EmptyCode(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "jon.snow@got.com", Code: ""}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "code")
}

func TestVerifySignInCode_InvalidCodeLength(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "jon.snow@got.com", Code: "12345"}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "code")

action2 := actions.VerifySignInCode{Email: "jon.snow@got.com", Code: "1234567"}
result2 := action2.Validate(context.Background(), nil)
ExpectFailed(result2, "code")
}

func TestVerifySignInCode_NonNumericCode(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "jon.snow@got.com", Code: "12345A"}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "code")
}

func TestVerifySignInCode_ValidCodeAndEmail(t *testing.T) {
RegisterT(t)

action := actions.VerifySignInCode{Email: "jon.snow@got.com", Code: "123456"}
result := action.Validate(context.Background(), nil)
ExpectSuccess(result)
}

func TestSignInByEmailWithName_EmptyEmailAndName(t *testing.T) {
RegisterT(t)

action := actions.SignInByEmailWithName{Email: "", Name: ""}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "email", "name")
}

func TestSignInByEmailWithName_InvalidEmail(t *testing.T) {
RegisterT(t)

action := actions.SignInByEmailWithName{Email: "invalid", Name: "Jon Snow"}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "email")
}

func TestSignInByEmailWithName_EmptyName(t *testing.T) {
RegisterT(t)

action := actions.SignInByEmailWithName{Email: "jon.snow@got.com", Name: ""}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "name")
}

func TestSignInByEmailWithName_LongName(t *testing.T) {
RegisterT(t)

// 101 characters
longName := "12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901"
action := actions.SignInByEmailWithName{Email: "jon.snow@got.com", Name: longName}
result := action.Validate(context.Background(), nil)
ExpectFailed(result, "name")
}

func TestSignInByEmailWithName_ValidEmailAndName(t *testing.T) {
RegisterT(t)

action := actions.NewSignInByEmailWithName()
action.Email = "jon.snow@got.com"
action.Name = "Jon Snow"

result := action.Validate(context.Background(), nil)
ExpectSuccess(result)
Expect(action.VerificationCode).IsNotEmpty()
Expect(len(action.VerificationCode)).Equals(6)
}
4 changes: 4 additions & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,16 @@ func routes(r *web.Engine) *web.Engine {
r.Use(middlewares.BlockPendingTenants())

r.Get("/signin", handlers.SignInPage())
r.Get("/signin/complete", handlers.CompleteSignInProfilePage())
r.Get("/loginemailsent", handlers.LoginEmailSentPage())
r.Get("/not-invited", handlers.NotInvitedPage())
r.Get("/signin/verify", handlers.VerifySignInKey(enum.EmailVerificationKindSignIn))
r.Get("/invite/verify", handlers.VerifySignInKey(enum.EmailVerificationKindUserInvitation))
r.Post("/_api/signin/complete", handlers.CompleteSignInProfile())
r.Post("/_api/signin", handlers.SignInByEmail())
r.Post("/_api/signin/newuser", handlers.SignInByEmailWithName())
r.Post("/_api/signin/verify", handlers.VerifySignInCode())
r.Post("/_api/signin/resend", handlers.ResendSignInCode())

// Block if it's private tenant with unauthenticated user
r.Use(middlewares.CheckTenantPrivacy())
Expand Down
Loading
Loading