diff --git a/Makefile b/Makefile index 2be0e4d7a..63591484f 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/app/actions/signin.go b/app/actions/signin.go index 4b5a3ebeb..7b8f8ebde 100644 --- a/app/actions/signin.go +++ b/app/actions/signin.go @@ -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(), } } @@ -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"` diff --git a/app/actions/signin_test.go b/app/actions/signin_test.go index 156ba5952..d7a5fde39 100644 --- a/app/actions/signin_test.go +++ b/app/actions/signin_test.go @@ -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) { @@ -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) +} diff --git a/app/cmd/routes.go b/app/cmd/routes.go index 389cb1888..e315776f2 100644 --- a/app/cmd/routes.go +++ b/app/cmd/routes.go @@ -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()) diff --git a/app/handlers/signin.go b/app/handlers/signin.go index 1f2812019..df03aa133 100644 --- a/app/handlers/signin.go +++ b/app/handlers/signin.go @@ -46,6 +46,20 @@ func LoginEmailSentPage() web.HandlerFunc { } } +// CompleteSignInProfilePage renders the complete profile page for code flow +func CompleteSignInProfilePage() web.HandlerFunc { + return func(c *web.Context) error { + return c.Page(http.StatusOK, web.Props{ + Page: "SignIn/CompleteSignInProfile.page", + Title: "Complete your profile", + Data: web.Map{ + "kind": enum.EmailVerificationKindSignIn, + "k": c.QueryParam("code"), + }, + }) + } +} + // NotInvitedPage renders the not invited page func NotInvitedPage() web.HandlerFunc { return func(c *web.Context) error { @@ -57,7 +71,7 @@ func NotInvitedPage() web.HandlerFunc { } } -// SignInByEmail sends a new email with verification key +// SignInByEmail checks if user exists and sends code only for existing users func SignInByEmail() web.HandlerFunc { return func(c *web.Context) error { action := actions.NewSignInByEmail() @@ -65,16 +79,191 @@ func SignInByEmail() web.HandlerFunc { return c.HandleValidation(result) } + // Check if user exists + userByEmail := &query.GetUserByEmail{Email: action.Email} + err := bus.Dispatch(c, userByEmail) + userExists := err == nil + + // Only send code if user exists + if userExists { + err := bus.Dispatch(c, &cmd.SaveVerificationKey{ + Key: action.VerificationCode, + Duration: 15 * time.Minute, + Request: action, + }) + if err != nil { + return c.Failure(err) + } + + c.Enqueue(tasks.SendSignInEmail(action.Email, action.VerificationCode)) + } + + return c.Ok(web.Map{ + "userExists": userExists, + }) + } +} + +// SignInByEmailWithName sends verification code for new users with their name +func SignInByEmailWithName() web.HandlerFunc { + return func(c *web.Context) error { + action := actions.NewSignInByEmailWithName() + if result := c.BindTo(action); !result.Ok { + return c.HandleValidation(result) + } + + // Check that user doesn't already exist + userByEmail := &query.GetUserByEmail{Email: action.Email} + err := bus.Dispatch(c, userByEmail) + if err == nil { + // User already exists, should use regular sign in + return c.BadRequest(web.Map{ + "email": "An account with this email already exists. Please sign in.", + }) + } + + // Check if tenant is private (new users not allowed) + if c.Tenant().IsPrivate { + return c.Forbidden() + } + + // Save verification with name + err = bus.Dispatch(c, &cmd.SaveVerificationKey{ + Key: action.VerificationCode, + Duration: 15 * time.Minute, + Request: action, + }) + if err != nil { + return c.Failure(err) + } + + c.Enqueue(tasks.SendSignInEmail(action.Email, action.VerificationCode)) + + return c.Ok(web.Map{}) + } +} + +// VerifySignInCode verifies the code entered by the user and signs them in +func VerifySignInCode() web.HandlerFunc { + return func(c *web.Context) error { + action := &actions.VerifySignInCode{} + if result := c.BindTo(action); !result.Ok { + return c.HandleValidation(result) + } + + // Get verification by email and code + verification := &query.GetVerificationByEmailAndCode{ + Email: action.Email, + Code: action.Code, + Kind: enum.EmailVerificationKindSignIn, + } + err := bus.Dispatch(c, verification) + if err != nil { + if errors.Cause(err) == app.ErrNotFound { + return c.BadRequest(web.Map{ + "code": "Invalid or expired verification code", + }) + } + return c.Failure(err) + } + + result := verification.Result + + // Check if already verified (with grace period) + if result.VerifiedAt != nil { + if time.Since(*result.VerifiedAt) > 5*time.Minute { + return c.Gone() + } + } else { + // Check if expired + if time.Now().After(result.ExpiresAt) { + // Mark as verified to prevent reuse + _ = bus.Dispatch(c, &cmd.SetKeyAsVerified{Key: action.Code}) + return c.Gone() + } + } + + // Check if user exists + userByEmail := &query.GetUserByEmail{Email: result.Email} + err = bus.Dispatch(c, userByEmail) + if err != nil { + if errors.Cause(err) == app.ErrNotFound { + // User doesn't exist + if c.Tenant().IsPrivate { + return c.Forbidden() + } + + // If name is provided in verification, create user account + if result.Name != "" { + user := &entity.User{ + Name: result.Name, + Email: result.Email, + Tenant: c.Tenant(), + Role: enum.RoleVisitor, + } + err = bus.Dispatch(c, &cmd.RegisterUser{User: user}) + if err != nil { + return c.Failure(err) + } + + // Mark code as verified + err = bus.Dispatch(c, &cmd.SetKeyAsVerified{Key: action.Code}) + if err != nil { + return c.Failure(err) + } + + // Authenticate newly created user + webutil.AddAuthUserCookie(c, user) + return c.Ok(web.Map{}) + } + + // Name not provided - shouldn't happen with new flow, but handle legacy case + return c.Ok(web.Map{ + "showProfileCompletion": true, + }) + } + return c.Failure(err) + } + + // Mark code as verified + err = bus.Dispatch(c, &cmd.SetKeyAsVerified{Key: action.Code}) + if err != nil { + return c.Failure(err) + } + + // Authenticate user + webutil.AddAuthUserCookie(c, userByEmail.Result) + + return c.Ok(web.Map{}) + } +} + +// ResendSignInCode invalidates the previous code and sends a new one +func ResendSignInCode() web.HandlerFunc { + return func(c *web.Context) error { + // Create new sign-in action with new code + action := actions.NewSignInByEmail() + if result := c.BindTo(action); !result.Ok { + return c.HandleValidation(result) + } + + // Authorization check + if !action.IsAuthorized(c, c.User()) { + return c.Forbidden() + } + + // Save new verification code err := bus.Dispatch(c, &cmd.SaveVerificationKey{ - Key: action.VerificationKey, - Duration: 30 * time.Minute, + Key: action.VerificationCode, + Duration: 15 * time.Minute, Request: action, }) if err != nil { return c.Failure(err) } - c.Enqueue(tasks.SendSignInEmail(action.Email, action.VerificationKey)) + // Send new email + c.Enqueue(tasks.SendSignInEmail(action.Email, action.VerificationCode)) return c.Ok(web.Map{}) } @@ -97,6 +286,30 @@ func VerifySignInKey(kind enum.EmailVerificationKind) web.HandlerFunc { return NotInvitedPage()(c) } + // If name is provided in verification, create user account immediately + if result.Name != "" { + user := &entity.User{ + Name: result.Name, + Email: result.Email, + Tenant: c.Tenant(), + Role: enum.RoleVisitor, + } + err = bus.Dispatch(c, &cmd.RegisterUser{User: user}) + if err != nil { + return c.Failure(err) + } + + err = bus.Dispatch(c, &cmd.SetKeyAsVerified{Key: key}) + if err != nil { + return c.Failure(err) + } + + webutil.AddAuthUserCookie(c, user) + baseURL := c.BaseURL() + return c.Redirect(baseURL) + } + + // Otherwise, show profile completion page return c.Page(http.StatusOK, web.Props{ Page: "SignIn/CompleteSignInProfile.page", Title: "Complete Sign In Profile", @@ -105,6 +318,7 @@ func VerifySignInKey(kind enum.EmailVerificationKind) web.HandlerFunc { "k": key, }, }) + } return c.Failure(err) } diff --git a/app/handlers/signin_test.go b/app/handlers/signin_test.go index 0bff24c2b..f00ea8d15 100644 --- a/app/handlers/signin_test.go +++ b/app/handlers/signin_test.go @@ -34,7 +34,7 @@ func TestSignInByEmailHandler_WithoutEmail(t *testing.T) { Expect(code).Equals(http.StatusBadRequest) } -func TestSignInByEmailHandler_WithEmail(t *testing.T) { +func TestSignInByEmailHandler_ExistingUser(t *testing.T) { RegisterT(t) var saveKeyCmd *cmd.SaveVerificationKey @@ -43,16 +43,107 @@ func TestSignInByEmailHandler_WithEmail(t *testing.T) { return nil }) + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + if q.Email == "jon.snow@got.com" { + q.Result = mock.JonSnow + return nil + } + return app.ErrNotFound + }) + server := mock.NewServer() - code, _ := server. + code, response := server. OnTenant(mock.DemoTenant). ExecutePost(handlers.SignInByEmail(), `{ "email": "jon.snow@got.com" }`) Expect(code).Equals(http.StatusOK) - Expect(saveKeyCmd.Key).HasLen(64) + Expect(saveKeyCmd.Key).HasLen(6) Expect(saveKeyCmd.Request.GetKind()).Equals(enum.EmailVerificationKindSignIn) Expect(saveKeyCmd.Request.GetEmail()).Equals("jon.snow@got.com") - Expect(saveKeyCmd.Request.GetName()).Equals("") + Expect(response.Body.String()).ContainsSubstring(`"userExists":true`) +} + +func TestSignInByEmailHandler_NewUser(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + server := mock.NewServer() + code, response := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.SignInByEmail(), `{ "email": "new.user@got.com" }`) + + Expect(code).Equals(http.StatusOK) + Expect(response.Body.String()).ContainsSubstring(`"userExists":false`) +} + +func TestSignInByEmailWithNameHandler_NewUser(t *testing.T) { + RegisterT(t) + + var saveKeyCmd *cmd.SaveVerificationKey + bus.AddHandler(func(ctx context.Context, c *cmd.SaveVerificationKey) error { + saveKeyCmd = c + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + server := mock.NewServer() + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.SignInByEmailWithName(), `{ "email": "new.user@got.com", "name": "New User" }`) + + Expect(code).Equals(http.StatusOK) + Expect(saveKeyCmd.Key).HasLen(6) + Expect(saveKeyCmd.Request.GetKind()).Equals(enum.EmailVerificationKindSignIn) + Expect(saveKeyCmd.Request.GetEmail()).Equals("new.user@got.com") + Expect(saveKeyCmd.Request.GetName()).Equals("New User") +} + +func TestSignInByEmailWithNameHandler_ExistingUser(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + if q.Email == "jon.snow@got.com" { + q.Result = mock.JonSnow + return nil + } + return app.ErrNotFound + }) + + server := mock.NewServer() + code, response := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.SignInByEmailWithName(), `{ "email": "jon.snow@got.com", "name": "Jon Snow" }`) + + Expect(code).Equals(http.StatusBadRequest) + Expect(response.Body.String()).ContainsSubstring("already exists") +} + +func TestSignInByEmailWithNameHandler_PrivateTenant(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + privateTenant := &entity.Tenant{ + ID: 1, + Name: "Private Tenant", + Subdomain: "private", + IsPrivate: true, + } + + server := mock.NewServer() + code, _ := server. + OnTenant(privateTenant). + ExecutePost(handlers.SignInByEmailWithName(), `{ "email": "new.user@got.com", "name": "New User" }`) + + Expect(code).Equals(http.StatusForbidden) } func TestVerifySignInKeyHandler_UnknownKey(t *testing.T) { @@ -266,6 +357,56 @@ func TestVerifySignInKeyHandler_CorrectKey_NewUser(t *testing.T) { Expect(code).Equals(http.StatusOK) } +func TestVerifySignInKeyHandler_CorrectKey_NewUser_WithName(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + key := "1234567890" + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByKey) error { + if q.Key == key && q.Kind == enum.EmailVerificationKindSignIn { + expiresAt := time.Now().Add(5 * time.Minute) + q.Result = &entity.EmailVerification{ + Key: q.Key, + Kind: q.Kind, + ExpiresAt: expiresAt, + Email: "hot.pie@got.com", + Name: "Hot Pie", + } + return nil + } + return app.ErrNotFound + }) + + var registeredUser *entity.User + bus.AddHandler(func(ctx context.Context, c *cmd.RegisterUser) error { + registeredUser = c.User + registeredUser.ID = 1 + Expect(c.User.Name).Equals("Hot Pie") + Expect(c.User.Email).Equals("hot.pie@got.com") + Expect(c.User.Role).Equals(enum.RoleVisitor) + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetKeyAsVerified) error { + return nil + }) + + server := mock.NewServer() + + code, response := server. + OnTenant(mock.DemoTenant). + WithURL("http://demo.test.fider.io/signin/verify?k=" + key). + Execute(handlers.VerifySignInKey(enum.EmailVerificationKindSignIn)) + + Expect(code).Equals(http.StatusTemporaryRedirect) + Expect(response.Header().Get("Location")).Equals("http://demo.test.fider.io") + Expect(registeredUser).IsNotNil() + ExpectFiderAuthCookie(response, registeredUser) +} + func TestVerifySignInKeyHandler_PrivateTenant_SignInRequest_NonInviteNewUser(t *testing.T) { RegisterT(t) @@ -642,6 +783,205 @@ func TestSignInPageHandler_PrivateTenant_UnauthenticatedUser(t *testing.T) { Expect(code).Equals(http.StatusOK) } +func TestVerifySignInCodeHandler_InvalidCode(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + return app.ErrNotFound + }) + + server := mock.NewServer() + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "jon.snow@got.com", "code": "999999" }`) + + Expect(code).Equals(http.StatusBadRequest) +} + +func TestVerifySignInCodeHandler_ExpiredCode(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + q.Result = &entity.EmailVerification{ + Email: "jon.snow@got.com", + Key: "123456", + CreatedAt: time.Now().Add(-20 * time.Minute), + ExpiresAt: time.Now().Add(-5 * time.Minute), + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetKeyAsVerified) error { + return nil + }) + + server := mock.NewServer() + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "jon.snow@got.com", "code": "123456" }`) + + Expect(code).Equals(http.StatusGone) +} + +func TestVerifySignInCodeHandler_CorrectCode_ExistingUser(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + q.Result = &entity.EmailVerification{ + Email: "jon.snow@got.com", + Key: "123456", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(15 * time.Minute), + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + q.Result = mock.JonSnow + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetKeyAsVerified) error { + return nil + }) + + server := mock.NewServer() + code, response := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "jon.snow@got.com", "code": "123456" }`) + + Expect(code).Equals(http.StatusOK) + ExpectFiderAuthCookie(response, mock.JonSnow) +} + +func TestVerifySignInCodeHandler_CorrectCode_NewUser_WithoutName(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + q.Result = &entity.EmailVerification{ + Email: "new.user@got.com", + Key: "123456", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(15 * time.Minute), + Name: "", // No name stored (legacy flow) + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + server := mock.NewServer() + code, response := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "new.user@got.com", "code": "123456" }`) + + Expect(code).Equals(http.StatusOK) + Expect(response.Body.String()).ContainsSubstring(`"showProfileCompletion":true`) +} + +func TestVerifySignInCodeHandler_CorrectCode_NewUser_WithName(t *testing.T) { + RegisterT(t) + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + q.Result = &entity.EmailVerification{ + Email: "new.user@got.com", + Key: "123456", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(15 * time.Minute), + Name: "New User", // Name stored (new flow) + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + userRegistered := false + var registeredUser *entity.User + bus.AddHandler(func(ctx context.Context, c *cmd.RegisterUser) error { + userRegistered = true + registeredUser = c.User + registeredUser.ID = 999 + return nil + }) + + bus.AddHandler(func(ctx context.Context, c *cmd.SetKeyAsVerified) error { + return nil + }) + + server := mock.NewServer() + code, response := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "new.user@got.com", "code": "123456" }`) + + Expect(code).Equals(http.StatusOK) + Expect(userRegistered).IsTrue() + Expect(registeredUser.Name).Equals("New User") + Expect(registeredUser.Email).Equals("new.user@got.com") + ExpectFiderAuthCookie(response, registeredUser) +} + +func TestVerifySignInCodeHandler_CorrectCode_NewUser_PrivateTenant(t *testing.T) { + RegisterT(t) + + server := mock.NewServer() + mock.DemoTenant.IsPrivate = true + + bus.AddHandler(func(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + q.Result = &entity.EmailVerification{ + Email: "new.user@got.com", + Key: "123456", + CreatedAt: time.Now(), + ExpiresAt: time.Now().Add(15 * time.Minute), + } + return nil + }) + + bus.AddHandler(func(ctx context.Context, q *query.GetUserByEmail) error { + return app.ErrNotFound + }) + + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.VerifySignInCode(), `{ "email": "new.user@got.com", "code": "123456" }`) + + Expect(code).Equals(http.StatusForbidden) +} + +func TestResendSignInCodeHandler_ValidEmail(t *testing.T) { + RegisterT(t) + + var saveKeyCmd *cmd.SaveVerificationKey + bus.AddHandler(func(ctx context.Context, c *cmd.SaveVerificationKey) error { + saveKeyCmd = c + return nil + }) + + server := mock.NewServer() + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.ResendSignInCode(), `{ "email": "jon.snow@got.com" }`) + + Expect(code).Equals(http.StatusOK) + Expect(saveKeyCmd.Key).HasLen(6) + Expect(saveKeyCmd.Request.GetKind()).Equals(enum.EmailVerificationKindSignIn) + Expect(saveKeyCmd.Request.GetEmail()).Equals("jon.snow@got.com") +} + +func TestResendSignInCodeHandler_InvalidEmail(t *testing.T) { + RegisterT(t) + + server := mock.NewServer() + code, _ := server. + OnTenant(mock.DemoTenant). + ExecutePost(handlers.ResendSignInCode(), `{ "email": "invalid" }`) + + Expect(code).Equals(http.StatusBadRequest) +} + func ExpectFiderAuthCookie(response *httptest.ResponseRecorder, expected *entity.User) { cookies := response.Header()["Set-Cookie"] if expected == nil { diff --git a/app/models/entity/email_verification.go b/app/models/entity/email_verification.go index 639f6707b..06825b395 100644 --- a/app/models/entity/email_verification.go +++ b/app/models/entity/email_verification.go @@ -23,3 +23,8 @@ type EmailVerification struct { func GenerateEmailVerificationKey() string { return rand.String(64) } + +// GenerateEmailVerificationCode returns a 6 digit numeric code +func GenerateEmailVerificationCode() string { + return rand.StringNumeric(6) +} diff --git a/app/models/query/tenant.go b/app/models/query/tenant.go index 41c9db5ff..fddc71c7a 100644 --- a/app/models/query/tenant.go +++ b/app/models/query/tenant.go @@ -29,6 +29,15 @@ type GetVerificationByKey struct { Result *entity.EmailVerification } +type GetVerificationByEmailAndCode struct { + Email string + Code string + Kind enum.EmailVerificationKind + + // Output + Result *entity.EmailVerification +} + type GetFirstTenant struct { // Output diff --git a/app/pkg/rand/random.go b/app/pkg/rand/random.go index 42c6e0267..fd4782a9b 100644 --- a/app/pkg/rand/random.go +++ b/app/pkg/rand/random.go @@ -6,6 +6,7 @@ import ( ) var chars = []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") +var numericChars = []byte("0123456789") // String returns a random string of given length func String(n int) string { @@ -25,3 +26,22 @@ func String(n int) string { return string(bytes) } + +// StringNumeric returns a random numeric string of given length +func StringNumeric(n int) string { + if n <= 0 { + return "" + } + + bytes := make([]byte, n) + charsetLen := big.NewInt(int64(len(numericChars))) + for i := 0; i < n; i++ { + c, err := rand.Int(rand.Reader, charsetLen) + if err != nil { + panic(err) + } + bytes[i] = numericChars[c.Int64()] + } + + return string(bytes) +} diff --git a/app/services/sqlstore/postgres/postgres.go b/app/services/sqlstore/postgres/postgres.go index e628494fb..f2247702a 100644 --- a/app/services/sqlstore/postgres/postgres.go +++ b/app/services/sqlstore/postgres/postgres.go @@ -114,6 +114,7 @@ func (s Service) Init() { bus.AddHandler(updateTenantAdvancedSettings) bus.AddHandler(getVerificationByKey) + bus.AddHandler(getVerificationByEmailAndCode) bus.AddHandler(saveVerificationKey) bus.AddHandler(setKeyAsVerified) bus.AddHandler(getPendingSignUpVerification) diff --git a/app/services/sqlstore/postgres/tenant.go b/app/services/sqlstore/postgres/tenant.go index 31ae9fdf5..5473ad2c4 100644 --- a/app/services/sqlstore/postgres/tenant.go +++ b/app/services/sqlstore/postgres/tenant.go @@ -213,6 +213,21 @@ func getVerificationByKey(ctx context.Context, q *query.GetVerificationByKey) er }) } +func getVerificationByEmailAndCode(ctx context.Context, q *query.GetVerificationByEmailAndCode) error { + return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { + verification := dbEmailVerification{} + + query := "SELECT id, email, name, key, created_at, verified_at, expires_at, kind, user_id FROM email_verifications WHERE tenant_id = $1 AND email = $2 AND key = $3 AND kind = $4 LIMIT 1" + err := trx.Get(&verification, query, tenant.ID, q.Email, q.Code, q.Kind) + if err != nil { + return errors.Wrap(err, "failed to get email verification by email and code") + } + + q.Result = verification.toModel() + return nil + }) +} + func saveVerificationKey(ctx context.Context, c *cmd.SaveVerificationKey) error { return using(ctx, func(trx *dbx.Trx, tenant *entity.Tenant, user *entity.User) error { var userID any diff --git a/app/tasks/signin.go b/app/tasks/signin.go index 4980f4914..0b66f6776 100644 --- a/app/tasks/signin.go +++ b/app/tasks/signin.go @@ -9,11 +9,12 @@ import ( ) // SendSignInEmail is used to send the sign in email to requestor -func SendSignInEmail(email, verificationKey string) worker.Task { +func SendSignInEmail(email, verificationCode string) worker.Task { return describe("Send sign in email", func(c *worker.Context) error { to := dto.NewRecipient("", email, dto.Props{ "siteName": c.Tenant().Name, - "link": link(web.BaseURL(c), "/signin/verify?k=%s", verificationKey), + "code": verificationCode, + "link": link(web.BaseURL(c), "/signin/verify?k=%s", verificationCode), }) bus.Publish(c, &cmd.SendMail{ diff --git a/app/tasks/signin_test.go b/app/tasks/signin_test.go index ee3ce22da..a554c487f 100644 --- a/app/tasks/signin_test.go +++ b/app/tasks/signin_test.go @@ -39,6 +39,7 @@ func TestSendSignInEmailTask(t *testing.T) { Address: "jon@got.com", Props: dto.Props{ "siteName": mock.DemoTenant.Name, + "code": "9876", "link": "http://domain.com/signin/verify?k=9876", }, }) diff --git a/e2e/features/ui/post.feature b/e2e/features/ui/post.feature index 4344fdbd4..7a69abb88 100644 --- a/e2e/features/ui/post.feature +++ b/e2e/features/ui/post.feature @@ -24,11 +24,11 @@ Feature: Post And I type "This is a draft post from a new user" as the description And I type my email address And I click continue with email - Then I should be on the confirmation link page - Given I click on the confirmation link - Then I should be on the complete profile page + Then I should see the name field Given I enter my name as "Matt" - And I click submit + And I click continue + Then I should be on the confirmation code page + Given I enter the confirmation code Then I should be on the home page And I should see the new post modal And I should see "This is a draft post from a new user" as the draft post title \ No newline at end of file diff --git a/e2e/setup.ts b/e2e/setup.ts index 4ff69bc9d..75335ab7e 100644 --- a/e2e/setup.ts +++ b/e2e/setup.ts @@ -11,8 +11,8 @@ type BrowserName = "chromium" | "firefox" | "webkit" BeforeAll({ timeout: 30 * 1000 }, async function () { const name = (process.env.BROWSER || "chromium") as BrowserName browser = await playwright[name].launch({ - headless: true, - slowMo: 10, + headless: process.env.HEADED !== "true", + slowMo: process.env.HEADED === "true" ? 100 : 10, }) if (!tenantName) { @@ -51,10 +51,10 @@ async function createNewSite() { const adminEmail = `admin-${tenantName}@fider.io` //Create site await page.goto("https://login.dev.fider.io:3000/signup") - await page.type("#input-name", "admin") - await page.type("#input-email", adminEmail) - await page.type("#input-tenantName", tenantName) - await page.type("#input-subdomain", tenantName) + await page.fill("#input-name", "admin") + await page.fill("#input-email", adminEmail) + await page.fill("#input-tenantName", tenantName) + await page.fill("#input-subdomain", tenantName) await page.check("#input-legalAgreement") await page.click(".c-button--primary") diff --git a/e2e/step_definitions/fns.ts b/e2e/step_definitions/fns.ts index 03096b022..acb118196 100644 --- a/e2e/step_definitions/fns.ts +++ b/e2e/step_definitions/fns.ts @@ -29,3 +29,19 @@ export async function getLatestLinkSentTo(address: string): Promise { return result[0] } + +export async function getLatestCodeSentTo(address: string): Promise { + await delay(1000) + + const response = await fetch(`http://localhost:8025/api/v2/search?kind=to&query=${address}`) + const responseBody = await response.json() + const emailHtml = responseBody.items[0].Content.Body + // Look for 6-digit code in the email + const reg = /\b\d{6}\b/ + const result = reg.exec(emailHtml) + if (!result) { + throw new Error("Could not find a 6-digit code in email content.") + } + + return result[0] +} diff --git a/e2e/step_definitions/home.steps.ts b/e2e/step_definitions/home.steps.ts index 8ca282a53..ac5f6dead 100644 --- a/e2e/step_definitions/home.steps.ts +++ b/e2e/step_definitions/home.steps.ts @@ -1,7 +1,7 @@ import { Given, Then } from "@cucumber/cucumber" import { FiderWorld } from "../world" import expect from "expect" -import { getLatestLinkSentTo } from "./fns" +import { getLatestCodeSentTo } from "./fns" Given("I go to the home page", async function (this: FiderWorld) { await this.page.goto(`https://${this.tenantName}.dev.fider.io:3000/`) @@ -44,19 +44,39 @@ Given("I click continue with email", async function () { await this.page.click(".c-signin-control button[type='submit']") }) +Then("I should see the name field", async function (this: FiderWorld) { + // Wait for the name field to appear + await this.page.waitForSelector("#input-name", { timeout: 5000 }) + const nameField = await this.page.locator("#input-name") + await expect(nameField).toBeVisible() +}) + +Given("I click continue", async function () { + await this.page.getByRole("button", { name: "Sign up" }).click() +}) + Given("I click submit your feedback", async function () { await this.page.click(".c-share-feedback__content .c-button--primary") }) -Given("I click on the confirmation link", async function (this: FiderWorld) { +Then("I should be on the confirmation code page", async function (this: FiderWorld) { const userEmail = `$user-${this.tenantName}@fider.io` - const activationLink = await getLatestLinkSentTo(userEmail) - await this.page.goto(activationLink) + // Wait for the code entry field to appear + await this.page.waitForSelector("#input-code", { timeout: 5000 }) + // Check for code entry instruction message + await expect(this.page.getByText(`Please type in the code we just sent to ${userEmail}`)).toBeVisible() }) -Then("I should be on the complete profile page", async function (this: FiderWorld) { - const container = await this.page.$$("#p-complete-profile") - await expect(container).toBeDefined() +Given("I enter the confirmation code", async function (this: FiderWorld) { + const userEmail = `$user-${this.tenantName}@fider.io` + const code = await getLatestCodeSentTo(userEmail) + + // Enter the code in the UI + await this.page.fill("#input-code", code) + await this.page.getByRole("button", { name: "submit" }).click() + + // Wait for navigation after successful code verification + await this.page.waitForLoadState("networkidle") }) Then("I should see the new post modal", async function (this: FiderWorld) { @@ -65,16 +85,11 @@ Then("I should see the new post modal", async function (this: FiderWorld) { }) Given("I enter my name as {string}", async function (this: FiderWorld, name: string) { - await this.page.type("#input-name", name) + await this.page.fill("#input-name", name) }) Given("I click submit", async function () { - await this.page.click("button[type='submit']") -}) - -Then("I should be on the confirmation link page", async function (this: FiderWorld) { - const userEmail = `$user-${this.tenantName}@fider.io` - await expect(this.page.getByText(`We have just sent a confirmation link to ${userEmail}`)).toBeVisible() + await this.page.getByRole("button", { name: "submit" }).click() }) Then("I should see {string} as the draft post title", async function (this: FiderWorld, title: string) { diff --git a/e2e/step_definitions/user.steps.ts b/e2e/step_definitions/user.steps.ts index 6d61c2b58..83469fce2 100644 --- a/e2e/step_definitions/user.steps.ts +++ b/e2e/step_definitions/user.steps.ts @@ -1,6 +1,6 @@ import { Given } from "@cucumber/cucumber" import { FiderWorld } from "e2e/world" -import { getLatestLinkSentTo, isAuthenticated, isAuthenticatedAsUser } from "./fns" +import { getLatestCodeSentTo, isAuthenticated, isAuthenticatedAsUser } from "./fns" Given("I sign in as {string}", async function (this: FiderWorld, userName: string) { if (await isAuthenticatedAsUser(this.page, userName)) { @@ -14,9 +14,14 @@ Given("I sign in as {string}", async function (this: FiderWorld, userName: strin const userEmail = `${userName}-${this.tenantName}@fider.io` await this.page.click(".c-menu .uppercase.text-sm") - await this.page.type(".c-signin-control #input-email", userEmail) + await this.page.fill(".c-signin-control #input-email", userEmail) await this.page.click(".c-signin-control .c-button--primary") - const activationLink = await getLatestLinkSentTo(userEmail) - await this.page.goto(activationLink) + // Get the code from email and enter it + const code = await getLatestCodeSentTo(userEmail) + await this.page.fill("#input-code", code) + await this.page.getByRole("button", { name: "submit" }).click() + + // Wait for navigation after successful code verification + await this.page.waitForLoadState("networkidle") }) diff --git a/locale/ar/client.json b/locale/ar/client.json index 74f2fb2d0..638761796 100644 --- a/locale/ar/client.json +++ b/locale/ar/client.json @@ -4,6 +4,7 @@ "action.close": "إغلاق", "action.commentsfeed": "تغذية التعليقات", "action.confirm": "تأكيد", + "action.continue": "", "action.copylink": "نسخ الرابط", "action.delete": "حذف", "action.edit": "تعديل", @@ -13,6 +14,7 @@ "action.respond": "رد", "action.save": "احفظ", "action.signin": "تسجيل الدخول", + "action.signup": "", "action.submit": "إرسال", "action.vote": "صوت لهذه الفكرة", "action.voted": "تم التصويت!", @@ -96,6 +98,15 @@ "legal.notice": "من خلال تسجيل الدخول، أنت توافق على <2/><0/> و <1/>.", "legal.privacypolicy": "سياسة الخصوصية", "legal.termsofservice": "شروط الخدمة", + "linkmodal.insert": "إدراج الرابط", + "linkmodal.text.label": "النص المراد عرضه", + "linkmodal.text.placeholder": "أدخل نص الرابط", + "linkmodal.text.required": "النص مطلوب", + "linkmodal.title": "إدراج الرابط", + "linkmodal.url.invalid": "الرجاء إدخال عنوان URL صالح", + "linkmodal.url.label": "عنوان URL", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "مطلوب عنوان URL", "menu.administration": "آلإدارة", "menu.mysettings": "إعداداتي", "menu.signout": "تسجيل الخروج", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "ما الذي يجري مع هذا المنشور؟ أخبر المستخدمين ما هي خططك...", "showpost.votespanel.more": "+{extraVotesCount} أكثر", "showpost.votespanel.seedetails": "عرض التفاصيل", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "البريد الإلكتروني", "signin.message.email": "متابعة بالبريد الإلكتروني", "signin.message.emaildisabled": "تم تعطيل مصادقة البريد الإلكتروني من قبل المسؤول. إذا كان لديك حساب مسؤول وتحتاج إلى تجاوز هذا التقييد، الرجاء <0>انقر هنا .", @@ -202,15 +219,8 @@ "signin.message.private.text": "إذا كان لديك حساب أو دعوت، فيمكنك استخدام الخيارات التالية لتسجيل الدخول.", "signin.message.private.title": "<0>{0} مساحة خاصة، يجب عليك تسجيل الدخول للمشاركة والتصويت.", "signin.message.socialbutton.intro": "تسجيل الدخول بواسطة", + "signin.name.placeholder": "", "validation.custom.maxattachments": "يُسمح بحد أقصى {number} من المرفقات.", "validation.custom.maximagesize": "يجب أن يكون حجم الصورة أصغر من {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, zero {}one {# وسم} two {# وسوم} few {# وسوم} many {# وسوم} other {# وسوم}}", - "linkmodal.insert": "إدراج الرابط", - "linkmodal.text.label": "النص المراد عرضه", - "linkmodal.text.placeholder": "أدخل نص الرابط", - "linkmodal.text.required": "النص مطلوب", - "linkmodal.title": "إدراج الرابط", - "linkmodal.url.invalid": "الرجاء إدخال عنوان URL صالح", - "linkmodal.url.label": "عنوان URL", - "linkmodal.url.required": "مطلوب عنوان URL" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, zero {}one {# وسم} two {# وسوم} few {# وسوم} many {# وسوم} other {# وسوم}}" } \ No newline at end of file diff --git a/locale/ar/server.json b/locale/ar/server.json index fb0e977bc..a43e4bd97 100644 --- a/locale/ar/server.json +++ b/locale/ar/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} تم حذفه.", "email.new_comment.text": "{userName} ترك تعليقًا على {title} ({postLink}).", "email.new_post.text": "{userName} أنشأ منشورًا جديدًا {title} ({postLink}).", - "email.signin_email.subject": "سجل الدخول إلى {siteName}", + "email.signin_email.subject": "رمز تسجيل الدخول إلى {siteName} هو {code}", "email.signin_email.text": "لقد طلبت منا أن نرسل لك رابط تسجيل الدخول وهنا هو كذلك.", "email.signin_email.confirmation": "انقر على الرابط أدناه لتسجيل الدخول إلى {siteName}.", "email.signup_email.subject": "موقع فايـدر الجديد الخاص بك", diff --git a/locale/cs/server.json b/locale/cs/server.json index b6f22f20a..2dfb812a2 100644 --- a/locale/cs/server.json +++ b/locale/cs/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "Soubor {title} byl smazán.", "email.new_comment.text": "{userName} zanechal(a) komentář k příspěvku {title} ({postLink}).", "email.new_post.text": "{userName} vytvořil(a) nový příspěvek {title} ({postLink}).", - "email.signin_email.subject": "Přihlaste se do {siteName}", + "email.signin_email.subject": "Váš přihlašovací kód pro {siteName} je {code}", "email.signin_email.text": "Požádali jste nás o zaslání přihlašovacího odkazu a tady je.", "email.signin_email.confirmation": "Klikněte na odkaz níže a přihlaste se do {siteName}.", "email.signup_email.subject": "Vaše nové stránky Fideru", diff --git a/locale/de/client.json b/locale/de/client.json index 5c3cb12f4..a2d68bbe7 100644 --- a/locale/de/client.json +++ b/locale/de/client.json @@ -4,6 +4,7 @@ "action.close": "Schließen", "action.commentsfeed": "Kommentar-Feed", "action.confirm": "Bestätigen", + "action.continue": "", "action.copylink": "Link kopieren", "action.delete": "Löschen", "action.edit": "Bearbeiten", @@ -13,6 +14,7 @@ "action.respond": "Antworten", "action.save": "Sichern", "action.signin": "Anmelden", + "action.signup": "", "action.submit": "Absenden", "action.vote": "Abstimmen", "action.voted": "Abgestimmt!", @@ -96,6 +98,15 @@ "legal.notice": "Mit der Anmeldung stimmst du den <2/><0/> und <1/> zu.", "legal.privacypolicy": "Datenschutzerklärung", "legal.termsofservice": "Nutzungsbedingungen", + "linkmodal.insert": "Link einfügen", + "linkmodal.text.label": "Anzuzeigender Text", + "linkmodal.text.placeholder": "Linktext eingeben", + "linkmodal.text.required": "Text ist erforderlich", + "linkmodal.title": "Link einfügen", + "linkmodal.url.invalid": "Bitte geben Sie eine gültige URL ein", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL ist erforderlich", "menu.administration": "Verwaltung", "menu.mysettings": "Meine Einstellungen", "menu.signout": "Abmelden", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Was passiert in diesem Beitrag? Lass deine Benutzer wissen, was deine Pläne sind...", "showpost.votespanel.more": "+{extraVotesCount} mehr", "showpost.votespanel.seedetails": "Details anschauen", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "E-Mail-Adresse", "signin.message.email": "Mit E-Mail fortfahren", "signin.message.emaildisabled": "Die E-Mail-Authentifizierung wurde von einem Administrator deaktiviert. Wenn du ein Administrator-Konto hast und diese Einschränkung umgehen musst, klicke bitte <0>hier.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Wenn du ein Konto oder eine Einladung hast, kannst du folgende Optionen nutzen, um dich anzumelden.", "signin.message.private.title": "<0>{0} ist ein privater Raum, du musst dich anmelden, um teilzunehmen und abstimmen zu können.", "signin.message.socialbutton.intro": "Einloggen mit", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Es sind maximal {number} Anhänge zulässig.", "validation.custom.maximagesize": "Die Bildgröße muss kleiner als {kilobytes}KB sein.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# Tag} other {# Tags}}", - "linkmodal.insert": "Link einfügen", - "linkmodal.text.label": "Anzuzeigender Text", - "linkmodal.text.placeholder": "Linktext eingeben", - "linkmodal.text.required": "Text ist erforderlich", - "linkmodal.title": "Link einfügen", - "linkmodal.url.invalid": "Bitte geben Sie eine gültige URL ein", - "linkmodal.url.required": "URL ist erforderlich" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# Tag} other {# Tags}}" } \ No newline at end of file diff --git a/locale/de/server.json b/locale/de/server.json index b9c73d8d5..bbf520042 100644 --- a/locale/de/server.json +++ b/locale/de/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} wurde gelöscht.", "email.new_comment.text": "{userName} hinterliess einen Kommentar auf {title} ({postLink}).", "email.new_post.text": "{userName} hat einen neuen Beitrag {title} ({postLink}) erstellt.", - "email.signin_email.subject": "Auf {siteName} registrieren", + "email.signin_email.subject": "Ihr Anmeldecode für {siteName} ist {code}", "email.signin_email.text": "Du hast uns gebeten, dir einen Login-Link zu schicken, und das hier ist er.", "email.signin_email.confirmation": "Klick auf den Link unten, um dich bei {siteName} anzumelden.", "email.signup_email.subject": "Deine neue Fider-Seite", diff --git a/locale/el/client.json b/locale/el/client.json index c239e8227..f6f90cdba 100644 --- a/locale/el/client.json +++ b/locale/el/client.json @@ -4,6 +4,7 @@ "action.close": "Κλείσιμο", "action.commentsfeed": "Ροή σχολίων", "action.confirm": "Επιβεβαίωση", + "action.continue": "", "action.copylink": "Αντιγραφή συνδέσμου", "action.delete": "Διαγραφή", "action.edit": "Επεξεργασία", @@ -13,6 +14,7 @@ "action.respond": "Απάντηση", "action.save": "Αποθήκευση", "action.signin": "Είσοδος", + "action.signup": "", "action.submit": "Υποβολή", "action.vote": "Ψηφίστε αυτήν την ιδέα", "action.voted": "Ψηφίστηκε!", @@ -96,6 +98,15 @@ "legal.notice": "Με την είσοδο, συμφωνείτε με το <2/><0/> και <1/>.", "legal.privacypolicy": "Πολιτική Απορρήτου", "legal.termsofservice": "Όροι χρήσης", + "linkmodal.insert": "Εισαγωγή συνδέσμου", + "linkmodal.text.label": "Κείμενο προς εμφάνιση", + "linkmodal.text.placeholder": "Εισαγάγετε κείμενο συνδέσμου", + "linkmodal.text.required": "Απαιτείται κείμενο", + "linkmodal.title": "Εισαγωγή συνδέσμου", + "linkmodal.url.invalid": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση URL", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "Απαιτείται διεύθυνση URL", "menu.administration": "Διαχείριση", "menu.mysettings": "Οι Ρυθμίσεις Μου", "menu.signout": "Αποσύνδεση", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Τι συμβαίνει με αυτή την ανάρτηση; Αφήστε τους χρήστες σας να γνωρίζουν ποια είναι τα σχέδιά σας...", "showpost.votespanel.more": "+{extraVotesCount} περισσότερα", "showpost.votespanel.seedetails": "δείτε λεπτομέρειες", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", "signin.message.email": "Συνέχεια με email", "signin.message.emaildisabled": "Ο έλεγχος ταυτότητας email έχει απενεργοποιηθεί από τον διαχειριστή. Εάν έχετε λογαριασμό διαχειριστή και πρέπει να παρακάμψετε αυτόν τον περιορισμό, παρακαλώ <0>κάντε κλικ εδώ.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Αν έχετε λογαριασμό ή πρόσκληση, μπορείτε να χρησιμοποιήσετε τις παρακάτω επιλογές για να συνδεθείτε.", "signin.message.private.title": "<0>{0} είναι ένας ιδιωτικός χώρος, πρέπει να συνδεθείτε για να συμμετάσχετε και να ψηφίσετε.", "signin.message.socialbutton.intro": "Συνδεθείτε με", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Επιτρέπονται έως {number} συνημμένα.", "validation.custom.maximagesize": "Το μέγεθος της εικόνας πρέπει να είναι μικρότερο από {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# ετικέτα} other {# ετικέτες}}", - "linkmodal.insert": "Εισαγωγή συνδέσμου", - "linkmodal.text.label": "Κείμενο προς εμφάνιση", - "linkmodal.text.placeholder": "Εισαγάγετε κείμενο συνδέσμου", - "linkmodal.text.required": "Απαιτείται κείμενο", - "linkmodal.title": "Εισαγωγή συνδέσμου", - "linkmodal.url.invalid": "Παρακαλώ εισάγετε μια έγκυρη διεύθυνση URL", - "linkmodal.url.required": "Απαιτείται διεύθυνση URL" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# ετικέτα} other {# ετικέτες}}" } \ No newline at end of file diff --git a/locale/el/server.json b/locale/el/server.json index 4fd94a43e..21d3693aa 100644 --- a/locale/el/server.json +++ b/locale/el/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} έχει διαγραφεί.", "email.new_comment.text": "{userName} άφησε ένα σχόλιο σχετικά με {title} ({postLink}).", "email.new_post.text": "{userName} δημιούργησε μια νέα ανάρτηση {title} ({postLink}).", - "email.signin_email.subject": "Συνδεθείτε στο {siteName}", + "email.signin_email.subject": "Ο κωδικός σύνδεσής σας για το {siteName} είναι {code}", "email.signin_email.text": "Μας ζητήσατε να σας στείλουμε έναν σύνδεσμο σύνδεσης και εδώ είναι.", "email.signin_email.confirmation": "Κάντε κλικ στον παρακάτω σύνδεσμο για να συνδεθείτε στο {siteName}.", "email.signup_email.subject": "Το νέο σας Fider site", diff --git a/locale/en/client.json b/locale/en/client.json index 7df12f02f..9fecb8f4b 100644 --- a/locale/en/client.json +++ b/locale/en/client.json @@ -4,6 +4,7 @@ "action.close": "Close", "action.commentsfeed": "Comment Feed", "action.confirm": "Confirm", + "action.continue": "Continue", "action.copylink": "Copy link", "action.delete": "Delete", "action.edit": "Edit", @@ -13,6 +14,7 @@ "action.respond": "Respond", "action.save": "Save", "action.signin": "Sign in", + "action.signup": "Sign up", "action.submit": "Submit", "action.vote": "Vote for this idea", "action.voted": "Voted!", @@ -91,6 +93,10 @@ "label.voters": "Voters", "labels.notagsavailable": "No tags available", "labels.notagsselected": "No tags selected", + "legal.agreement": "I have read and agree to the <0/> and <1/>.", + "legal.notice": "By signing in, you agree to the <2/><0/> and <1/>.", + "legal.privacypolicy": "Privacy Policy", + "legal.termsofservice": "Terms of Service", "linkmodal.insert": "Insert Link", "linkmodal.text.label": "Text to display", "linkmodal.text.placeholder": "Enter link text", @@ -100,10 +106,6 @@ "linkmodal.url.label": "URL", "linkmodal.url.placeholder": "https://example.com", "linkmodal.url.required": "URL is required", - "legal.agreement": "I have read and agree to the <0/> and <1/>.", - "legal.notice": "By signing in, you agree to the <2/><0/> and <1/>.", - "legal.privacypolicy": "Privacy Policy", - "legal.termsofservice": "Terms of Service", "menu.administration": "Administration", "menu.mysettings": "My Settings", "menu.signout": "Sign out", @@ -200,6 +202,14 @@ "showpost.responseform.text.placeholder": "What's going on with this post? Let your users know what are your plans...", "showpost.votespanel.more": "+{extraVotesCount} more", "showpost.votespanel.seedetails": "see details", + "signin.code.edit": "Edit", + "signin.code.expired": "This code has expired or already been used. Please request a new one.", + "signin.code.getnew": "Get a new code", + "signin.code.instruction": "Please type in the code we just sent to <0>{email}", + "signin.code.invalid": "The code you entered is invalid. Please try again.", + "signin.code.placeholder": "Type in the code here", + "signin.code.sent": "A new code has been sent to your email.", + "signin.code.submit": "Submit", "signin.email.placeholder": "Email address", "signin.message.email": "Continue with Email", "signin.message.emaildisabled": "Email authentication has been disabled by an administrator. If you have an administrator account and need to bypass this restriction, please <0>click here.", @@ -210,6 +220,7 @@ "signin.message.private.text": "If you have an account or an invitation, you may use following options to sign in.", "signin.message.private.title": "<0>{0} is a private space, you must sign in to participate and vote.", "signin.message.socialbutton.intro": "Continue with", + "signin.name.placeholder": "Your name", "validation.custom.maxattachments": "A maximum of {number} attachments are allowed.", "validation.custom.maximagesize": "The image size must be smaller than {kilobytes}KB.", "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" diff --git a/locale/en/server.json b/locale/en/server.json index 885983422..e2bd144eb 100644 --- a/locale/en/server.json +++ b/locale/en/server.json @@ -52,11 +52,14 @@ "email.delete_post.text": "{title} has been deleted.", "email.new_comment.text": "{userName} left a comment on {title} ({postLink}).", "email.new_post.text": "{userName} created a new post {title} ({postLink}).", - "email.signin_email.subject": "Sign in to {siteName}", - "email.signin_email.text": "You asked us to send you a sign-in link and here it is.", - "email.signin_email.confirmation": "Click the link below to sign in to {siteName}.", + "email.signin_email.subject": "Your sign in code for {siteName} is {code}", + "email.signin_email.text": "Here is your sign-in code.", + "email.signin_email.confirmation": "Use the one-time code below to sign in to {siteName}.", "email.signup_email.subject": "Your new Fider site", "email.signup_email.text": "You are one step away from activating your Fider site.", + "email.signin_email.your_code": "Your sign-in code is:", + "email.signin_email.code_expires": "This code will expire in 15 minutes.", + "email.signin_email.alternative": "Alternatively, you can click the link below to sign in directly:", "email.signup_email.confirmation": "Through the link below you can verify your email address and complete the activation process.", "email.footer.subscription_notice": "You are receiving this email because you are subscribed to this post. You can {view}, {unsubscribe} or {change}.", "email.footer.subscription_notice2": "You are receiving this email because you are subscribed to this post. You can {change}.", diff --git a/locale/es-ES/client.json b/locale/es-ES/client.json index 12adde04b..2ae282314 100644 --- a/locale/es-ES/client.json +++ b/locale/es-ES/client.json @@ -4,6 +4,7 @@ "action.close": "Cerrar", "action.commentsfeed": "Feed de comentarios", "action.confirm": "Confirmar", + "action.continue": "", "action.copylink": "Copiar enlace", "action.delete": "Eliminar", "action.edit": "Editar", @@ -13,6 +14,7 @@ "action.respond": "Responder", "action.save": "Guardar", "action.signin": "Iniciar sesión", + "action.signup": "", "action.submit": "Enviar", "action.vote": "Vota por esta idea", "action.voted": "¡Votado!", @@ -92,6 +94,10 @@ "label.voters": "Votantes", "labels.notagsavailable": "No hay etiquetas disponibles", "labels.notagsselected": "No hay etiquetas seleccionadas", + "legal.agreement": "He leído y acepto los <0/> y <1/>.", + "legal.notice": "Al iniciar sesión, aceptas los <2/><0/> y <1/>.", + "legal.privacypolicy": "Política de Privacidad", + "legal.termsofservice": "Términos del servicio", "linkmodal.insert": "Insertar un enlace", "linkmodal.text.label": "Texto a mostrar", "linkmodal.text.placeholder": "Ingresa el texto del enlace", @@ -101,10 +107,6 @@ "linkmodal.url.label": "URL", "linkmodal.url.placeholder": "https://example.com", "linkmodal.url.required": "La URL es requerida", - "legal.agreement": "He leído y acepto los <0/> y <1/>.", - "legal.notice": "Al iniciar sesión, aceptas los <2/><0/> y <1/>.", - "legal.privacypolicy": "Política de Privacidad", - "legal.termsofservice": "Términos del servicio", "menu.administration": "Administración", "menu.mysettings": "Mis ajustes", "menu.signout": "Cerrar sesión", @@ -201,6 +203,12 @@ "showpost.responseform.text.placeholder": "¿Qué está pasando con esta publicación? Dile a tus usuarios cuáles son tus planes...", "showpost.votespanel.more": "+{extraVotesCount} más", "showpost.votespanel.seedetails": "ver detalles", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Dirección de correo electrónico", "signin.message.email": "Continuar con el correo electrónico", "signin.message.emaildisabled": "La autenticación de correo electrónico ha sido deshabilitada por un administrador. Si tiene una cuenta de administrador y necesita eludir esta restricción, por favor <0>haga clic aquí.", @@ -211,6 +219,7 @@ "signin.message.private.text": "Si tienes una cuenta o una invitación, puedes usar las siguientes opciones para iniciar sesión.", "signin.message.private.title": "<0>{0} es un espacio privado, debes iniciar sesión para participar y votar.", "signin.message.socialbutton.intro": "Iniciar sesión con", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Se permite un máximo de {number} archivos adjuntos.", "validation.custom.maximagesize": "El tamaño de la imagen debe ser menor que {kilobytes}KB.", "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# etiqueta} other {# etiquetas}}" diff --git a/locale/es-ES/server.json b/locale/es-ES/server.json index 41ca3cc77..4b318486d 100644 --- a/locale/es-ES/server.json +++ b/locale/es-ES/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} ha sido eliminado.", "email.new_comment.text": "{userName} dejó un comentario en {title} ({postLink}).", "email.new_post.text": "{userName} dejó un comentario en {title} ({postLink}).", - "email.signin_email.subject": "Iniciar sesión en {siteName}", + "email.signin_email.subject": "Su código de inicio de sesión para {siteName} es {code}", "email.signin_email.text": "Nos ha pedido que le enviemos un enlace de inicio de sesión y aquí está.", "email.signin_email.confirmation": "Haz clic en el siguiente enlace para iniciar sesión en {siteName}.", "email.signup_email.subject": "Tu nuevo sitio de Fider", diff --git a/locale/fa/client.json b/locale/fa/client.json index c819e501f..f8e6848ca 100644 --- a/locale/fa/client.json +++ b/locale/fa/client.json @@ -4,6 +4,7 @@ "action.close": "بستن", "action.commentsfeed": "فید نظرات", "action.confirm": "تأیید", + "action.continue": "", "action.copylink": "کپی لینک", "action.delete": "حذف", "action.edit": "ویرایش", @@ -13,6 +14,7 @@ "action.respond": "پاسخ", "action.save": "ذخیره", "action.signin": "ورود", + "action.signup": "", "action.submit": "ارسال", "action.vote": "به این ایده رأی دهید", "action.voted": "رأی داده شد!", @@ -96,6 +98,15 @@ "legal.notice": "با ورود، شما با <2/><0/> و <1/> موافقید.", "legal.privacypolicy": "سیاست حفظ حریم خصوصی", "legal.termsofservice": "شرایط خدمات", + "linkmodal.insert": "درج لینک", + "linkmodal.text.label": "متن برای نمایش", + "linkmodal.text.placeholder": "متن لینک را وارد کنید", + "linkmodal.text.required": "متن الزامی است", + "linkmodal.title": "درج لینک", + "linkmodal.url.invalid": "لطفا یک URL معتبر وارد کنید", + "linkmodal.url.label": "آدرس اینترنتی", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "آدرس اینترنتی (URL) الزامی است", "menu.administration": "مدیریت", "menu.mysettings": "تنظیمات من", "menu.signout": "خروج", @@ -156,6 +167,7 @@ "mysettings.notification.title": "رویدادهایی را که می‌خواهید اعلان دریافت کنید انتخاب کنید", "mysettings.page.subtitle": "تنظیمات پروفایل خود را مدیریت کنید", "mysettings.page.title": "تنظیمات", + "newpost.modal.addimage": "اضافه کردن تصاویر", "newpost.modal.description.placeholder": "در موردش به ما بگو. کامل توضیح بده، دریغ نکن، هر چه اطلاعات بیشتر، بهتر.", "newpost.modal.submit": "ایده خود را ثبت کنید", "newpost.modal.title": "ایده خود را به اشتراک بگذارید...", @@ -191,6 +203,12 @@ "showpost.responseform.text.placeholder": "برنامهٔ خود را دربارهٔ این پست با کاربران در میان بگذارید...", "showpost.votespanel.more": "+{extraVotesCount} بیشتر", "showpost.votespanel.seedetails": "مشاهدهٔ جزئیات", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "آدرس ایمیل", "signin.message.email": "ادامه با ایمیل", "signin.message.emaildisabled": "ورود با ایمیل توسط مدیر غیرفعال شده است. اگر مدیر هستید و نیاز به دسترسی دارید <0>اینجا کلیک کنید.", @@ -201,16 +219,8 @@ "signin.message.private.text": "اگر حساب یا دعوت‌نامه دارید، از گزینه‌های زیر برای ورود استفاده کنید.", "signin.message.private.title": "<0>{0} یک فضای خصوصی است؛ برای مشارکت باید وارد شوید.", "signin.message.socialbutton.intro": "ورود با", + "signin.name.placeholder": "", "validation.custom.maxattachments": "حداکثر تعداد {number} پیوست مجاز است.", "validation.custom.maximagesize": "حجم تصویر باید کمتر از {kilobytes}KB باشد.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# برچسب} other {# برچسب}}", - "linkmodal.insert": "درج لینک", - "linkmodal.text.label": "متن برای نمایش", - "linkmodal.text.placeholder": "متن لینک را وارد کنید", - "linkmodal.text.required": "متن الزامی است", - "linkmodal.title": "درج لینک", - "linkmodal.url.invalid": "لطفا یک URL معتبر وارد کنید", - "linkmodal.url.label": "آدرس اینترنتی", - "linkmodal.url.required": "آدرس اینترنتی (URL) الزامی است", - "newpost.modal.addimage": "اضافه کردن تصاویر" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# برچسب} other {# برچسب}}" } \ No newline at end of file diff --git a/locale/fa/server.json b/locale/fa/server.json index 5880dc086..3defc857d 100644 --- a/locale/fa/server.json +++ b/locale/fa/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} حذف شد.", "email.new_comment.text": "{userName} در {title} ({postLink}) نظری گذاشت.", "email.new_post.text": "{userName} پست جدیدی ایجاد کرد {title} ({postLink}).", - "email.signin_email.subject": "ورود به {siteName}", + "email.signin_email.subject": "کد ورود شما برای {siteName} این است: {code}", "email.signin_email.text": "شما درخواست کردید لینکی برای ورود ارسال کنیم و اینک در اختیارتان است.", "email.signin_email.confirmation": "برای ورود به {siteName} روی لینک زیر کلیک کنید.", "email.signup_email.subject": "سایت Fider جدید شما", diff --git a/locale/fr/client.json b/locale/fr/client.json index a7664d9af..a1dbc55f7 100644 --- a/locale/fr/client.json +++ b/locale/fr/client.json @@ -4,6 +4,7 @@ "action.close": "Fermer", "action.commentsfeed": "Flux de commentaires", "action.confirm": "Confirmer", + "action.continue": "", "action.copylink": "Copier le lien", "action.delete": "Supprimer", "action.edit": "Modifier", @@ -13,6 +14,7 @@ "action.respond": "Répondre", "action.save": "Enregistrer", "action.signin": "Se connecter", + "action.signup": "", "action.submit": "Valider", "action.vote": "Voter pour cette idée", "action.voted": "Votée !", @@ -96,6 +98,15 @@ "legal.notice": "En vous connectant, vous acceptez les <2/><0/> et <1/>.", "legal.privacypolicy": "Politique de confidentialité", "legal.termsofservice": "Conditions générales d’utilisation", + "linkmodal.insert": "Insérer un lien", + "linkmodal.text.label": "Texte à afficher", + "linkmodal.text.placeholder": "Entrez le texte du lien", + "linkmodal.text.required": "Le texte est obligatoire", + "linkmodal.title": "Insérer un lien", + "linkmodal.url.invalid": "Veuillez saisir une URL valide", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "https://exemple.com", + "linkmodal.url.required": "L'URL est requise", "menu.administration": "Administration", "menu.mysettings": "Mes paramètres", "menu.signout": "Se déconnecter", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Que se passe-t-il avec ce message ? Faites savoir à vos utilisateurs quels sont vos plans...", "showpost.votespanel.more": "+{extraVotesCount} de plus", "showpost.votespanel.seedetails": "voir les détails", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Adresse email", "signin.message.email": "Continuer avec une addresse email", "signin.message.emaildisabled": "L'authentification par e-mail a été désactivée par un administrateur. Si vous avez un compte administrateur, <0>cliquez ici pour vous connecter.", @@ -202,15 +219,8 @@ "signin.message.private.text": "Si vous avez un compte ou une invitation, vous pouvez utiliser les options suivantes pour vous connecter.", "signin.message.private.title": "<0>{0} est un espace privé, vous devez vous connecter pour participer et voter.", "signin.message.socialbutton.intro": "Se connecter avec", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Un maximum de {number} pièces jointes est autorisé.", "validation.custom.maximagesize": "La taille de l'image doit être inférieure à {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Insérer un lien", - "linkmodal.text.label": "Texte à afficher", - "linkmodal.text.placeholder": "Entrez le texte du lien", - "linkmodal.text.required": "Le texte est obligatoire", - "linkmodal.title": "Insérer un lien", - "linkmodal.url.invalid": "Veuillez saisir une URL valide", - "linkmodal.url.placeholder": "https://exemple.com", - "linkmodal.url.required": "L'URL est requise" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/fr/server.json b/locale/fr/server.json index 9770ee255..6794bf316 100644 --- a/locale/fr/server.json +++ b/locale/fr/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} a été supprimé.", "email.new_comment.text": "{userName} a laissé un commentaire sur {title} ({postLink}).", "email.new_post.text": "{userName} a créé un nouveau message {title} ({postLink}).", - "email.signin_email.subject": "Se connecter à {siteName}", + "email.signin_email.subject": "Votre code de connexion pour {siteName} est {code}", "email.signin_email.text": "Vous nous avez demandé de vous envoyer un lien de connexion et le voici.", "email.signin_email.confirmation": "Cliquez sur le lien ci-dessous pour vous connecter à {siteName}.", "email.signup_email.subject": "Votre nouveau site Fider", diff --git a/locale/it/client.json b/locale/it/client.json index 6a5178133..0c54c9b27 100644 --- a/locale/it/client.json +++ b/locale/it/client.json @@ -4,6 +4,7 @@ "action.close": "Chiudi", "action.commentsfeed": "Feed dei commenti", "action.confirm": "Conferma", + "action.continue": "", "action.copylink": "Copia il collegamento", "action.delete": "Cancella", "action.edit": "Modifica", @@ -13,6 +14,7 @@ "action.respond": "Rispondi", "action.save": "Salva", "action.signin": "Accedi", + "action.signup": "", "action.submit": "Invia", "action.vote": "Vota questa idea", "action.voted": "Votato!", @@ -96,6 +98,15 @@ "legal.notice": "Accedendo, accetti i <2/><0/> e <1/>.", "legal.privacypolicy": "Informativa sulla privacy", "legal.termsofservice": "Termini di servizio", + "linkmodal.insert": "Inserisci collegamento", + "linkmodal.text.label": "Testo da visualizzare", + "linkmodal.text.placeholder": "Inserisci il testo del collegamento", + "linkmodal.text.required": "Il testo è obbligatorio", + "linkmodal.title": "Inserisci collegamento", + "linkmodal.url.invalid": "Inserisci un URL valido", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "https://esempio.com", + "linkmodal.url.required": "L'URL è obbligatorio", "menu.administration": "Amministrazione", "menu.mysettings": "Le mie preferenze", "menu.signout": "Disconnettersi", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Cosa succede con questo post? Fate sapere ai vostri utenti quali sono i vostri piani...", "showpost.votespanel.more": "+{extraVotesCount} di più", "showpost.votespanel.seedetails": "vedi dettagli", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Indirizzo e-mail", "signin.message.email": "Continua con l'email", "signin.message.emaildisabled": "L'autenticazione email è stata disabilitata da un amministratore. Se hai un account amministratore e hai bisogno di bypassare questa restrizione, per favore <0>clicca qui.", @@ -202,15 +219,8 @@ "signin.message.private.text": "Se si dispone di un account o di un invito, è possibile utilizzare le seguenti opzioni per accedere.", "signin.message.private.title": "<0>{0} è uno spazio privato, è necessario registrarsi per partecipare e votare.", "signin.message.socialbutton.intro": "Accedi con", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Sono consentiti al massimo {number} allegati.", "validation.custom.maximagesize": "La dimensione dell'immagine deve essere inferiore a {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Inserisci collegamento", - "linkmodal.text.label": "Testo da visualizzare", - "linkmodal.text.placeholder": "Inserisci il testo del collegamento", - "linkmodal.text.required": "Il testo è obbligatorio", - "linkmodal.title": "Inserisci collegamento", - "linkmodal.url.invalid": "Inserisci un URL valido", - "linkmodal.url.placeholder": "https://esempio.com", - "linkmodal.url.required": "L'URL è obbligatorio" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/it/server.json b/locale/it/server.json index 3d745ab62..8df7b5d38 100644 --- a/locale/it/server.json +++ b/locale/it/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} è stato cancellato.", "email.new_comment.text": "{userName} ha lasciato un commento su {title} ({postLink}).", "email.new_post.text": "{userName} ha creato un nuovo post {title} ({postLink}).", - "email.signin_email.subject": "Accedi a {siteName}", + "email.signin_email.subject": "Il tuo codice di accesso per {siteName} è {code}", "email.signin_email.text": "Ci hai chiesto d'inviarti un link di accesso ed è qui.", "email.signin_email.confirmation": "Clicca sul link qui sotto per accedere a {siteName}.", "email.signup_email.subject": "Il tuo nuovo sito Fider", diff --git a/locale/ja/client.json b/locale/ja/client.json index 46b3089d1..e86e3765e 100644 --- a/locale/ja/client.json +++ b/locale/ja/client.json @@ -4,6 +4,7 @@ "action.close": "閉じる", "action.commentsfeed": "コメントフィード", "action.confirm": "確認", + "action.continue": "", "action.copylink": "リンクをコピー", "action.delete": "削除", "action.edit": "編集", @@ -13,6 +14,7 @@ "action.respond": "回答", "action.save": "保存", "action.signin": "ログイン", + "action.signup": "", "action.submit": "送信", "action.vote": "このアイデアに投票", "action.voted": "投票完了!", @@ -96,6 +98,15 @@ "legal.notice": "サインインすると、<2/><0/> および <1/> に同意したことになります。", "legal.privacypolicy": "プライバシーポリシー", "legal.termsofservice": "サービス利用規約", + "linkmodal.insert": "リンクを挿入", + "linkmodal.text.label": "表示するテキスト", + "linkmodal.text.placeholder": "リンクテキストを入力", + "linkmodal.text.required": "テキストは必須です", + "linkmodal.title": "リンクを挿入", + "linkmodal.url.invalid": "有効なURLを入力してください", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URLは必須です", "menu.administration": "管理", "menu.mysettings": "ユーザー設定", "menu.signout": "ログアウト", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "この記事はどうなっていますか? あなたのプランをユーザーに知らせてください...", "showpost.votespanel.more": "+{extraVotesCount} 以上", "showpost.votespanel.seedetails": "詳細を表示", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "電子メールアドレス", "signin.message.email": "メールで続行", "signin.message.emaildisabled": "メール認証は管理者によって無効にされています。管理者アカウントを持っていて、この制限を回避する必要がある場合は、<0>ここをクリックしてください。", @@ -202,14 +219,8 @@ "signin.message.private.text": "アカウントや招待状をお持ちの場合は、以下のオプションを使用してサインインできます。", "signin.message.private.title": "<0>{0} はプライベートなスペースです。サインインして投票してください。", "signin.message.socialbutton.intro": "ログイン", + "signin.name.placeholder": "", "validation.custom.maxattachments": "最大 {number} 個の添付ファイルが許可されます。", "validation.custom.maximagesize": "画像サイズは{kilobytes}KB未満である必要があります。", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "リンクを挿入", - "linkmodal.text.label": "表示するテキスト", - "linkmodal.text.placeholder": "リンクテキストを入力", - "linkmodal.text.required": "テキストは必須です", - "linkmodal.title": "リンクを挿入", - "linkmodal.url.invalid": "有効なURLを入力してください", - "linkmodal.url.required": "URLは必須です" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/ja/server.json b/locale/ja/server.json index 855ad5709..5dc35d560 100644 --- a/locale/ja/server.json +++ b/locale/ja/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title}削除されました。", "email.new_comment.text": "{userName} さんが {title} ({postLink}) にコメントを残しました。", "email.new_post.text": "{userName} が新しい投稿 {title} ({postLink}) を作成しました。", - "email.signin_email.subject": "{siteName} にログイン", + "email.signin_email.subject": "{siteName} のサインインコードは {code} です", "email.signin_email.text": "ご要望のサインインリンクはこれです。", "email.signin_email.confirmation": "{siteName} にサインインするには、下のリンクをクリックしてください。", "email.signup_email.subject": "あなたの新しい Fider サイト", diff --git a/locale/ko/server.json b/locale/ko/server.json index d60f2a2f3..5e2403118 100644 --- a/locale/ko/server.json +++ b/locale/ko/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title}삭제되었습니다.", "email.new_comment.text": "{userName}님이 {title} ({postLink})에 댓글을 남겼습니다.", "email.new_post.text": "{userName}님이 새 게시물 {title} ({postLink})을 만들었습니다.", - "email.signin_email.subject": "{siteName}에 로그인하세요", + "email.signin_email.subject": "{siteName}의 로그인 코드는 {code}입니다", "email.signin_email.text": "귀하께서 로그인 링크를 보내달라고 요청하셨고, 여기에 링크가 있습니다.", "email.signin_email.confirmation": "아래 링크를 클릭하여 {siteName}에 로그인하세요.", "email.signup_email.subject": "새로운 Fider 사이트", diff --git a/locale/nl/client.json b/locale/nl/client.json index 4d93764bc..106b044d2 100644 --- a/locale/nl/client.json +++ b/locale/nl/client.json @@ -4,6 +4,7 @@ "action.close": "Sluiten", "action.commentsfeed": "Reactiefeed", "action.confirm": "Bevestigen", + "action.continue": "", "action.copylink": "Link kopiëren", "action.delete": "Verwijderen", "action.edit": "Bewerken", @@ -13,6 +14,7 @@ "action.respond": "Reageren", "action.save": "Opslaan", "action.signin": "Inloggen", + "action.signup": "", "action.submit": "Verzenden", "action.vote": "Stem op dit idee", "action.voted": "Gestemd!", @@ -96,6 +98,15 @@ "legal.notice": "Door in te loggen, ga je akkoord met de <2/><0/> en <1/>.", "legal.privacypolicy": "Privacybeleid", "legal.termsofservice": "Algemene voorwaarden", + "linkmodal.insert": "Link invoegen", + "linkmodal.text.label": "Weer te geven tekst", + "linkmodal.text.placeholder": "Voer linktekst in", + "linkmodal.text.required": "Tekst is vereist", + "linkmodal.title": "Link invoegen", + "linkmodal.url.invalid": "Voer een geldige URL in", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "https://voorbeeld.com", + "linkmodal.url.required": "URL is vereist", "menu.administration": "Beheer", "menu.mysettings": "Mijn instellingen", "menu.signout": "Uitloggen", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Wat gebeurt er met dit bericht? Laat je gebruikers weten wat je plannen zijn...", "showpost.votespanel.more": "+{extraVotesCount} meer", "showpost.votespanel.seedetails": "details bekijken", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "E-mailadres", "signin.message.email": "Doorgaan met e-mail", "signin.message.emaildisabled": "Inloggen met e-mail is uitgeschakeld door een beheerder. Als u een beheerder account heeft en deze beperking moet omzeilen, <0>klik dan hier.", @@ -202,15 +219,8 @@ "signin.message.private.text": "Als je een account of een uitnodiging hebt, kun je de volgende methodes gebruiken om in te loggen.", "signin.message.private.title": "<0>{0} is een privéruimte. U moet ingelogd zijn om deel te nemen en te stemmen.", "signin.message.socialbutton.intro": "Inloggen met", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Er zijn maximaal {number} bijlagen toegestaan.", "validation.custom.maximagesize": "De afbeeldingsgrootte moet kleiner zijn dan {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Link invoegen", - "linkmodal.text.label": "Weer te geven tekst", - "linkmodal.text.placeholder": "Voer linktekst in", - "linkmodal.text.required": "Tekst is vereist", - "linkmodal.title": "Link invoegen", - "linkmodal.url.invalid": "Voer een geldige URL in", - "linkmodal.url.placeholder": "https://voorbeeld.com", - "linkmodal.url.required": "URL is vereist" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/nl/server.json b/locale/nl/server.json index 1593a9695..5fa4678de 100644 --- a/locale/nl/server.json +++ b/locale/nl/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} is verwijderd.", "email.new_comment.text": "{userName} heeft een reactie achtergelaten op {title} ({postLink}).", "email.new_post.text": "{userName} heeft een nieuw bericht gemaakt {title} ({postLink}).", - "email.signin_email.subject": "Log in bij {siteName}", + "email.signin_email.subject": "Uw inlogcode voor {siteName} is {code}", "email.signin_email.text": "Je hebt ons gevraagd om een link om in te loggen en hier is-ie dan.", "email.signin_email.confirmation": "Klik op de link hieronder om in te loggen op {siteName}.", "email.signup_email.subject": "Jouw nieuwe Fider-site", diff --git a/locale/pl/client.json b/locale/pl/client.json index 2de7df8c2..4d8a50183 100644 --- a/locale/pl/client.json +++ b/locale/pl/client.json @@ -4,6 +4,7 @@ "action.close": "Zamknij", "action.commentsfeed": "Kanał komentarzy", "action.confirm": "Potwierdź", + "action.continue": "", "action.copylink": "Kopiuj link", "action.delete": "Usuń", "action.edit": "Edytuj", @@ -13,6 +14,7 @@ "action.respond": "Odpowiedz", "action.save": "Zapisz", "action.signin": "Zaloguj się", + "action.signup": "", "action.submit": "Prześlij", "action.vote": "Zagłosuj na ten pomysł", "action.voted": "Zagłosowane!", @@ -96,6 +98,15 @@ "legal.notice": "Logując się, akceptujesz <2/><0/> i <1/>.", "legal.privacypolicy": "Polityka Prywatności", "legal.termsofservice": "Warunki Świadczenia Usług", + "linkmodal.insert": "Wstaw link", + "linkmodal.text.label": "Tekst do wyświetlenia", + "linkmodal.text.placeholder": "Wprowadź tekst linku", + "linkmodal.text.required": "Tekst jest wymagany", + "linkmodal.title": "Wstaw link", + "linkmodal.url.invalid": "Proszę podać prawidłowy adres URL", + "linkmodal.url.label": "Adres URL", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "Adres URL jest wymagany", "menu.administration": "Administracja", "menu.mysettings": "Moje Ustawienia", "menu.signout": "Wyloguj się", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Co się dzieje w temacie tego posta? Daj swoim użytkownikom znać o swoich planach...", "showpost.votespanel.more": "+{extraVotesCount} więcej", "showpost.votespanel.seedetails": "pokaż szczegóły", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Adres e-mail", "signin.message.email": "Kontynuuj z e-mailem", "signin.message.emaildisabled": "Autoryzacja za pomocą adresu email została wyłączona przez administratora. Jeśli posiadasz konto administratora i chcesz obejść to ograniczenie <0>kliknij tutaj.", @@ -202,15 +219,8 @@ "signin.message.private.text": "Jeśli posiadasz konto lub zaproszenie możesz użyć poniższych opcji aby się zalogować.", "signin.message.private.title": "<0>{0} to przestrzeń prywatna, musisz się zalogować, aby uczestniczyć i głosować.", "signin.message.socialbutton.intro": "Zaloguj się za pomocą", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Maksymalna liczba załączników to {number}.", "validation.custom.maximagesize": "Rozmiar obrazu musi być mniejszy niż {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} few {# tagów} many {# tagów} other {# tagi}}", - "linkmodal.insert": "Wstaw link", - "linkmodal.text.label": "Tekst do wyświetlenia", - "linkmodal.text.placeholder": "Wprowadź tekst linku", - "linkmodal.text.required": "Tekst jest wymagany", - "linkmodal.title": "Wstaw link", - "linkmodal.url.invalid": "Proszę podać prawidłowy adres URL", - "linkmodal.url.label": "Adres URL", - "linkmodal.url.required": "Adres URL jest wymagany" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} few {# tagów} many {# tagów} other {# tagi}}" } \ No newline at end of file diff --git a/locale/pl/server.json b/locale/pl/server.json index f5cbea568..f6d81bbd3 100644 --- a/locale/pl/server.json +++ b/locale/pl/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} został usunięty.", "email.new_comment.text": "{userName} skomentował {title} ({postLink}).", "email.new_post.text": "{userName} opublikował post {title} ({postLink}).", - "email.signin_email.subject": "Zaloguj się do {siteName}", + "email.signin_email.subject": "Twój kod logowania do {siteName} to {code}", "email.signin_email.text": "Poprosiłeś o wysłanie linku do logowania i oto on.", "email.signin_email.confirmation": "Kliknij poniższy link, aby zalogować się do {siteName}.", "email.signup_email.subject": "Twój nowy Fider", diff --git a/locale/pt-BR/client.json b/locale/pt-BR/client.json index 3b317e1d2..00954f204 100644 --- a/locale/pt-BR/client.json +++ b/locale/pt-BR/client.json @@ -4,6 +4,7 @@ "action.close": "Fechar", "action.commentsfeed": "Feed de comentários", "action.confirm": "Confirmar", + "action.continue": "", "action.copylink": "Copiar link", "action.delete": "Deletar", "action.edit": "Editar", @@ -13,6 +14,7 @@ "action.respond": "Responder", "action.save": "Salvar", "action.signin": "Iniciar sessão", + "action.signup": "", "action.submit": "Enviar", "action.vote": "Votar", "action.voted": "Votado", @@ -96,6 +98,15 @@ "legal.notice": "Ao fazer o login, você concorda com os <2/><0/> e <1/>.", "legal.privacypolicy": "Política de Privacidade", "legal.termsofservice": "Termos do Serviço", + "linkmodal.insert": "Inserir link", + "linkmodal.text.label": "Texto para exibir", + "linkmodal.text.placeholder": "Insira o texto do link", + "linkmodal.text.required": "Texto é obrigatório", + "linkmodal.title": "Inserir link", + "linkmodal.url.invalid": "Por favor, insira um URL válido", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL é obrigatório", "menu.administration": "Administração", "menu.mysettings": "Minhas Configurações", "menu.signout": "Finalizar sessão", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "O que está acontecendo com esta postagem? Informe seus usuários quais são os seus planos...", "showpost.votespanel.more": "+{extraVotesCount} mais", "showpost.votespanel.seedetails": "ver detalhes", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Endereço de e-mail", "signin.message.email": "Entrar com email", "signin.message.emaildisabled": "A autenticação por e-mail foi desativada por um administrador. Se você tem uma conta de administrador e precisa ignorar esta restrição, <0>clique aqui.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Se você tem uma conta ou um convite, você pode usar as seguintes opções para fazer o login.", "signin.message.private.title": "<0>{0} é um espaço privado, você deve se inscrever para participar e votar.", "signin.message.socialbutton.intro": "Fazer login com", + "signin.name.placeholder": "", "validation.custom.maxattachments": "São permitidos no máximo {number} anexos.", "validation.custom.maximagesize": "O tamanho da imagem deve ser menor que {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Inserir link", - "linkmodal.text.label": "Texto para exibir", - "linkmodal.text.placeholder": "Insira o texto do link", - "linkmodal.text.required": "Texto é obrigatório", - "linkmodal.title": "Inserir link", - "linkmodal.url.invalid": "Por favor, insira um URL válido", - "linkmodal.url.required": "URL é obrigatório" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/pt-BR/server.json b/locale/pt-BR/server.json index d51a7e341..226f459f7 100644 --- a/locale/pt-BR/server.json +++ b/locale/pt-BR/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} foi removido.", "email.new_comment.text": "{userName} deixou um comentário em {title} ({postLink}).", "email.new_post.text": "{userName} criou uma nova postagem {title} ({postLink}).", - "email.signin_email.subject": "Seu link de acesso a {siteName}", + "email.signin_email.subject": "Seu código de acesso para {siteName} é {code}", "email.signin_email.text": "Você solicitou um link para login e aqui está.", "email.signin_email.confirmation": "Clique no link abaixo para fazer login em {siteName}.", "email.signup_email.subject": "Seu novo site no Fider", diff --git a/locale/ru/client.json b/locale/ru/client.json index 9bc4bcf3e..321256ccc 100644 --- a/locale/ru/client.json +++ b/locale/ru/client.json @@ -4,6 +4,7 @@ "action.close": "Закрыть", "action.commentsfeed": "Лента комментариев", "action.confirm": "Подтвердить", + "action.continue": "", "action.copylink": "Копировать ссылку", "action.delete": "Удалить", "action.edit": "Изменить", @@ -13,6 +14,7 @@ "action.respond": "Ответить", "action.save": "Сохранить", "action.signin": "Войти", + "action.signup": "", "action.submit": "Продолжить", "action.vote": "Проголосуйте за эту идею", "action.voted": "Проголосовал!", @@ -96,6 +98,15 @@ "legal.notice": "Войдя в систему, вы соглашаетесь с <2/><0/> и <1/>.", "legal.privacypolicy": "Политика Конфиденциальности", "legal.termsofservice": "Условия Использования", + "linkmodal.insert": "Вставить ссылку", + "linkmodal.text.label": "Текст для отображения", + "linkmodal.text.placeholder": "Введите текст ссылки", + "linkmodal.text.required": "Текст обязателен", + "linkmodal.title": "Вставить ссылку", + "linkmodal.url.invalid": "Пожалуйста, введите действительный URL-адрес", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL-адрес обязателен", "menu.administration": "Администрирование", "menu.mysettings": "Мой аккаунт", "menu.signout": "Выйти", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Что произойдёт с этим предложением? Дайте людям знать о ваших планах...", "showpost.votespanel.more": "и ещё {extraVotesCount}", "showpost.votespanel.seedetails": "подробнее", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Адрес электронной почты", "signin.message.email": "Продолжить с электронной почтой", "signin.message.emaildisabled": "Аутентификация по адресу электронной почты отключена. <0>Я администратор и мне нужно обойти это ограничение.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Если у вас есть аккаунт или приглашение, вы можете использовать их для входа.", "signin.message.private.title": "<0>{0} является приватным пространством, вы должны войти в систему, чтобы принять участие и проголосовать.", "signin.message.socialbutton.intro": "Войти с помощью", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Разрешено максимум {number} вложений.", "validation.custom.maximagesize": "Размер изображения должен быть меньше {kilobytes}КБ.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Вставить ссылку", - "linkmodal.text.label": "Текст для отображения", - "linkmodal.text.placeholder": "Введите текст ссылки", - "linkmodal.text.required": "Текст обязателен", - "linkmodal.title": "Вставить ссылку", - "linkmodal.url.invalid": "Пожалуйста, введите действительный URL-адрес", - "linkmodal.url.required": "URL-адрес обязателен" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/ru/server.json b/locale/ru/server.json index bb34f86b3..082424a53 100644 --- a/locale/ru/server.json +++ b/locale/ru/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "Пост {title} был удалён.", "email.new_comment.text": "{userName} оставил комментарий на {title} ({postLink}).", "email.new_post.text": "{userName} создал новый пост: {title} ({postLink}).", - "email.signin_email.subject": "Вход на {siteName}", + "email.signin_email.subject": "Ваш код входа для {siteName}: {code}", "email.signin_email.text": "Вы попросили отправить ссылку для входа, и вот она.", "email.signin_email.confirmation": "Перейдите по ссылке ниже, чтобы войти на {siteName}.", "email.signup_email.subject": "Ваш новый сайт с Fider", diff --git a/locale/si-LK/server.json b/locale/si-LK/server.json index a2244335a..09169d9fa 100644 --- a/locale/si-LK/server.json +++ b/locale/si-LK/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} මකා දමා ඇත.", "email.new_comment.text": "{userName} {title} ({postLink}) හි අදහසක් දැක්වීය.", "email.new_post.text": "{userName} නව සටහනක් නිර්මාණය කළා {title} ({postLink}).", - "email.signin_email.subject": "{siteName} වෙත පුරනය වන්න", + "email.signin_email.subject": "{siteName} සඳහා ඔබගේ පිවිසුම් කේතය {code} වේ", "email.signin_email.text": "ඔබ අපෙන් ඔබට පුරනය වීමේ සබැඳියක් එවන ලෙස ඉල්ලා සිටියා, මෙන්න එය.", "email.signin_email.confirmation": "{siteName} වෙත පුරනය වීමට පහත සබැඳිය ක්ලික් කරන්න.", "email.signup_email.subject": "ඔබේ නව ෆයිඩර් අඩවිය", diff --git a/locale/sk/client.json b/locale/sk/client.json index 4af08fec9..6bdf3fc4f 100644 --- a/locale/sk/client.json +++ b/locale/sk/client.json @@ -4,6 +4,7 @@ "action.close": "Zavrieť", "action.commentsfeed": "Kanál komentárov", "action.confirm": "Potvrdiť", + "action.continue": "", "action.copylink": "Kopírovať odkaz", "action.delete": "Vymazať", "action.edit": "Upraviť", @@ -13,6 +14,7 @@ "action.respond": "Odpovedať", "action.save": "Uložiť", "action.signin": "Prihlásiť sa", + "action.signup": "", "action.submit": "Potvrdiť", "action.vote": "Hlasovať za tento nápad", "action.voted": "Zahlasované!", @@ -96,6 +98,15 @@ "legal.notice": "Prihlásením súhlasíte s <2/><0/> a <1/>.", "legal.privacypolicy": "Zásady ochrany osobných údajov", "legal.termsofservice": "Podmienky služby", + "linkmodal.insert": "Vložiť odkaz", + "linkmodal.text.label": "Text na zobrazenie", + "linkmodal.text.placeholder": "Zadajte text odkazu", + "linkmodal.text.required": "Vyžaduje sa text", + "linkmodal.title": "Vložiť odkaz", + "linkmodal.url.invalid": "Zadajte platnú URL adresu", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL adresa je povinná", "menu.administration": "Administrácia", "menu.mysettings": "Moje nastavenia", "menu.signout": "Odhlásiť sa", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Čo sa deje s týmto príspevkom? Dajte svojim používateľom vedieť, aké máte plány...", "showpost.votespanel.more": "+{extraVotesCount} viac", "showpost.votespanel.seedetails": "pozri detaily", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "Emailová adresa", "signin.message.email": "Pokračovať pomocou e-mailu", "signin.message.emaildisabled": "Správca zakázal overovanie emailu. Ak máte účet správcu a potrebujete obísť toto obmedzenie, <0>kliknite sem.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Ak máte účet alebo pozvánku, na prihlásenie môžete použiť nasledujúce možnosti.", "signin.message.private.title": "<0>{0} je súkromný priestor, ak sa chcete zúčastniť diskusie a hlasovať, musíte sa prihlásiť.", "signin.message.socialbutton.intro": "Prihlásiť sa pomocou", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Maximálny počet príloh je {number}.", "validation.custom.maximagesize": "Veľkosť obrázka musí byť menšia ako {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "Vložiť odkaz", - "linkmodal.text.label": "Text na zobrazenie", - "linkmodal.text.placeholder": "Zadajte text odkazu", - "linkmodal.text.required": "Vyžaduje sa text", - "linkmodal.title": "Vložiť odkaz", - "linkmodal.url.invalid": "Zadajte platnú URL adresu", - "linkmodal.url.required": "URL adresa je povinná" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/sk/server.json b/locale/sk/server.json index 0fe43caa2..83f22f4fc 100644 --- a/locale/sk/server.json +++ b/locale/sk/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "Príspevok {title} bol vymazaný.", "email.new_comment.text": "{userName} zanechal komentár na príspevku {title} ({postLink}).", "email.new_post.text": "{userName} vytvoril nový príspevok {title} ({postLink}).", - "email.signin_email.subject": "Prihláste sa do {siteName}", + "email.signin_email.subject": "Váš prihlasovací kód pre {siteName} je {code}", "email.signin_email.text": "Požiadali ste nás, aby sme vám poslali odkaz na prihlásenie, a tu je.", "email.signin_email.confirmation": "Prihláste sa kliknutím na odkaz nižšie {siteName}.", "email.signup_email.subject": "Vaša nová stránka Fider", diff --git a/locale/sv-SE/client.json b/locale/sv-SE/client.json index afd19a2e7..752f83f4e 100644 --- a/locale/sv-SE/client.json +++ b/locale/sv-SE/client.json @@ -4,6 +4,7 @@ "action.close": "Stäng", "action.commentsfeed": "Kommentarflöde", "action.confirm": "Bekräfta", + "action.continue": "", "action.copylink": "Kopiera länk", "action.delete": "Radera", "action.edit": "Ändra", @@ -13,6 +14,7 @@ "action.respond": "Svara", "action.save": "Spara", "action.signin": "Logga in", + "action.signup": "", "action.submit": "Skicka", "action.vote": "Rösta på den här idén", "action.voted": "Röstade!", @@ -96,6 +98,15 @@ "legal.notice": "Genom att logga in godkänner du <2/><0/> och <1/>.", "legal.privacypolicy": "Integritetspolicy", "legal.termsofservice": "Användarvillkor", + "linkmodal.insert": "Infoga länk", + "linkmodal.text.label": "Text att visa", + "linkmodal.text.placeholder": "Ange länktext", + "linkmodal.text.required": "Text krävs", + "linkmodal.title": "Infoga länk", + "linkmodal.url.invalid": "Ange en giltig URL", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL krävs", "menu.administration": "Administration", "menu.mysettings": "Mina Inställningar", "menu.signout": "Logga ut", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Vad händer med det här inlägget? Låt dina användare veta vad du planerar...", "showpost.votespanel.more": "+{extraVotesCount} ytterligare", "showpost.votespanel.seedetails": "visa detaljer", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "E-postadress", "signin.message.email": "Fortsätt med e-post", "signin.message.emaildisabled": "E-postautentisering har inaktiverats av en administratör. Om du har ett administratörskonto och behöver kringgå denna begränsning, <0>klicka här.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Om du har ett konto eller en inbjudan kan du använda följande alternativ för att logga in.", "signin.message.private.title": "<0>{0} är ett privat utrymme, du måste logga in för att delta och rösta.", "signin.message.socialbutton.intro": "Logga in med", + "signin.name.placeholder": "", "validation.custom.maxattachments": "Maximalt {number} bilagor är tillåtna.", "validation.custom.maximagesize": "Bildstorleken måste vara mindre än {kilobytes}KB.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, =1 {# etikett} other {# etiketter}}", - "linkmodal.insert": "Infoga länk", - "linkmodal.text.label": "Text att visa", - "linkmodal.text.placeholder": "Ange länktext", - "linkmodal.text.required": "Text krävs", - "linkmodal.title": "Infoga länk", - "linkmodal.url.invalid": "Ange en giltig URL", - "linkmodal.url.required": "URL krävs" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, =1 {# etikett} other {# etiketter}}" } \ No newline at end of file diff --git a/locale/sv-SE/server.json b/locale/sv-SE/server.json index 0eb7253f4..8febef244 100644 --- a/locale/sv-SE/server.json +++ b/locale/sv-SE/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} har raderats.", "email.new_comment.text": "{userName} skrev en kommentar på {title} ({postLink}).", "email.new_post.text": "{userName} skapade ett nytt inlägg {title} ({postLink}).", - "email.signin_email.subject": "Logga in på {siteName}", + "email.signin_email.subject": "Din inloggningskod för {siteName} är {code}", "email.signin_email.text": "Du bad oss att skicka dig en inloggningslänk och här är den.", "email.signin_email.confirmation": "Klicka på länken nedan för att logga in på {siteName}.", "email.signup_email.subject": "Din nya Fider-webbplats", diff --git a/locale/tr/client.json b/locale/tr/client.json index 25ffe8345..fd6796b63 100644 --- a/locale/tr/client.json +++ b/locale/tr/client.json @@ -4,6 +4,7 @@ "action.close": "Kapat", "action.commentsfeed": "Yorum Beslemesi", "action.confirm": "Onayla", + "action.continue": "", "action.copylink": "Bağlantıyı kopyala", "action.delete": "Sil", "action.edit": "Düzenle", @@ -13,6 +14,7 @@ "action.respond": "Yanıtla", "action.save": "Kaydet", "action.signin": "Giriş Yap", + "action.signup": "", "action.submit": "Gönder", "action.vote": "Bu fikre oy verin", "action.voted": "Oy verildi!", @@ -96,6 +98,15 @@ "legal.notice": "Giriş yaparak <2/><0/> ve <1/> maddelerini kabul etmiş olursunuz.", "legal.privacypolicy": "Gizlilik Politikası", "legal.termsofservice": "Hizmet Koşulları", + "linkmodal.insert": "Bağlantı Ekle", + "linkmodal.text.label": "Görüntülenecek metin", + "linkmodal.text.placeholder": "Bağlantı metnini girin", + "linkmodal.text.required": "Metin gereklidir", + "linkmodal.title": "Bağlantı Ekle", + "linkmodal.url.invalid": "Lütfen geçerli bir URL girin", + "linkmodal.url.label": "", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "URL gerekli", "menu.administration": "Yönetim", "menu.mysettings": "Ayarlarım", "menu.signout": "Çıkış yap", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "Bu öneriye neler oluyor? Kullanıcılara planlarınız hakkında bilgi verin...", "showpost.votespanel.more": "+{extraVotesCount} daha", "showpost.votespanel.seedetails": "ayrıntıları gör", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "E-posta adresi", "signin.message.email": "E-postayla devam et", "signin.message.emaildisabled": "E-posta doğrulaması bir yönetici tarafından devre dışı bırakıldı. Eğer bir yönetici hesabınız varsa ve bu kısıtı kaldırmak istiyorsanız <0>buraya tıklayın.", @@ -202,14 +219,8 @@ "signin.message.private.text": "Eğer bir hesabınız ya da davetiyeniz varsa aşağıdaki seçenekleri kullanarak giriş yapabilirsiniz.", "signin.message.private.title": "<0>{0} özel bir alandır ve katılabilmek için davetiye almanız gerekir.", "signin.message.socialbutton.intro": "İle giriş yapın", + "signin.name.placeholder": "", "validation.custom.maxattachments": "En fazla {number} ek dosyaya izin verilir.", "validation.custom.maximagesize": "Resim boyutu {kilobytes}KB'den küçük olmalıdır.", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# etiket} other {# etiket}}", - "linkmodal.insert": "Bağlantı Ekle", - "linkmodal.text.label": "Görüntülenecek metin", - "linkmodal.text.placeholder": "Bağlantı metnini girin", - "linkmodal.text.required": "Metin gereklidir", - "linkmodal.title": "Bağlantı Ekle", - "linkmodal.url.invalid": "Lütfen geçerli bir URL girin", - "linkmodal.url.required": "URL gerekli" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# etiket} other {# etiket}}" } \ No newline at end of file diff --git a/locale/tr/server.json b/locale/tr/server.json index 38e04e350..ba034029d 100644 --- a/locale/tr/server.json +++ b/locale/tr/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} silinmiştir.", "email.new_comment.text": "{userName}, {title} ({postLink}) kaydına yorum eklemiştir.", "email.new_post.text": "{userName}, {title} ({postLink}) yeni bir kayıt oluşturmuştur.", - "email.signin_email.subject": "{siteName} sayfasına giriş yapın", + "email.signin_email.subject": "{siteName} için giriş kodunuz {code}", "email.signin_email.text": "Bizden bir giriş bağlantısı talep etmiştiniz, işte burada bulabilirsiniz.", "email.signin_email.confirmation": "{siteName} alanına giriş yapabilmek için aşağıdaki bağlantıya tıklayın.", "email.signup_email.subject": "Yeni Fider sayfanız", diff --git a/locale/zh-CN/client.json b/locale/zh-CN/client.json index 263649983..73740a6b4 100644 --- a/locale/zh-CN/client.json +++ b/locale/zh-CN/client.json @@ -4,6 +4,7 @@ "action.close": "关闭", "action.commentsfeed": "评论提要", "action.confirm": "确认", + "action.continue": "", "action.copylink": "复制链接", "action.delete": "删除", "action.edit": "编辑", @@ -13,6 +14,7 @@ "action.respond": "回复/标记", "action.save": "保存", "action.signin": "登录", + "action.signup": "", "action.submit": "提交", "action.vote": "投票支持这个想法", "action.voted": "已投票!", @@ -96,6 +98,15 @@ "legal.notice": "在签署时,您同意<2/><0/>和<1/>。", "legal.privacypolicy": "隐私政策", "legal.termsofservice": "服务条款", + "linkmodal.insert": "插入链接", + "linkmodal.text.label": "要显示的文本", + "linkmodal.text.placeholder": "输入链接文本", + "linkmodal.text.required": "文本为必填项", + "linkmodal.title": "插入链接", + "linkmodal.url.invalid": "请输入有效的 URL", + "linkmodal.url.label": "网址", + "linkmodal.url.placeholder": "", + "linkmodal.url.required": "需要 URL", "menu.administration": "管理", "menu.mysettings": "我的设置", "menu.signout": "退出", @@ -192,6 +203,12 @@ "showpost.responseform.text.placeholder": "这篇文章怎么了?让你的用户知道你的计划是什么...", "showpost.votespanel.more": "+{extraVotesCount} 更多", "showpost.votespanel.seedetails": "查看详细信息", + "signin.code.edit": "", + "signin.code.getnew": "", + "signin.code.instruction": "", + "signin.code.placeholder": "", + "signin.code.sent": "", + "signin.code.submit": "", "signin.email.placeholder": "电子邮件", "signin.message.email": "通过电子邮件继续", "signin.message.emaildisabled": "管理员已禁用电子邮件身份验证。如果您有管理员帐户并且需要绕过此限制,请 <0>点击这里.", @@ -202,15 +219,8 @@ "signin.message.private.text": "如果您有帐户或邀请,您可以使用以下选项登录.", "signin.message.private.title": "<0>{0} 这是一个私人空间,您必须登录才能参与和投票.", "signin.message.socialbutton.intro": "使用以下方式登录", + "signin.name.placeholder": "", "validation.custom.maxattachments": "最多允许 {number} 个附件。", "validation.custom.maximagesize": "图像大小必须小于{kilobytes}KB。", - "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}", - "linkmodal.insert": "插入链接", - "linkmodal.text.label": "要显示的文本", - "linkmodal.text.placeholder": "输入链接文本", - "linkmodal.text.required": "文本为必填项", - "linkmodal.title": "插入链接", - "linkmodal.url.invalid": "请输入有效的 URL", - "linkmodal.url.label": "网址", - "linkmodal.url.required": "需要 URL" + "{count, plural, one {# tag} other {# tags}}": "{count, plural, one {# tag} other {# tags}}" } \ No newline at end of file diff --git a/locale/zh-CN/server.json b/locale/zh-CN/server.json index a0f876c80..e4e14fe16 100644 --- a/locale/zh-CN/server.json +++ b/locale/zh-CN/server.json @@ -52,7 +52,7 @@ "email.delete_post.text": "{title} 已经 删除了.", "email.new_comment.text": "{userName} 发表评论 {title} ({postLink}).", "email.new_post.text": "{userName} 创建了一个新帖子 {title} ({postLink}).", - "email.signin_email.subject": "登录到 {siteName}", + "email.signin_email.subject": "您的 {siteName} 登录代码是 {code}", "email.signin_email.text": "您要求我们向您发送登录链接,现在是.", "email.signin_email.confirmation": "单击下面的链接登录 {siteName}.", "email.signup_email.subject": "您的新 Fider 网站", diff --git a/public/components/SignInModal.tsx b/public/components/SignInModal.tsx index 137f6c39a..c27a65718 100644 --- a/public/components/SignInModal.tsx +++ b/public/components/SignInModal.tsx @@ -1,6 +1,6 @@ -import React, { useState, useEffect } from "react" +import React from "react" import { Modal, SignInControl, LegalFooter, TenantLogo } from "@fider/components" -import { Button, CloseIcon } from "./common" +import { CloseIcon } from "./common" import { Trans } from "@lingui/react/macro" import { HStack, VStack } from "./layout" @@ -10,54 +10,28 @@ interface SignInModalProps { } export const SignInModal: React.FC = (props) => { - const [email, setEmail] = useState("") - - useEffect(() => { - if (email) { - setTimeout(() => setEmail(""), 5000) - } - }, [email]) - - const onEmailSent = (value: string): void => { - setEmail(value) - } - - const closeModal = () => { - setEmail("") + const onCodeVerified = (): void => { + // User is authenticated - close modal and reload to refresh the page props.onClose() + location.reload() } - const content = email ? ( - <> -

- - We have just sent a confirmation link to {email}. Click the link and you’ll be signed in. - -

-

- -

- - ) : ( - - ) - return ( - + - +

Join the conversation

- {content} + + +
) diff --git a/public/components/common/SignInControl.scss b/public/components/common/SignInControl.scss index e26dd3552..c2494e0b9 100644 --- a/public/components/common/SignInControl.scss +++ b/public/components/common/SignInControl.scss @@ -1,6 +1,9 @@ @use "~@fider/assets/styles/variables.scss" as *; .c-signin-control { + max-width: 500px; + margin-left: auto; + margin-right: auto; padding-left: 45px; padding-right: 45px; padding-bottom: 40px; diff --git a/public/components/common/SignInControl.tsx b/public/components/common/SignInControl.tsx index 6044e8c4e..fac58a92d 100644 --- a/public/components/common/SignInControl.tsx +++ b/public/components/common/SignInControl.tsx @@ -14,15 +14,24 @@ interface SignInControlProps { onSubmit?: () => void onEmailSent?: (email: string) => void signInButtonText?: string + onCodeVerified?: () => void +} + +enum EmailSigninStep { + EnterEmail, + EnterName, + EnterCode, } export const SignInControl: React.FunctionComponent = (props) => { const fider = useFider() const [showEmailForm, setShowEmailForm] = useState(fider.session.tenant ? fider.session.tenant.isEmailAuthAllowed : true) const [email, setEmail] = useState("") + const [emailSignInStep, setEmailSignInStep] = useState(EmailSigninStep.EnterEmail) + const [userName, setUserName] = useState("") + const [code, setCode] = useState("") const [error, setError] = useState(undefined) - - const signInText = props.signInButtonText || i18n._({ id: "action.signin", message: "Sign in" }) + const [resendMessage, setResendMessage] = useState("") const forceShowEmailForm = (e: React.MouseEvent) => { e.preventDefault() @@ -39,22 +48,119 @@ export const SignInControl: React.FunctionComponent = (props doPreSigninAction() } + const editEmail = () => { + setEmailSignInStep(EmailSigninStep.EnterEmail) + setUserName("") + setCode("") + setError(undefined) + setResendMessage("") + } + const signIn = async () => { await doPreSigninAction() const result = await actions.signIn(email) if (result.ok) { - setEmail("") setError(undefined) - if (props.onEmailSent) { - props.onEmailSent(email) + const data = result.data as { userExists?: boolean } | undefined + if (data && data.userExists === false) { + // New user - show name field + setEmailSignInStep(EmailSigninStep.EnterName) + } else { + // Existing user - show code entry + setEmailSignInStep(EmailSigninStep.EnterCode) } } else if (result.error) { setError(result.error) } } + const submitNewUser = async () => { + doPreSigninAction() + const result = await actions.signInNewUser(email, userName) + if (result.ok) { + setError(undefined) + setEmailSignInStep(EmailSigninStep.EnterCode) + } else if (result.error) { + setError(result.error) + } + } + + const verifyCode = async () => { + const result = await actions.verifySignInCode(email, code) + if (result.ok) { + if (props.onCodeVerified) { + // Let the parent component decide what to do + props.onCodeVerified() + } else { + // Default behavior: reload the page + location.reload() + } + } else { + // Handle validation errors - convert data object to Failure format + const data = result.data as Record | undefined + if (data && typeof data === "object") { + const errors = Object.entries(data).map(([field, message]) => ({ + field, + message, + })) + setError({ errors }) + } else if (result.error) { + // Display the error from the server + setError(result.error) + } + } + } + + const resendCode = async () => { + setResendMessage("") + const result = await actions.resendSignInCode(email) + if (result.ok) { + setError(undefined) + setCode("") + setResendMessage(i18n._({ id: "signin.code.sent", message: "A new code has been sent to your email." })) + } else if (result.error) { + setError(result.error) + } + } + const providersLen = fider.settings.oauth.length + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault() + // Form submission handler that routes to the correct function based on step + if (emailSignInStep === EmailSigninStep.EnterEmail) { + await signIn() + } else if (emailSignInStep === EmailSigninStep.EnterName) { + await submitNewUser() + } else if (emailSignInStep === EmailSigninStep.EnterCode) { + await verifyCode() + } + } + + const renderSigninEmailButton = () => { + if (emailSignInStep === EmailSigninStep.EnterEmail) { + return ( + + ) + } + if (emailSignInStep === EmailSigninStep.EnterName) { + return ( + + ) + } + if (emailSignInStep === EmailSigninStep.EnterCode) { + return ( + + ) + } + } + if (!isCookieEnabled()) { return ( @@ -82,24 +188,15 @@ export const SignInControl: React.FunctionComponent = (props {props.useEmail && (showEmailForm ? (
-
- - + + {(emailSignInStep === EmailSigninStep.EnterEmail || emailSignInStep === EmailSigninStep.EnterName) && renderEmailField()} + + {emailSignInStep === EmailSigninStep.EnterName && renderNameField()} + + {emailSignInStep === EmailSigninStep.EnterCode && renderCodeField()} + +
{renderSigninEmailButton()}
- {!fider.session.tenant.isEmailAuthAllowed && ( -

- Currently only allowed to sign in to an administrator account -

- )}
) : (
@@ -116,4 +213,86 @@ export const SignInControl: React.FunctionComponent = (props ))}
) + + function renderNameField() { + return ( + + ) + } + + function renderEmailField(): React.ReactNode { + return ( + <> + + {!fider.session.tenant.isEmailAuthAllowed && ( +

+ Currently only allowed to sign in to an administrator account +

+ )} + + ) + } + + function renderCodeField(): React.ReactNode { + return ( + <> +

+ + Please type in the code we just sent to {email} + {" "} + { + e.preventDefault() + editEmail() + }} + > + Edit + +

+ + {resendMessage &&

{resendMessage}

} +

+ { + e.preventDefault() + resendCode() + }} + > + Get a new code + +

+ + ) + } } diff --git a/public/components/common/form/Form.tsx b/public/components/common/form/Form.tsx index 1096b5cdc..9a532207c 100644 --- a/public/components/common/form/Form.tsx +++ b/public/components/common/form/Form.tsx @@ -13,6 +13,8 @@ interface FormProps { children?: React.ReactNode className?: string error?: Failure + autoComplete?: string + onSubmit?: (e: React.FormEvent) => void } export const ValidationContext = React.createContext({ @@ -45,8 +47,15 @@ export const Form: React.FunctionComponent = (props) => { } } + const handleSubmit = (e: React.FormEvent) => { + if (props.onSubmit) { + e.preventDefault() + props.onSubmit(e) + } + } + return ( -
+ {props.children} diff --git a/public/components/common/form/Input.tsx b/public/components/common/form/Input.tsx index eb77ecb8b..516bca7bd 100644 --- a/public/components/common/form/Input.tsx +++ b/public/components/common/form/Input.tsx @@ -22,6 +22,7 @@ interface InputProps { disabled?: boolean suffix?: string | JSX.Element placeholder?: string + inputMode?: "text" | "numeric" | "decimal" | "tel" | "search" | "email" | "url" onIconClick?: () => void onFocus?: () => void inputRef?: React.MutableRefObject @@ -80,6 +81,7 @@ export const Input: React.FunctionComponent = (props) => { id={`input-${props.field}`} type="text" autoComplete={props.autoComplete} + inputMode={props.inputMode} tabIndex={props.noTabFocus ? -1 : undefined} ref={props.inputRef} autoFocus={props.autoFocus} diff --git a/public/pages/Home/components/ShareFeedback.tsx b/public/pages/Home/components/ShareFeedback.tsx index 7b453e532..c13ae6314 100644 --- a/public/pages/Home/components/ShareFeedback.tsx +++ b/public/pages/Home/components/ShareFeedback.tsx @@ -200,8 +200,9 @@ export const ShareFeedback: React.FC = (props) => { } } - const onEmailSent = (email: string) => { - window.location.href = "/loginemailsent?email=" + encodeURIComponent(email) + const onCodeVerified = (): void => { + // User is authenticated - finalize the feedback submission + finaliseFeedback() } const handleEditorFocus = () => { @@ -274,7 +275,7 @@ export const ShareFeedback: React.FC = (props) => { { export const SignInPage = () => { const fider = useFider() - const onEmailSent = (email: string) => { - notify.success( - - - We have just sent a confirmation link to {email}. Click the link and you’ll be signed in. - - - ) + const onCodeVerified = () => { + // User is authenticated - redirect to the appropriate URL + const redirect = new URLSearchParams(window.location.search).get("redirect") + if (redirect && redirect.startsWith("/")) { + location.href = fider.settings.baseURL + redirect + } else { + location.href = fider.settings.baseURL + } } const getRedirectToUrl = () => { @@ -63,7 +62,7 @@ export const SignInPage = () => {
{fider.session.tenant.isPrivate ? : }
- + ) diff --git a/public/services/actions/tenant.ts b/public/services/actions/tenant.ts index 8e71231c6..58e5d62d9 100644 --- a/public/services/actions/tenant.ts +++ b/public/services/actions/tenant.ts @@ -54,11 +54,20 @@ export const checkAvailability = async (subdomain: string): Promise(`/_api/tenants/${subdomain}/availability`) } -export const signIn = async (email: string, code?: string): Promise => { - return await http.post("/_api/signin", { - email, - code, - }) +export const signIn = async (email: string): Promise => { + return await http.post("/_api/signin", { email }) +} + +export const signInNewUser = async (email: string, name: string): Promise => { + return await http.post("/_api/signin/newuser", { email, name }) +} + +export const verifySignInCode = async (email: string, code: string): Promise => { + return await http.post("/_api/signin/verify", { email, code }) +} + +export const resendSignInCode = async (email: string): Promise => { + return await http.post("/_api/signin/resend", { email }) } export const completeProfile = async (kind: EmailVerificationKind, key: string, name: string): Promise => { diff --git a/views/email/signin_email.html b/views/email/signin_email.html index 63d17b932..45af3795d 100644 --- a/views/email/signin_email.html +++ b/views/email/signin_email.html @@ -1,4 +1,4 @@ -{{define "subject"}}{{ translate "email.signin_email.subject" (dict "siteName" .siteName) }}{{end}} +{{define "subject"}}{{ translate "email.signin_email.subject" (dict "siteName" .siteName "code" .code) }}{{end}} {{define "body"}} @@ -6,7 +6,16 @@

{{ "email.greetings" | translate }}

{{ "email.signin_email.text" | translate }}

{{ translate "email.signin_email.confirmation" (dict "siteName" (.siteName | stripHtml)) | html }}

-

{{ .link | html }}

+ +
+

{{ "email.signin_email.your_code" | translate }}

+
+

{{ .code }}

+
+

{{ "email.signin_email.code_expires" | translate }}

+
+ +

{{ "email.signin_email.alternative" | translate }}
{{ .link | html }}

{{end}} \ No newline at end of file