feat: React-based sign-in page with /api/web/user/sign-in (#8285)

This commit is contained in:
ᴊᴏᴇ ᴄʜᴇɴ
2026-05-22 00:28:27 -04:00
committed by GitHub
parent c93373baec
commit dd6be39208
72 changed files with 1653 additions and 510 deletions
+1
View File
@@ -23,6 +23,7 @@ This applies to all texts, including but not limited to UI, documentation, code
- 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.
- Meet WCAG 2.2 AA at minimum. Specifically: every interactive control has a discernible accessible name (visible label or `aria-label`); color is never the sole carrier of information (pair with text, icon, or shape); text and meaningful icons meet 4.5:1 contrast against their background (3:1 for large text and UI components); focus is always visible and never trapped; touch targets are at least 24×24 CSS px (40×40 preferred). When unsure, lean toward more contrast, larger targets, and explicit labels.
- For work under `web/`, follow the patterns in [`web/DESIGN.md`](web/DESIGN.md) (typography, color hierarchy, surface chrome, file naming, accessibility specifics). Update that doc when a pattern is used in two places.
- When a page needs server data to render, fetch it in the TanStack Router route's `loader` so the page only mounts after the response arrives. Do not fire that fetch from a `useEffect` inside the page component, which causes a flash of empty UI before the data lands.
## Build instructions
-96
View File
@@ -1,96 +0,0 @@
package web
import (
stdctx "context"
"encoding/json"
"net/http"
"github.com/flamego/flamego"
"github.com/go-macaron/session"
"gopkg.in/macaron.v1"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
)
type (
webAPIUserKey struct{}
webAPISessionKey struct{}
webAPIMacaronKey struct{}
)
func bridgeToWebAPI(webHandler http.Handler) func(c *context.Context) {
return func(c *context.Context) {
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)
webHandler.ServeHTTP(c.Resp, c.Req.WithContext(ctx))
}
}
func webAPIInjector(c flamego.Context) {
ctx := c.Request().Context()
user, _ := ctx.Value(webAPIUserKey{}).(*database.User)
sess, _ := ctx.Value(webAPISessionKey{}).(session.Store)
mc, _ := ctx.Value(webAPIMacaronKey{}).(*macaron.Context)
c.Map(user, sess, mc)
}
func mountWebAPIRoutes(f *flamego.Flame) {
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
w := c.ResponseWriter()
w.Header().Set("Cache-Control", "no-store")
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
return
}
if resp == nil {
w.WriteHeader(statusCode)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(resp)
})
f.Group("/api/web", func() {
f.Group("/user", func() {
f.Get("/info", userInfoHandler)
f.Post("/sign-out", userSignOutHandler)
})
}, webAPIInjector)
}
type userInfo struct {
Username string `json:"username"`
AvatarURL string `json:"avatarURL"`
IsAdmin bool `json:"isAdmin"`
CanCreateOrganization bool `json:"canCreateOrganization"`
}
func userInfoHandler(user *database.User) (statusCode int, resp *userInfo, err error) {
if user == nil {
return http.StatusNoContent, nil, nil
}
return http.StatusOK,
&userInfo{
Username: user.Name,
AvatarURL: user.AvatarURL(),
IsAdmin: user.IsAdmin,
CanCreateOrganization: user.CanCreateOrganization(),
},
nil
}
func userSignOutHandler(sess session.Store, mc *macaron.Context) (statusCode int, resp any, err error) {
_ = sess.Flush()
_ = sess.Destory(mc)
mc.SetCookie(conf.Security.CookieUsername, "", -1, conf.Server.Subpath)
mc.SetCookie(conf.Security.CookieRememberName, "", -1, conf.Server.Subpath)
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
return http.StatusNoContent, nil, nil
}
+1 -30
View File
@@ -1,7 +1,6 @@
package web
import (
stdctx "context"
"crypto/tls"
"encoding/json"
"fmt"
@@ -89,8 +88,6 @@ func Run(configPath string, portOverride int) error {
// ***** START: User *****
m.Group("/user", func() {
m.Group("/login", func() {
m.Combo("").Get(user.Login).
Post(bindIgnErr(form.SignIn{}), user.LoginPost)
m.Combo("/two_factor").Get(user.LoginTwoFactor).Post(user.LoginTwoFactorPost)
m.Combo("/two_factor_recovery_code").Get(user.LoginTwoFactorRecoveryCode).Post(user.LoginTwoFactorRecoveryCodePost)
})
@@ -533,6 +530,7 @@ func Run(configPath string, portOverride int) error {
}, ignSignIn)
m.Any("/api/web/*", bridgeToWebAPI(webHandler))
m.Any("/*", func(c *context.Context) { c.ServeWeb() })
},
session.Sessioner(session.Options{
Provider: conf.Session.Provider,
@@ -605,33 +603,6 @@ func Run(configPath string, portOverride int) error {
}
})
// True 404s never reach context.Contexter, so populate WebContext
// explicitly. Without this, subpath deployments would emit a shell with
// root-relative asset URLs that the browser cannot resolve. Read the
// language preference straight from the cookie that the i18n middleware
// previously wrote, but only accept values that match a configured
// locale. The cookie value lands in the HTML via raw string substitution
// in renderIndex, so an unvalidated value would let an attacker who can
// set this cookie inject markup into the 404 shell.
langAllowed := make(map[string]struct{}, len(conf.I18n.Langs))
for _, lang := range conf.I18n.Langs {
langAllowed[lang] = struct{}{}
}
m.NotFound(func(w http.ResponseWriter, r *http.Request) {
lang := "en-US"
if c, err := r.Cookie("lang"); err == nil {
if _, ok := langAllowed[c.Value]; ok {
lang = c.Value
}
}
ctx := stdctx.WithValue(r.Context(), context.WebContextKey{}, context.WebContext{
Lang: lang,
SubURL: conf.Server.Subpath,
StatusCode: http.StatusNotFound,
})
webHandler.ServeHTTP(w, r.WithContext(ctx))
})
// Flag for port number in case first time run conflict.
if portOverride > 0 {
port := strconv.Itoa(portOverride)
+269
View File
@@ -0,0 +1,269 @@
package web
import (
stdctx "context"
"encoding/json"
"net/http"
"strings"
"github.com/cockroachdb/errors"
"github.com/flamego/binding"
"github.com/flamego/flamego"
"github.com/flamego/validator"
"github.com/go-macaron/i18n"
"github.com/go-macaron/session"
"gopkg.in/macaron.v1"
log "unknwon.dev/clog/v2"
"gogs.io/gogs/internal/auth"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
"gogs.io/gogs/internal/urlx"
)
type (
webAPIUserKey struct{}
webAPISessionKey struct{}
webAPIMacaronKey struct{}
webAPILocaleKey struct{}
)
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)
webHandler.ServeHTTP(c.Resp, c.Req.WithContext(ctx))
}
}
func webAPIInjector(c flamego.Context) {
ctx := c.Request().Context()
user, _ := ctx.Value(webAPIUserKey{}).(*database.User)
sess, _ := ctx.Value(webAPISessionKey{}).(session.Store)
mc, _ := ctx.Value(webAPIMacaronKey{}).(*macaron.Context)
l, _ := ctx.Value(webAPILocaleKey{}).(i18n.Locale)
c.Map(user, sess, mc, l)
}
func webAPIBodyLimiter(c flamego.Context) {
r := c.Request().Request
r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB
}
func mountWebAPIRoutes(f *flamego.Flame) {
f.ReturnHandler(func(c flamego.Context, statusCode int, resp any, err error) {
w := c.ResponseWriter()
w.Header().Set("Cache-Control", "no-store")
if err != nil {
msg := err.Error()
if statusCode >= http.StatusInternalServerError && conf.IsProdMode() {
msg = "Internal server error"
}
resp = map[string]any{"error": msg}
}
if resp == nil {
w.WriteHeader(statusCode)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(resp)
})
f.Group("/api/web", func() {
f.Group("/user", func() {
f.Get("/info", getUserInfo)
f.Combo("/sign-in").
Get(getUserSignIn).
Post(binding.JSON(userSignInRequest{}), postUserSignIn)
f.Post("/sign-out", postUserSignOut)
})
}, webAPIBodyLimiter, webAPIInjector)
}
// 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.
type bindingErrorResponse struct {
Error string `json:"error,omitempty"`
Fields map[string]*string `json:"fields,omitempty"`
}
// ruleSuffixKeys maps a validator tag to the shared "form.*_error" suffix key
// (e.g. "max" -> "form.max_size_error"). Messages are composed as
// <field label> + <suffix>, mirroring the legacy Macaron binding behavior.
var ruleSuffixKeys = map[string]string{
"required": "form.require_error",
"max": "form.max_size_error",
"min": "form.min_size_error",
"len": "form.size_error",
"email": "form.email_error",
"url": "form.url_error",
}
// renderBindingErrors maps binding.Errors to the response shape, looking up
// localized messages via the request's locale. The per-field label comes from
// "form.<StructField>" (e.g. "form.UserName"); the rule suffix comes from
// ruleSuffixKeys. Rule parameters (e.g. "254" for `max=254`) are passed
// through to the suffix translation for %s expansion. Always HTTP 400.
func renderBindingErrors(l i18n.Locale, errs binding.Errors) *bindingErrorResponse {
for _, e := range errs {
if e.Category == binding.ErrorCategoryDeserialization {
return &bindingErrorResponse{Error: l.Tr("form.invalid_request") + ": " + e.Err.Error()}
}
}
out := make(map[string]*string)
for _, e := range errs {
var ves validator.ValidationErrors
ok := errors.As(e.Err, &ves)
if !ok {
continue
}
for _, ve := range ves {
field := strings.ToLower(ve.StructField())
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.
continue
}
label := l.Tr("form." + ve.StructField())
suffixKey, known := ruleSuffixKeys[ve.Tag()]
var msg string
switch {
case !known:
msg = l.Tr("form.unknown_error") + " " + ve.Tag()
case ve.Param() != "":
msg = label + l.Tr(suffixKey, ve.Param())
default:
msg = label + l.Tr(suffixKey)
}
out[field] = &msg
}
}
return &bindingErrorResponse{Fields: out}
}
type loginSource struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsDefault bool `json:"isDefault"`
}
type userSignInPageResponse struct {
LoginSources []loginSource `json:"loginSources"`
}
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)
return http.StatusInternalServerError, nil, errors.Wrap(err, "list activated login sources")
}
loginSources := make([]loginSource, 0, len(sources))
for _, s := range sources {
loginSources = append(loginSources, loginSource{ID: s.ID, Name: s.Name, IsDefault: s.IsDefault})
}
return http.StatusOK, &userSignInPageResponse{LoginSources: loginSources}, nil
}
type userSignInRequest struct {
Username string `json:"username" validate:"required,max=254"`
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"`
}
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
}
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},
}, 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)
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
}
if req.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)
}
_ = sess.Set("uid", u.ID)
_ = sess.Set("uname", u.Name)
_ = sess.Delete("twoFactorRemember")
_ = sess.Delete("twoFactorUserID")
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 + "/"
}
return http.StatusOK, &userSignInResponse{RedirectTo: redirectTo}, nil
}
type userInfo struct {
Username string `json:"username"`
AvatarURL string `json:"avatarURL"`
IsAdmin bool `json:"isAdmin"`
CanCreateOrganization bool `json:"canCreateOrganization"`
}
func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error) {
if user == nil {
return http.StatusNoContent, nil, nil
}
return http.StatusOK,
&userInfo{
Username: user.Name,
AvatarURL: user.AvatarURL(),
IsAdmin: user.IsAdmin,
CanCreateOrganization: user.CanCreateOrganization(),
},
nil
}
func postUserSignOut(sess session.Store, mc *macaron.Context) (statusCode int, resp any, err error) {
_ = sess.Flush()
_ = sess.Destory(mc)
mc.SetCookie(conf.Security.CookieUsername, "", -1, conf.Server.Subpath)
mc.SetCookie(conf.Security.CookieRememberName, "", -1, conf.Server.Subpath)
mc.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
return http.StatusNoContent, nil, nil
}
+12 -4
View File
@@ -7,7 +7,7 @@ help = Help
sign_in = Sign in
sign_out = Sign out
sign_up = Sign Up
register = Register
register = Create account
website = Website
page = Page
template = Template
@@ -17,8 +17,10 @@ user_profile_and_more = User profile and more
signed_in_as = Signed in as
username = Username
username_placeholder = Enter your username or email
email = Email
password = Password
password_placeholder = Enter your password
re_type = Re-Type
captcha = Captcha
@@ -156,16 +158,20 @@ search = Search
[auth]
create_new_account = Create New Account
sign_in_submitting = Signing in...
sign_in_failed = Sign-in failed. Please try again.
show_password = Show password
hide_password = Hide password
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
local = Local
remember_me = Remember Me
remember_me = Remember me
forgot_password= Forgot Password
forget_password = Forgot password?
sign_up_now = Need an account? Sign up now.
sign_up_now = Create a new account
confirmation_mail_sent_prompt = A new confirmation email has been sent to <b>%s</b>, please check your inbox within the next %d hours to complete the registration process.
active_your_account = Activate Your Account
prohibit_login = Login Prohibited
@@ -201,7 +207,9 @@ no = No
modify = Modify
[form]
invalid_request = The request could not be processed
UserName = Username
Username = Username
RepoName = Repository name
Email = Email address
Password = Password
@@ -239,7 +247,7 @@ repo_name_been_taken = Repository name has already been taken.
org_name_been_taken = Organization name has already been taken.
team_name_been_taken = Team name has already been taken.
email_been_used = Email address has already been used.
username_password_incorrect = Username or password is not correct.
username_password_incorrect = Username or password is incorrect.
auth_source_mismatch = The authentication source selected is not associated with the user.
enterred_invalid_repo_name = Please make sure that the repository name you entered is correct.
enterred_invalid_owner_name = Please make sure that the owner name you entered is correct.
+4 -1
View File
@@ -9,7 +9,9 @@ require (
github.com/derision-test/go-mockgen/v2 v2.1.1
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/flamego v1.12.0
github.com/flamego/validator v1.0.0
github.com/glebarez/go-sqlite v1.21.2
github.com/glebarez/sqlite v1.11.0
github.com/go-ldap/ldap/v3 v3.4.12
@@ -19,7 +21,7 @@ require (
github.com/go-macaron/csrf v0.0.0-20190812063352-946f6d303a4c
github.com/go-macaron/gzip v0.0.0-20160222043647-cad1c6580a07
github.com/go-macaron/i18n v0.6.0
github.com/go-macaron/session v1.0.3
github.com/go-macaron/session v1.0.4
github.com/gogs/chardet v0.0.0-20150115103509-2404f7772561
github.com/gogs/cron v0.0.0-20171120032916-9f6c956d3e14
github.com/gogs/git-module v1.8.7
@@ -107,6 +109,7 @@ require (
github.com/klauspost/compress v1.18.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/lib/pq v1.10.2 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
+15 -2
View File
@@ -100,8 +100,12 @@ github.com/edsrzf/mmap-go v1.0.0/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaB
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
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/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=
github.com/flamego/validator v1.0.0/go.mod h1:POYn0/5iW4sdamdPAYPrzqN6DFC4YaczY0gYY+Pyx5E=
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
@@ -144,8 +148,10 @@ github.com/go-macaron/inject v0.0.0-20160627170012-d8a0b8677191/go.mod h1:VFI2o2
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b h1:/aWj44HoEycE4MDi2HZf4t+XI7hKwZRltZf4ih5tB2c=
github.com/go-macaron/inject v0.0.0-20200308113650-138e5925c53b/go.mod h1:VFI2o2q9kYsC4o7VP1HrEVosiZZTd+MVT3YZx4gqvJw=
github.com/go-macaron/session v0.0.0-20190805070824-1a3cdc6f5659/go.mod h1:tLd0QEudXocQckwcpCq5pCuTCuYc24I0bRJDuRe9OuQ=
github.com/go-macaron/session v1.0.3 h1:YnSfcm24a4HHRnZzBU30FGvoo4kR6vYbTeyTlA1dya4=
github.com/go-macaron/session v1.0.3/go.mod h1:NKoSrKpBFGEgeDtdLr/mnGaxa2LZVOg8/LwZKwPgQr0=
github.com/go-macaron/session v1.0.4 h1:fIvtOwdYBsqlb+icre1LvWB7YKnosfoSpaqT1nybh8E=
github.com/go-macaron/session v1.0.4/go.mod h1:NKoSrKpBFGEgeDtdLr/mnGaxa2LZVOg8/LwZKwPgQr0=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
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=
@@ -281,6 +287,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
@@ -468,6 +476,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -497,6 +506,7 @@ golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
@@ -532,6 +542,8 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
@@ -617,6 +629,7 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210105161348-2e78108cf5f8/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
+2 -2
View File
@@ -72,7 +72,7 @@ func Toggle(options *ToggleOptions) macaron.Handler {
}
c.SetCookie("redirect_to", url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
c.RedirectSubpath("/user/login")
c.RedirectSubpath("/user/sign-in")
return
} else if !c.User.IsActive && conf.Auth.RequireEmailConfirmation {
c.Title("auth.active_your_account")
@@ -85,7 +85,7 @@ func Toggle(options *ToggleOptions) macaron.Handler {
if !options.SignOutRequired && !c.IsLogged && !isAPIPath(c.Req.URL.Path) &&
len(c.GetCookie(conf.Security.CookieUsername)) > 0 {
c.SetCookie("redirect_to", url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
c.RedirectSubpath("/user/login")
c.RedirectSubpath("/user/sign-in")
return
}
-11
View File
@@ -72,17 +72,6 @@ func (f *Register) Validate(ctx *macaron.Context, errs binding.Errors) binding.E
return validate(errs, ctx.Data, f, ctx.Locale)
}
type SignIn struct {
UserName string `binding:"Required;MaxSize(254)"`
Password string `binding:"Required;MaxSize(255)"`
LoginSource int64
Remember bool
}
func (f *SignIn) Validate(ctx *macaron.Context, errs binding.Errors) binding.Errors {
return validate(errs, ctx.Data, f, ctx.Locale)
}
// __________________________________________.___ _______ ________ _________
// / _____/\_ _____/\__ ___/\__ ___/| |\ \ / _____/ / _____/
// \_____ \ | __)_ | | | | | |/ | \/ \ ___ \_____ \
+1 -1
View File
@@ -31,7 +31,7 @@ func Home(c *context.Context) {
// Check auto-login.
uname := c.GetCookie(conf.Security.CookieUsername)
if uname != "" {
c.Redirect(conf.Server.Subpath + "/user/login")
c.Redirect(conf.Server.Subpath + "/user/sign-in")
return
}
+1 -1
View File
@@ -415,5 +415,5 @@ func InstallPost(c *context.Context, f form.Install) {
log.Info("First-time run install finished!")
c.Flash.Success(c.Tr("install.install_success"))
c.Redirect(f.AppUrl + "user/login")
c.Redirect(f.AppUrl + "user/sign-in")
}
+2 -2
View File
@@ -110,7 +110,7 @@ func issues(c *context.Context, isPullList bool) {
// Must sign in to see issues about you.
if viewType != "all" && !c.IsLogged {
c.SetCookie("redirect_to", "/"+url.QueryEscape(conf.Server.Subpath+c.Req.RequestURI), 0, conf.Server.Subpath)
c.Redirect(conf.Server.Subpath + "/user/login")
c.Redirect(conf.Server.Subpath + "/user/sign-in")
return
}
@@ -656,7 +656,7 @@ func viewIssue(c *context.Context, isPullList bool) {
c.Data["NumParticipants"] = len(participants)
c.Data["Issue"] = issue
c.Data["IsIssueOwner"] = c.Repo.IsWriter() || (c.IsLogged && issue.IsPoster(c.User.ID))
c.Data["SignInLink"] = conf.Server.Subpath + "/user/login?redirect_to=" + c.Data["Link"].(string)
c.Data["SignInLink"] = conf.Server.Subpath + "/user/sign-in?redirect_to=" + c.Data["Link"].(string)
c.Success(tmplRepoIssueView)
}
+2 -139
View File
@@ -7,11 +7,9 @@ import (
"net/url"
"strconv"
"github.com/cockroachdb/errors"
"github.com/go-macaron/captcha"
log "unknwon.dev/clog/v2"
"gogs.io/gogs/internal/auth"
"gogs.io/gogs/internal/conf"
"gogs.io/gogs/internal/context"
"gogs.io/gogs/internal/database"
@@ -23,7 +21,6 @@ import (
)
const (
tmplUserAuthLogin = "user/auth/login"
tmplUserAuthTwoFactor = "user/auth/two_factor"
tmplUserAuthTwoFactorRecoveryCode = "user/auth/two_factor_recovery_code"
tmplUserAuthSignup = "user/auth/signup"
@@ -32,93 +29,6 @@ const (
tmplUserAuthResetPassword = "user/auth/reset_passwd"
)
// AutoLogin reads cookie and try to auto-login.
func AutoLogin(c *context.Context) (bool, error) {
if !database.HasEngine {
return false, nil
}
uname := c.GetCookie(conf.Security.CookieUsername)
if uname == "" {
return false, nil
}
isSucceed := false
defer func() {
if !isSucceed {
log.Trace("auto-login cookie cleared: %s", uname)
c.SetCookie(conf.Security.CookieUsername, "", -1, conf.Server.Subpath)
c.SetCookie(conf.Security.CookieRememberName, "", -1, conf.Server.Subpath)
c.SetCookie(conf.Security.LoginStatusCookieName, "", -1, conf.Server.Subpath)
}
}()
u, err := database.Handle.Users().GetByUsername(c.Req.Context(), uname)
if err != nil {
if !database.IsErrUserNotExist(err) {
return false, errors.Newf("get user by name: %v", err)
}
return false, nil
}
if val, ok := c.GetSuperSecureCookie(u.Rands+u.Password, conf.Security.CookieRememberName); !ok || val != u.Name {
return false, nil
}
isSucceed = true
_ = c.Session.Set("uid", u.ID)
_ = c.Session.Set("uname", u.Name)
c.SetCookie(conf.Session.CSRFCookieName, "", -1, conf.Server.Subpath)
if conf.Security.EnableLoginStatusCookie {
c.SetCookie(conf.Security.LoginStatusCookieName, "true", 0, conf.Server.Subpath)
}
return true, nil
}
func Login(c *context.Context) {
c.Title("sign_in")
// Check auto-login
isSucceed, err := AutoLogin(c)
if err != nil {
c.Error(err, "auto login")
return
}
redirectTo := c.Query("redirect_to")
if len(redirectTo) > 0 {
c.SetCookie("redirect_to", redirectTo, 0, conf.Server.Subpath)
} else {
redirectTo, _ = url.QueryUnescape(c.GetCookie("redirect_to"))
}
if isSucceed {
if urlx.IsSameSite(redirectTo) {
c.Redirect(redirectTo)
} else {
c.RedirectSubpath("/")
}
c.SetCookie("redirect_to", "", -1, conf.Server.Subpath)
return
}
// Display normal login page
loginSources, err := database.Handle.LoginSources().List(c.Req.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
if err != nil {
c.Error(err, "list activated login sources")
return
}
c.Data["LoginSources"] = loginSources
for i := range loginSources {
if loginSources[i].IsDefault {
c.Data["DefaultLoginSource"] = loginSources[i]
c.Data["login_source"] = loginSources[i].ID
break
}
}
c.Success(tmplUserAuthLogin)
}
func afterLogin(c *context.Context, u *database.User, remember bool) {
if remember {
days := 86400 * conf.Security.LoginRememberDays
@@ -147,53 +57,6 @@ func afterLogin(c *context.Context, u *database.User, remember bool) {
c.RedirectSubpath("/")
}
func LoginPost(c *context.Context, f form.SignIn) {
c.Title("sign_in")
loginSources, err := database.Handle.LoginSources().List(c.Req.Context(), database.ListLoginSourceOptions{OnlyActivated: true})
if err != nil {
c.Error(err, "list activated login sources")
return
}
c.Data["LoginSources"] = loginSources
if c.HasError() {
c.HTML(http.StatusBadRequest, tmplUserAuthLogin)
return
}
u, err := database.Handle.Users().Authenticate(c.Req.Context(), f.UserName, f.Password, f.LoginSource)
if err != nil {
switch {
case auth.IsErrBadCredentials(err):
c.FormErr("UserName", "Password")
c.RenderWithErr(c.Tr("form.username_password_incorrect"), http.StatusUnauthorized, tmplUserAuthLogin, &f)
case database.IsErrLoginSourceMismatch(err):
c.FormErr("LoginSource")
c.RenderWithErr(c.Tr("form.auth_source_mismatch"), http.StatusUnprocessableEntity, tmplUserAuthLogin, &f)
default:
c.Error(err, "authenticate user")
}
for i := range loginSources {
if loginSources[i].IsDefault {
c.Data["DefaultLoginSource"] = loginSources[i]
break
}
}
return
}
if !database.Handle.TwoFactors().IsEnabled(c.Req.Context(), u.ID) {
afterLogin(c, u, f.Remember)
return
}
_ = c.Session.Set("twoFactorRemember", f.Remember)
_ = c.Session.Set("twoFactorUserID", u.ID)
c.RedirectSubpath("/user/login/two_factor")
}
func LoginTwoFactor(c *context.Context) {
_, ok := c.Session.Get("twoFactorUserID").(int64)
if !ok {
@@ -399,7 +262,7 @@ func SignUpPost(c *context.Context, cpt *captcha.Captcha, f form.Register) {
return
}
c.RedirectSubpath("/user/login")
c.RedirectSubpath("/user/sign-in")
}
// parseUserFromCode returns user by username encoded in code.
@@ -635,7 +498,7 @@ func ResetPasswdPost(c *context.Context) {
}
log.Trace("User password reset: %s", u.Name)
c.RedirectSubpath("/user/login")
c.RedirectSubpath("/user/sign-in")
return
}
+291
View File
@@ -16,12 +16,24 @@ importers:
'@fontsource-variable/geist-mono':
specifier: ^5.2.8
version: 5.2.8
'@radix-ui/react-checkbox':
specifier: ^1.3.3
version: 1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-toggle-group':
specifier: ^1.1.11
version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@tanstack/react-router':
specifier: ^1.137.0
version: 1.170.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -289,6 +301,9 @@ packages:
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
@@ -305,6 +320,32 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.3.3':
resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-collection@1.1.7':
resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
@@ -323,6 +364,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-direction@1.1.1':
resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
@@ -367,6 +417,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-label@2.1.8':
resolution: {integrity: sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-popover@1.1.15':
resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==}
peerDependencies:
@@ -432,6 +495,45 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.4':
resolution: {integrity: sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-roving-focus@1.1.11':
resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
@@ -450,6 +552,32 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-toggle-group@1.1.11':
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-toggle@1.1.10':
resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -495,6 +623,15 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-use-previous@1.1.1':
resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
@@ -513,6 +650,19 @@ packages:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
@@ -2241,6 +2391,8 @@ snapshots:
'@oxc-project/types@0.130.0': {}
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
@@ -2252,6 +2404,34 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-checkbox@1.3.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-size': 1.1.1(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-collection@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.2.6)':
dependencies:
react: 19.2.6
@@ -2264,6 +2444,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-direction@1.1.1(@types/react@19.2.14)(react@19.2.6)':
dependencies:
react: 19.2.6
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-dismissable-layer@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2301,6 +2487,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-label@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -2371,6 +2566,61 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-primitive@2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/number': 1.1.1
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-focus-guards': 1.1.3(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-focus-scope': 1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
aria-hidden: 1.2.6
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.2.6)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.6)
@@ -2385,6 +2635,32 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-toggle-group@1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-direction': 1.1.1(@types/react@19.2.14)(react@19.2.6)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.2.14)(react@19.2.6)':
dependencies:
react: 19.2.6
@@ -2419,6 +2695,12 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-previous@1.1.1(@types/react@19.2.14)(react@19.2.6)':
dependencies:
react: 19.2.6
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-use-rect@1.1.1(@types/react@19.2.14)(react@19.2.6)':
dependencies:
'@radix-ui/rect': 1.1.1
@@ -2433,6 +2715,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
'@radix-ui/react-visually-hidden@1.2.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/react@19.2.14)
'@radix-ui/rect@1.1.1': {}
'@rolldown/binding-android-arm64@1.0.1':
+1 -1
View File
@@ -180,7 +180,7 @@
<i class="octicon octicon-person"></i> {{.i18n.Tr "register"}}
</a>
{{end}}
<a class="item{{if .PageIsSignIn}} active{{end}}" href="{{AppSubURL}}/user/login?redirect_to={{.Link}}">
<a class="item{{if .PageIsSignIn}} active{{end}}" href="{{AppSubURL}}/user/sign-in?redirect_to={{.Link}}">
<i class="octicon octicon-sign-in"></i> {{.i18n.Tr "sign_in"}}
</a>
</div><!-- end anonymous right menu -->
+1 -1
View File
@@ -8,7 +8,7 @@
<body>
<p>Hi <b>{{.Username}}</b>, this is your registration confirmation email for {{AppName}}!</p>
<p>You can now login via username: {{.Username}}.</p>
<p><a href="{{AppURL}}user/login">{{AppURL}}user/login</a></p>
<p><a href="{{AppURL}}user/sign-in">{{AppURL}}user/sign-in</a></p>
<p>© {{Year}} <a target="_blank" rel="noopener noreferrer" href="{{AppURL}}">{{AppName}}</a></p>
</body>
</html>
-66
View File
@@ -1,66 +0,0 @@
{{template "base/head" .}}
<div class="user signin">
<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 header">
{{.i18n.Tr "sign_in"}}
</h3>
<div class="ui attached segment">
{{template "base/alert" .}}
<div class="required inline field {{if .Err_UserName}}error{{end}}">
<label for="user_name">{{.i18n.Tr "home.uname_holder"}}</label>
<input id="user_name" name="user_name" value="{{.user_name}}" autofocus required>
</div>
<div class="required inline field {{if .Err_Password}}error{{end}}">
<label for="password">{{.i18n.Tr "password"}}</label>
<input id="password" name="password" type="password" autocomplete="off" value="{{.password}}" required>
</div>
{{if .LoginSources}}
<div class="required inline field {{if .Err_LoginSource}}error{{end}}">
<label>{{.i18n.Tr "auth.auth_source"}}</label>
<div class="ui selection dropdown">
<input type="hidden" id="login_source" name="login_source" value="{{.login_source}}" required>
<span class="text">
{{if .DefaultLoginSource}}
{{.DefaultLoginSource.Name}}
{{else}}
{{.i18n.Tr "auth.local"}}
{{end}}
</span>
<i class="dropdown icon"></i>
<div class="menu">
<div class="item" data-value="0">{{.i18n.Tr "auth.local"}}</div>
{{range .LoginSources}}
<div class="item" data-value="{{.ID}}">{{.Name}}</div>
{{end}}
</div>
</div>
</div>
{{end}}
<div class="inline field">
<label></label>
<div class="ui checkbox">
<label>{{.i18n.Tr "auth.remember_me"}}</label>
<input name="remember" type="checkbox">
</div>
</div>
<div class="inline field">
<label></label>
<button class="ui green button">{{.i18n.Tr "sign_in"}}</button>
<a href="{{AppSubURL}}/user/forget_password">{{.i18n.Tr "auth.forget_password"}}</a>
</div>
{{if .ShowRegistrationButton}}
<div class="inline field">
<label></label>
<a href="{{AppSubURL}}/user/sign_up">{{.i18n.Tr "auth.sign_up_now" | Str2HTML}}</a>
</div>
{{end}}
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+1 -1
View File
@@ -45,7 +45,7 @@
</div>
<div class="inline field">
<label></label>
<a href="{{AppSubURL}}/user/login">{{.i18n.Tr "auth.register_hepler_msg"}}</a>
<a href="{{AppSubURL}}/user/sign-in">{{.i18n.Tr "auth.register_hepler_msg"}}</a>
</div>
{{end}}
</div>
+4 -2
View File
@@ -39,8 +39,10 @@ Use these tokens. Don't introduce raw hex values in components.
**Structure**
- `--color-border`: borders on dividers, popovers, the terminal frame.
- `--color-input`: input field borders. Not currently used; reserved for forms.
- `--color-border`: soft container and divider lines. Used for the navbar bottom border, popover edges, card outlines, mobile-menu separators. Deliberately low-contrast (close to `--color-secondary`) so chrome reads as quiet boundary, not as a hard rule.
- `--color-input`: input field borders. Similar weight to `--color-border` but kept as a separate token so form fields can drift independently if needed.
**The terminal frame is the exception.** `NotFound.tsx` wraps its faux-CLI output in a heavy outline so it actually looks like a terminal window — that frame uses `border-(--color-foreground)/80` (light) and the regular `--color-border` token (dark) directly, instead of the shared chrome token. Don't reuse this heavy outline elsewhere. If you need to introduce another heavy outline, promote a `--color-frame` token rather than inlining `--color-foreground`.
**Peer-item rule**
+4
View File
@@ -14,8 +14,12 @@
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@fontsource-variable/geist-mono": "^5.2.8",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-toggle-group": "^1.1.11",
"@tanstack/react-router": "^1.137.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+13
View File
@@ -39,6 +39,19 @@ const REUSED_KEYS = [
"theme_light",
"theme_dark",
"theme_system",
"username",
"username_placeholder",
"password",
"password_placeholder",
"auth_source",
"local",
"remember_me",
"forget_password",
"sign_up_now",
"sign_in_submitting",
"sign_in_failed",
"show_password",
"hide_password",
];
// Lightweight INI parser: handles `key = value` and `key=value`, ignores
+5 -15
View File
@@ -1,17 +1,7 @@
import { Footer } from "@/components/Footer";
import { Navbar } from "@/components/Navbar";
import { subUrl } from "@/lib/url";
import { Landing } from "@/pages/Landing";
import { NotFound } from "@/pages/NotFound";
import type { UserInfo } from "@/lib/user-info";
export function App() {
const path = typeof window === "undefined" ? "/" : window.location.pathname.replace(/\/+$/, "") || "/";
const isLanding = path === subUrl("/");
return (
<div className="flex min-h-dvh flex-col">
<Navbar />
{isLanding ? <Landing /> : <NotFound />}
<Footer />
</div>
);
import { AppRouter } from "./router";
export function App({ user }: { user: UserInfo | null }) {
return <AppRouter user={user} />;
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { subUrl } from "@/lib/url";
export function Footer() {
return (
<footer className="border-t border-(--color-border)">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-5 gap-y-3 px-4 py-6 text-sm text-(--color-muted-foreground) sm:px-6">
<footer className="mt-8 border-t border-(--color-border)">
<div className="mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-x-5 gap-y-2 px-4 py-3 text-xs text-(--color-muted-foreground) sm:px-6">
<span>© {new Date().getFullYear()} Gogs®</span>
<div className="flex flex-wrap items-center gap-x-2 gap-y-2">
<a
+64 -31
View File
@@ -1,3 +1,4 @@
import { Link } from "@tanstack/react-router";
import {
Building2,
ChevronDown,
@@ -25,7 +26,7 @@ export function Navbar() {
const user = useUserInfo();
return (
<header className="sticky top-0 z-10 border-b border-(--color-border) bg-(--color-background)/95 backdrop-blur">
<header className="sticky top-0 z-10 border-b border-(--color-border) bg-(--color-background)">
<nav className="mx-auto flex h-14 max-w-6xl items-center gap-3 px-4 text-sm sm:gap-4 sm:px-6">
<a href={subUrl("/")} className="flex shrink-0 items-center" aria-label="Gogs">
<img src={subUrl("/img/favicon.png")} alt="" width="28" height="28" className="size-7" />
@@ -41,7 +42,9 @@ export function Navbar() {
</>
) : (
<>
<NavLink href="/">{t("home")}</NavLink>
<NavLink href="/" spa>
{t("home")}
</NavLink>
<NavLink href="/explore/repos">{t("explore")}</NavLink>
<NavLink href="https://gogs.io" external>
{t("help")}
@@ -59,8 +62,10 @@ export function Navbar() {
</>
) : (
<>
<NavLink href="/user/sign-in" spa>
{t("sign_in")}
</NavLink>
<NavLink href="/user/sign_up">{t("register")}</NavLink>
<NavLink href="/user/login">{t("sign_in")}</NavLink>
</>
)}
</div>
@@ -78,6 +83,21 @@ export function Navbar() {
<ul className="flex flex-col text-sm">
{user ? (
<>
<li className="px-2 py-1.5 text-xs text-(--color-muted-foreground)">
{t("signed_in_as")} <strong className="text-(--color-foreground)">{user.username}</strong>
</li>
<MobileLink href={`/${user.username}`} onClick={() => setOpen(false)}>
{t("your_profile")}
</MobileLink>
<MobileLink href="/user/settings" onClick={() => setOpen(false)}>
{t("your_settings")}
</MobileLink>
{user.isAdmin && (
<MobileLink href="/admin" onClick={() => setOpen(false)}>
{t("admin_panel")}
</MobileLink>
)}
<li className="my-1 h-px bg-(--color-border)" />
<MobileLink href="/" onClick={() => setOpen(false)}>
{t("dashboard")}
</MobileLink>
@@ -103,23 +123,9 @@ export function Navbar() {
</MobileLink>
)}
<li className="my-1 h-px bg-(--color-border)" />
<li className="px-2 py-1.5 text-xs text-(--color-muted-foreground)">
{t("signed_in_as")} <strong className="text-(--color-foreground)">{user.username}</strong>
</li>
<MobileLink href={`/${user.username}`} onClick={() => setOpen(false)}>
{t("your_profile")}
</MobileLink>
<MobileLink href="/user/settings" onClick={() => setOpen(false)}>
{t("your_settings")}
</MobileLink>
<MobileLink href="https://gogs.io" external onClick={() => setOpen(false)}>
{t("help")}
</MobileLink>
{user.isAdmin && (
<MobileLink href="/admin" onClick={() => setOpen(false)}>
{t("admin_panel")}
</MobileLink>
)}
<li>
<SignOutForm>
<button
@@ -134,7 +140,7 @@ export function Navbar() {
</>
) : (
<>
<MobileLink href="/" onClick={() => setOpen(false)}>
<MobileLink href="/" spa onClick={() => setOpen(false)}>
{t("home")}
</MobileLink>
<MobileLink href="/explore/repos" onClick={() => setOpen(false)}>
@@ -144,12 +150,12 @@ export function Navbar() {
{t("help")}
</MobileLink>
<li className="my-1 h-px bg-(--color-border)" />
<MobileLink href="/user/sign-in" spa onClick={() => setOpen(false)}>
{t("sign_in")}
</MobileLink>
<MobileLink href="/user/sign_up" onClick={() => setOpen(false)}>
{t("register")}
</MobileLink>
<MobileLink href="/user/login" onClick={() => setOpen(false)}>
{t("sign_in")}
</MobileLink>
</>
)}
</ul>
@@ -291,12 +297,30 @@ function MenuLink({
);
}
function NavLink({ href, external, children }: { href: string; external?: boolean; children: React.ReactNode }) {
function NavLink({
href,
external,
spa,
children,
}: {
href: string;
external?: boolean;
spa?: boolean;
children: React.ReactNode;
}) {
const className = "inline-flex rounded-md px-3 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)";
if (spa) {
return (
<Link to={href} className={className}>
{children}
</Link>
);
}
return (
<a
href={external ? href : subUrl(href)}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="inline-flex rounded-md px-3 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)"
className={className}
>
{children}
</a>
@@ -334,24 +358,33 @@ async function signOut() {
function MobileLink({
href,
external,
spa,
onClick,
children,
}: {
href: string;
external?: boolean;
spa?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
const className = "flex w-full rounded-sm px-2 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)";
return (
<li>
<a
href={external ? href : subUrl(href)}
onClick={onClick}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="flex w-full rounded-sm px-2 py-1.5 text-(--color-foreground) hover:bg-(--color-surface)"
>
{children}
</a>
{spa ? (
<Link to={href} onClick={onClick} className={className}>
{children}
</Link>
) : (
<a
href={external ? href : subUrl(href)}
onClick={onClick}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className={className}
>
{children}
</a>
)}
</li>
);
}
+22 -54
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { webContext } from "@/lib/context";
import { type Theme, useTheme } from "@/lib/theme";
import { cn } from "@/lib/utils";
@@ -58,28 +59,27 @@ export function SettingsMenu() {
</PopoverTrigger>
<PopoverContent align="end" className="w-64 p-0" onOpenAutoFocus={(e) => e.preventDefault()}>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-(--color-muted-foreground)">{t("theme")}</div>
<div className="grid grid-cols-3 gap-1 p-1">
<ThemeOption
value="light"
current={theme}
onSelect={setTheme}
icon={<Sun className="size-4" />}
label={t("theme_light")}
/>
<ThemeOption
value="dark"
current={theme}
onSelect={setTheme}
icon={<Moon className="size-4" />}
label={t("theme_dark")}
/>
<ThemeOption
value="system"
current={theme}
onSelect={setTheme}
icon={<Monitor className="size-4" />}
label={t("theme_system")}
/>
<div className="p-1">
<ToggleGroup
type="single"
value={theme}
onValueChange={(v) => v && setTheme(v as Theme)}
size="tile"
className="grid grid-cols-3 gap-1"
>
<ToggleGroupItem value="light" aria-label={t("theme_light")}>
<Sun className="size-4" aria-hidden />
{t("theme_light")}
</ToggleGroupItem>
<ToggleGroupItem value="dark" aria-label={t("theme_dark")}>
<Moon className="size-4" aria-hidden />
{t("theme_dark")}
</ToggleGroupItem>
<ToggleGroupItem value="system" aria-label={t("theme_system")}>
<Monitor className="size-4" aria-hidden />
{t("theme_system")}
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="my-1 h-px bg-(--color-border)" />
@@ -116,35 +116,3 @@ export function SettingsMenu() {
</Popover>
);
}
function ThemeOption({
value,
current,
onSelect,
icon,
label,
}: {
value: Theme;
current: Theme;
onSelect: (t: Theme) => void;
icon: React.ReactNode;
label: string;
}) {
const isActive = current === value;
return (
<button
type="button"
onClick={() => onSelect(value)}
aria-pressed={isActive}
className={cn(
"flex cursor-pointer flex-col items-center gap-1 rounded-md px-2 py-2 text-xs hover:bg-(--color-surface)",
isActive
? "bg-(--color-surface) text-(--color-foreground)"
: "text-(--color-muted-foreground) hover:text-(--color-foreground)",
)}
>
{icon}
{label}
</button>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium outline-none transition-colors focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:pointer-events-none disabled:opacity-60",
{
variants: {
variant: {
default: "bg-(--color-primary) text-(--color-primary-foreground) hover:opacity-90",
outline:
"border border-(--color-input) bg-(--color-background) text-(--color-foreground) hover:bg-(--color-surface)",
ghost: "text-(--color-foreground) hover:bg-(--color-surface)",
link: "text-(--color-foreground) underline-offset-4 hover:underline",
destructive: "bg-(--color-destructive) text-(--color-destructive-foreground) hover:opacity-90",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-8 px-3",
icon: "size-9",
inline: "h-auto p-0",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
+20
View File
@@ -0,0 +1,20 @@
import { Slot } from "@radix-ui/react-slot";
import { type VariantProps } from "class-variance-authority";
import * as React from "react";
import { buttonVariants } from "@/components/ui/button-variants";
import { cn } from "@/lib/utils";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return <Comp ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />;
},
);
Button.displayName = "Button";
export { Button };
+50
View File
@@ -0,0 +1,50 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-(--color-border) bg-(--color-card) text-(--color-card-foreground) shadow-xs",
className,
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 px-5 pt-5 sm:px-6 sm:pt-6", className)} {...props} />
),
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h2 ref={ref} className={cn("text-xl font-semibold text-(--color-foreground)", className)} {...props} />
),
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-(--color-muted-foreground)", className)} {...props} />
),
);
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => <div ref={ref} className={cn("px-5 py-5 sm:px-6 sm:py-6", className)} {...props} />,
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center px-5 pb-5 sm:px-6 sm:pb-6", className)} {...props} />
),
);
CardFooter.displayName = "CardFooter";
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };
+29
View File
@@ -0,0 +1,29 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { Check } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Checkbox = React.forwardRef<
React.ElementRef<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer flex size-4 shrink-0 items-center justify-center rounded-sm border border-(--color-input) bg-(--color-background) outline-none transition-colors",
"focus-visible:border-(--color-ring) focus-visible:ring-1 focus-visible:ring-(--color-ring)",
"disabled:cursor-not-allowed disabled:opacity-60",
"data-[state=checked]:border-(--color-primary) data-[state=checked]:bg-(--color-primary) data-[state=checked]:text-(--color-primary-foreground)",
className,
)}
{...props}
>
<CheckboxPrimitive.Indicator className="flex items-center justify-center">
<Check className="size-3" strokeWidth={3} aria-hidden />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
));
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
export { Checkbox };
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
({ className, type, ...props }, ref) => (
<input
ref={ref}
type={type}
className={cn(
"block w-full rounded-md border border-(--color-input) bg-(--color-background) px-3 py-2 text-sm text-(--color-foreground) placeholder:text-(--color-muted-foreground) outline-none transition-colors",
"focus-visible:border-(--color-ring) focus-visible:ring-1 focus-visible:ring-(--color-ring)",
"disabled:cursor-not-allowed disabled:opacity-60",
"aria-invalid:border-(--color-destructive) aria-invalid:focus-visible:border-(--color-destructive) aria-invalid:focus-visible:ring-(--color-destructive)",
className,
)}
{...props}
/>
),
);
Input.displayName = "Input";
export { Input };
+21
View File
@@ -0,0 +1,21 @@
import * as LabelPrimitive from "@radix-ui/react-label";
import * as React from "react";
import { cn } from "@/lib/utils";
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(
"block text-sm font-medium text-(--color-foreground) peer-disabled:cursor-not-allowed peer-disabled:opacity-60",
className,
)}
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName;
export { Label };
+79
View File
@@ -0,0 +1,79 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
const Select = SelectPrimitive.Root;
const SelectGroup = SelectPrimitive.Group;
const SelectValue = SelectPrimitive.Value;
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-(--color-input) bg-(--color-background) px-3 py-2 text-sm text-(--color-foreground) outline-none transition-colors",
"focus-visible:border-(--color-ring) focus-visible:ring-1 focus-visible:ring-(--color-ring)",
"disabled:cursor-not-allowed disabled:opacity-60",
"data-[placeholder]:text-(--color-muted-foreground)",
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="size-4 text-(--color-muted-foreground)" aria-hidden />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
));
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
position={position}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) overflow-hidden rounded-md border border-(--color-border) bg-(--color-popover) text-(--color-popover-foreground) shadow-md outline-none",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1",
className,
)}
{...props}
>
<SelectPrimitive.Viewport className="p-1">{children}</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
));
SelectContent.displayName = SelectPrimitive.Content.displayName;
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"focus:bg-(--color-surface) data-[disabled]:pointer-events-none data-[disabled]:opacity-60",
className,
)}
{...props}
>
<span className="absolute left-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="size-4" aria-hidden />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
export { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue };
+48
View File
@@ -0,0 +1,48 @@
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import * as React from "react";
import { toggleVariants } from "@/components/ui/toggle-variants";
import { cn } from "@/lib/utils";
type ToggleVariantProps = VariantProps<typeof toggleVariants>;
const ToggleGroupContext = React.createContext<ToggleVariantProps>({
variant: "default",
size: "default",
});
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> & ToggleVariantProps
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root ref={ref} className={cn("flex items-center justify-center gap-1", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
));
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName;
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & ToggleVariantProps
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant ?? variant,
size: context.size ?? size,
}),
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
});
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName;
export { ToggleGroup, ToggleGroupItem };
+23
View File
@@ -0,0 +1,23 @@
import { cva } from "class-variance-authority";
export const toggleVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md text-sm font-medium text-(--color-muted-foreground) outline-none transition-colors hover:bg-(--color-surface) hover:text-(--color-foreground) focus-visible:ring-1 focus-visible:ring-(--color-ring) disabled:pointer-events-none disabled:opacity-60 data-[state=on]:bg-(--color-surface) data-[state=on]:text-(--color-foreground)",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-(--color-input) bg-transparent hover:bg-(--color-surface) data-[state=on]:border-(--color-ring)",
},
size: {
default: "h-10 px-3",
sm: "h-8 px-2",
tile: "h-auto flex-col px-2 py-2 text-xs",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
},
);
+6 -6
View File
@@ -43,7 +43,7 @@
--color-card-foreground: #2b2c34;
--color-popover: #fffffe;
--color-popover-foreground: #2b2c34;
--color-primary: #6246ea;
--color-primary: #059669;
--color-primary-foreground: #fffffe;
--color-secondary: #d1d1e9;
--color-secondary-foreground: #2b2c34;
@@ -51,9 +51,9 @@
--color-muted-foreground: #5f6172;
--color-destructive: #c2392f;
--color-destructive-foreground: #fffffe;
--color-border: #2b2c34;
--color-input: #2b2c34;
--color-ring: #6246ea;
--color-border: #d1d1e9;
--color-input: #c8c8d8;
--color-ring: #059669;
}
:root.dark {
@@ -65,7 +65,7 @@
--color-card-foreground: #fffffe;
--color-popover: #242629;
--color-popover-foreground: #fffffe;
--color-primary: #7551e9;
--color-primary: #059669;
--color-primary-foreground: #fffffe;
--color-secondary: #5c5f68;
--color-secondary-foreground: #fffffe;
@@ -75,7 +75,7 @@
--color-destructive-foreground: #fffffe;
--color-border: #5c5f68;
--color-input: #5c5f68;
--color-ring: #7551e9;
--color-ring: #059669;
}
@layer base {
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Административен панел",
"settings": "Настройки",
"language": "Език",
"page_not_found": "Страницата не е намерена"
"page_not_found": "Страницата не е намерена",
"username": "Потребител",
"password": "Парола",
"auth_source": "Източник за удостоверяване",
"local": "Локален",
"remember_me": "Запомни ме",
"forget_password": "Забравена парола?",
"sign_up_now": "Нуждаете се от профил? Регистрирайте се сега."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panel správce",
"settings": "Nastavení",
"language": "Jazyk",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Uživatelské jméno",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Administration",
"settings": "Einstellungen",
"language": "Sprache",
"page_not_found": "Seite nicht gefunden"
"page_not_found": "Seite nicht gefunden",
"username": "Benutzername",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Admin Panel",
"settings": "Settings",
"language": "Language",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Username",
"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."
}
+15 -2
View File
@@ -6,7 +6,7 @@
"pull_requests": "Pull requests",
"explore": "Explore",
"help": "Help",
"register": "Register",
"register": "Create account",
"sign_in": "Sign in",
"sign_out": "Sign out",
"create_new": "Create...",
@@ -24,5 +24,18 @@
"theme": "Theme",
"theme_light": "Light",
"theme_dark": "Dark",
"theme_system": "System"
"theme_system": "System",
"username": "Username",
"username_placeholder": "Enter your username or email",
"password": "Password",
"password_placeholder": "Enter your password",
"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.",
"show_password": "Show password",
"hide_password": "Hide password"
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panel de administración",
"settings": "Configuraciones",
"language": "Idioma",
"page_not_found": "Página no encontrada"
"page_not_found": "Página no encontrada",
"username": "Nombre de usuario",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "پنل مدیریت",
"settings": "تنظيمات",
"language": "زبان",
"page_not_found": "صفحه مورد نظر یافت نشد."
"page_not_found": "صفحه مورد نظر یافت نشد.",
"username": "نام کاربری",
"password": "رمز عبور",
"auth_source": "محل احراز هویت",
"local": "محلی",
"remember_me": "مرا به خاطر بسپار",
"forget_password": "رمز عبور خود را فراموش کرده‌اید؟",
"sign_up_now": "نیاز به یک حساب دارید؟ هم‌اکنون ثبت نام کنید."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Ylläpito paneeli",
"settings": "Asetukset",
"language": "Kieli",
"page_not_found": "Sivua ei löydy"
"page_not_found": "Sivua ei löydy",
"username": "Käyttäjätunnus",
"password": "Salasana",
"auth_source": "Todennuslähde",
"local": "Paikallinen",
"remember_me": "Muista minut",
"forget_password": "Unohtuiko salasana?",
"sign_up_now": "Tarvitsetko tilin? Rekisteröidy nyt."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Administration",
"settings": "Paramètres",
"language": "Langue",
"page_not_found": "Page non trouvée"
"page_not_found": "Page non trouvée",
"username": "Nom d'utilisateur",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panel de administración",
"settings": "Configuracións",
"language": "Idioma",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Nome da persoa usuaria",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Rendszergazdai felület",
"settings": "Beállítások",
"language": "Nyelv",
"page_not_found": "Az oldal nem található"
"page_not_found": "Az oldal nem található",
"username": "Felhasználónév",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panel Admin",
"settings": "Pengaturan",
"language": "Bahasa",
"page_not_found": "Halaman tidak ditemukan"
"page_not_found": "Halaman tidak ditemukan",
"username": "Nama pengguna",
"password": "Sandi",
"auth_source": "Sumber Autentikasi",
"local": "Lokal",
"remember_me": "Ingat saya",
"forget_password": "Lupa sandi?",
"sign_up_now": "Membutuhkan akun? Daftar sekarang."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Pannello di amministrazione",
"settings": "Impostazioni",
"language": "Lingua",
"page_not_found": "Pagina Non Trovata"
"page_not_found": "Pagina Non Trovata",
"username": "Nome utente",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "管理者パネル",
"settings": "設定",
"language": "言語",
"page_not_found": "ページが見つかりません"
"page_not_found": "ページが見つかりません",
"username": "ユーザー名",
"password": "パスワード",
"auth_source": "認証ソース",
"local": "ローカル",
"remember_me": "ログインしたままにする",
"forget_password": "パスワードを忘れましたか?",
"sign_up_now": "アカウントが必要ですか?今すぐ登録しましょう!"
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "관리자 패널",
"settings": "설정",
"language": "언어",
"page_not_found": "페이지를 찾을 수 없음"
"page_not_found": "페이지를 찾을 수 없음",
"username": "사용자명",
"password": "비밀번호",
"auth_source": "인증 소스 편집",
"local": "로컬",
"remember_me": "자동 로그인",
"forget_password": "비밀번호를 잊으셨습니까?",
"sign_up_now": "계정이 필요하신가요? 지금 가입하세요."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Admin panelis",
"settings": "Iestatījumi",
"language": "Valoda",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Lietotājvārds",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Админ удирдлага",
"settings": "Тохиргоо",
"language": "Хэл",
"page_not_found": "Хуудас олдсонгүй"
"page_not_found": "Хуудас олдсонгүй",
"username": "Нэвтрэх нэр",
"password": "Нууц үг",
"auth_source": "Баталгаажуулалтын эх сурвалж",
"local": "Локал",
"remember_me": "Сануулах",
"forget_password": "Нууц үг сэргээх?",
"sign_up_now": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Adminpaneel",
"settings": "Instellingen",
"language": "Taal",
"page_not_found": "Pagina niet gevonden"
"page_not_found": "Pagina niet gevonden",
"username": "Gebruikersnaam",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panel admina",
"settings": "Ustawienia",
"language": "Język",
"page_not_found": "Strona nie została znaleziona"
"page_not_found": "Strona nie została znaleziona",
"username": "Nazwa użytkownika",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Painel do administrador",
"settings": "Configurações",
"language": "Idioma",
"page_not_found": "Página Não Encontrada"
"page_not_found": "Página Não Encontrada",
"username": "Usuário",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Painel de Administração",
"settings": "Definições",
"language": "Língua",
"page_not_found": "Página Não Encontrada"
"page_not_found": "Página Não Encontrada",
"username": "Nome de utilizador",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Panou Administrator",
"settings": "Setări",
"language": "Limba",
"page_not_found": "Pagina nu a fost găsită"
"page_not_found": "Pagina nu a fost găsită",
"username": "Numele de utilizator",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Панель администратора",
"settings": "Настройки",
"language": "Язык",
"page_not_found": "Страница не найдена"
"page_not_found": "Страница не найдена",
"username": "Имя пользователя",
"password": "Пароль",
"auth_source": "Тип аутентификации",
"local": "Локальный",
"remember_me": "Запомнить меня",
"forget_password": "Забыли пароль?",
"sign_up_now": "Нужен аккаунт? Зарегистрируйтесь."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Admin Panel",
"settings": "Nastavenia",
"language": "Jazyk",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Používateľské meno",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Админ панела",
"settings": "Подешавања",
"language": "Језик",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "Корисничко име",
"password": "Лозинка",
"auth_source": "Извор аутентикације",
"local": "Локално",
"remember_me": "Запамти ме",
"forget_password": "Заборавили сте лозинку?",
"sign_up_now": "Немате налог? Пријавите се."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Admin-panel",
"settings": "inställningar",
"language": "Språk",
"page_not_found": "Sidan hittades inte"
"page_not_found": "Sidan hittades inte",
"username": "Användarnamn",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Yönetim Paneli",
"settings": "Ayarlar",
"language": "Dil",
"page_not_found": "Sayfa Bulunamadı"
"page_not_found": "Sayfa Bulunamadı",
"username": "Kullanıcı Adı",
"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."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Панель адміністратора",
"settings": "Налаштування",
"language": "Мова",
"page_not_found": "Сторінку не знайдено"
"page_not_found": "Сторінку не знайдено",
"username": "Ім'я користувача",
"password": "Пароль",
"auth_source": "Джерело автентифікації",
"local": "Локальний",
"remember_me": "Запам'ятати мене",
"forget_password": "Забули пароль?",
"sign_up_now": "Потрібен обліковий запис? Зареєструватися зараз."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "Bảng quản trị",
"settings": "Cài đặt",
"language": "Ngôn ngữ",
"page_not_found": "Không tìm thấy trang này!"
"page_not_found": "Không tìm thấy trang này!",
"username": "Username",
"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ờ."
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "管理面板",
"settings": "帐户设置",
"language": "语言选项",
"page_not_found": "页面未找到"
"page_not_found": "页面未找到",
"username": "用户名",
"password": "密码",
"auth_source": "认证源",
"local": "本地",
"remember_me": "记住登录",
"forget_password": "忘记密码?",
"sign_up_now": "还没帐户?马上注册。"
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "管理面板",
"settings": "設定",
"language": "語言",
"page_not_found": "Page Not Found"
"page_not_found": "Page Not Found",
"username": "用戶名稱",
"password": "密碼",
"auth_source": "Authentication Source",
"local": "Local",
"remember_me": "記住登錄",
"forget_password": "忘記密碼?",
"sign_up_now": "還沒帳戶?馬上註冊。"
}
+8 -1
View File
@@ -20,5 +20,12 @@
"admin_panel": "管理面板",
"settings": "設定",
"language": "語言",
"page_not_found": "找不到頁面"
"page_not_found": "找不到頁面",
"username": "用戶名稱",
"password": "密碼",
"auth_source": "認證來源",
"local": "本地",
"remember_me": "記住登錄",
"forget_password": "忘記密碼?",
"sign_up_now": "還沒帳戶?馬上註冊。"
}
+1 -1
View File
@@ -12,7 +12,7 @@ const root = document.getElementById("root");
if (root) {
createRoot(root).render(
<UserInfoProvider value={userInfo}>
<App />
<App user={userInfo} />
</UserInfoProvider>,
);
}
+33 -6
View File
@@ -1,3 +1,4 @@
import { Link } from "@tanstack/react-router";
import { useTranslation } from "react-i18next";
import { usePageTitle } from "@/lib/page-title";
@@ -42,7 +43,7 @@ export function Landing() {
<span className="text-(--color-muted-foreground)">$ </span>
<span>gogs help</span>
{"\n"}
<CmdLink href="/user/login" cmd="sign-in" desc={t("sign_in")} />
<CmdLink href="/user/sign-in" cmd="sign-in" desc={t("sign_in")} spa />
{"\n"}
<CmdLink href="/user/sign_up" cmd="sign-up" desc={t("register")} />
{"\n"}
@@ -60,16 +61,42 @@ export function Landing() {
);
}
function CmdLink({ href, cmd, desc, external }: { href: string; cmd: string; desc: string; external?: boolean }) {
function CmdLink({
href,
cmd,
desc,
external,
spa,
}: {
href: string;
cmd: string;
desc: string;
external?: boolean;
spa?: boolean;
}) {
const className =
"group inline-flex items-baseline gap-2 rounded-sm hover:bg-(--color-surface) hover:text-(--color-foreground)";
const inner = (
<>
<span className="inline-block w-16 text-(--color-foreground) sm:w-20">{cmd}</span>
<span className="text-(--color-muted-foreground) group-hover:text-(--color-foreground)/80"> {desc}</span>
<span className="text-(--color-muted-foreground) group-hover:text-(--color-foreground)"></span>
</>
);
if (spa) {
return (
<Link to={href} className={className}>
{inner}
</Link>
);
}
return (
<a
href={external ? href : subUrl(href)}
{...(external ? { target: "_blank", rel: "noopener noreferrer" } : {})}
className="group inline-flex items-baseline gap-2 rounded-sm hover:bg-(--color-surface) hover:text-(--color-foreground)"
className={className}
>
<span className="inline-block w-16 text-(--color-foreground) sm:w-20">{cmd}</span>
<span className="text-(--color-muted-foreground) group-hover:text-(--color-foreground)/80"> {desc}</span>
<span className="text-(--color-muted-foreground) group-hover:text-(--color-foreground)"></span>
{inner}
</a>
);
}
+2 -2
View File
@@ -9,8 +9,8 @@ export function NotFound() {
return (
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
<div className="w-full max-w-2xl">
<div className="rounded-lg border border-(--color-border) bg-(--color-surface)/40 font-mono shadow-xs">
<div className="flex items-center gap-1.5 border-b border-(--color-border) px-3 py-2 sm:px-4 sm:py-2.5">
<div className="rounded-lg border border-(--color-foreground)/80 bg-(--color-surface)/40 font-mono shadow-xs dark:border-(--color-border)">
<div className="flex items-center gap-1.5 border-b border-(--color-foreground)/80 px-3 py-2 sm:px-4 sm:py-2.5 dark:border-(--color-border)">
<span className="size-2.5 rounded-full bg-(--color-destructive)/70" />
<span className="size-2.5 rounded-full bg-(--color-warning,oklch(0.795_0.184_86.047))/70" />
<span className="size-2.5 rounded-full bg-(--color-foreground)/20" />
+218
View File
@@ -0,0 +1,218 @@
import { getRouteApi } from "@tanstack/react-router";
import { Eye, EyeOff } from "lucide-react";
import { 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 { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { usePageTitle } from "@/lib/page-title";
import { subUrl } from "@/lib/url";
export interface LoginSource {
id: number;
name: string;
isDefault: boolean;
}
export interface SignInPage {
loginSources: LoginSource[];
}
interface SignInResponse {
twoFactor?: boolean;
redirectTo?: string;
}
interface SignInErrorResponse {
error?: string;
fields?: Record<string, string | null>;
}
// Field display order; the first key with a server-side error gets focus.
const FIELD_ORDER = ["username", "password"] as const;
const route = getRouteApi("/user/sign-in");
export function SignIn() {
const { t } = useTranslation();
usePageTitle(t("sign_in"));
const { loginSources } = route.useLoaderData();
const defaultSource = loginSources.find((s) => s.isDefault);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loginSource, setLoginSource] = useState<number>(defaultSource?.id ?? 0);
const [remember, setRemember] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [fieldErrors, setFieldErrors] = useState<Record<string, string | null>>({});
const usernameRef = useRef<HTMLInputElement>(null);
const passwordRef = useRef<HTMLInputElement>(null);
function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setFormError(null);
setFieldErrors({});
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 }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as SignInErrorResponse;
if (body.error) setFormError(body.error);
else setFormError(null);
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();
}
if (!body.error && !body.fields) {
setFormError(t("sign_in_failed"));
}
return;
}
const data = (await res.json()) as SignInResponse;
if (data.twoFactor) {
window.location.assign(subUrl("/user/login/two_factor"));
return;
}
window.location.assign(data.redirectTo || subUrl("/"));
} catch {
setFormError(t("sign_in_failed"));
} finally {
setSubmitting(false);
}
})();
}
return (
<main className="flex flex-1 items-center justify-center px-4 py-10 sm:px-6 sm:py-16">
<Card className="w-full max-w-md">
<CardHeader className="items-center text-center">
<CardTitle>{t("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
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")}>{t("forget_password")}</a>
</Button>
</div>
<div className="relative">
<Input
ref={passwordRef}
id="password"
name="password"
type={showPassword ? "text" : "password"}
autoComplete="current-password"
required
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"
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)"
>
{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))}>
<SelectTrigger id="login_source">
<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" 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} 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")}>{t("sign_up_now")}</a>
</Button>
</div>
</form>
</CardContent>
</Card>
</main>
);
}
+92
View File
@@ -0,0 +1,92 @@
import {
Outlet,
RouterProvider,
createRootRouteWithContext,
createRoute,
createRouter,
redirect,
} from "@tanstack/react-router";
import { Footer } from "@/components/Footer";
import { Navbar } from "@/components/Navbar";
import { webContext } from "@/lib/context";
import { subUrl } from "@/lib/url";
import type { UserInfo } from "@/lib/user-info";
import { Landing } from "@/pages/Landing";
import { NotFound } from "@/pages/NotFound";
import { SignIn, type SignInPage } from "@/pages/SignIn";
interface RouterContext {
user: UserInfo | null;
}
function RootLayout() {
return (
<div className="flex min-h-dvh flex-col">
<Navbar />
<Outlet />
<Footer />
</div>
);
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
notFoundComponent: NotFound,
});
const landingRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: Landing,
});
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 });
}
},
loader: async (): Promise<SignInPage> => {
const res = await fetch(subUrl("/api/web/user/sign-in"), { credentials: "same-origin" });
if (!res.ok) {
return { loginSources: [] };
}
return (await res.json()) as SignInPage;
},
component: SignIn,
});
const routeTree = rootRoute.addChildren([landingRoute, signInRoute]);
function makeRouter(context: RouterContext) {
return createRouter({
routeTree,
basepath: webContext.subURL || "/",
defaultNotFoundComponent: NotFound,
context,
});
}
type AppRouterInstance = ReturnType<typeof makeRouter>;
declare module "@tanstack/react-router" {
interface Register {
router: AppRouterInstance;
}
}
export function AppRouter({ user }: { user: UserInfo | null }) {
const router = makeRouter({ user });
return <RouterProvider router={router} />;
}