mirror of
https://github.com/gogs/gogs.git
synced 2026-05-28 21:30:36 +00:00
feat(web): migrate account activation page to React (#8296)
This commit is contained in:
@@ -127,7 +127,6 @@ func Run(configPath string, portOverride int) error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
m.Group("/user", func() {
|
m.Group("/user", func() {
|
||||||
m.Any("/activate", user.Activate)
|
|
||||||
m.Any("/activate_email", user.ActivateEmail)
|
m.Any("/activate_email", user.ActivateEmail)
|
||||||
m.Get("/email2user", user.Email2User)
|
m.Get("/email2user", user.Email2User)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
stdctx "context"
|
stdctx "context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -27,7 +29,7 @@ import (
|
|||||||
"gogs.io/gogs/internal/context"
|
"gogs.io/gogs/internal/context"
|
||||||
"gogs.io/gogs/internal/database"
|
"gogs.io/gogs/internal/database"
|
||||||
"gogs.io/gogs/internal/email"
|
"gogs.io/gogs/internal/email"
|
||||||
"gogs.io/gogs/internal/route/user"
|
"gogs.io/gogs/internal/tool"
|
||||||
"gogs.io/gogs/internal/userx"
|
"gogs.io/gogs/internal/userx"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,6 +82,33 @@ func webAPIBodyLimiter(c flamego.Context) {
|
|||||||
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
|
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseUserFromCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||||
|
if len(code) <= tool.TimeLimitCodeLength {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hexStr := code[tool.TimeLimitCodeLength:]
|
||||||
|
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||||
|
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||||
|
return user
|
||||||
|
} else if !database.IsErrUserNotExist(err) {
|
||||||
|
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyUserActiveCode(ctx stdctx.Context, code string) (user *database.User) {
|
||||||
|
if user = parseUserFromCode(ctx, code); user != nil {
|
||||||
|
prefix := code[:tool.TimeLimitCodeLength]
|
||||||
|
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
||||||
|
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// webAPIValidator is the shared validator instance used by every webapi
|
// webAPIValidator is the shared validator instance used by every webapi
|
||||||
// binding. Registering the json-tag name function makes validation errors
|
// binding. Registering the json-tag name function makes validation errors
|
||||||
// carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the
|
// carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the
|
||||||
@@ -159,6 +188,12 @@ func mountWebAPIRoutes(f *flamego.Flame) {
|
|||||||
Post(bindJSON(userMFARequest{}), postUserMFA)
|
Post(bindJSON(userMFARequest{}), postUserMFA)
|
||||||
f.Post("/recovery", bindJSON(userMFARecoveryRequest{}), postUserMFARecovery)
|
f.Post("/recovery", bindJSON(userMFARecoveryRequest{}), postUserMFARecovery)
|
||||||
})
|
})
|
||||||
|
f.Group("/activate", func() {
|
||||||
|
f.Combo("").
|
||||||
|
Get(getUserActivate).
|
||||||
|
Post(postUserActivate)
|
||||||
|
f.Post("/complete", bindJSON(userActivateCompleteRequest{}), postUserActivateComplete)
|
||||||
|
})
|
||||||
f.Post("/sign-out", postUserSignOut)
|
f.Post("/sign-out", postUserSignOut)
|
||||||
})
|
})
|
||||||
}, webAPIBodyLimiter)
|
}, webAPIBodyLimiter)
|
||||||
@@ -368,7 +403,7 @@ func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPa
|
|||||||
code := r.URL.Query().Get("code")
|
code := r.URL.Query().Get("code")
|
||||||
return http.StatusOK, &getUserResetPasswordResponse{
|
return http.StatusOK, &getUserResetPasswordResponse{
|
||||||
EmailEnabled: conf.Email.Enabled,
|
EmailEnabled: conf.Email.Enabled,
|
||||||
Valid: code != "" && user.VerifyUserActiveCode(code) != nil,
|
Valid: code != "" && verifyUserActiveCode(r.Context(), code) != nil,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,7 +461,7 @@ func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req u
|
|||||||
}
|
}
|
||||||
|
|
||||||
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
|
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
|
||||||
u := user.VerifyUserActiveCode(req.Code)
|
u := verifyUserActiveCode(r.Context(), req.Code)
|
||||||
if u == nil {
|
if u == nil {
|
||||||
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||||
}
|
}
|
||||||
@@ -600,6 +635,88 @@ func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error
|
|||||||
nil
|
nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type getUserActivateResponse struct {
|
||||||
|
Email string `json:"email,omitempty"`
|
||||||
|
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getUserActivate(u *database.User) (statusCode int, resp any, err error) {
|
||||||
|
if u == nil {
|
||||||
|
return http.StatusUnauthorized, nil, nil
|
||||||
|
}
|
||||||
|
// An already-active and authenticated user has no business on the activation page.
|
||||||
|
if u.IsActive {
|
||||||
|
return http.StatusNotFound, nil, nil
|
||||||
|
}
|
||||||
|
return http.StatusOK, &getUserActivateResponse{
|
||||||
|
Email: u.Email,
|
||||||
|
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type postUserActivateResponse struct {
|
||||||
|
RateLimited bool `json:"rateLimited,omitempty"`
|
||||||
|
CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postUserActivate(r *http.Request, u *database.User, mc *macaron.Context, ca cache.Cache, l i18n.Locale) (statusCode int, resp any, err error) {
|
||||||
|
if u == nil {
|
||||||
|
return http.StatusUnauthorized, nil, nil
|
||||||
|
}
|
||||||
|
if u.IsActive {
|
||||||
|
return http.StatusNotFound, nil, nil
|
||||||
|
}
|
||||||
|
if !conf.Auth.RequireEmailConfirmation {
|
||||||
|
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
|
||||||
|
return http.StatusOK, &postUserActivateResponse{
|
||||||
|
RateLimited: true,
|
||||||
|
CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60,
|
||||||
|
}, nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
log.Error("postUserActivate: get mail resend cache for user %q: %v", u.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil {
|
||||||
|
log.Error("postUserActivate: send activation mail to user %q: %v", u.Name, err)
|
||||||
|
}
|
||||||
|
if err := ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil {
|
||||||
|
log.Error("postUserActivate: put mail resend cache for user %q: %v", u.Name, err)
|
||||||
|
}
|
||||||
|
return http.StatusOK, &postUserActivateResponse{CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type userActivateCompleteRequest struct {
|
||||||
|
Code string `json:"code" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func postUserActivateComplete(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userActivateCompleteRequest) (statusCode int, resp any, err error) {
|
||||||
|
target := verifyUserActiveCode(r.Context(), req.Code)
|
||||||
|
if target == nil {
|
||||||
|
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v := true
|
||||||
|
if err := database.Handle.Users().Update(
|
||||||
|
r.Context(),
|
||||||
|
target.ID,
|
||||||
|
database.UpdateUserOptions{
|
||||||
|
GenerateNewRands: true,
|
||||||
|
IsActivated: &v,
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
log.Error("postUserActivateComplete: update user %q: %v", target.Name, err)
|
||||||
|
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace("User activated: %s", target.Name)
|
||||||
|
completeSignIn(sess, mc, target)
|
||||||
|
return http.StatusNoContent, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
type postUserSignOutResponse struct {
|
type postUserSignOutResponse struct {
|
||||||
RedirectTo string `json:"redirectTo,omitempty"`
|
RedirectTo string `json:"redirectTo,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,12 +194,18 @@ forgot_password= Forgot Password
|
|||||||
forget_password = Forgot password?
|
forget_password = Forgot password?
|
||||||
sign_up_now = Create a new account
|
sign_up_now = Create a new account
|
||||||
confirmation_email_sent = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
|
confirmation_email_sent = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
|
||||||
active_your_account = Activate Your Account
|
activate_your_account = Activate your account
|
||||||
prohibit_login = Login Prohibited
|
prohibit_login = Login Prohibited
|
||||||
prohibit_login_desc = Your account is prohibited from logging in. Please contact the site admin.
|
prohibit_login_desc = Your account is prohibited from logging in. Please contact the site admin.
|
||||||
resent_limit_prompt = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.
|
resend_rate_limited = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.
|
||||||
has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (<b>%s</b>). If you haven't received a confirmation email or need to receive a new one, please click the button below.
|
has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (<b>%s</b>). If you haven't received a confirmation email or need to receive a new one, please click the button below.
|
||||||
resend_mail = Click here to resend your activation email
|
send_activation_email = Send activation email
|
||||||
|
check_activation_email = Please check your email and click the activation link to finish creating your account.
|
||||||
|
activation_email_pending = Your email address <email>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.
|
||||||
|
activation_email_sent = A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} hours</hours>.
|
||||||
|
sending_activation_email = Sending activation email...
|
||||||
|
send_activation_email_failed = Could not send activation email, please try again.
|
||||||
|
activating_account = Activating your account...
|
||||||
send_reset_email = Send password reset email
|
send_reset_email = Send password reset email
|
||||||
reset_password_email_submitting = Sending password reset email...
|
reset_password_email_submitting = Sending password reset email...
|
||||||
reset_password_email_failed = Could not send password reset email, please try again.
|
reset_password_email_failed = Could not send password reset email, please try again.
|
||||||
|
|||||||
@@ -80,8 +80,9 @@ func Toggle(options *ToggleOptions) macaron.Handler {
|
|||||||
c.RedirectSubpath("/user/sign-in")
|
c.RedirectSubpath("/user/sign-in")
|
||||||
return
|
return
|
||||||
} else if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
} else if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
||||||
c.Title("auth.active_your_account")
|
// Inactive users get bounced to the React activation page, which
|
||||||
c.Success("user/auth/activate")
|
// is responsible for offering a resend and showing status.
|
||||||
|
c.RedirectSubpath("/user/activate")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ const (
|
|||||||
func Home(c *context.Context) {
|
func Home(c *context.Context) {
|
||||||
if c.IsLogged {
|
if c.IsLogged {
|
||||||
if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
|
||||||
c.Data["Title"] = c.Tr("auth.active_your_account")
|
c.RedirectSubpath("/user/activate")
|
||||||
c.Success(user.TmplUserAuthActivate)
|
|
||||||
} else {
|
} else {
|
||||||
user.Dashboard(c)
|
user.Dashboard(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
package user
|
|
||||||
|
|
||||||
import (
|
|
||||||
gocontext "context"
|
|
||||||
"encoding/hex"
|
|
||||||
"strconv"
|
|
||||||
|
|
||||||
log "unknwon.dev/clog/v2"
|
|
||||||
|
|
||||||
"gogs.io/gogs/internal/conf"
|
|
||||||
"gogs.io/gogs/internal/context"
|
|
||||||
"gogs.io/gogs/internal/database"
|
|
||||||
"gogs.io/gogs/internal/email"
|
|
||||||
"gogs.io/gogs/internal/tool"
|
|
||||||
"gogs.io/gogs/internal/userx"
|
|
||||||
)
|
|
||||||
|
|
||||||
const TmplUserAuthActivate = "user/auth/activate"
|
|
||||||
|
|
||||||
// parseUserFromCode returns user by username encoded in code.
|
|
||||||
// It returns nil if code or username is invalid.
|
|
||||||
func parseUserFromCode(code string) (user *database.User) {
|
|
||||||
if len(code) <= tool.TimeLimitCodeLength {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use tail hex username to query user
|
|
||||||
hexStr := code[tool.TimeLimitCodeLength:]
|
|
||||||
if b, err := hex.DecodeString(hexStr); err == nil {
|
|
||||||
if user, err = database.Handle.Users().GetByUsername(gocontext.TODO(), string(b)); user != nil {
|
|
||||||
return user
|
|
||||||
} else if !database.IsErrUserNotExist(err) {
|
|
||||||
log.Error("Failed to get user by name %q: %v", string(b), err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// VerifyUserActiveCode verifies an account activation or password reset code.
|
|
||||||
func VerifyUserActiveCode(code string) (user *database.User) {
|
|
||||||
minutes := conf.Auth.ActivateCodeLives
|
|
||||||
|
|
||||||
if user = parseUserFromCode(code); user != nil {
|
|
||||||
// time limit code
|
|
||||||
prefix := code[:tool.TimeLimitCodeLength]
|
|
||||||
data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands
|
|
||||||
|
|
||||||
if tool.VerifyTimeLimitCode(data, minutes, prefix) {
|
|
||||||
return user
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// verify active code when active account
|
|
||||||
func verifyActiveEmailCode(code, email string) *database.EmailAddress {
|
|
||||||
minutes := conf.Auth.ActivateCodeLives
|
|
||||||
|
|
||||||
if user := parseUserFromCode(code); user != nil {
|
|
||||||
// time limit code
|
|
||||||
prefix := code[:tool.TimeLimitCodeLength]
|
|
||||||
data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands
|
|
||||||
|
|
||||||
if tool.VerifyTimeLimitCode(data, minutes, prefix) {
|
|
||||||
emailAddress, err := database.Handle.Users().GetEmail(gocontext.TODO(), user.ID, email, false)
|
|
||||||
if err == nil {
|
|
||||||
return emailAddress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Activate(c *context.Context) {
|
|
||||||
code := c.Query("code")
|
|
||||||
if code == "" {
|
|
||||||
c.Data["IsActivatePage"] = true
|
|
||||||
if c.User.IsActive {
|
|
||||||
c.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Resend confirmation email.
|
|
||||||
if conf.Auth.RequireEmailConfirmation {
|
|
||||||
if c.Cache.IsExist(userx.MailResendCacheKey(c.User.ID)) {
|
|
||||||
c.Data["ResendLimited"] = true
|
|
||||||
} else {
|
|
||||||
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
|
|
||||||
if err := email.SendActivateAccountMail(c.Context, database.NewMailerUser(c.User)); err != nil {
|
|
||||||
log.Error("Failed to send activate account mail: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := c.Cache.Put(userx.MailResendCacheKey(c.User.ID), 1, 180); err != nil {
|
|
||||||
log.Error("Failed to put cache key 'mail resend': %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c.Data["ServiceNotEnabled"] = true
|
|
||||||
}
|
|
||||||
c.Success(TmplUserAuthActivate)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify code.
|
|
||||||
if user := VerifyUserActiveCode(code); user != nil {
|
|
||||||
v := true
|
|
||||||
err := database.Handle.Users().Update(
|
|
||||||
c.Req.Context(),
|
|
||||||
user.ID,
|
|
||||||
database.UpdateUserOptions{
|
|
||||||
GenerateNewRands: true,
|
|
||||||
IsActivated: &v,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err, "update user")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace("User activated: %s", user.Name)
|
|
||||||
|
|
||||||
_ = c.Session.Set("uid", user.ID)
|
|
||||||
_ = c.Session.Set("uname", user.Name)
|
|
||||||
c.RedirectSubpath("/")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Data["IsActivateFailed"] = true
|
|
||||||
c.Success(TmplUserAuthActivate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ActivateEmail(c *context.Context) {
|
|
||||||
code := c.Query("code")
|
|
||||||
emailAddr := c.Query("email")
|
|
||||||
|
|
||||||
// Verify code.
|
|
||||||
if email := verifyActiveEmailCode(code, emailAddr); email != nil {
|
|
||||||
err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email)
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err, "activate email")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Trace("Email activated: %s", email.Email)
|
|
||||||
c.Flash.Success(c.Tr("settings.add_email_success"))
|
|
||||||
}
|
|
||||||
|
|
||||||
c.RedirectSubpath("/user/settings/email")
|
|
||||||
}
|
|
||||||
@@ -4,11 +4,13 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
gocontext "context"
|
gocontext "context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/cockroachdb/errors"
|
"github.com/cockroachdb/errors"
|
||||||
"github.com/pquerna/otp"
|
"github.com/pquerna/otp"
|
||||||
@@ -240,6 +242,54 @@ func SettingsEmails(c *context.Context) {
|
|||||||
c.Success(tmplUserSettingsEmail)
|
c.Success(tmplUserSettingsEmail)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseUserFromCode(ctx gocontext.Context, code string) (user *database.User) {
|
||||||
|
if len(code) <= tool.TimeLimitCodeLength {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hexStr := code[tool.TimeLimitCodeLength:]
|
||||||
|
if b, err := hex.DecodeString(hexStr); err == nil {
|
||||||
|
if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil {
|
||||||
|
return user
|
||||||
|
} else if !database.IsErrUserNotExist(err) {
|
||||||
|
log.Error("parseUserFromCode: get user by name %q: %v", string(b), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyActiveEmailCode(ctx gocontext.Context, code, email string) *database.EmailAddress {
|
||||||
|
if user := parseUserFromCode(ctx, code); user != nil {
|
||||||
|
prefix := code[:tool.TimeLimitCodeLength]
|
||||||
|
data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands
|
||||||
|
if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) {
|
||||||
|
emailAddress, err := database.Handle.Users().GetEmail(ctx, user.ID, email, false)
|
||||||
|
if err == nil {
|
||||||
|
return emailAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ActivateEmail(c *context.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
emailAddr := c.Query("email")
|
||||||
|
|
||||||
|
if email := verifyActiveEmailCode(c.Req.Context(), code, emailAddr); email != nil {
|
||||||
|
err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email)
|
||||||
|
if err != nil {
|
||||||
|
c.Error(err, "activate email")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Trace("Email activated: %s", email.Email)
|
||||||
|
c.Flash.Success(c.Tr("settings.add_email_success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.RedirectSubpath("/user/settings/email")
|
||||||
|
}
|
||||||
|
|
||||||
func SettingsEmailPost(c *context.Context, f form.AddEmail) {
|
func SettingsEmailPost(c *context.Context, f form.AddEmail) {
|
||||||
c.Title("settings.emails")
|
c.Title("settings.emails")
|
||||||
c.PageIs("SettingsEmails")
|
c.PageIs("SettingsEmails")
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
{{template "base/head" .}}
|
|
||||||
<div class="user activate">
|
|
||||||
<div class="ui middle very relaxed page grid">
|
|
||||||
<div class="column">
|
|
||||||
<form class="ui form" action="{{AppSubURL}}/user/activate" method="post">
|
|
||||||
{{.CSRFTokenHTML}}
|
|
||||||
<h2 class="ui top attached header">
|
|
||||||
{{.i18n.Tr "auth.active_your_account"}}
|
|
||||||
</h2>
|
|
||||||
<div class="ui attached segment">
|
|
||||||
{{template "base/alert" .}}
|
|
||||||
{{if .IsActivatePage}}
|
|
||||||
{{if .ServiceNotEnabled}}
|
|
||||||
<p class="center">{{.i18n.Tr "auth.disable_register_mail"}}</p>
|
|
||||||
{{else if .ResendLimited}}
|
|
||||||
<p class="center">{{.i18n.Tr "auth.resent_limit_prompt"}}</p>
|
|
||||||
{{else}}
|
|
||||||
<p>{{.i18n.Tr "auth.confirmation_email_sent" .LoggedUser.Email .Hours | Str2HTML}}</p>
|
|
||||||
{{end}}
|
|
||||||
{{else}}
|
|
||||||
{{if .IsSendRegisterMail}}
|
|
||||||
<p>{{.i18n.Tr "auth.confirmation_email_sent" .Email .Hours | Str2HTML}}</p>
|
|
||||||
{{else if .IsActivateFailed}}
|
|
||||||
<p>{{.i18n.Tr "auth.invalid_code"}}</p>
|
|
||||||
{{else}}
|
|
||||||
<p>{{.i18n.Tr "auth.has_unconfirmed_mail" .LoggedUser.Name .LoggedUser.Email | Str2HTML}}</p>
|
|
||||||
<div class="ui divider"></div>
|
|
||||||
<div class="text right">
|
|
||||||
<button class="ui blue button">{{.i18n.Tr "auth.resend_mail"}}</button>
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{template "base/footer" .}}
|
|
||||||
@@ -63,7 +63,6 @@ const REUSED_KEYS = [
|
|||||||
"disable_register_prompt",
|
"disable_register_prompt",
|
||||||
"reset_password_resend_limited",
|
"reset_password_resend_limited",
|
||||||
"non_local_account",
|
"non_local_account",
|
||||||
"confirmation_email_sent",
|
|
||||||
"create_new_account",
|
"create_new_account",
|
||||||
"register_hepler_msg",
|
"register_hepler_msg",
|
||||||
"sign_up",
|
"sign_up",
|
||||||
@@ -98,6 +97,15 @@ const REUSED_KEYS = [
|
|||||||
"mfa_verifying",
|
"mfa_verifying",
|
||||||
"mfa_session_expired",
|
"mfa_session_expired",
|
||||||
"mfa_verify_failed",
|
"mfa_verify_failed",
|
||||||
|
"activate_your_account",
|
||||||
|
"resend_rate_limited",
|
||||||
|
"send_activation_email",
|
||||||
|
"check_activation_email",
|
||||||
|
"activation_email_pending",
|
||||||
|
"activation_email_sent",
|
||||||
|
"sending_activation_email",
|
||||||
|
"send_activation_email_failed",
|
||||||
|
"activating_account",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
|
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
|
||||||
|
|||||||
@@ -49,7 +49,6 @@
|
|||||||
"disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
"disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.",
|
||||||
"reset_password_resend_limited": "You already requested a password reset email recently. Please wait 3 minutes then try again.",
|
"reset_password_resend_limited": "You already requested a password reset email recently. Please wait 3 minutes then try again.",
|
||||||
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
|
||||||
"confirmation_email_sent": "A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.",
|
|
||||||
"create_new_account": "Create new account",
|
"create_new_account": "Create new account",
|
||||||
"register_hepler_msg": "Already have an account? Sign in now!",
|
"register_hepler_msg": "Already have an account? Sign in now!",
|
||||||
"sign_up": "Sign up",
|
"sign_up": "Sign up",
|
||||||
@@ -83,5 +82,14 @@
|
|||||||
"mfa_verify": "Verify",
|
"mfa_verify": "Verify",
|
||||||
"mfa_verifying": "Verifying...",
|
"mfa_verifying": "Verifying...",
|
||||||
"mfa_session_expired": "Your sign-in session has expired. Please sign in again.",
|
"mfa_session_expired": "Your sign-in session has expired. Please sign in again.",
|
||||||
"mfa_verify_failed": "Verification failed. Please try again."
|
"mfa_verify_failed": "Verification failed. Please try again.",
|
||||||
|
"activate_your_account": "Activate your account",
|
||||||
|
"resend_rate_limited": "Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.",
|
||||||
|
"send_activation_email": "Send activation email",
|
||||||
|
"check_activation_email": "Please check your email and click the activation link to finish creating your account.",
|
||||||
|
"activation_email_pending": "Your email address <email>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.",
|
||||||
|
"activation_email_sent": "A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} hours</hours>.",
|
||||||
|
"sending_activation_email": "Sending activation email...",
|
||||||
|
"send_activation_email_failed": "Could not send activation email, please try again.",
|
||||||
|
"activating_account": "Activating your account..."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { usePageTitle } from "@/lib/page-title";
|
||||||
|
import { subUrl } from "@/lib/url";
|
||||||
|
import { useUserInfo } from "@/lib/use-user-info";
|
||||||
|
|
||||||
|
export interface ActivatePage {
|
||||||
|
code: string;
|
||||||
|
email: string;
|
||||||
|
codeLifetimeHours: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivateResponse {
|
||||||
|
rateLimited?: boolean;
|
||||||
|
codeLifetimeHours?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivateErrorResponse {
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = getRouteApi("/user/activate");
|
||||||
|
|
||||||
|
export function Activate() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { code, email, codeLifetimeHours } = route.useLoaderData();
|
||||||
|
const authenticated = useUserInfo() !== null;
|
||||||
|
usePageTitle(t("activate_your_account"));
|
||||||
|
|
||||||
|
const isVerifying = code !== "";
|
||||||
|
const [verifyFailed, setVerifyFailed] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [resent, setResent] = useState<ActivateResponse | null>(null);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
const verifyOnceRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isVerifying || verifyOnceRef.current) return;
|
||||||
|
verifyOnceRef.current = true;
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(subUrl("/api/web/user/activate/complete"), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
setVerifyFailed(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign(subUrl("/"));
|
||||||
|
} catch {
|
||||||
|
setVerifyFailed(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [isVerifying, code]);
|
||||||
|
|
||||||
|
function onResend(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setFormError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(subUrl("/api/web/user/activate"), {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = (await res.json().catch(() => ({}))) as ActivateErrorResponse;
|
||||||
|
setFormError(body.error ?? t("send_activation_email_failed"));
|
||||||
|
setSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setResent((await res.json()) as ActivateResponse);
|
||||||
|
setSubmitting(false);
|
||||||
|
} catch {
|
||||||
|
setFormError(t("send_activation_email_failed"));
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<CardTitle>{t("activate_your_account")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-2">{renderContent()}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
function renderContent() {
|
||||||
|
if (isVerifying) {
|
||||||
|
if (verifyFailed) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 text-center">
|
||||||
|
<p role="alert" className="text-sm text-(--color-destructive)">
|
||||||
|
{t("invalid_code")}
|
||||||
|
</p>
|
||||||
|
<Button variant="link" size="inline" asChild className="self-center">
|
||||||
|
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-center text-sm text-(--color-foreground)">
|
||||||
|
{t("activating_account")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 text-center">
|
||||||
|
<p className="text-sm text-(--color-foreground)">{t("check_activation_email")}</p>
|
||||||
|
<Button variant="link" size="inline" asChild className="self-center">
|
||||||
|
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resent) {
|
||||||
|
return (
|
||||||
|
<p role="status" className="text-center text-sm text-(--color-foreground)">
|
||||||
|
{resent.rateLimited ? (
|
||||||
|
t("resend_rate_limited")
|
||||||
|
) : (
|
||||||
|
<Trans
|
||||||
|
i18nKey="activation_email_sent"
|
||||||
|
values={{ email, hours: resent.codeLifetimeHours }}
|
||||||
|
components={{ email: <b />, hours: <b /> }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={onResend} noValidate>
|
||||||
|
<fieldset disabled={submitting} className="contents">
|
||||||
|
{formError && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="mb-4 rounded-md border border-(--color-destructive) bg-(--color-destructive)/10 px-3 py-2 text-sm text-(--color-destructive)"
|
||||||
|
>
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-(--color-foreground)">
|
||||||
|
<Trans
|
||||||
|
i18nKey="activation_email_pending"
|
||||||
|
values={{ email, hours: codeLifetimeHours }}
|
||||||
|
components={{ email: <b />, hours: <b /> }}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<Button type="submit" disabled={submitting} className="w-full">
|
||||||
|
{submitting ? t("sending_activation_email") : t("send_activation_email")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
import { getRouteApi, useNavigate } from "@tanstack/react-router";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { PasswordInput } from "@/components/PasswordInput";
|
import { PasswordInput } from "@/components/PasswordInput";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -140,10 +140,11 @@ export function SignUp() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 text-center">
|
<div className="flex flex-col gap-4 text-center">
|
||||||
<p role="status" className="text-sm text-(--color-foreground)">
|
<p role="status" className="text-sm text-(--color-foreground)">
|
||||||
{t("confirmation_email_sent")
|
<Trans
|
||||||
.replace(/<[^>]+>/g, "")
|
i18nKey="activation_email_sent"
|
||||||
.replace("%s", sent.email!)
|
values={{ email: sent.email, hours: sent.hours }}
|
||||||
.replace("%d", String(sent.hours))}
|
components={{ email: <b />, hours: <b /> }}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
<Button variant="link" size="inline" asChild className="self-center">
|
<Button variant="link" size="inline" asChild className="self-center">
|
||||||
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
|
||||||
+3
-90
@@ -1,25 +1,13 @@
|
|||||||
import {
|
import { Outlet, RouterProvider, createRootRouteWithContext, createRoute, createRouter } from "@tanstack/react-router";
|
||||||
Outlet,
|
|
||||||
RouterProvider,
|
|
||||||
createRootRouteWithContext,
|
|
||||||
createRoute,
|
|
||||||
createRouter,
|
|
||||||
redirect,
|
|
||||||
} from "@tanstack/react-router";
|
|
||||||
|
|
||||||
import { Footer } from "@/components/Footer";
|
import { Footer } from "@/components/Footer";
|
||||||
import { Navbar } from "@/components/Navbar";
|
import { Navbar } from "@/components/Navbar";
|
||||||
import { webContext } from "@/lib/context";
|
import { webContext } from "@/lib/context";
|
||||||
import { loaderResponseError } from "@/lib/loader-error";
|
|
||||||
import { subUrl } from "@/lib/url";
|
|
||||||
import type { UserInfo } from "@/lib/user-info";
|
import type { UserInfo } from "@/lib/user-info";
|
||||||
import { Landing } from "@/pages/Landing";
|
import { Landing } from "@/pages/Landing";
|
||||||
import { MFA } from "@/pages/MFA";
|
|
||||||
import { NotFound } from "@/pages/NotFound";
|
import { NotFound } from "@/pages/NotFound";
|
||||||
import { ResetPassword, type ResetPasswordPage } from "@/pages/ResetPassword";
|
|
||||||
import { ServerError } from "@/pages/ServerError";
|
import { ServerError } from "@/pages/ServerError";
|
||||||
import { SignIn, type SignInPage } from "@/pages/SignIn";
|
import { createUserRoutes } from "@/routes/user";
|
||||||
import { SignUp, type SignUpPage } from "@/pages/SignUp";
|
|
||||||
|
|
||||||
interface RouterContext {
|
interface RouterContext {
|
||||||
user: UserInfo | null;
|
user: UserInfo | null;
|
||||||
@@ -46,82 +34,7 @@ const landingRoute = createRoute({
|
|||||||
component: Landing,
|
component: Landing,
|
||||||
});
|
});
|
||||||
|
|
||||||
function requireUnauthenticated({ context }: { context: RouterContext }) {
|
const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute)]);
|
||||||
if (!context.user) return;
|
|
||||||
// Bounce authenticated visits to "/" via full navigation so the server-rendered
|
|
||||||
// dashboard handler runs.
|
|
||||||
window.location.assign(subUrl("/"));
|
|
||||||
// The thrown redirect is a sentinel to halt loader execution;
|
|
||||||
// the document-level navigation above is what actually moves the user.
|
|
||||||
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
|
|
||||||
throw redirect({ to: "/", replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const signInRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/user/sign-in",
|
|
||||||
beforeLoad: requireUnauthenticated,
|
|
||||||
loader: async (): Promise<SignInPage> => {
|
|
||||||
const res = await fetch(subUrl("/api/web/user/sign-in"), { credentials: "same-origin" });
|
|
||||||
if (!res.ok) {
|
|
||||||
return { loginSources: [] };
|
|
||||||
}
|
|
||||||
return (await res.json()) as SignInPage;
|
|
||||||
},
|
|
||||||
component: SignIn,
|
|
||||||
});
|
|
||||||
|
|
||||||
const signUpRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/user/sign-up",
|
|
||||||
beforeLoad: requireUnauthenticated,
|
|
||||||
loader: async (): Promise<SignUpPage> => {
|
|
||||||
const res = await fetch(subUrl("/api/web/user/sign-up"), { credentials: "same-origin" });
|
|
||||||
if (!res.ok) {
|
|
||||||
throw await loaderResponseError(res);
|
|
||||||
}
|
|
||||||
return (await res.json()) as SignUpPage;
|
|
||||||
},
|
|
||||||
component: SignUp,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetPasswordRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/user/reset-password",
|
|
||||||
beforeLoad: requireUnauthenticated,
|
|
||||||
loader: async (): Promise<ResetPasswordPage> => {
|
|
||||||
const code = new URLSearchParams(window.location.search).get("code") ?? "";
|
|
||||||
const url = code
|
|
||||||
? subUrl("/api/web/user/reset-password") + "?code=" + encodeURIComponent(code)
|
|
||||||
: subUrl("/api/web/user/reset-password");
|
|
||||||
const res = await fetch(url, { credentials: "same-origin" });
|
|
||||||
if (!res.ok) {
|
|
||||||
return { code, emailEnabled: false, valid: false };
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as { emailEnabled: boolean; valid: boolean };
|
|
||||||
return { code, emailEnabled: data.emailEnabled, valid: data.valid };
|
|
||||||
},
|
|
||||||
component: ResetPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mfaRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: "/user/mfa",
|
|
||||||
loader: async (): Promise<{ pending: boolean }> => {
|
|
||||||
const res = await fetch(subUrl("/api/web/user/mfa"), { credentials: "same-origin" });
|
|
||||||
if (res.status === 404) {
|
|
||||||
// No pending MFA challenge — there is nothing to verify here, so fall
|
|
||||||
// through to the server-rendered home, which will redirect to sign-in
|
|
||||||
// for anonymous visitors and to the dashboard for signed-in ones.
|
|
||||||
window.location.assign(subUrl("/"));
|
|
||||||
return { pending: false };
|
|
||||||
}
|
|
||||||
return { pending: res.ok };
|
|
||||||
},
|
|
||||||
component: MFA,
|
|
||||||
});
|
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([landingRoute, signInRoute, signUpRoute, resetPasswordRoute, mfaRoute]);
|
|
||||||
|
|
||||||
function makeRouter(context: RouterContext) {
|
function makeRouter(context: RouterContext) {
|
||||||
return createRouter({
|
return createRouter({
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { type AnyRoute, createRoute, redirect } from "@tanstack/react-router";
|
||||||
|
|
||||||
|
import { loaderResponseError } from "@/lib/loader-error";
|
||||||
|
import { subUrl } from "@/lib/url";
|
||||||
|
import type { UserInfo } from "@/lib/user-info";
|
||||||
|
import { Activate, type ActivatePage } from "@/pages/user/Activate";
|
||||||
|
import { MFA } from "@/pages/user/MFA";
|
||||||
|
import { ResetPassword, type ResetPasswordPage } from "@/pages/user/ResetPassword";
|
||||||
|
import { SignIn, type SignInPage } from "@/pages/user/SignIn";
|
||||||
|
import { SignUp, type SignUpPage } from "@/pages/user/SignUp";
|
||||||
|
|
||||||
|
interface RouterContext {
|
||||||
|
user: UserInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireUnauthenticated({ context }: { context: RouterContext }) {
|
||||||
|
if (!context.user) return;
|
||||||
|
// Bounce authenticated visits to "/" via full navigation so the server-rendered
|
||||||
|
// dashboard handler runs.
|
||||||
|
window.location.assign(subUrl("/"));
|
||||||
|
// The thrown redirect is a sentinel to halt loader execution;
|
||||||
|
// the document-level navigation above is what actually moves the user.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
|
||||||
|
throw redirect({ to: "/", replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createUserRoutes(rootRoute: AnyRoute) {
|
||||||
|
const signInRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/user/sign-in",
|
||||||
|
beforeLoad: requireUnauthenticated,
|
||||||
|
loader: async (): Promise<SignInPage> => {
|
||||||
|
const res = await fetch(subUrl("/api/web/user/sign-in"), { credentials: "same-origin" });
|
||||||
|
if (!res.ok) {
|
||||||
|
return { loginSources: [] };
|
||||||
|
}
|
||||||
|
return (await res.json()) as SignInPage;
|
||||||
|
},
|
||||||
|
component: SignIn,
|
||||||
|
});
|
||||||
|
|
||||||
|
const signUpRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/user/sign-up",
|
||||||
|
beforeLoad: requireUnauthenticated,
|
||||||
|
loader: async (): Promise<SignUpPage> => {
|
||||||
|
const res = await fetch(subUrl("/api/web/user/sign-up"), { credentials: "same-origin" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw await loaderResponseError(res);
|
||||||
|
}
|
||||||
|
return (await res.json()) as SignUpPage;
|
||||||
|
},
|
||||||
|
component: SignUp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetPasswordRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/user/reset-password",
|
||||||
|
beforeLoad: requireUnauthenticated,
|
||||||
|
loader: async (): Promise<ResetPasswordPage> => {
|
||||||
|
const code = new URLSearchParams(window.location.search).get("code") ?? "";
|
||||||
|
const url = code
|
||||||
|
? subUrl("/api/web/user/reset-password") + "?code=" + encodeURIComponent(code)
|
||||||
|
: subUrl("/api/web/user/reset-password");
|
||||||
|
const res = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!res.ok) {
|
||||||
|
return { code, emailEnabled: false, valid: false };
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { emailEnabled: boolean; valid: boolean };
|
||||||
|
return { code, emailEnabled: data.emailEnabled, valid: data.valid };
|
||||||
|
},
|
||||||
|
component: ResetPassword,
|
||||||
|
});
|
||||||
|
|
||||||
|
const activateRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/user/activate",
|
||||||
|
loader: async ({ context }): Promise<ActivatePage> => {
|
||||||
|
const code = new URLSearchParams(window.location.search).get("code") ?? "";
|
||||||
|
const routerContext = context as RouterContext;
|
||||||
|
if (!routerContext.user) {
|
||||||
|
if (code !== "") {
|
||||||
|
return { code, email: "", codeLifetimeHours: 0 };
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
|
||||||
|
throw redirect({ to: "/user/sign-in", replace: true });
|
||||||
|
}
|
||||||
|
const res = await fetch(subUrl("/api/web/user/activate"), { credentials: "same-origin" });
|
||||||
|
if (res.status === 404) {
|
||||||
|
// Already-active user hit a stale activation link. Send them home via
|
||||||
|
// a full navigation so the server-rendered dashboard handler decides
|
||||||
|
// where to land.
|
||||||
|
window.location.assign(subUrl("/"));
|
||||||
|
return { code, email: "", codeLifetimeHours: 0 };
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw await loaderResponseError(res);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as Omit<ActivatePage, "code">;
|
||||||
|
return { code, ...data };
|
||||||
|
},
|
||||||
|
component: Activate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mfaRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/user/mfa",
|
||||||
|
loader: async (): Promise<{ pending: boolean }> => {
|
||||||
|
const res = await fetch(subUrl("/api/web/user/mfa"), { credentials: "same-origin" });
|
||||||
|
if (res.status === 404) {
|
||||||
|
// No pending MFA challenge. Fall through to the server-rendered home,
|
||||||
|
// which will redirect to sign-in for anonymous visitors and to the
|
||||||
|
// dashboard for signed-in ones.
|
||||||
|
window.location.assign(subUrl("/"));
|
||||||
|
return { pending: false };
|
||||||
|
}
|
||||||
|
return { pending: res.ok };
|
||||||
|
},
|
||||||
|
component: MFA,
|
||||||
|
});
|
||||||
|
|
||||||
|
return [signInRoute, signUpRoute, resetPasswordRoute, activateRoute, mfaRoute];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user