mirror of
https://github.com/gogs/gogs.git
synced 2026-05-28 21:30:36 +00:00
feat: React-based sign-in page with /api/web/user/sign-in (#8285)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// __________________________________________.___ _______ ________ _________
|
||||
// / _____/\_ _____/\__ ___/\__ ___/| |\ \ / _____/ / _____/
|
||||
// \_____ \ | __)_ | | | | | |/ | \/ \ ___ \_____ \
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Generated
+291
@@ -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':
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" .}}
|
||||
@@ -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
@@ -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**
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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": "Нуждаете се от профил? Регистрирайте се сега."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "نیاز به یک حساب دارید؟ هماکنون ثبت نام کنید."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "アカウントが必要ですか?今すぐ登録しましょう!"
|
||||
}
|
||||
|
||||
@@ -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": "계정이 필요하신가요? 지금 가입하세요."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Данс үүсгэх бол? Одоо бүртгүүлнэ үү."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Нужен аккаунт? Зарегистрируйтесь."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Немате налог? Пријавите се."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Потрібен обліковий запис? Зареєструватися зараз."
|
||||
}
|
||||
|
||||
@@ -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ờ."
|
||||
}
|
||||
|
||||
@@ -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": "还没帐户?马上注册。"
|
||||
}
|
||||
|
||||
@@ -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": "還沒帳戶?馬上註冊。"
|
||||
}
|
||||
|
||||
@@ -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
@@ -12,7 +12,7 @@ const root = document.getElementById("root");
|
||||
if (root) {
|
||||
createRoot(root).render(
|
||||
<UserInfoProvider value={userInfo}>
|
||||
<App />
|
||||
<App user={userInfo} />
|
||||
</UserInfoProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user