web: move password reset to React (#8290)

This commit is contained in:
ᴊᴏᴇ ᴄʜᴇɴ
2026-05-23 21:55:22 -04:00
committed by GitHub
parent 71dfd3c7ac
commit 4935e7a63b
62 changed files with 1094 additions and 340 deletions
+89
View File
@@ -0,0 +1,89 @@
package web
import (
"crypto/tls"
"strconv"
"strings"
"time"
"github.com/cockroachdb/errors"
"github.com/flamego/cache"
"github.com/flamego/cache/redis"
"gopkg.in/ini.v1"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/strx"
)
func parseCacheOptions(confOpts conf.CacheOptions) (cache.Options, error) {
opts := cache.Options{
GCInterval: time.Duration(confOpts.Interval) * time.Second,
}
switch strx.Coalesce(strings.ToLower(confOpts.Adapter), "memory") {
case "memory":
opts.Initer = cache.MemoryIniter()
case "file":
opts.Initer = cache.FileIniter()
opts.Config = cache.FileConfig{RootDir: confOpts.Host}
case "redis":
cfg, err := parseRedisConfig(confOpts.Host)
if err != nil {
return cache.Options{}, errors.Wrap(err, "parse redis config")
}
opts.Initer = redis.Initer()
opts.Config = cfg
default:
return cache.Options{}, errors.Errorf("unsupported adapter %q", confOpts.Adapter)
}
return opts, nil
}
func parseRedisConfig(host string) (redis.Config, error) {
cfg, err := ini.Load([]byte(strings.ReplaceAll(host, ",", "\n")))
if err != nil {
return redis.Config{}, errors.Wrap(err, "load HOST")
}
var config redis.Config
for k, v := range cfg.Section("").KeysHash() {
switch k {
case "network":
config.Options.Network = v
case "addr":
config.Options.Addr = v
case "password":
config.Options.Password = v
case "db":
n, err := strconv.Atoi(v)
if err != nil {
return redis.Config{}, errors.Wrapf(err, "parse db %q", v)
}
config.Options.DB = n
case "pool_size":
n, err := strconv.Atoi(v)
if err != nil {
return redis.Config{}, errors.Wrapf(err, "parse pool_size %q", v)
}
config.Options.PoolSize = n
case "idle_timeout":
d, err := time.ParseDuration(v + "s")
if err != nil {
return redis.Config{}, errors.Wrapf(err, "parse idle_timeout %q", v)
}
config.Options.ConnMaxIdleTime = d
case "prefix":
config.KeyPrefix = v
case "tls":
// Matches go-macaron/session/redis: any non-empty `tls=` value enables
// TLS with InsecureSkipVerify.
config.Options.TLSConfig = &tls.Config{InsecureSkipVerify: true}
case "hset_name":
// Macaron stored values in a single Redis hash named by this key,
// whereas Flamego stores per-key with KeyPrefix, so this knob has no equivalent.
default:
return redis.Config{}, errors.Errorf("unsupported redis HOST key %q", k)
}
}
return config, nil
}
+23 -14
View File
@@ -14,9 +14,10 @@ import (
"strings"
"github.com/cockroachdb/errors"
"github.com/flamego/cache"
"github.com/flamego/flamego"
"github.com/go-macaron/binding"
"github.com/go-macaron/cache"
macaroncache "github.com/go-macaron/cache"
"github.com/go-macaron/captcha"
"github.com/go-macaron/csrf"
"github.com/go-macaron/gzip"
@@ -36,12 +37,12 @@ import (
"gogs.io/gogs/internal/route"
"gogs.io/gogs/internal/route/admin"
apiv1 "gogs.io/gogs/internal/route/api/v1"
"gogs.io/gogs/internal/route/dev"
"gogs.io/gogs/internal/route/lfs"
"gogs.io/gogs/internal/route/org"
"gogs.io/gogs/internal/route/repo"
"gogs.io/gogs/internal/route/user"
"gogs.io/gogs/internal/template"
"gogs.io/gogs/internal/urlx"
"gogs.io/gogs/public"
"gogs.io/gogs/templates"
)
@@ -89,8 +90,6 @@ func Run(configPath string, portOverride int) error {
m.Group("/user", func() {
m.Get("/sign_up", user.SignUp)
m.Post("/sign_up", bindIgnErr(form.Register{}), user.SignUpPost)
m.Get("/reset_password", user.ResetPasswd)
m.Post("/reset_password", user.ResetPasswdPost)
}, reqSignOut)
m.Group("/user/settings", func() {
@@ -137,8 +136,6 @@ func Run(configPath string, portOverride int) error {
m.Any("/activate", user.Activate)
m.Any("/activate_email", user.ActivateEmail)
m.Get("/email2user", user.Email2User)
m.Get("/forget_password", user.ForgotPasswd)
m.Post("/forget_password", user.ForgotPasswdPost)
m.Post("/logout", user.SignOut)
})
// ***** END: User *****
@@ -229,10 +226,6 @@ func Run(configPath string, portOverride int) error {
m.Post("/action/:action", user.Action)
}, reqSignIn, context.InjectParamsUser())
if macaron.Env == macaron.DEV {
m.Get("/template/*", dev.TemplatePreview)
}
reqRepoAdmin := context.RequireRepoAdmin()
reqRepoWriter := context.RequireRepoWriter()
@@ -689,14 +682,30 @@ func newRoutingHandler() (http.Handler, error) {
f := flamego.New()
f.Use(flamego.Recovery())
mountWebAPIRoutes(f)
cacherOpts, err := parseCacheOptions(conf.Cache)
if err != nil {
return nil, errors.Wrap(err, "parse cache options")
}
f.Use(cache.Cacher(cacherOpts))
if err := mountWebRoutes(f); err != nil {
return nil, errors.Wrap(err, "mount web routes")
f.Get("/redirect", getRedirect)
mountWebAPIRoutes(f)
err = mountWebAppRoutes(f)
if err != nil {
return nil, errors.Wrap(err, "mount web app routes")
}
return f, nil
}
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)
}
// newMacaron initializes Macaron instance.
func newMacaron() (*macaron.Macaron, error) {
m := macaron.New()
@@ -780,7 +789,7 @@ func newMacaron() (*macaron.Macaron, error) {
DefaultLang: "en-US",
Redirect: true,
}))
m.Use(cache.Cacher(cache.Options{
m.Use(macaroncache.Cacher(macaroncache.Options{
Adapter: conf.Cache.Adapter,
AdapterConfig: conf.Cache.Host,
Interval: conf.Cache.Interval,
+103 -23
View File
@@ -4,14 +4,16 @@ import (
stdctx "context"
"encoding/json"
"net/http"
"os"
"reflect"
"strings"
"time"
"github.com/cockroachdb/errors"
"github.com/flamego/binding"
"github.com/flamego/cache"
"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"
@@ -21,7 +23,8 @@ import (
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
"gogs.io/gogs/internal/urlx"
"gogs.io/gogs/internal/email"
"gogs.io/gogs/internal/route/user"
"gogs.io/gogs/internal/userx"
)
@@ -30,17 +33,15 @@ type (
webAPISessionKey struct{}
webAPIMacaronKey struct{}
webAPILocaleKey struct{}
webAPICacheKey struct{}
)
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) {
func bridgeToWebAPI(webHandler http.Handler) func(c *context.Context, l i18n.Locale) {
return func(c *context.Context, l i18n.Locale) {
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))
}
}
@@ -51,8 +52,7 @@ func webAPIInjector(c flamego.Context) {
sess, _ := ctx.Value(webAPISessionKey{}).(session.Store)
mc, _ := ctx.Value(webAPIMacaronKey{}).(*macaron.Context)
l, _ := ctx.Value(webAPILocaleKey{}).(i18n.Locale)
ca, _ := ctx.Value(webAPICacheKey{}).(cache.Cache)
c.Map(user, sess, mc, l, ca)
c.Map(user, sess, mc, l)
}
func webAPIBodyLimiter(c flamego.Context) {
@@ -116,6 +116,12 @@ func mountWebAPIRoutes(f *flamego.Flame) {
f.Group("/api/web", func() {
f.Group("/user", func() {
f.Get("/info", getUserInfo)
f.Group("/reset-password", func() {
f.Combo("").
Get(getUserResetPassword).
Post(bindJSON(userResetPasswordEmailRequest{}), postUserResetPassword)
f.Post("/complete", bindJSON(userResetPasswordCompleteRequest{}), postUserResetPasswordComplete)
})
f.Combo("/sign-in").
Get(getUserSignIn).
Post(bindJSON(userSignInRequest{}), postUserSignIn)
@@ -128,16 +134,6 @@ func mountWebAPIRoutes(f *flamego.Flame) {
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
@@ -216,11 +212,11 @@ type loginSource struct {
IsDefault bool `json:"isDefault"`
}
type userSignInPageResponse struct {
type getUserSignInResponse struct {
LoginSources []loginSource `json:"loginSources"`
}
func getUserSignIn(r *http.Request) (statusCode int, resp *userSignInPageResponse, err error) {
func getUserSignIn(r *http.Request) (statusCode int, resp *getUserSignInResponse, 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)
@@ -230,7 +226,7 @@ func getUserSignIn(r *http.Request) (statusCode int, resp *userSignInPageRespons
for _, s := range sources {
loginSources = append(loginSources, loginSource{ID: s.ID, Name: s.Name, IsDefault: s.IsDefault})
}
return http.StatusOK, &userSignInPageResponse{LoginSources: loginSources}, nil
return http.StatusOK, &getUserSignInResponse{LoginSources: loginSources}, nil
}
type userSignInRequest struct {
@@ -239,6 +235,87 @@ type userSignInRequest struct {
LoginSource int64 `json:"loginSource"`
}
type getUserResetPasswordResponse struct {
EmailEnabled bool `json:"emailEnabled"`
Valid bool `json:"valid"`
}
func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPasswordResponse, err error) {
code := r.URL.Query().Get("code")
return http.StatusOK, &getUserResetPasswordResponse{
EmailEnabled: conf.Email.Enabled,
Valid: code != "" && user.VerifyUserActiveCode(code) != nil,
}, nil
}
type userResetPasswordEmailRequest struct {
Email string `json:"email" validate:"required,email,max=254"`
}
type userResetPasswordCompleteRequest struct {
Code string `json:"code" validate:"required"`
Password string `json:"password" validate:"required,min=6,max=255"`
}
type userResetPasswordResponse struct {
Hours int `json:"hours,omitempty"`
ResendLimited bool `json:"resendLimited,omitempty"`
}
func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req userResetPasswordEmailRequest) (statusCode int, resp any, err error) {
if !conf.Email.Enabled {
return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil
}
ctx := r.Context()
u, err := database.Handle.Users().GetByEmail(ctx, req.Email)
if err != nil {
if database.IsErrUserNotExist(err) {
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
}
log.Error("postUserResetPassword: get user by email %q: %v", req.Email, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "get user by email")
}
if !u.IsLocal() {
msg := l.Tr("auth.non_local_account")
return http.StatusForbidden, &bindingErrorResponse{Fields: fieldErrors{"email": &msg}}, nil
}
if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil {
return http.StatusOK, &userResetPasswordResponse{
Hours: conf.Auth.ActivateCodeLives / 60,
ResendLimited: true,
}, nil
} else if !errors.Is(err, os.ErrNotExist) {
log.Error("postUserResetPassword: get mail resend cache for user %q: %v", u.Name, err)
}
if err = email.SendResetPasswordMail(l, database.NewMailerUser(u)); err != nil {
log.Error("postUserResetPassword: send reset password 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("postUserResetPassword: put mail resend cache for user %q: %v", u.Name, err)
}
return http.StatusOK, &userResetPasswordResponse{Hours: conf.Auth.ActivateCodeLives / 60}, nil
}
func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) {
u := user.VerifyUserActiveCode(req.Code)
if u == nil {
return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil
}
if err := database.Handle.Users().Update(r.Context(), u.ID, database.UpdateUserOptions{Password: &req.Password}); err != nil {
log.Error("postUserResetPasswordComplete: update password for user %q: %v", u.Name, err)
return http.StatusInternalServerError, nil, errors.Wrap(err, "update user")
}
log.Trace("User password reset: %s", u.Name)
return http.StatusNoContent, nil, nil
}
type userSignInResponse struct {
// MFA is true when the account has MFA enabled and the password step
// succeeded but a second factor is still required. The client should
@@ -324,13 +401,16 @@ func postUserMFA(r *http.Request, sess session.Store, mc *macaron.Context, ca ca
}, nil
}
if ca.IsExist(userx.TwoFactorCacheKey(userID, req.Passcode)) {
cacheKey := userx.TwoFactorCacheKey(userID, req.Passcode)
if _, err := ca.Get(r.Context(), cacheKey); err == nil {
msg := l.Tr("auth.mfa_reused_passcode")
return http.StatusUnauthorized, &bindingErrorResponse{
Fields: fieldErrors{"passcode": &msg},
}, nil
} else if !errors.Is(err, os.ErrNotExist) {
log.Error("postUserMFA: get two factor passcode cache for user %d: %v", userID, err)
}
if err = ca.Put(userx.TwoFactorCacheKey(userID, req.Passcode), 1, 60); err != nil {
if err = ca.Set(r.Context(), cacheKey, 1, 60*time.Second); err != nil {
log.Error("postUserMFA: cache two factor passcode for user %d: %v", userID, err)
}
@@ -18,7 +18,7 @@ import (
"gogs.io/gogs/internal/context"
)
func mountWebRoutes(f *flamego.Flame) error {
func mountWebAppRoutes(f *flamego.Flame) error {
viteURL, err := url.Parse("http://localhost:5173")
if err != nil {
return errors.Wrap(err, "parse Vite URL")
@@ -15,7 +15,7 @@ import (
"gogs.io/gogs/public"
)
func mountWebRoutes(f *flamego.Flame) error {
func mountWebAppRoutes(f *flamego.Flame) error {
webFS, err := fs.Sub(public.WebAssets, "dist")
if err != nil {
return errors.Wrap(err, "load embedded web assets")
+15 -5
View File
@@ -193,11 +193,21 @@ prohibit_login_desc = Your account is prohibited from logging in. Please contact
resent_limit_prompt = 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.
resend_mail = Click here to resend your activation email
send_reset_mail = Click here to (re)send your password reset email
reset_password = Reset Your Password
invalid_code = Sorry, your confirmation code has expired or not valid.
reset_password_helper = Click here to reset your password
password_too_short = Password length must be at least 6 characters.
send_reset_email = Send 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_sent = A password reset email has been sent to <email>{email}</email>, please check your inbox within <hours>{hours} hours</hours>.
reset_password = Reset your password
invalid_code = The confirmation code has expired or not valid.
reset_password_submit = Reset password
reset_password_submitting = Resetting password...
reset_password_resend_limited = You already requested a password reset email recently. Please wait 3 minutes then try again.
reset_password_failed = Could not reset password, please try again.
new_password = New password
new_password_placeholder = Enter your new password
confirm_new_password = Confirm new password
confirm_new_password_placeholder = Re-enter your new password
reset_password_mismatch = The two passwords do not match.
non_local_account = Non-local accounts cannot change passwords through Gogs.
[mail]
+4 -1
View File
@@ -10,6 +10,7 @@ require (
github.com/editorconfig/editorconfig-core-go/v2 v2.6.4
github.com/fatih/color v1.18.0
github.com/flamego/binding v1.3.0
github.com/flamego/cache v1.5.1
github.com/flamego/flamego v1.12.0
github.com/flamego/validator v1.0.0
github.com/glebarez/go-sqlite v1.21.2
@@ -66,6 +67,7 @@ require (
bitbucket.org/creachadair/shell v0.0.7 // indirect
charm.land/lipgloss/v2 v2.0.1 // indirect
charm.land/log/v2 v2.0.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/alecthomas/participle/v2 v2.1.4 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
@@ -94,7 +96,7 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b // indirect
github.com/go-redis/redis/v8 v8.11.5 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
@@ -128,6 +130,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/redis/go-redis/v9 v9.5.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
+12 -1
View File
@@ -8,6 +8,8 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMT
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.37.4 h1:glPeL3BQJsbF6aIIYfZizMwc5LTYz250bDMjttbBGAU=
cloud.google.com/go v0.37.4/go.mod h1:NHPJ89PdicEuT9hdPXMROBD91xc5uRDxsMtSB16k7hw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
gitea.com/lunny/log v0.0.0-20190322053110-01b5df579c4e/go.mod h1:uJEsN4LQpeGYRCjuPXPZBClU7N5pWzGuyF4uqLpE/e0=
gitea.com/lunny/nodb v0.0.0-20200923032308-3238c4655727/go.mod h1:h0OwsgcpJLSYtHcM5+Xciw9OEeuxi6ty4HDiO8C7aIY=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
@@ -38,6 +40,10 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
github.com/bradfitz/gomemcache v0.0.0-20190329173943-551aad21a668/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@@ -102,6 +108,8 @@ github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/flamego/binding v1.3.0 h1:CPbnSuP0SxT50JR7lK2khTjcQi1oOECqRK7kbOYw91U=
github.com/flamego/binding v1.3.0/go.mod h1:xgm6FEpEKKkF8CQilK2X3MJ5kTjOTnYdz/ooFctDTdc=
github.com/flamego/cache v1.5.1 h1:2B4QhLFV7je0oUMCVKsAGAT+OyDHlXhozOoUffm+O3s=
github.com/flamego/cache v1.5.1/go.mod h1:cTWYm/Ls35KKHo8vwcKgTlJUNXswEhzFWqVCTFzj24s=
github.com/flamego/flamego v1.12.0 h1:BS0iY6RytweVvu5j40fQJ53X2ZcUVeuQ8ZSigVkDB9A=
github.com/flamego/flamego v1.12.0/go.mod h1:MM4kNGS7SvJtwUZYb2oGySR+ncdtIvtJHsl8OhH1Ngo=
github.com/flamego/validator v1.0.0 h1:ixuWHVgiVGp4pVGtUn/0d6HBjZJbbXfJHDNkxW+rZoY=
@@ -155,8 +163,9 @@ github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvSc
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-xorm/sqlfiddle v0.0.0-20180821085327-62ce714f951a h1:9wScpmSP5A3Bk8V3XHWUcJmYTh+ZnlHVyc+A4oZYS3Y=
@@ -386,6 +395,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8=
github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+8 -7
View File
@@ -85,13 +85,6 @@ var (
CSRFCookieName string `ini:"CSRF_COOKIE_NAME"`
}
// Cache settings
Cache struct {
Adapter string
Interval int
Host string
}
// HTTP settings
HTTP struct {
AccessControlAllowOrigin string
@@ -227,6 +220,14 @@ var (
HasRobotsTxt bool
)
type CacheOptions struct {
Adapter string
Interval int
Host string
}
var Cache CacheOptions
type AppOpts struct {
// ⚠️ WARNING: Should only be set by the main package (i.e. "cmd/gogs/main.go").
Version string `ini:"-"`
+2
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/cockroachdb/errors"
"github.com/flamego/flamego"
"github.com/gogs/git-module"
"gopkg.in/ini.v1"
"gopkg.in/macaron.v1"
@@ -35,6 +36,7 @@ const (
func checkRunMode() {
if conf.IsProdMode() {
macaron.Env = macaron.PROD
flamego.SetEnv(flamego.EnvTypeProd)
macaron.ColorLog = false
git.SetOutput(nil)
} else {
+5 -117
View File
@@ -19,10 +19,8 @@ import (
)
const (
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"
)
func SignOut(c *context.Context) {
@@ -163,8 +161,8 @@ func parseUserFromCode(code string) (user *database.User) {
return nil
}
// verify active code when active account
func verifyUserActiveCode(code string) (user *database.User) {
// 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 {
@@ -228,7 +226,7 @@ func Activate(c *context.Context) {
}
// Verify code.
if user := verifyUserActiveCode(code); user != nil {
if user := VerifyUserActiveCode(code); user != nil {
v := true
err := database.Handle.Users().Update(
c.Req.Context(),
@@ -273,113 +271,3 @@ func ActivateEmail(c *context.Context) {
c.RedirectSubpath("/user/settings/email")
}
func ForgotPasswd(c *context.Context) {
c.Title("auth.forgot_password")
if !conf.Email.Enabled {
c.Data["IsResetDisable"] = true
c.Success(tmplUserAuthForgotPassword)
return
}
c.Data["IsResetRequest"] = true
c.Success(tmplUserAuthForgotPassword)
}
func ForgotPasswdPost(c *context.Context) {
c.Title("auth.forgot_password")
if !conf.Email.Enabled {
c.Status(403)
return
}
c.Data["IsResetRequest"] = true
emailAddr := c.Query("email")
c.Data["Email"] = emailAddr
u, err := database.Handle.Users().GetByEmail(c.Req.Context(), emailAddr)
if err != nil {
if database.IsErrUserNotExist(err) {
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
c.Data["IsResetSent"] = true
c.Success(tmplUserAuthForgotPassword)
return
}
c.Error(err, "get user by email")
return
}
if !u.IsLocal() {
c.FormErr("Email")
c.RenderWithErr(c.Tr("auth.non_local_account"), http.StatusForbidden, tmplUserAuthForgotPassword, nil)
return
}
if c.Cache.IsExist(userx.MailResendCacheKey(u.ID)) {
c.Data["ResendLimited"] = true
c.Success(tmplUserAuthForgotPassword)
return
}
if err = email.SendResetPasswordMail(c.Context, database.NewMailerUser(u)); err != nil {
log.Error("Failed to send reset password mail: %v", err)
}
if err = c.Cache.Put(userx.MailResendCacheKey(u.ID), 1, 180); err != nil {
log.Error("Failed to put cache key 'mail resend': %v", err)
}
c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60
c.Data["IsResetSent"] = true
c.Success(tmplUserAuthForgotPassword)
}
func ResetPasswd(c *context.Context) {
c.Title("auth.reset_password")
code := c.Query("code")
if code == "" {
c.NotFound()
return
}
c.Data["Code"] = code
c.Data["IsResetForm"] = true
c.Success(tmplUserAuthResetPassword)
}
func ResetPasswdPost(c *context.Context) {
c.Title("auth.reset_password")
code := c.Query("code")
if code == "" {
c.NotFound()
return
}
c.Data["Code"] = code
if u := verifyUserActiveCode(code); u != nil {
// Validate password length.
password := c.Query("password")
if len(password) < 6 {
c.Data["IsResetForm"] = true
c.Data["Err_Password"] = true
c.RenderWithErr(c.Tr("auth.password_too_short"), http.StatusBadRequest, tmplUserAuthResetPassword, nil)
return
}
err := database.Handle.Users().Update(c.Req.Context(), u.ID, database.UpdateUserOptions{Password: &password})
if err != nil {
c.Error(err, "update user")
return
}
log.Trace("User password reset: %s", u.Name)
c.RedirectSubpath("/user/sign-in")
return
}
c.Data["IsResetFailed"] = true
c.Success(tmplUserAuthResetPassword)
}
+10
View File
@@ -7,6 +7,16 @@ import (
"unicode"
)
// Coalesce returns the value of the first string that is not empty.
func Coalesce(ss ...string) string {
for _, s := range ss {
if s != "" {
return s
}
}
return ""
}
// ToUpperFirst returns s with only the first Unicode letter mapped to its upper case.
func ToUpperFirst(s string) string {
for i, v := range s {
+17
View File
@@ -6,6 +6,23 @@ import (
"github.com/stretchr/testify/assert"
)
func TestCoalesce(t *testing.T) {
tests := []struct {
in []string
want string
}{
{[]string{"a", "b"}, "a"},
{[]string{"", "b", "c"}, "b"},
{[]string{"", "", ""}, ""},
}
for _, test := range tests {
t.Run(test.want, func(t *testing.T) {
got := Coalesce(test.in...)
assert.Equal(t, test.want, got)
})
}
}
func TestToUpperFirst(t *testing.T) {
tests := []struct {
name string
+2 -2
View File
@@ -127,7 +127,7 @@ tasks:
&& mv .bin/custom/conf/app.ini.tmp .bin/custom/conf/app.ini
dev:
command: ".bin/gogs web"
script: "cd .bin && ./gogs web"
preset: "server"
env:
TTY_FORCE: "1"
@@ -137,7 +137,7 @@ tasks:
- "portless"
prod:
command: ".bin/gogs web"
script: "cd .bin && ./gogs web"
preset: "server"
env:
TTY_FORCE: "1"
+1 -1
View File
@@ -8,7 +8,7 @@
<body>
<p>Hi <b>{{.Username}}</b>,</p>
<p>Please click the following link to reset your password within <b>{{.ResetPwdCodeLives}} hours</b>:</p>
<p><a href="{{AppURL}}user/reset_password?code={{.Code}}">{{AppURL}}user/reset_password?code={{.Code}}</a></p>
<p><a href="{{AppURL}}user/reset-password?code={{.Code}}">{{AppURL}}user/reset-password?code={{.Code}}</a></p>
<p>Not working? Try copying and pasting it to your browser.</p>
<p>© {{Year}} <a target="_blank" rel="noopener noreferrer" href="{{AppURL}}">{{AppName}}</a></p>
</body>
-34
View File
@@ -1,34 +0,0 @@
{{template "base/head" .}}
<div class="user forgot password">
<div class="ui middle very relaxed page grid">
<div class="column">
<form class="ui form" action="{{.Link}}" method="post">
{{.CSRFTokenHTML}}
<h2 class="ui top attached header">
{{.i18n.Tr "auth.forgot_password"}}
</h2>
<div class="ui attached segment">
{{template "base/alert" .}}
{{if .IsResetSent}}
<p>{{.i18n.Tr "auth.confirmation_mail_sent_prompt" .Email .Hours | Str2HTML}}</p>
{{else if .IsResetRequest}}
<div class="required inline field {{if .Err_Email}}error{{end}}">
<label for="email">{{.i18n.Tr "email"}}</label>
<input id="email" name="email" type="email" value="{{.Email}}" autofocus required>
</div>
<div class="ui divider"></div>
<div class="inline field">
<label></label>
<button class="ui blue button">{{.i18n.Tr "auth.send_reset_mail"}}</button>
</div>
{{else if .IsResetDisable}}
<p class="center">{{.i18n.Tr "auth.disable_register_mail"}}</p>
{{else if .ResendLimited}}
<p class="center">{{.i18n.Tr "auth.resent_limit_prompt"}}</p>
{{end}}
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
-31
View File
@@ -1,31 +0,0 @@
{{template "base/head" .}}
<div class="user reset password">
<div class="ui middle very relaxed page grid">
<div class="column">
<form class="ui form" action="{{.Link}}" method="post">
{{.CSRFTokenHTML}}
<input name="code" type="hidden" value="{{.Code}}">
<h2 class="ui top attached header">
{{.i18n.Tr "auth.reset_password"}}
</h2>
<div class="ui attached segment">
{{template "base/alert" .}}
{{if .IsResetForm}}
<div class="required inline field {{if .Err_Password}}error{{end}}">
<label for="password">{{.i18n.Tr "password"}}</label>
<input id="password" name="password" type="password" value="{{.password}}" autofocus required>
</div>
<div class="ui divider"></div>
<div class="inline field">
<label></label>
<button class="ui blue button">{{.i18n.Tr "auth.reset_password_helper"}}</button>
</div>
{{else}}
<p class="center">{{.i18n.Tr "auth.invalid_code"}}</p>
{{end}}
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 14 KiB

+18
View File
@@ -41,18 +41,36 @@ const REUSED_KEYS = [
"theme_system",
"username",
"username_placeholder",
"email",
"password",
"password_placeholder",
"auth_source",
"local",
"remember_me",
"forget_password",
"send_reset_email",
"reset_password_email_submitting",
"reset_password_email_failed",
"reset_password_email_sent",
"disable_register_mail",
"reset_password_resend_limited",
"non_local_account",
"sign_up_now",
"sign_in_submitting",
"sign_in_failed",
"show_password",
"hide_password",
"back_to_sign_in",
"reset_password",
"invalid_code",
"reset_password_submit",
"reset_password_submitting",
"reset_password_failed",
"new_password",
"new_password_placeholder",
"confirm_new_password",
"confirm_new_password_placeholder",
"reset_password_mismatch",
"mfa_title",
"mfa_passcode",
"mfa_passcode_placeholder",
Binary file not shown.
+106
View File
@@ -0,0 +1,106 @@
The fonts in this directory and the fontsource packages bundled into the
built web assets are licensed under the SIL Open Font License, Version 1.1.
Copyright holders:
Geist (Sans)
Copyright 2024 The Geist Project Authors
https://github.com/vercel/geist-font
Geist Mono
Copyright 2024 The Geist Project Authors
https://github.com/vercel/geist-font
Geist Pixel
Copyright (c) 2023 Vercel, in collaboration with basement.studio
https://github.com/vercel/geist-font
The full text of the license is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION AND CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+72
View File
@@ -3,10 +3,19 @@
@import "@fontsource-variable/geist";
@import "@fontsource-variable/geist-mono";
@font-face {
font-family: "Geist Pixel";
font-style: normal;
font-weight: 100 900;
font-display: swap;
src: url("./assets/fonts/GeistPixel-Square.woff2") format("woff2");
}
@theme inline {
--font-sans:
"Geist Variable", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", system-ui, -apple-system, sans-serif;
--font-mono: "Geist Mono Variable", "PingFang SC", "Microsoft YaHei", Consolas, "Liberation Mono", Menlo, monospace;
--font-pixel: "Geist Pixel", "Geist Mono Variable", Consolas, "Liberation Mono", Menlo, monospace;
}
@custom-variant dark (&:where(.dark, .dark *));
@@ -88,3 +97,66 @@
-webkit-font-smoothing: antialiased;
}
}
/* Flame flicker for terminal-style hover affordances: a Hermès-orange glow
that irregularly pulses, like an ember catching from underneath the text.
Pauses for users who prefer reduced motion. */
@property --flame-core {
syntax: "<color>";
inherits: true;
initial-value: #ff6b35;
}
@property --flame-halo {
syntax: "<color>";
inherits: true;
initial-value: #ff8a4c;
}
@keyframes flame-flicker {
0% {
text-shadow:
0 0 4px color-mix(in srgb, var(--flame-core) 50%, transparent),
0 0 8px color-mix(in srgb, var(--flame-halo) 25%, transparent);
}
18% {
text-shadow:
0 0 6px color-mix(in srgb, var(--flame-core) 70%, transparent),
0 0 14px color-mix(in srgb, var(--flame-halo) 40%, transparent);
}
31% {
text-shadow:
0 0 3px color-mix(in srgb, var(--flame-core) 35%, transparent),
0 0 6px color-mix(in srgb, var(--flame-halo) 18%, transparent);
}
47% {
text-shadow:
0 0 8px color-mix(in srgb, var(--flame-core) 75%, transparent),
0 0 18px color-mix(in srgb, var(--flame-halo) 45%, transparent);
}
62% {
text-shadow:
0 0 4px color-mix(in srgb, var(--flame-core) 50%, transparent),
0 0 8px color-mix(in srgb, var(--flame-halo) 25%, transparent);
}
78% {
text-shadow:
0 0 5px color-mix(in srgb, var(--flame-core) 60%, transparent),
0 0 11px color-mix(in srgb, var(--flame-halo) 32%, transparent);
}
100% {
text-shadow:
0 0 4px color-mix(in srgb, var(--flame-core) 50%, transparent),
0 0 8px color-mix(in srgb, var(--flame-halo) 25%, transparent);
}
}
@media (prefers-reduced-motion: reduce) {
@keyframes flame-flicker {
0%,
100% {
text-shadow:
0 0 4px color-mix(in srgb, var(--flame-core) 50%, transparent),
0 0 8px color-mix(in srgb, var(--flame-halo) 25%, transparent);
}
}
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Език",
"page_not_found": "Страницата не е намерена",
"username": "Потребител",
"email": "Ел. поща",
"password": "Парола",
"auth_source": "Източник за удостоверяване",
"local": "Локален",
"remember_me": "Запомни ме",
"forget_password": "Забравена парола?",
"sign_up_now": "Нуждаете се от профил? Регистрирайте се сега."
"disable_register_mail": "За съжаление потвърждението на регистрации е изключено.",
"non_local_account": "Нелокални потребители не могат да сменят паролата си през Gogs.",
"sign_up_now": "Нуждаете се от профил? Регистрирайте се сега.",
"reset_password": "Нулиране на паролата",
"invalid_code": "За съжаление Вашия код за потвърждение е изтекъл или е невалиден.",
"new_password": "Нова парола"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Jazyk",
"page_not_found": "Page Not Found",
"username": "Uživatelské jméno",
"email": "E-mail",
"password": "Heslo",
"auth_source": "Zdroj ověření",
"local": "Lokální",
"remember_me": "Zapamatovat si mne",
"forget_password": "Zapomněli jste heslo?",
"sign_up_now": "Potřebujete účet? Zaregistrujte se."
"disable_register_mail": "Omlouváme se, ale e-mailové služby jsou vypnuté. Kontaktujte správce systému.",
"non_local_account": "Externí účty nemohou měnit hesla přes Gogs.",
"sign_up_now": "Potřebujete účet? Zaregistrujte se.",
"reset_password": "Obnova vašeho hesla",
"invalid_code": "Omlouváme se, ale kód z vašeho potvrzovacího e-mailu už vypršel nebo není správný.",
"new_password": "Nové heslo"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Sprache",
"page_not_found": "Seite nicht gefunden",
"username": "Benutzername",
"email": "E-Mail",
"password": "Passwort",
"auth_source": "Authentifizierungsquelle",
"local": "Lokal",
"remember_me": "Angemeldet bleiben",
"forget_password": "Passwort vergessen?",
"sign_up_now": "Benötigen Sie ein Konto? Registrieren Sie sich jetzt."
"disable_register_mail": "Es tut uns leid, die Bestätigung der Registrierungs-E-Mail wurde deaktiviert.",
"non_local_account": "Nicht-lokale Konten können Passwörter nicht via Gogs ändern.",
"sign_up_now": "Benötigen Sie ein Konto? Registrieren Sie sich jetzt.",
"reset_password": "Passwort zurücksetzen",
"invalid_code": "Es tut uns leid, der Bestätigungscode ist abgelaufen oder ungültig.",
"new_password": "Neues Passwort"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Language",
"page_not_found": "Page Not Found",
"username": "Username",
"email": "Email",
"password": "Password",
"auth_source": "Authentication Source",
"local": "Local",
"remember_me": "Remember Me",
"forget_password": "Forgot password?",
"sign_up_now": "Need an account? Sign up now."
"disable_register_mail": "Sorry, Register Mail Confirmation has been disabled.",
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
"sign_up_now": "Need an account? Sign up now.",
"reset_password": "Reset Your Password",
"invalid_code": "Sorry, your confirmation code has expired or not valid.",
"new_password": "New Password"
}
+18
View File
@@ -27,17 +27,35 @@
"theme_system": "System",
"username": "Username",
"username_placeholder": "Enter your username or email",
"email": "Email",
"password": "Password",
"password_placeholder": "Enter your password",
"auth_source": "Authentication source",
"local": "Local",
"forget_password": "Forgot password?",
"send_reset_email": "Send 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_sent": "A password reset email has been sent to <email>{email}</email>, please check your inbox within <hours>{hours} hours</hours>.",
"disable_register_mail": "Sorry, email services are 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.",
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
"sign_up_now": "Create a new account",
"sign_in_submitting": "Signing in...",
"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",
"reset_password": "Reset your password",
"invalid_code": "The confirmation code has expired or not valid.",
"reset_password_submit": "Reset password",
"reset_password_submitting": "Resetting password...",
"reset_password_failed": "Could not reset password, please try again.",
"new_password": "New password",
"new_password_placeholder": "Enter your new password",
"confirm_new_password": "Confirm new password",
"confirm_new_password_placeholder": "Re-enter your new password",
"reset_password_mismatch": "The two passwords do not match.",
"mfa_title": "Multi-factor authentication",
"mfa_passcode": "Passcode",
"mfa_passcode_placeholder": "Enter the 6-digit code from your authenticator",
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Idioma",
"page_not_found": "Página no encontrada",
"username": "Nombre de usuario",
"email": "Correo electrónico",
"password": "Contraseña",
"auth_source": "Authentication Source",
"local": "Local",
"remember_me": "Recuérdame",
"forget_password": "¿Has olvidado tu contraseña?",
"sign_up_now": "¿Necesitas una cuenta? Regístrate ahora."
"disable_register_mail": "Lo sentimos. Los correos de Confirmación de Registro están deshabilitados.",
"non_local_account": "Cuentas que no son locales no pueden cambiar las contraseñas a través de Gogs.",
"sign_up_now": "¿Necesitas una cuenta? Regístrate ahora.",
"reset_password": "Restablecer su contraseña",
"invalid_code": "Lo sentimos, su código de confirmación ha expirado o no es valido.",
"new_password": "Nueva contraseña"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "زبان",
"page_not_found": "صفحه مورد نظر یافت نشد.",
"username": "نام کاربری",
"email": "ایمیل",
"password": "رمز عبور",
"auth_source": "محل احراز هویت",
"local": "محلی",
"remember_me": "مرا به خاطر بسپار",
"forget_password": "رمز عبور خود را فراموش کرده‌اید؟",
"sign_up_now": "نیاز به یک حساب دارید؟ هم‌اکنون ثبت نام کنید."
"disable_register_mail": "با عرض پوزش، تایید ایمیل ثبت نام غیر فعال شده است.",
"non_local_account": "حساب های کاربری غیر محلی قادر به تغییر رمز عبور از طریق Gogs نمی باشند.",
"sign_up_now": "نیاز به یک حساب دارید؟ هم‌اکنون ثبت نام کنید.",
"reset_password": "تنظیم مجدد رمز عبور",
"invalid_code": "با عرض پوزش، کد تایید شما منقضی شده است و یا معتبر نیست.",
"new_password": "رمز عبور جدید"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Kieli",
"page_not_found": "Sivua ei löydy",
"username": "Käyttäjätunnus",
"email": "Sähköposti",
"password": "Salasana",
"auth_source": "Todennuslähde",
"local": "Paikallinen",
"remember_me": "Muista minut",
"forget_password": "Unohtuiko salasana?",
"sign_up_now": "Tarvitsetko tilin? Rekisteröidy nyt."
"disable_register_mail": "Valitettavasti sähköpostipalvelut ovat poissa käytöstä. Otathan yhteyttä sivuston ylläpitoon.",
"non_local_account": "Vain paikallisten käyttäjätilien salasanan vaihto onnistuu Gogsin kautta.",
"sign_up_now": "Tarvitsetko tilin? Rekisteröidy nyt.",
"reset_password": "Nollaa salasanasi",
"invalid_code": "Sori, varmistuskoodisi on vanhentunut tai väärä.",
"new_password": "Uusi salasana"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Langue",
"page_not_found": "Page non trouvée",
"username": "Nom d'utilisateur",
"email": "E-mail",
"password": "Mot de passe",
"auth_source": "Sources d'authentification",
"local": "Locale",
"remember_me": "Se souvenir de moi",
"forget_password": "Mot de passe oublié ?",
"sign_up_now": "Pas de compte ? Inscrivez-vous maintenant."
"disable_register_mail": "Désolé, la confirmation par courriel des enregistrements a été désactivée.",
"non_local_account": "Les comptes non locaux ne peuvent pas changer leur mot de passe via Gogs.",
"sign_up_now": "Pas de compte ? Inscrivez-vous maintenant.",
"reset_password": "Réinitialiser le mot de passe",
"invalid_code": "Désolé, votre code de confirmation est invalide ou a expiré.",
"new_password": "Nouveau mot de passe"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Idioma",
"page_not_found": "Page Not Found",
"username": "Nome da persoa usuaria",
"email": "Correo electrónico",
"password": "Contrasinal",
"auth_source": "Fonte de Autenticación",
"local": "Configuración rexional",
"remember_me": "Recórdame",
"forget_password": "Esqueciches o teu contrasinal?",
"sign_up_now": "Necesitas unha conta? Rexístrate agora."
"disable_register_mail": "Sentímolo. Os correos de confirmación de rexistro están deshabilitados.",
"non_local_account": "Contas que non son locais non poden cambiar os contrasinais a través de Gogs.",
"sign_up_now": "Necesitas unha conta? Rexístrate agora.",
"reset_password": "Restablecer o teu contrasinal",
"invalid_code": "Sentímolo, o teu código de confirmación expirou ou non é válido.",
"new_password": "Novo contrasinal"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Nyelv",
"page_not_found": "Az oldal nem található",
"username": "Felhasználónév",
"email": "E-mail",
"password": "Jelszó",
"auth_source": "Hitelesítési forrás",
"local": "Helyi",
"remember_me": "Emlékezz rám",
"forget_password": "Elfelejtette a jelszavát?",
"sign_up_now": "Szeretne bejelentkezni? Regisztráljon most."
"disable_register_mail": "Elnézést, az email regisztráció megerősítését kikapcsolták.",
"non_local_account": "Nem helyi felhasználó nem cserélhet jelszót a Gogsban.",
"sign_up_now": "Szeretne bejelentkezni? Regisztráljon most.",
"reset_password": "Jelszó visszaállítása",
"invalid_code": "Elnézést, a megerősítő kód lejárt vagy hibás.",
"new_password": "Új jelszó"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Bahasa",
"page_not_found": "Halaman tidak ditemukan",
"username": "Nama pengguna",
"email": "Email",
"password": "Sandi",
"auth_source": "Sumber Autentikasi",
"local": "Lokal",
"remember_me": "Ingat saya",
"forget_password": "Lupa sandi?",
"sign_up_now": "Membutuhkan akun? Daftar sekarang."
"disable_register_mail": "Maaf, konfirmasi pendaftaran melalui email telah dinonaktifkan.",
"non_local_account": "Akun non-lokal tidak dapat mengganti password lewat Gogs.",
"sign_up_now": "Membutuhkan akun? Daftar sekarang.",
"reset_password": "Atur Ulang Sandi",
"invalid_code": "Maaf, kode konfirmasi Anda telah kadaluarsa atau tidak valid.",
"new_password": "Sandi baru"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Lingua",
"page_not_found": "Pagina Non Trovata",
"username": "Nome utente",
"email": "E-mail",
"password": "Password",
"auth_source": "Fonte di autenticazione",
"local": "Locale",
"remember_me": "Ricordami",
"forget_password": "Password dimenticata?",
"sign_up_now": "Bisogno di un account? Iscriviti ora."
"disable_register_mail": "Siamo spiacenti, la conferma di registrazione via Mail è stata disattivata.",
"non_local_account": "Gli account non locali non possono modificare le password tramite Gogs.",
"sign_up_now": "Bisogno di un account? Iscriviti ora.",
"reset_password": "Reimposta la tua Password",
"invalid_code": "Siamo spiacenti, il codice di conferma è scaduto o non valido.",
"new_password": "Nuova Password"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "言語",
"page_not_found": "ページが見つかりません",
"username": "ユーザー名",
"email": "メールアドレス",
"password": "パスワード",
"auth_source": "認証ソース",
"local": "ローカル",
"remember_me": "ログインしたままにする",
"forget_password": "パスワードを忘れましたか?",
"sign_up_now": "アカウントが必要ですか?今すぐ登録しましょう!"
"disable_register_mail": "申し訳ありませんが、登録メールの確認機能が無効になっています。",
"non_local_account": "非ローカルアカウントではGogs経由でのパスワード変更はできません。",
"sign_up_now": "アカウントが必要ですか?今すぐ登録しましょう!",
"reset_password": "パスワードリセット",
"invalid_code": "申し訳ありませんが、確認用コードが期限切れまたは無効です。",
"new_password": "新しいパスワード"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "언어",
"page_not_found": "페이지를 찾을 수 없음",
"username": "사용자명",
"email": "이메일",
"password": "비밀번호",
"auth_source": "인증 소스 편집",
"local": "로컬",
"remember_me": "자동 로그인",
"forget_password": "비밀번호를 잊으셨습니까?",
"sign_up_now": "계정이 필요하신가요? 지금 가입하세요."
"disable_register_mail": "죄송합니다. 메일 등록이 비활성화 되었습니다.",
"non_local_account": "Gogs 계정이 아니면 암호를 변경할 수 없습니다.",
"sign_up_now": "계정이 필요하신가요? 지금 가입하세요.",
"reset_password": "비밀번호 초기화",
"invalid_code": "죄송합니다. 확인 코드가 만료되었거나 유효하지 않습니다.",
"new_password": "새 비밀번호"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Valoda",
"page_not_found": "Page Not Found",
"username": "Lietotājvārds",
"email": "E-pasts",
"password": "Parole",
"auth_source": "Autentificēšanas avots",
"local": "Local",
"remember_me": "Atcerēties mani",
"forget_password": "Aizmirsi paroli?",
"sign_up_now": "Nepieciešams konts? Reģistrējies tagad."
"disable_register_mail": "Atvainojiet, reģistrācijas e-pasta apstiprināšana ir atspējota.",
"non_local_account": "Tikai lokālie konti var nomainīt savu paroli Gogs.",
"sign_up_now": "Nepieciešams konts? Reģistrējies tagad.",
"reset_password": "Atjaunot savu paroli",
"invalid_code": "Atvainojiet, Jūsu apstiprināšanas kodam ir beidzies derīguma termiņš vai arī tas ir nepareizs.",
"new_password": "Jauna parole"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Хэл",
"page_not_found": "Хуудас олдсонгүй",
"username": "Нэвтрэх нэр",
"email": "Имэйл",
"password": "Нууц үг",
"auth_source": "Баталгаажуулалтын эх сурвалж",
"local": "Локал",
"remember_me": "Сануулах",
"forget_password": "Нууц үг сэргээх?",
"sign_up_now": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү."
"disable_register_mail": "Уучлаарай, имэйлийн үйлчилгээ идэвхгүй байна. Сайтын админтай холбоо барина уу.",
"non_local_account": "Гадаад хэрэглэгчид нууц үгээ солих боломжгүй.",
"sign_up_now": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү.",
"reset_password": "Нууц үгээ сэргээх",
"invalid_code": "Уучлаарай, таны баталгаажуулах кодын хугацаа дууссан эсвэл хүчин төгөлдөр бус байна.",
"new_password": "Шинэ нууц үг"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Taal",
"page_not_found": "Pagina niet gevonden",
"username": "Gebruikersnaam",
"email": "E-mail",
"password": "Wachtwoord",
"auth_source": "Authenticatiebron",
"local": "Lokaal",
"remember_me": "Onthoud mij",
"forget_password": "Wachtwoord vergeten?",
"sign_up_now": "Een account nodig? Meld u nu aan."
"disable_register_mail": "Sorry, bevestiging van registratie per e-mail is uitgeschakeld.",
"non_local_account": "Niet lokale accounts mogen hun wachtwoord niet veranderen via Gogs.",
"sign_up_now": "Een account nodig? Meld u nu aan.",
"reset_password": "Reset uw wachtwoord",
"invalid_code": "Sorry, uw bevestigingscode is verlopen of niet meer geldig.",
"new_password": "Nieuw wachtwoord"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Język",
"page_not_found": "Strona nie została znaleziona",
"username": "Nazwa użytkownika",
"email": "E-mail",
"password": "Hasło",
"auth_source": "Źródło uwierzytelniania",
"local": "Lokalne",
"remember_me": "Zapamiętaj mnie",
"forget_password": "Zapomniałeś hasła?",
"sign_up_now": "Potrzebujesz konta? Zarejestruj się teraz."
"disable_register_mail": "Przepraszamy, potwierdzenia rejestracji zostały wyłączone przez administratora.",
"non_local_account": "Nie lokalne konta nie mogą zmieniać haseł przez Gogs.",
"sign_up_now": "Potrzebujesz konta? Zarejestruj się teraz.",
"reset_password": "Resetowanie hasła",
"invalid_code": "Niestety, Twój kod potwierdzający wygasł lub jest nieprawidłowy.",
"new_password": "Nowe hasło"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Idioma",
"page_not_found": "Página Não Encontrada",
"username": "Usuário",
"email": "E-mail",
"password": "Senha",
"auth_source": "Fonte de autenticação",
"local": "Local",
"remember_me": "Lembrar de mim",
"forget_password": "Esqueceu a senha?",
"sign_up_now": "Precisa de uma conta? Cadastre-se agora."
"disable_register_mail": "Desculpe, a confirmação de registro por e-mail foi desabilitada.",
"non_local_account": "Não é possível mudar a senha de contas remotas pelo Gogs.",
"sign_up_now": "Precisa de uma conta? Cadastre-se agora.",
"reset_password": "Redefinir sua senha",
"invalid_code": "Desculpe, seu código de confirmação expirou ou não é válido.",
"new_password": "Nova senha"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Língua",
"page_not_found": "Página Não Encontrada",
"username": "Nome de utilizador",
"email": "Endereço de email",
"password": "Palavra-chave",
"auth_source": "Tipo de autenticação",
"local": "Local",
"remember_me": "Manter sessão iniciada",
"forget_password": "Esqueceu a sua senha?",
"sign_up_now": "Precisa de uma conta? Inscreva-se agora."
"disable_register_mail": "Desculpe, os serviços de email estão desativados. Por favor contacte o administrador.",
"non_local_account": "Contas não-locais não podem mudar a palavra-passe através do Gogs.",
"sign_up_now": "Precisa de uma conta? Inscreva-se agora.",
"reset_password": "Restaurar a sua senha",
"invalid_code": "Desculpe, o seu código de confirmação expirou ou é inválido.",
"new_password": "Nova senha"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Limba",
"page_not_found": "Pagina nu a fost găsită",
"username": "Numele de utilizator",
"email": "E-mail",
"password": "Parolă",
"auth_source": "Sursa de autentificare",
"local": "Local",
"remember_me": "Ține-mă minte",
"forget_password": "Ați uitat parola?",
"sign_up_now": "Nevoie de un cont? Inscrie-te acum."
"disable_register_mail": "Ne pare rău, serviciile de e-mail sunt dezactivate. Vă rugăm să contactați administratorul site-ului.",
"non_local_account": "Conturile non-locale nu pot schimba parolele prin Gogs.",
"sign_up_now": "Nevoie de un cont? Inscrie-te acum.",
"reset_password": "Resetați-vă parola",
"invalid_code": "Ne pare rău, codul dvs. de confirmare a expirat sau nu este valabil.",
"new_password": "Parolă nouă"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Язык",
"page_not_found": "Страница не найдена",
"username": "Имя пользователя",
"email": "Эл. почта",
"password": "Пароль",
"auth_source": "Тип аутентификации",
"local": "Локальный",
"remember_me": "Запомнить меня",
"forget_password": "Забыли пароль?",
"sign_up_now": "Нужен аккаунт? Зарегистрируйтесь."
"disable_register_mail": "К сожалению подтверждение регистрации по почте отключено.",
"non_local_account": "Нелокальные аккаунты не могут изменить пароль через Gogs.",
"sign_up_now": "Нужен аккаунт? Зарегистрируйтесь.",
"reset_password": "Сброс пароля",
"invalid_code": "Извините, ваш код подтверждения истек или не является допустимым.",
"new_password": "Новый пароль"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Jazyk",
"page_not_found": "Page Not Found",
"username": "Používateľské meno",
"email": "E-mail",
"password": "Heslo",
"auth_source": "Zdroj overovania",
"local": "Lokálny",
"remember_me": "Zapamätať prihlásenie",
"forget_password": "Zabudli ste heslo?",
"sign_up_now": "Potrebujete účet? Zaregistrujte sa teraz."
"disable_register_mail": "Ospravedlňujeme sa, potvrdenie registračného e-mailu bolo vypnuté.",
"non_local_account": "Miestne účty nemôžu meniť heslá cez Gogs.",
"sign_up_now": "Potrebujete účet? Zaregistrujte sa teraz.",
"reset_password": "Obnovenie hesla",
"invalid_code": "Ospravedlňujeme sa, váš potvrdzovací kód vypršal alebo nie je platný.",
"new_password": "Nové heslo"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Језик",
"page_not_found": "Page Not Found",
"username": "Корисничко име",
"email": "E-пошта",
"password": "Лозинка",
"auth_source": "Извор аутентикације",
"local": "Локално",
"remember_me": "Запамти ме",
"forget_password": "Заборавили сте лозинку?",
"sign_up_now": "Немате налог? Пријавите се."
"disable_register_mail": "Извините, потврда путем поште је онемогућено.",
"non_local_account": "Нелокални налози не могу да промените лозинку преко Gogs.",
"sign_up_now": "Немате налог? Пријавите се.",
"reset_password": "Ресет лозинке",
"invalid_code": "Извините, ваш код за потврду је истекао или није валидан.",
"new_password": "Нова лозинка"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Språk",
"page_not_found": "Sidan hittades inte",
"username": "Användarnamn",
"email": "E-post",
"password": "Lösenord",
"auth_source": "Autentiseringskälla",
"local": "Lokal",
"remember_me": "Kom ihåg mig",
"forget_password": "Glömt lösenordet?",
"sign_up_now": "Behöver du ett konto? Registrera dig nu."
"disable_register_mail": "Tyvärr så är registreringsbekräftelemailutskick inaktiverat.",
"non_local_account": "Icke-lokala konton får inte ändra lösenord genom Gogs.",
"sign_up_now": "Behöver du ett konto? Registrera dig nu.",
"reset_password": "Återställ ditt lösenord",
"invalid_code": "Tyvärr, din bekräftelsekod har antingen upphört att gälla eller är ogiltig.",
"new_password": "Nytt lösenord"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Dil",
"page_not_found": "Sayfa Bulunamadı",
"username": "Kullanıcı Adı",
"email": "E-Posta",
"password": "Parola",
"auth_source": "Yetkilendirme Kaynağı",
"local": "Yerel",
"remember_me": "Beni Hatırla",
"forget_password": "Parolanızı mı unuttunuz?",
"sign_up_now": "Bir hesaba mı ihtiyacınız var? Şimdi kaydolun."
"disable_register_mail": "Üzgünüz, kayıt doğrulama e-postası devre dışı bırakıldı.",
"non_local_account": "Yerel olmayan hesapların şifrelerini Gogs aracılığıyla değiştiremezsiniz.",
"sign_up_now": "Bir hesaba mı ihtiyacınız var? Şimdi kaydolun.",
"reset_password": "Parolanızı Sıfırlayın",
"invalid_code": "Üzgünüz, doğrulama kodunuz geçersiz veya süresi dolmuş.",
"new_password": "Yeni Parola"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Мова",
"page_not_found": "Сторінку не знайдено",
"username": "Ім'я користувача",
"email": "Електронна пошта",
"password": "Пароль",
"auth_source": "Джерело автентифікації",
"local": "Локальний",
"remember_me": "Запам'ятати мене",
"forget_password": "Забули пароль?",
"sign_up_now": "Потрібен обліковий запис? Зареєструватися зараз."
"disable_register_mail": "На жаль, підтвердження реєстрації на електрону пошту вимкнено адміністратором.",
"non_local_account": "Нелокальні облікові записи не можуть змінити пароль через Gogs.",
"sign_up_now": "Потрібен обліковий запис? Зареєструватися зараз.",
"reset_password": "Скинути пароль",
"invalid_code": "На жаль, код підтвердження, закінчився або помилковий.",
"new_password": "Новий пароль"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "Ngôn ngữ",
"page_not_found": "Không tìm thấy trang này!",
"username": "Username",
"email": "Email",
"password": "Mật khẩu",
"auth_source": "Authentication Source",
"local": "Local",
"remember_me": "Ghi nhớ tôi",
"forget_password": "Quên mật khẩu?",
"sign_up_now": "Cần một tài khoản? Đăng ký bây giờ."
"disable_register_mail": "Xin lỗi, đăng ký đã bị vô hiệu. Xin vui lòng liên hệ với người quản trị trang web.",
"non_local_account": "Tài khoản Non-local không thể thay đổi mật khẩu thông qua Gogs.",
"sign_up_now": "Cần một tài khoản? Đăng ký bây giờ.",
"reset_password": "Đặt lại mật khẩu của bạn",
"invalid_code": "Xin lỗi, mã số xác nhận của bạn đã hết hạn hoặc không hợp lệ.",
"new_password": "Mật khẩu mới"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "语言选项",
"page_not_found": "页面未找到",
"username": "用户名",
"email": "邮箱",
"password": "密码",
"auth_source": "认证源",
"local": "本地",
"remember_me": "记住登录",
"forget_password": "忘记密码?",
"sign_up_now": "还没帐户?马上注册。"
"disable_register_mail": "对不起,注册邮箱确认功能已被关闭。",
"non_local_account": "非本地类型的帐户无法通过 Gogs 修改密码。",
"sign_up_now": "还没帐户?马上注册。",
"reset_password": "重置密码",
"invalid_code": "对不起,您的确认代码已过期或已失效。",
"new_password": "新的密码"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "語言",
"page_not_found": "Page Not Found",
"username": "用戶名稱",
"email": "電子郵件",
"password": "密碼",
"auth_source": "Authentication Source",
"local": "Local",
"remember_me": "記住登錄",
"forget_password": "忘記密碼?",
"sign_up_now": "還沒帳戶?馬上註冊。"
"disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
"non_local_account": "Non-local accounts cannot change passwords through Gogs.",
"sign_up_now": "還沒帳戶?馬上註冊。",
"reset_password": "重置密碼",
"invalid_code": "對不起,您的確認代碼已過期或已失效。",
"new_password": "新的密碼"
}
+7 -1
View File
@@ -22,10 +22,16 @@
"language": "語言",
"page_not_found": "找不到頁面",
"username": "用戶名稱",
"email": "電子郵件",
"password": "密碼",
"auth_source": "認證來源",
"local": "本地",
"remember_me": "記住登錄",
"forget_password": "忘記密碼?",
"sign_up_now": "還沒帳戶?馬上註冊。"
"disable_register_mail": "對不起,註冊郵箱確認功能已被關閉。",
"non_local_account": "非本地帳戶無法通過 Gogs 修改密碼。",
"sign_up_now": "還沒帳戶?馬上註冊。",
"reset_password": "重置密碼",
"invalid_code": "對不起,您的確認代碼已過期或已失效。",
"new_password": "新的密碼"
}
+11 -11
View File
@@ -17,26 +17,26 @@ export function Landing() {
<span className="size-2.5 rounded-full bg-(--color-foreground)/20" />
<span className="ml-2 text-xs text-(--color-muted-foreground) sm:ml-3">gogs zsh</span>
</div>
<pre className="px-4 py-4 text-xs leading-relaxed break-all whitespace-pre-wrap text-(--color-foreground) sm:px-5 sm:py-5 sm:text-sm">
<pre className="px-4 py-4 font-pixel text-sm leading-relaxed break-all whitespace-pre-wrap text-(--color-foreground) sm:px-5 sm:py-5 sm:text-base">
<span className="text-(--color-muted-foreground)">$ </span>
<span>cat /etc/motd</span>
{"\n"}
<img
src={subUrl("/img/banner-light.svg")}
src={subUrl("/img/banner-light.png")}
alt="Gogs"
width="775"
height="294"
className="mx-auto block max-w-[280px] dark:hidden sm:max-w-sm"
width="200"
height="76"
className="mx-auto block h-auto w-[280px] [image-rendering:pixelated] dark:hidden sm:w-96"
/>
<img
src={subUrl("/img/banner-dark.svg")}
src={subUrl("/img/banner-dark.png")}
alt="Gogs"
width="775"
height="294"
className="mx-auto hidden max-w-[280px] dark:block sm:max-w-sm"
width="200"
height="76"
className="mx-auto hidden h-auto w-[280px] [image-rendering:pixelated] dark:block sm:w-96"
/>
{"\n"}
<span className="block text-center font-sans text-base text-(--color-muted-foreground) sm:text-lg">
<span className="block text-center text-base text-(--color-muted-foreground) sm:text-lg">
{t("app_desc")}
</span>
{"\n"}
@@ -75,7 +75,7 @@ function CmdLink({
spa?: boolean;
}) {
const className =
"group inline-flex items-baseline gap-2 rounded-sm hover:bg-(--color-surface) hover:text-(--color-foreground)";
"group inline-flex items-baseline gap-2 rounded-sm hover:text-(--color-foreground) hover:[animation:flame-flicker_2.4s_ease-in-out_infinite]";
const inner = (
<>
<span className="inline-block w-16 text-(--color-foreground) sm:w-20">{cmd}</span>
+1 -1
View File
@@ -16,7 +16,7 @@ export function NotFound() {
<span className="size-2.5 rounded-full bg-(--color-foreground)/20" />
<span className="ml-2 text-xs text-(--color-muted-foreground) sm:ml-3">gogs zsh</span>
</div>
<pre className="px-4 py-4 text-xs leading-relaxed break-all whitespace-pre-wrap text-(--color-foreground) sm:px-5 sm:py-5 sm:text-sm">
<pre className="px-4 py-4 font-pixel text-sm leading-relaxed break-all whitespace-pre-wrap text-(--color-foreground) sm:px-5 sm:py-5 sm:text-base">
<span className="text-(--color-muted-foreground)">$ </span>
<span>gogs show {path}</span>
{"\n"}
+324
View File
@@ -0,0 +1,324 @@
import { getRouteApi, useNavigate } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { usePageTitle } from "@/lib/page-title";
import { subUrl } from "@/lib/url";
export interface ResetPasswordPage {
code: string;
emailEnabled: boolean;
valid: boolean;
}
interface ResetPasswordResponse {
hours?: number;
resendLimited?: boolean;
}
interface ResetPasswordErrorResponse {
error?: string;
fields?: Record<string, string | null>;
}
const route = getRouteApi("/user/reset-password");
export function ResetPassword() {
const { t } = useTranslation();
const navigate = useNavigate();
const { code, emailEnabled, valid } = route.useLoaderData();
const isResetForm = code !== "";
usePageTitle(t("reset_password"));
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [sent, setSent] = useState<ResetPasswordResponse | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string | null>>({});
const emailRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
const confirmPasswordRef = useRef<HTMLInputElement>(null);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (isResetForm && !valid) return;
if (!isResetForm && !emailEnabled) return;
if (isResetForm && password !== confirmPassword) {
setFormError(null);
setFieldErrors({ password: null, confirmPassword: t("reset_password_mismatch") });
requestAnimationFrame(() => confirmPasswordRef.current?.focus());
return;
}
setFormError(null);
setFieldErrors({});
setSubmitting(true);
void (async () => {
try {
const res = await fetch(
subUrl(isResetForm ? "/api/web/user/reset-password/complete" : "/api/web/user/reset-password"),
{
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(isResetForm ? { code, password } : { email }),
},
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as ResetPasswordErrorResponse;
if (body.error) setFormError(body.error);
if (body.fields) setFieldErrors(body.fields);
if (!body.error && !body.fields) {
setFormError(t(isResetForm ? "reset_password_failed" : "reset_password_email_failed"));
}
setSubmitting(false);
requestAnimationFrame(() => {
if (isResetForm) passwordRef.current?.focus();
else emailRef.current?.focus();
});
return;
}
if (isResetForm) {
await navigate({ to: "/user/sign-in" });
return;
}
setSent((await res.json()) as ResetPasswordResponse);
setSubmitting(false);
} catch {
setFormError(t(isResetForm ? "reset_password_failed" : "reset_password_email_failed"));
setSubmitting(false);
}
})();
}
const title = t("reset_password");
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>{title}</CardTitle>
</CardHeader>
<CardContent className="pt-2">{isResetForm ? renderResetContent() : renderRequestContent()}</CardContent>
</Card>
</main>
);
function renderRequestContent() {
if (!emailEnabled) {
return (
<p role="alert" className="text-center text-sm text-(--color-destructive)">
{t("disable_register_mail")}
</p>
);
}
if (sent) {
return (
<div className="flex flex-col gap-4 text-center">
<p role="status" className="text-sm text-(--color-foreground)">
{sent.resendLimited ? (
t("reset_password_resend_limited")
) : (
<Trans
i18nKey="reset_password_email_sent"
values={{ email, hours: sent.hours }}
components={{ email: <b />, hours: <b /> }}
/>
)}
</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 (
<form onSubmit={onSubmit} noValidate>
<fieldset disabled={submitting} className="contents">
{renderFormError()}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="email">{t("email")}</Label>
<Input
ref={emailRef}
id="email"
name="email"
type="email"
autoComplete="email"
required
autoFocus
tabIndex={1}
value={email}
onChange={(e) => setEmail(e.target.value)}
aria-invalid={"email" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
{fieldErrors.email && (
<p id="email-error" className="text-sm text-(--color-destructive)">
{fieldErrors.email}
</p>
)}
</div>
<FormActions
submitLabel={submitting ? t("reset_password_email_submitting") : t("send_reset_email")}
submitTabIndex={3}
/>
</div>
</fieldset>
</form>
);
}
function renderResetContent() {
if (!valid) {
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 (
<form onSubmit={onSubmit} noValidate>
<fieldset disabled={submitting} className="contents">
{renderFormError()}
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="password">{t("new_password")}</Label>
<div className="relative">
<Input
ref={passwordRef}
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="new-password"
required
autoFocus
tabIndex={1}
placeholder={t("new_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={2}
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>
<div className="flex flex-col gap-1.5">
<Label htmlFor="confirmPassword">{t("confirm_new_password")}</Label>
<div className="relative">
<Input
ref={confirmPasswordRef}
id="confirmPassword"
name="confirmPassword"
type={showConfirmPassword ? "text" : "password"}
autoComplete="new-password"
required
tabIndex={3}
placeholder={t("confirm_new_password_placeholder")}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
aria-invalid={"confirmPassword" in fieldErrors ? true : undefined}
aria-describedby={fieldErrors.confirmPassword ? "confirmPassword-error" : undefined}
className="pr-10"
/>
<button
type="button"
tabIndex={4}
disabled={submitting}
onClick={() => setShowConfirmPassword((v) => !v)}
aria-label={showConfirmPassword ? t("hide_password") : t("show_password")}
aria-pressed={showConfirmPassword}
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"
>
{showConfirmPassword ? (
<EyeOff className="size-4" aria-hidden />
) : (
<Eye className="size-4" aria-hidden />
)}
</button>
</div>
{fieldErrors.confirmPassword && (
<p id="confirmPassword-error" className="text-sm text-(--color-destructive)">
{fieldErrors.confirmPassword}
</p>
)}
</div>
<FormActions
submitLabel={submitting ? t("reset_password_submitting") : t("reset_password_submit")}
submitTabIndex={5}
/>
</div>
</fieldset>
</form>
);
}
function renderFormError() {
if (!formError) return null;
return (
<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>
);
}
function FormActions({ submitLabel, submitTabIndex }: { submitLabel: string; submitTabIndex: number }) {
return (
<div className="mt-2 flex flex-col gap-3">
<Button type="submit" disabled={submitting} tabIndex={submitTabIndex} className="w-full">
{submitLabel}
</Button>
<Button variant="link" size="inline" asChild className="self-center">
<a
href={subUrl("/user/sign-in")}
tabIndex={submitting ? -1 : submitTabIndex + 1}
aria-disabled={submitting || undefined}
className={submitting ? "pointer-events-none opacity-50" : undefined}
onClick={(e) => {
if (submitting) e.preventDefault();
}}
>
{t("back_to_sign_in")}
</a>
</Button>
</div>
);
}
}
+1 -1
View File
@@ -155,7 +155,7 @@ export function SignIn() {
<Label htmlFor="password">{t("password")}</Label>
<Button variant="link" size="inline" asChild>
<a
href={subUrl("/user/forget_password")}
href={subUrl("/user/reset-password")}
tabIndex={submitting ? -1 : 6}
aria-disabled={submitting || undefined}
className={submitting ? "pointer-events-none opacity-50" : undefined}
+33 -14
View File
@@ -15,6 +15,7 @@ import type { UserInfo } from "@/lib/user-info";
import { Landing } from "@/pages/Landing";
import { MFA } from "@/pages/MFA";
import { NotFound } from "@/pages/NotFound";
import { ResetPassword, type ResetPasswordPage } from "@/pages/ResetPassword";
import { SignIn, type SignInPage } from "@/pages/SignIn";
interface RouterContext {
@@ -42,22 +43,21 @@ const landingRoute = createRoute({
component: Landing,
});
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 });
}
const signInRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/sign-in",
beforeLoad: ({ context }) => {
if (context.user) {
// Full navigation to "/" so the server-rendered dashboard handler runs.
// A client-side TanStack redirect would render the SPA's "/" route
// (Landing, the anon page) and make an authed user look signed out.
window.location.assign(subUrl("/"));
// Throw to halt loader execution. TanStack treats the thrown redirect
// as a sentinel; we never reach a SPA navigation because the line
// above already started a document-level one.
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
throw redirect({ to: "/", replace: true });
}
},
beforeLoad: requireUnauthenticated,
loader: async (): Promise<SignInPage> => {
const res = await fetch(subUrl("/api/web/user/sign-in"), { credentials: "same-origin" });
if (!res.ok) {
@@ -68,6 +68,25 @@ const signInRoute = createRoute({
component: SignIn,
});
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",
@@ -85,7 +104,7 @@ const mfaRoute = createRoute({
component: MFA,
});
const routeTree = rootRoute.addChildren([landingRoute, signInRoute, mfaRoute]);
const routeTree = rootRoute.addChildren([landingRoute, signInRoute, resetPasswordRoute, mfaRoute]);
function makeRouter(context: RouterContext) {
return createRouter({