diff --git a/AGENTS.md b/AGENTS.md index ef5fc3583..f787d06bc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/cmd/gogs/internal/web/api.go b/cmd/gogs/internal/web/api.go deleted file mode 100644 index 344cd5ed4..000000000 --- a/cmd/gogs/internal/web/api.go +++ /dev/null @@ -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 -} diff --git a/cmd/gogs/internal/web/web.go b/cmd/gogs/internal/web/web.go index 3b38055fd..a4ba0621e 100644 --- a/cmd/gogs/internal/web/web.go +++ b/cmd/gogs/internal/web/web.go @@ -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) diff --git a/cmd/gogs/internal/web/webapi.go b/cmd/gogs/internal/web/webapi.go new file mode 100644 index 000000000..c375dba1c --- /dev/null +++ b/cmd/gogs/internal/web/webapi.go @@ -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 +// + , 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." (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 +} diff --git a/conf/locale/locale_en-US.ini b/conf/locale/locale_en-US.ini index 7b51f2b63..0c06ceaf5 100644 --- a/conf/locale/locale_en-US.ini +++ b/conf/locale/locale_en-US.ini @@ -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 %s, 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. diff --git a/go.mod b/go.mod index fa545a80b..ffeadd9e0 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 673f4ff71..06c04598d 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/context/auth.go b/internal/context/auth.go index 745857551..1a331ed43 100644 --- a/internal/context/auth.go +++ b/internal/context/auth.go @@ -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 } diff --git a/internal/form/user.go b/internal/form/user.go index fed402fbc..0c046d234 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -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) -} - // __________________________________________.___ _______ ________ _________ // / _____/\_ _____/\__ ___/\__ ___/| |\ \ / _____/ / _____/ // \_____ \ | __)_ | | | | | |/ | \/ \ ___ \_____ \ diff --git a/internal/route/home.go b/internal/route/home.go index e311fca0b..771316e0f 100644 --- a/internal/route/home.go +++ b/internal/route/home.go @@ -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 } diff --git a/internal/route/install.go b/internal/route/install.go index 75e417776..380f36b94 100644 --- a/internal/route/install.go +++ b/internal/route/install.go @@ -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") } diff --git a/internal/route/repo/issue.go b/internal/route/repo/issue.go index fc31ededb..8d4444050 100644 --- a/internal/route/repo/issue.go +++ b/internal/route/repo/issue.go @@ -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) } diff --git a/internal/route/user/auth.go b/internal/route/user/auth.go index adf2c1c95..c1506ea71 100644 --- a/internal/route/user/auth.go +++ b/internal/route/user/auth.go @@ -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 } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbb96b49f..a2dab2cd4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index b9aaa0774..6aa7439b8 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -180,7 +180,7 @@ {{.i18n.Tr "register"}} {{end}} - + {{.i18n.Tr "sign_in"}} diff --git a/templates/mail/auth/register_notify.tmpl b/templates/mail/auth/register_notify.tmpl index dc9f5c8c9..9ceb6a32f 100644 --- a/templates/mail/auth/register_notify.tmpl +++ b/templates/mail/auth/register_notify.tmpl @@ -8,7 +8,7 @@

Hi {{.Username}}, this is your registration confirmation email for {{AppName}}!

You can now login via username: {{.Username}}.

-

{{AppURL}}user/login

+

{{AppURL}}user/sign-in

© {{Year}} {{AppName}}

diff --git a/templates/user/auth/login.tmpl b/templates/user/auth/login.tmpl deleted file mode 100644 index 583d3e18e..000000000 --- a/templates/user/auth/login.tmpl +++ /dev/null @@ -1,66 +0,0 @@ -{{template "base/head" .}} - -{{template "base/footer" .}} diff --git a/templates/user/auth/signup.tmpl b/templates/user/auth/signup.tmpl index 6022047fd..d8e956a00 100644 --- a/templates/user/auth/signup.tmpl +++ b/templates/user/auth/signup.tmpl @@ -45,7 +45,7 @@ {{end}} diff --git a/web/DESIGN.md b/web/DESIGN.md index 2377a4c49..a474f08f7 100644 --- a/web/DESIGN.md +++ b/web/DESIGN.md @@ -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** diff --git a/web/package.json b/web/package.json index 8f7ebb01a..eb228f95f 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/scripts/extract-locales.mjs b/web/scripts/extract-locales.mjs index c929762ea..94c2fc27b 100644 --- a/web/scripts/extract-locales.mjs +++ b/web/scripts/extract-locales.mjs @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index 3e5e6d236..de47a0077 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 ( -
- - {isLanding ? : } -
-
- ); +import { AppRouter } from "./router"; + +export function App({ user }: { user: UserInfo | null }) { + return ; } diff --git a/web/src/components/Footer.tsx b/web/src/components/Footer.tsx index b8673f332..61d598ad3 100644 --- a/web/src/components/Footer.tsx +++ b/web/src/components/Footer.tsx @@ -2,8 +2,8 @@ import { subUrl } from "@/lib/url"; export function Footer() { return ( -