Move sign-in MFA step to React with /api/web/user/mfa (#8288)

This commit is contained in:
ᴊᴏᴇ ᴄʜᴇɴ
2026-05-22 15:33:06 -04:00
committed by GitHub
parent d54f98f5a4
commit f739682e9a
18 changed files with 710 additions and 364 deletions
+4
View File
@@ -0,0 +1,4 @@
$schema: "https://moonrepo.dev/schemas/tasks.json"
taskOptions:
outputStyle: "stream"
+4
View File
@@ -18,6 +18,10 @@ This applies to all texts, including but not limited to UI, documentation, code
- Use `github.com/cockroachdb/errors` for error handling.
- Use `github.com/stretchr/testify` for assertions in tests. Be mindful about the choice of `require` and `assert`, the former should be used when the test cannot proceed meaningfully after a failed assertion.
## Localization
- Only edit `conf/locale/locale_en-US.ini`. The other `locale_*.ini` files are community-maintained translations. Do not add, remove, or rewrite keys in them, even when removing keys that are dead on the Go/template side.
## UI guidelines
- Design mobile-friendly. Every UI must look and work well on narrow viewports before adding desktop refinements via responsive breakpoints. Test at ~375px width before considering a UI done.
+1 -5
View File
@@ -87,11 +87,6 @@ func Run(configPath string, portOverride int) error {
// ***** START: User *****
m.Group("/user", func() {
m.Group("/login", func() {
m.Combo("/two_factor").Get(user.LoginTwoFactor).Post(user.LoginTwoFactorPost)
m.Combo("/two_factor_recovery_code").Get(user.LoginTwoFactorRecoveryCode).Post(user.LoginTwoFactorRecoveryCodePost)
})
m.Get("/sign_up", user.SignUp)
m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost)
m.Get("/reset_password", user.ResetPasswd)
@@ -530,6 +525,7 @@ func Run(configPath string, portOverride int) error {
}, ignSignIn)
m.Any("/api/web/*", bridgeToWebAPI(webHandler))
m.Get("/redirect", bridgeToWebAPI(webHandler))
m.Any("/*", func(c *context.Context) { c.ServeWeb() })
},
session.Sessioner(session.Options{
+183 -35
View File
@@ -4,12 +4,14 @@ import (
stdctx "context"
"encoding/json"
"net/http"
"reflect"
"strings"
"github.com/cockroachdb/errors"
"github.com/flamego/binding"
"github.com/flamego/flamego"
"github.com/flamego/validator"
"github.com/go-macaron/cache"
"github.com/go-macaron/i18n"
"github.com/go-macaron/session"
"gopkg.in/macaron.v1"
@@ -20,6 +22,7 @@ import (
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
"gogs.io/gogs/internal/urlx"
"gogs.io/gogs/internal/userx"
)
type (
@@ -27,15 +30,17 @@ type (
webAPISessionKey struct{}
webAPIMacaronKey struct{}
webAPILocaleKey struct{}
webAPICacheKey struct{}
)
func bridgeToWebAPI(webHandler http.Handler) func(c *context.Context, l i18n.Locale) {
return func(c *context.Context, l i18n.Locale) {
func bridgeToWebAPI(webHandler http.Handler) func(c *context.Context, l i18n.Locale, ca cache.Cache) {
return func(c *context.Context, l i18n.Locale, ca cache.Cache) {
ctx := c.Req.Context()
ctx = stdctx.WithValue(ctx, webAPIUserKey{}, c.User)
ctx = stdctx.WithValue(ctx, webAPISessionKey{}, c.Session)
ctx = stdctx.WithValue(ctx, webAPIMacaronKey{}, c.Context)
ctx = stdctx.WithValue(ctx, webAPILocaleKey{}, l)
ctx = stdctx.WithValue(ctx, webAPICacheKey{}, ca)
webHandler.ServeHTTP(c.Resp, c.Req.WithContext(ctx))
}
}
@@ -46,7 +51,8 @@ func webAPIInjector(c flamego.Context) {
sess, _ := ctx.Value(webAPISessionKey{}).(session.Store)
mc, _ := ctx.Value(webAPIMacaronKey{}).(*macaron.Context)
l, _ := ctx.Value(webAPILocaleKey{}).(i18n.Locale)
c.Map(user, sess, mc, l)
ca, _ := ctx.Value(webAPICacheKey{}).(cache.Cache)
c.Map(user, sess, mc, l, ca)
}
func webAPIBodyLimiter(c flamego.Context) {
@@ -54,6 +60,39 @@ func webAPIBodyLimiter(c flamego.Context) {
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
}
// webAPIValidator is the shared validator instance used by every webapi
// binding. Registering the json-tag name function makes validation errors
// carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the
// 400 payload keys match what the React client sends and reads.
var webAPIValidator = func() *validator.Validate {
v := validator.New()
v.RegisterTagNameFunc(func(fld reflect.StructField) string {
name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0]
if name == "-" {
return ""
}
return name
})
return v
}()
// bindJSON binds the request body to T. On binding or validation failure it
// short-circuits with a 400 carrying the standard renderBindingErrors payload,
// so downstream handlers can drop the `if len(bindErrs) > 0` boilerplate and
// the binding.Errors parameter entirely.
func bindJSON(model any) flamego.Handler {
return binding.JSON(model, binding.Options{
Validator: webAPIValidator,
ErrorHandler: func(c flamego.Context, l i18n.Locale, errs binding.Errors) {
w := c.ResponseWriter()
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
_ = json.NewEncoder(w).Encode(renderBindingErrors(l, errs))
},
})
}
func mountWebAPIRoutes(f *flamego.Flame) {
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
w := c.ResponseWriter()
@@ -79,22 +118,41 @@ func mountWebAPIRoutes(f *flamego.Flame) {
f.Get("/info", getUserInfo)
f.Combo("/sign-in").
Get(getUserSignIn).
Post(binding.JSON(userSignInRequest{}), postUserSignIn)
Post(bindJSON(userSignInRequest{}), postUserSignIn)
f.Group("/mfa", func() {
f.Combo("").
Get(getUserMFA).
Post(bindJSON(userMFARequest{}), postUserMFA)
f.Post("/recovery", bindJSON(userMFARecoveryRequest{}), postUserMFARecovery)
})
f.Post("/sign-out", postUserSignOut)
})
}, webAPIBodyLimiter, webAPIInjector)
f.Get("/redirect", getRedirect)
}
func getRedirect(c flamego.Context) {
to := c.Request().URL.Query().Get("to")
if !urlx.IsSameSite(to) {
to = conf.Server.Subpath + "/"
}
c.Redirect(to, http.StatusSeeOther)
}
// fieldErrors maps JSON field names to per-field localized messages. A non-nil
// value renders inline under the input. A nil value marks the input as
// invalid (highlight + focus eligibility) without duplicating text. Used in
// concert with bindingErrorResponse.Error to surface one banner message while
// highlighting multiple inputs.
type fieldErrors map[string]*string
// bindingErrorResponse carries form-validation failures. Error is the top-level
// message shown as a banner above the form (used when the failure is not tied to
// a specific input, e.g. malformed body, bad credentials). Fields maps JSON
// field names to per-field localized messages. A non-nil value renders inline
// under the input. nil marks the input as invalid (highlight + focus
// eligibility) without duplicating text. Pair Error with nil entries in Fields
// to surface one banner message while highlighting multiple inputs.
// message shown as a banner above the form (used when the failure is not tied
// to a specific input, e.g. malformed body, bad credentials).
type bindingErrorResponse struct {
Error string `json:"error,omitempty"`
Fields map[string]*string `json:"fields,omitempty"`
Error string `json:"error,omitempty"`
Fields fieldErrors `json:"fields,omitempty"`
}
// ruleSuffixKeys maps a validator tag to the shared "form.*_error" suffix key
@@ -121,7 +179,7 @@ func renderBindingErrors(l i18n.Locale, errs binding.Errors) *bindingErrorRespon
}
}
out := make(map[string]*string)
out := make(fieldErrors)
for _, e := range errs {
var ves validator.ValidationErrors
ok := errors.As(e.Err, &ves)
@@ -129,7 +187,7 @@ func renderBindingErrors(l i18n.Locale, errs binding.Errors) *bindingErrorRespon
continue
}
for _, ve := range ves {
field := strings.ToLower(ve.StructField())
field := ve.Field()
if _, exists := out[field]; exists {
// Keep the first rule that failed for a given field so the client renders one
// message per input. Subsequent rules surface only after the first is fixed.
@@ -165,7 +223,7 @@ type userSignInPageResponse struct {
func getUserSignIn(r *http.Request) (statusCode int, resp *userSignInPageResponse, err error) {
sources, err := database.Handle.LoginSources().List(r.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
if err != nil {
log.Error("getUserSignIn: list activated login sources: %+v", err)
log.Error("getUserSignIn: list activated login sources: %v", err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "list activated login sources")
}
loginSources := make([]loginSource, 0, len(sources))
@@ -180,42 +238,48 @@ type userSignInRequest struct {
Password string `json:"password" validate:"required,max=255"`
LoginSource int64 `json:"loginSource"`
Remember bool `json:"remember"`
RedirectTo string `json:"redirectTo"`
}
type userSignInResponse struct {
TwoFactor bool `json:"twoFactor,omitempty"`
RedirectTo string `json:"redirectTo,omitempty"`
// MFA is true when the account has MFA enabled and the password step
// succeeded but a second factor is still required. The client should
// navigate to /user/mfa to complete the challenge.
MFA bool `json:"mfa,omitempty"`
}
func postUserSignIn(r *http.Request, sess session.Store, mc *macaron.Context, l i18n.Locale, req userSignInRequest, bindErrs binding.Errors) (statusCode int, resp any, err error) {
if len(bindErrs) > 0 {
return http.StatusBadRequest, renderBindingErrors(l, bindErrs), nil
}
func postUserSignIn(r *http.Request, sess session.Store, mc *macaron.Context, l i18n.Locale, req userSignInRequest) (statusCode int, resp any, err error) {
u, err := database.Handle.Users().Authenticate(r.Context(), req.Username, req.Password, req.LoginSource)
if err != nil {
switch {
case auth.IsErrBadCredentials(err):
return http.StatusUnauthorized, &bindingErrorResponse{
Error: l.Tr("form.username_password_incorrect"),
Fields: map[string]*string{"username": nil, "password": nil},
Fields: fieldErrors{"username": nil, "password": nil},
}, nil
case database.IsErrLoginSourceMismatch(err):
return http.StatusUnprocessableEntity, nil, errors.New(l.Tr("form.auth_source_mismatch"))
default:
log.Error("postUserSignIn: authenticate user %q: %+v", req.Username, err)
log.Error("postUserSignIn: authenticate user %q: %v", req.Username, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "authenticate user")
}
}
if database.Handle.TwoFactors().IsEnabled(r.Context(), u.ID) {
_ = sess.Set("twoFactorRemember", req.Remember)
_ = sess.Set("twoFactorUserID", u.ID)
return http.StatusOK, &userSignInResponse{TwoFactor: true}, nil
_ = sess.Set("mfaRemember", req.Remember)
_ = sess.Set("mfaUserID", u.ID)
return http.StatusOK, &userSignInResponse{MFA: true}, nil
}
if req.Remember {
completeSignIn(sess, mc, u, req.Remember)
return http.StatusOK, &userSignInResponse{}, nil
}
// completeSignIn finalizes the sign-in session for u: writes the auth session,
// clears any in-flight MFA session, and sets remember-me / login-status
// cookies. The caller is responsible for navigating to a post-login
// destination via /redirect?to=.
func completeSignIn(sess session.Store, mc *macaron.Context, u *database.User, remember bool) {
if remember {
days := 86400 * conf.Security.LoginRememberDays
mc.SetCookie(conf.Security.CookieUsername, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
mc.SetSuperSecureCookie(u.Rands+u.Password, conf.Security.CookieRememberName, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
@@ -223,19 +287,103 @@ func postUserSignIn(r *http.Request, sess session.Store, mc *macaron.Context, l
_ = sess.Set("uid", u.ID)
_ = sess.Set("uname", u.Name)
_ = sess.Delete("twoFactorRemember")
_ = sess.Delete("twoFactorUserID")
_ = sess.Delete("mfaRemember")
_ = sess.Delete("mfaUserID")
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
if conf.Security.EnableLoginStatusCookie {
mc.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
}
}
redirectTo := req.RedirectTo
if !urlx.IsSameSite(redirectTo) {
redirectTo = conf.Server.Subpath + "/"
func getUserMFA(sess session.Store) (statusCode int, resp any, err error) {
if _, ok := sess.Get("mfaUserID").(int64); !ok {
return http.StatusNotFound, nil, nil
}
return http.StatusOK, &userSignInResponse{RedirectTo: redirectTo}, nil
return http.StatusNoContent, nil, nil
}
type userMFARequest struct {
Passcode string `json:"passcode" validate:"required,len=6"`
}
type userMFAResponse struct{}
func postUserMFA(r *http.Request, sess session.Store, mc *macaron.Context, ca cache.Cache, l i18n.Locale, req userMFARequest) (statusCode int, resp any, err error) {
userID, ok := sess.Get("mfaUserID").(int64)
if !ok {
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
}
t, err := database.Handle.TwoFactors().GetByUserID(r.Context(), userID)
if err != nil {
log.Error("postUserMFA: get two factor by user ID %d: %v", userID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "get two factor by user ID")
}
valid, err := t.ValidateTOTP(req.Passcode)
if err != nil {
log.Error("postUserMFA: validate TOTP for user %d: %v", userID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "validate TOTP")
}
if !valid {
msg := l.Tr("auth.mfa_invalid_passcode")
return http.StatusUnauthorized, &bindingErrorResponse{
Fields: fieldErrors{"passcode": &msg},
}, nil
}
if ca.IsExist(userx.TwoFactorCacheKey(userID, req.Passcode)) {
msg := l.Tr("auth.mfa_reused_passcode")
return http.StatusUnauthorized, &bindingErrorResponse{
Fields: fieldErrors{"passcode": &msg},
}, nil
}
if err = ca.Put(userx.TwoFactorCacheKey(userID, req.Passcode), 1, 60); err != nil {
log.Error("postUserMFA: cache two factor passcode for user %d: %v", userID, err)
}
u, err := database.Handle.Users().GetByID(r.Context(), userID)
if err != nil {
log.Error("postUserMFA: get user by ID %d: %v", userID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
}
remember, _ := sess.Get("mfaRemember").(bool)
completeSignIn(sess, mc, u, remember)
return http.StatusOK, &userMFAResponse{}, nil
}
type userMFARecoveryRequest struct {
RecoveryCode string `json:"recoveryCode" validate:"required,len=11"`
}
func postUserMFARecovery(r *http.Request, sess session.Store, mc *macaron.Context, l i18n.Locale, req userMFARecoveryRequest) (statusCode int, resp any, err error) {
userID, ok := sess.Get("mfaUserID").(int64)
if !ok {
return http.StatusUnauthorized, &bindingErrorResponse{Error: l.Tr("auth.mfa_session_expired")}, nil
}
if err := database.Handle.TwoFactors().UseRecoveryCode(r.Context(), userID, req.RecoveryCode); err != nil {
if database.IsTwoFactorRecoveryCodeNotFound(err) {
msg := l.Tr("auth.mfa_invalid_recovery_code")
return http.StatusUnauthorized, &bindingErrorResponse{
Fields: fieldErrors{"recoveryCode": &msg},
}, nil
}
log.Error("postUserMFARecovery: use recovery code for user %d: %v", userID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "use recovery code")
}
u, err := database.Handle.Users().GetByID(r.Context(), userID)
if err != nil {
log.Error("postUserMFARecovery: get user by ID %d: %v", userID, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by ID")
}
remember, _ := sess.Get("mfaRemember").(bool)
completeSignIn(sess, mc, u, remember)
return http.StatusOK, &userMFAResponse{}, nil
}
type userInfo struct {
+21 -12
View File
@@ -159,14 +159,29 @@ search = Search
[auth]
create_new_account = Create New Account
sign_in_submitting = Signing in...
sign_in_failed = Sign-in failed. Please try again.
sign_in_failed = Could not sign in, please try again.
show_password = Show password
hide_password = Hide password
back_to_sign_in = Back to sign in
mfa_title = Multi-factor authentication
mfa_passcode = Passcode
mfa_passcode_placeholder = Enter the 6-digit code from your authenticator
mfa_recovery_code = Recovery code
mfa_recovery_code_placeholder = Enter a recovery code
mfa_use_recovery_code = Use a recovery code instead
mfa_use_passcode = Use a passcode instead
mfa_verify = Verify
mfa_verifying = Verifying...
mfa_session_expired = Your sign-in session has expired. Please sign in again.
mfa_verify_failed = Verification failed. Please try again.
mfa_invalid_passcode = The passcode you entered is not valid.
mfa_reused_passcode = The passcode you entered has already been used, please try another one.
mfa_invalid_recovery_code = Recovery code already used or invalid.
register_hepler_msg = Already have an account? Sign in now!
social_register_hepler_msg = Already have an account? Bind now!
disable_register_prompt = Sorry, registration has been disabled. Please contact the site administrator.
disable_register_mail = Sorry, email services are disabled. Please contact the site administrator.
auth_source = Authentication Source
auth_source = Authentication source
local = Local
remember_me = Remember me
forgot_password= Forgot Password
@@ -186,14 +201,6 @@ reset_password_helper = Click here to reset your password
password_too_short = Password length must be at least 6 characters.
non_local_account = Non-local accounts cannot change passwords through Gogs.
login_two_factor = Two-factor Authentication
login_two_factor_passcode = Authentication Passcode
login_two_factor_enter_recovery_code = Enter a two-factor recovery code
login_two_factor_recovery = Two-factor Recovery
login_two_factor_recovery_code = Recovery Code
login_two_factor_enter_passcode = Enter a two-factor passcode
login_two_factor_invalid_recovery_code = Recovery code already used or invalid.
[mail]
activate_account = Please activate your account
activate_email = Verify your email address
@@ -220,6 +227,8 @@ PayloadUrl = Payload URL
TeamName = Team name
AuthName = Authorization name
AdminEmail = Admin email
Passcode = Passcode
RecoveryCode = Recovery code
NewBranchName = New branch name
CommitSummary = Commit summary
@@ -1097,7 +1106,7 @@ users.created = Created
users.send_register_notify = Send Registration Notification To User
users.new_success = New account '%s' has been created successfully.
users.edit = Edit
users.auth_source = Authentication Source
users.auth_source = Authentication source
users.local = Local
users.auth_login_name = Authentication Login Name
users.password_helper = Leave it empty to remain unchanged.
@@ -1130,7 +1139,7 @@ repos.stars = Stars
repos.issues = Issues
repos.size = Size
auths.auth_sources = Authentication Sources
auths.auth_sources = Authentication sources
auths.new = Add New Source
auths.name = Name
auths.type = Type
+4 -1
View File
@@ -38,13 +38,16 @@ Gogs has the following dependencies:
1. Install dependencies:
```bash
brew install go postgresql git npm moon
brew install go postgresql git npm moon portless
portless trust
npm install -g less
npm install -g less-plugin-clean-css
go install github.com/derision-test/go-mockgen/cmd/go-mockgen@v1.3.3
go install golang.org/x/tools/cmd/goimports@latest
```
`portless trust` adds the local CA to your system trust store so `https://gogs.localhost` works without browser warnings. The `moon run gogs:dev` task will start the proxy and register the route automatically.
1. Configure PostgreSQL to start automatically:
```bash
+15 -2
View File
@@ -2,12 +2,19 @@ package smtp
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strconv"
"time"
"github.com/cockroachdb/errors"
)
// dialTimeout bounds how long the SMTP authentication flow waits on the
// underlying TCP connect. Without it, an unreachable or misspelled host hangs
// the sign-in request until the OS-level connect timeout (minutes).
const dialTimeout = 10 * time.Second
// Config contains configuration for SMTP authentication.
//
// ⚠️ WARNING: Change to the field name must preserve the INI key name for backward compatibility.
@@ -21,10 +28,16 @@ type Config struct {
}
func (c *Config) doAuth(auth smtp.Auth) error {
client, err := smtp.Dial(fmt.Sprintf("%s:%d", c.Host, c.Port))
addr := net.JoinHostPort(c.Host, strconv.Itoa(c.Port))
conn, err := net.DialTimeout("tcp", addr, dialTimeout)
if err != nil {
return err
}
client, err := smtp.NewClient(conn, c.Host)
if err != nil {
_ = conn.Close()
return err
}
defer client.Close()
if err = client.Hello("gogs"); err != nil {
+1
View File
@@ -116,6 +116,7 @@ func isWebPath(p string) bool {
p = strings.TrimPrefix(p, conf.Server.Subpath)
switch {
case p == "/user/sign-in",
p == "/user/mfa",
strings.HasPrefix(p, "/assets/"),
strings.HasPrefix(p, "/src/"),
strings.HasPrefix(p, "/node_modules/"),
+4 -124
View File
@@ -4,7 +4,6 @@ import (
gocontext "context"
"encoding/hex"
"net/http"
"net/url"
"strconv"
"github.com/go-macaron/captcha"
@@ -16,135 +15,16 @@ import (
"gogs.io/gogs/internal/email"
"gogs.io/gogs/internal/form"
"gogs.io/gogs/internal/tool"
"gogs.io/gogs/internal/urlx"
"gogs.io/gogs/internal/userx"
)
const (
tmplUserAuthTwoFactor = "user/auth/two_factor"
tmplUserAuthTwoFactorRecoveryCode = "user/auth/two_factor_recovery_code"
tmplUserAuthSignup = "user/auth/signup"
TmplUserAuthActivate = "user/auth/activate"
tmplUserAuthForgotPassword = "user/auth/forgot_passwd"
tmplUserAuthResetPassword = "user/auth/reset_passwd"
tmplUserAuthSignup = "user/auth/signup"
TmplUserAuthActivate = "user/auth/activate"
tmplUserAuthForgotPassword = "user/auth/forgot_passwd"
tmplUserAuthResetPassword = "user/auth/reset_passwd"
)
func afterLogin(c *context.Context, u *database.User, remember bool) {
if remember {
days := 86400 * conf.Security.LoginRememberDays
c.SetCookie(conf.Security.CookieUsername, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
c.SetSuperSecureCookie(u.Rands+u.Password, conf.Security.CookieRememberName, u.Name, days, conf.Server.Subpath, "", conf.Security.CookieSecure, true)
}
_ = c.Session.Set("uid", u.ID)
_ = c.Session.Set("uname", u.Name)
_ = c.Session.Delete("twoFactorRemember")
_ = c.Session.Delete("twoFactorUserID")
// Clear whatever CSRF has right now, force to generate a new one
c.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
if conf.Security.EnableLoginStatusCookie {
c.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
}
redirectTo, _ := url.QueryUnescape(c.GetCookie("redirect_to"))
c.SetCookie("redirect_to", "", -1, conf.Server.Subpath)
if urlx.IsSameSite(redirectTo) {
c.Redirect(redirectTo)
return
}
c.RedirectSubpath("/")
}
func LoginTwoFactor(c *context.Context) {
_, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
c.NotFound()
return
}
c.Success(tmplUserAuthTwoFactor)
}
func LoginTwoFactorPost(c *context.Context) {
userID, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
c.NotFound()
return
}
t, err := database.Handle.TwoFactors().GetByUserID(c.Req.Context(), userID)
if err != nil {
c.Error(err, "get two factor by user ID")
return
}
passcode := c.Query("passcode")
valid, err := t.ValidateTOTP(passcode)
if err != nil {
c.Error(err, "validate TOTP")
return
} else if !valid {
c.Flash.Error(c.Tr("settings.two_factor_invalid_passcode"))
c.RedirectSubpath("/user/login/two_factor")
return
}
u, err := database.Handle.Users().GetByID(c.Req.Context(), userID)
if err != nil {
c.Error(err, "get user by ID")
return
}
// Prevent same passcode from being reused
if c.Cache.IsExist(userx.TwoFactorCacheKey(u.ID, passcode)) {
c.Flash.Error(c.Tr("settings.two_factor_reused_passcode"))
c.RedirectSubpath("/user/login/two_factor")
return
}
if err = c.Cache.Put(userx.TwoFactorCacheKey(u.ID, passcode), 1, 60); err != nil {
log.Error("Failed to put cache 'two factor passcode': %v", err)
}
afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool))
}
func LoginTwoFactorRecoveryCode(c *context.Context) {
_, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
c.NotFound()
return
}
c.Success(tmplUserAuthTwoFactorRecoveryCode)
}
func LoginTwoFactorRecoveryCodePost(c *context.Context) {
userID, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
c.NotFound()
return
}
if err := database.Handle.TwoFactors().UseRecoveryCode(c.Req.Context(), userID, c.Query("recovery_code")); err != nil {
if database.IsTwoFactorRecoveryCodeNotFound(err) {
c.Flash.Error(c.Tr("auth.login_two_factor_invalid_recovery_code"))
c.RedirectSubpath("/user/login/two_factor_recovery_code")
} else {
c.Error(err, "use recovery code")
}
return
}
u, err := database.Handle.Users().GetByID(c.Req.Context(), userID)
if err != nil {
c.Error(err, "get user by ID")
return
}
afterLogin(c, u, c.Session.Get("twoFactorRemember").(bool))
}
func SignOut(c *context.Context) {
_ = c.Session.Flush()
_ = c.Session.Destory(c.Context)
+40
View File
@@ -88,6 +88,44 @@ tasks:
- "install"
- "web:build"
portless:
script: |
portless alias gogs 3000 --force >/dev/null
portless proxy start >/dev/null 2>&1 || true
mkdir -p .bin/custom/conf
touch .bin/custom/conf/app.ini
awk '
BEGIN { in_server=0; saw_server=0; set_domain=0; set_url=0 }
/^\[server\]/ { in_server=1; saw_server=1; print; next }
/^\[/ {
if (in_server) {
if (!set_domain) print "DOMAIN = gogs.localhost"
if (!set_url) print "EXTERNAL_URL = https://gogs.localhost/"
in_server=0
}
print; next
}
in_server && /^[[:space:]]*DOMAIN[[:space:]]*=/ {
print "DOMAIN = gogs.localhost"; set_domain=1; next
}
in_server && /^[[:space:]]*EXTERNAL_URL[[:space:]]*=/ {
print "EXTERNAL_URL = https://gogs.localhost/"; set_url=1; next
}
{ print }
END {
if (in_server) {
if (!set_domain) print "DOMAIN = gogs.localhost"
if (!set_url) print "EXTERNAL_URL = https://gogs.localhost/"
} else if (!saw_server) {
print ""
print "[server]"
print "DOMAIN = gogs.localhost"
print "EXTERNAL_URL = https://gogs.localhost/"
}
}
' .bin/custom/conf/app.ini > .bin/custom/conf/app.ini.tmp \
&& mv .bin/custom/conf/app.ini.tmp .bin/custom/conf/app.ini
dev:
command: ".bin/gogs web"
preset: "server"
@@ -96,6 +134,7 @@ tasks:
deps:
- "build"
- "web:dev"
- "portless"
prod:
command: ".bin/gogs web"
@@ -104,3 +143,4 @@ tasks:
TTY_FORCE: "1"
deps:
- "build-prod"
- "portless"
-28
View File
@@ -1,28 +0,0 @@
{{template "base/head" .}}
<div class="user signin two-factor">
<div class="ui middle very relaxed page grid">
<div class="column">
<form class="ui form" action="{{.Link}}" method="post">
{{.CSRFTokenHTML}}
<h3 class="ui top attached center header">
{{.i18n.Tr "auth.login_two_factor"}}
</h3>
<div class="ui attached segment">
{{template "base/alert" .}}
<div class="required field">
<label for="passcode">{{.i18n.Tr "auth.login_two_factor_passcode"}}</label>
<div class="ui fluid input">
<input id="passcode" name="passcode" autofocus required>
</div>
</div>
<button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button>
</div>
<p>
<a href="{{AppSubURL}}/user/login/two_factor_recovery_code">{{.i18n.Tr "auth.login_two_factor_enter_recovery_code"}}</a>
</p>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
@@ -1,28 +0,0 @@
{{template "base/head" .}}
<div class="user signin two-factor">
<div class="ui middle very relaxed page grid">
<div class="column">
<form class="ui form" action="{{.Link}}" method="post">
{{.CSRFTokenHTML}}
<h3 class="ui top attached center header">
{{.i18n.Tr "auth.login_two_factor_recovery"}}
</h3>
<div class="ui attached segment">
{{template "base/alert" .}}
<div class="required field">
<label for="recovery_code">{{.i18n.Tr "auth.login_two_factor_recovery_code"}}</label>
<div class="ui fluid input">
<input id="recovery_code" name="recovery_code" autofocus required>
</div>
</div>
<button class="ui fluid green button">{{.i18n.Tr "settings.two_factor_verify"}}</button>
</div>
<p>
<a href="{{AppSubURL}}/user/login/two_factor">{{.i18n.Tr "auth.login_two_factor_enter_passcode"}}</a>
</p>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+8
View File
@@ -67,6 +67,14 @@ Two conventions coexist in `web/src/`:
Library modules in `lib/` are plain `.ts` files in lowercase (`i18n.ts`, `theme.ts`, `utils.ts`).
## Forms
Disable the entire form while a submit is in flight, not just the submit button. Wrap the body in `<fieldset disabled={submitting} className="contents">` — native `disabled` propagates to every nested input and button.
Anchor links inside the form aren't covered by `disabled`. For each, set `tabIndex={submitting ? -1 : N}`, `aria-disabled={submitting || undefined}`, `className={submitting ? "pointer-events-none opacity-50" : undefined}`, and an `onClick` that calls `e.preventDefault()` when submitting.
Swap the submit label to a present-continuous string ("Signing in…", "Verifying…") while submitting. Keep idle and active strings as separate locale keys.
## Accessibility
WCAG 2.2 AA is the floor. Apply these patterns in components:
+12
View File
@@ -52,6 +52,18 @@ const REUSED_KEYS = [
"sign_in_failed",
"show_password",
"hide_password",
"back_to_sign_in",
"mfa_title",
"mfa_passcode",
"mfa_passcode_placeholder",
"mfa_recovery_code",
"mfa_recovery_code_placeholder",
"mfa_use_recovery_code",
"mfa_use_passcode",
"mfa_verify",
"mfa_verifying",
"mfa_session_expired",
"mfa_verify_failed",
];
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
+15 -3
View File
@@ -29,13 +29,25 @@
"username_placeholder": "Enter your username or email",
"password": "Password",
"password_placeholder": "Enter your password",
"auth_source": "Authentication Source",
"auth_source": "Authentication source",
"local": "Local",
"remember_me": "Remember me",
"forget_password": "Forgot password?",
"sign_up_now": "Create a new account",
"sign_in_submitting": "Signing in...",
"sign_in_failed": "Sign-in failed. Please try again.",
"sign_in_failed": "Could not sign in, please try again.",
"show_password": "Show password",
"hide_password": "Hide password"
"hide_password": "Hide password",
"back_to_sign_in": "Back to sign in",
"mfa_title": "Multi-factor authentication",
"mfa_passcode": "Passcode",
"mfa_passcode_placeholder": "Enter the 6-digit code from your authenticator",
"mfa_recovery_code": "Recovery code",
"mfa_recovery_code_placeholder": "Enter a recovery code",
"mfa_use_recovery_code": "Use a recovery code instead",
"mfa_use_passcode": "Use a passcode instead",
"mfa_verify": "Verify",
"mfa_verifying": "Verifying...",
"mfa_session_expired": "Your sign-in session has expired. Please sign in again.",
"mfa_verify_failed": "Verification failed. Please try again."
}
+213
View File
@@ -0,0 +1,213 @@
import { Link, getRouteApi, useNavigate } from "@tanstack/react-router";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePageTitle } from "@/lib/page-title";
import { subUrl } from "@/lib/url";
interface MFAErrorResponse {
error?: string;
fields?: Record<string, string | null>;
}
type Mode = "passcode" | "recovery";
const route = getRouteApi("/user/mfa");
export function MFA() {
const { t } = useTranslation();
usePageTitle(t("mfa_title"));
const navigate = useNavigate();
// When no challenge is pending the loader has already kicked off a full
// navigation away; the early return keeps this page from flashing.
const { pending } = route.useLoaderData();
const [mode, setMode] = useState<Mode>("passcode");
const [passcode, setPasscode] = useState("");
const [recoveryCode, setRecoveryCode] = useState("");
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string | null>>({});
const passcodeRef = useRef<HTMLInputElement>(null);
const recoveryRef = useRef<HTMLInputElement>(null);
// Focus the active input on initial render and on every mode swap.
// `autoFocus` on the JSX doesn't reliably fire when the route mounts via
// a TanStack navigate or when the conditional swaps which input is in the
// tree, so we drive focus explicitly off the mode.
useEffect(() => {
if (!pending) return;
if (mode === "passcode") passcodeRef.current?.focus();
else recoveryRef.current?.focus();
}, [mode, pending]);
if (!pending) {
return null;
}
function switchMode(next: Mode) {
setMode(next);
setFormError(null);
setFieldErrors({});
}
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setFormError(null);
setFieldErrors({});
setSubmitting(true);
void (async () => {
try {
const url = mode === "passcode" ? subUrl("/api/web/user/mfa") : subUrl("/api/web/user/mfa/recovery");
const body = mode === "passcode" ? JSON.stringify({ passcode }) : JSON.stringify({ recoveryCode });
const res = await fetch(url, {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body,
});
if (!res.ok) {
const errBody = (await res.json().catch(() => ({}))) as MFAErrorResponse;
if (res.status === 401 && !errBody.fields) {
// Session-expired or missing 2FA session: send the user back to start.
await navigate({ to: "/user/sign-in" });
return;
}
if (errBody.error) setFormError(errBody.error);
if (errBody.fields) setFieldErrors(errBody.fields);
if (!errBody.error && !errBody.fields) {
setFormError(t("mfa_verify_failed"));
}
setSubmitting(false);
// Focus after React re-enables the fieldset; .focus() is a no-op
// while the input is still inside a disabled fieldset, so we defer
// past the commit with rAF.
requestAnimationFrame(() => {
if (mode === "passcode") passcodeRef.current?.focus();
else recoveryRef.current?.focus();
});
return;
}
const to = new URLSearchParams(window.location.search).get("redirect_to") ?? "";
window.location.assign(subUrl("/redirect") + "?to=" + encodeURIComponent(to));
} catch {
setFormError(t("mfa_verify_failed"));
setSubmitting(false);
}
})();
}
const isPasscode = mode === "passcode";
const inputId = isPasscode ? "passcode" : "recovery_code";
const inputErrorKey = isPasscode ? "passcode" : "recoveryCode";
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("mfa_title")}</CardTitle>
</CardHeader>
<CardContent className="pt-2">
<form onSubmit={onSubmit} 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">
{isPasscode ? (
<div className="flex flex-col gap-1.5">
<Label htmlFor={inputId}>{t("mfa_passcode")}</Label>
<Input
ref={passcodeRef}
id={inputId}
name="passcode"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
required
autoFocus
tabIndex={1}
placeholder={t("mfa_passcode_placeholder")}
value={passcode}
onChange={(e) => setPasscode(e.target.value)}
aria-invalid={inputErrorKey in fieldErrors ? true : undefined}
aria-describedby={fieldErrors[inputErrorKey] ? `${inputId}-error` : undefined}
/>
{fieldErrors[inputErrorKey] && (
<p id={`${inputId}-error`} className="text-sm text-(--color-destructive)">
{fieldErrors[inputErrorKey]}
</p>
)}
</div>
) : (
<div className="flex flex-col gap-1.5">
<Label htmlFor={inputId}>{t("mfa_recovery_code")}</Label>
<Input
ref={recoveryRef}
id={inputId}
name="recovery_code"
type="text"
autoComplete="one-time-code"
required
autoFocus
tabIndex={1}
placeholder={t("mfa_recovery_code_placeholder")}
value={recoveryCode}
onChange={(e) => setRecoveryCode(e.target.value)}
aria-invalid={inputErrorKey in fieldErrors ? true : undefined}
aria-describedby={fieldErrors[inputErrorKey] ? `${inputId}-error` : undefined}
/>
{fieldErrors[inputErrorKey] && (
<p id={`${inputId}-error`} className="text-sm text-(--color-destructive)">
{fieldErrors[inputErrorKey]}
</p>
)}
</div>
)}
<div className="mt-2 flex flex-col gap-3">
<Button type="submit" disabled={submitting} tabIndex={2} className="w-full">
{submitting ? t("mfa_verifying") : t("mfa_verify")}
</Button>
<Button
type="button"
variant="link"
size="inline"
tabIndex={3}
className="self-center"
onClick={() => switchMode(isPasscode ? "recovery" : "passcode")}
>
{isPasscode ? t("mfa_use_recovery_code") : t("mfa_use_passcode")}
</Button>
<Button variant="link" size="inline" asChild className="self-center">
<Link
to="/user/sign-in"
tabIndex={submitting ? -1 : 4}
aria-disabled={submitting || undefined}
className={submitting ? "pointer-events-none opacity-50" : undefined}
onClick={(e) => {
if (submitting) e.preventDefault();
}}
>
{t("back_to_sign_in")}
</Link>
</Button>
</div>
</div>
</fieldset>
</form>
</CardContent>
</Card>
</main>
);
}
+166 -125
View File
@@ -1,4 +1,4 @@
import { getRouteApi } from "@tanstack/react-router";
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -23,8 +23,7 @@ export interface SignInPage {
}
interface SignInResponse {
twoFactor?: boolean;
redirectTo?: string;
mfa?: boolean;
}
interface SignInErrorResponse {
@@ -40,6 +39,7 @@ const route = getRouteApi("/user/sign-in");
export function SignIn() {
const { t } = useTranslation();
usePageTitle(t("sign_in"));
const navigate = useNavigate();
const { loginSources } = route.useLoaderData();
const defaultSource = loginSources.find((s) => s.isDefault);
@@ -61,35 +61,47 @@ export function SignIn() {
setSubmitting(true);
void (async () => {
try {
const redirectTo = new URLSearchParams(window.location.search).get("redirect_to") ?? "";
const res = await fetch(subUrl("/api/web/user/sign-in"), {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password, loginSource, remember, redirectTo }),
body: JSON.stringify({ username, password, loginSource, remember }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as SignInErrorResponse;
if (body.error) setFormError(body.error);
else setFormError(null);
let focusField: (typeof FIELD_ORDER)[number] | undefined;
if (body.fields) {
setFieldErrors(body.fields);
const first = FIELD_ORDER.find((f) => f in (body.fields ?? {}));
if (first === "username") usernameRef.current?.focus();
else if (first === "password") passwordRef.current?.focus();
focusField = FIELD_ORDER.find((f) => f in (body.fields ?? {}));
}
if (!body.error && !body.fields) {
setFormError(t("sign_in_failed"));
}
setSubmitting(false);
// Defer focus past the React commit so the fieldset is re-enabled
// (.focus() is a no-op while the field is inside a disabled fieldset).
requestAnimationFrame(() => {
if (focusField === "username") usernameRef.current?.focus();
else if (focusField === "password") passwordRef.current?.focus();
});
return;
}
const data = (await res.json()) as SignInResponse;
if (data.twoFactor) {
window.location.assign(subUrl("/user/login/two_factor"));
if (data.mfa) {
// Preserve ?redirect_to= so the MFA step can finalize the same target.
const search = new URLSearchParams(window.location.search);
const redirectTo = search.get("redirect_to");
await navigate({
to: "/user/mfa",
search: redirectTo ? { redirect_to: redirectTo } : {},
});
return;
}
window.location.assign(data.redirectTo || subUrl("/"));
const to = new URLSearchParams(window.location.search).get("redirect_to") ?? "";
// /redirect is a server endpoint (303), must be a full navigation.
window.location.assign(subUrl("/redirect") + "?to=" + encodeURIComponent(to));
} catch {
setFormError(t("sign_in_failed"));
setSubmitting(false);
@@ -104,124 +116,153 @@ export function SignIn() {
<CardTitle>{t("sign_in")}</CardTitle>
</CardHeader>
<CardContent className="pt-2">
<form onSubmit={onSubmit} noValidate className="flex flex-col gap-4">
{formError && (
<div
role="alert"
className="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-1.5">
<Label htmlFor="username">{t("username")}</Label>
<Input
ref={usernameRef}
id="username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
tabIndex={1}
placeholder={t("username_placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
aria-invalid={"username" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.username ? "username-error" : undefined}
/>
{fieldErrors.username && (
<p id="username-error" className="text-sm text-(--color-destructive)">
{fieldErrors.username}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="password">{t("password")}</Label>
<Button variant="link" size="inline" asChild>
<a href={subUrl("/user/forget_password")} tabIndex={7}>
{t("forget_password")}
</a>
</Button>
</div>
<div className="relative">
<Input
ref={passwordRef}
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
tabIndex={2}
placeholder={t("password_placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={"password" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.password ? "password-error" : undefined}
className="pr-10"
/>
<button
type="button"
tabIndex={3}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("hide_password") : t("show_password")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded-r-md text-(--color-muted-foreground) outline-none hover:text-(--color-foreground) focus-visible:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring)"
<form onSubmit={onSubmit} 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)"
>
{showPassword ? <EyeOff className="size-4" aria-hidden /> : <Eye className="size-4" aria-hidden />}
</button>
</div>
{fieldErrors.password && (
<p id="password-error" className="text-sm text-(--color-destructive)">
{fieldErrors.password}
</p>
{formError}
</div>
)}
</div>
{loginSources.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="login_source">{t("auth_source")}</Label>
<Select value={String(loginSource)} onValueChange={(v) => setLoginSource(Number(v))}>
<SelectTrigger id="login_source" tabIndex={4}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("local")}</SelectItem>
{loginSources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="username">{t("username")}</Label>
<Input
ref={usernameRef}
id="username"
name="username"
type="text"
autoComplete="username"
required
autoFocus
tabIndex={1}
placeholder={t("username_placeholder")}
value={username}
onChange={(e) => setUsername(e.target.value)}
aria-invalid={"username" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.username ? "username-error" : undefined}
/>
{fieldErrors.username && (
<p id="username-error" className="text-sm text-(--color-destructive)">
{fieldErrors.username}
</p>
)}
</div>
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-3">
<Label htmlFor="password">{t("password")}</Label>
<Button variant="link" size="inline" asChild>
<a
href={subUrl("/user/forget_password")}
tabIndex={submitting ? -1 : 7}
aria-disabled={submitting || undefined}
className={submitting ? "pointer-events-none opacity-50" : undefined}
onClick={(e) => {
if (submitting) e.preventDefault();
}}
>
{t("forget_password")}
</a>
</Button>
</div>
<div className="relative">
<Input
ref={passwordRef}
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
tabIndex={2}
placeholder={t("password_placeholder")}
value={password}
onChange={(e) => setPassword(e.target.value)}
aria-invalid={"password" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.password ? "password-error" : undefined}
className="pr-10"
/>
<button
type="button"
tabIndex={3}
disabled={submitting}
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? t("hide_password") : t("show_password")}
aria-pressed={showPassword}
className="absolute inset-y-0 right-0 flex w-10 cursor-pointer items-center justify-center rounded-r-md text-(--color-muted-foreground) outline-none hover:text-(--color-foreground) focus-visible:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:cursor-not-allowed disabled:opacity-50"
>
{showPassword ? (
<EyeOff className="size-4" aria-hidden />
) : (
<Eye className="size-4" aria-hidden />
)}
</button>
</div>
{fieldErrors.password && (
<p id="password-error" className="text-sm text-(--color-destructive)">
{fieldErrors.password}
</p>
)}
</div>
{loginSources.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="login_source">{t("auth_source")}</Label>
<Select
value={String(loginSource)}
onValueChange={(v) => setLoginSource(Number(v))}
disabled={submitting}
>
<SelectTrigger id="login_source" tabIndex={4}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("local")}</SelectItem>
{loginSources.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="remember"
tabIndex={5}
checked={remember}
onCheckedChange={(v) => setRemember(v === true)}
/>
<Label htmlFor="remember" className="cursor-pointer font-normal">
{t("remember_me")}
</Label>
</div>
<div className="mt-2 flex flex-col gap-3">
<Button type="submit" disabled={submitting} tabIndex={6} className="w-full">
{submitting ? t("sign_in_submitting") : t("sign_in")}
</Button>
<Button variant="link" size="inline" asChild className="self-center">
<a
href={subUrl("/user/sign_up")}
tabIndex={submitting ? -1 : 8}
aria-disabled={submitting || undefined}
className={submitting ? "pointer-events-none opacity-50" : undefined}
onClick={(e) => {
if (submitting) e.preventDefault();
}}
>
{t("sign_up_now")}
</a>
</Button>
</div>
</div>
)}
<div className="flex items-center gap-2">
<Checkbox
id="remember"
tabIndex={5}
checked={remember}
onCheckedChange={(v) => setRemember(v === true)}
/>
<Label htmlFor="remember" className="cursor-pointer font-normal">
{t("remember_me")}
</Label>
</div>
<div className="mt-2 flex flex-col gap-3">
<Button type="submit" disabled={submitting} tabIndex={6} className="w-full">
{submitting ? t("sign_in_submitting") : t("sign_in")}
</Button>
<Button variant="link" size="inline" asChild className="self-center">
<a href={subUrl("/user/sign_up")} tabIndex={8}>
{t("sign_up_now")}
</a>
</Button>
</div>
</fieldset>
</form>
</CardContent>
</Card>
+19 -1
View File
@@ -13,6 +13,7 @@ import { webContext } from "@/lib/context";
import { subUrl } from "@/lib/url";
import type { UserInfo } from "@/lib/user-info";
import { Landing } from "@/pages/Landing";
import { MFA } from "@/pages/MFA";
import { NotFound } from "@/pages/NotFound";
import { SignIn, type SignInPage } from "@/pages/SignIn";
@@ -67,7 +68,24 @@ const signInRoute = createRoute({
component: SignIn,
});
const routeTree = rootRoute.addChildren([landingRoute, signInRoute]);
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, mfaRoute]);
function makeRouter(context: RouterContext) {
return createRouter({