From adea243ee8cdc25f1101237c9a87cf14eb0a9358 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=B4=8A=E1=B4=8F=E1=B4=87=20=E1=B4=84=CA=9C=E1=B4=87?= =?UTF-8?q?=C9=B4?= Date: Sun, 24 May 2026 22:35:41 -0400 Subject: [PATCH] feat(web): migrate account activation page to React (#8296) --- cmd/gogs/internal/web/web.go | 1 - cmd/gogs/internal/web/webapi.go | 123 ++++++++++++++- conf/locale/locale_en-US.ini | 12 +- internal/context/auth.go | 5 +- internal/route/home.go | 3 +- internal/route/user/auth.go | 149 ------------------ internal/route/user/setting.go | 50 ++++++ templates/user/auth/activate.tmpl | 38 ----- web/scripts/extract-locales.mjs | 10 +- web/src/locales/en-US.json | 12 +- web/src/pages/user/Activate.tsx | 174 +++++++++++++++++++++ web/src/pages/{ => user}/MFA.tsx | 0 web/src/pages/{ => user}/ResetPassword.tsx | 0 web/src/pages/{ => user}/SignIn.tsx | 0 web/src/pages/{ => user}/SignUp.tsx | 11 +- web/src/router.tsx | 93 +---------- web/src/routes/user.tsx | 123 +++++++++++++++ 17 files changed, 508 insertions(+), 296 deletions(-) delete mode 100644 internal/route/user/auth.go delete mode 100644 templates/user/auth/activate.tmpl create mode 100644 web/src/pages/user/Activate.tsx rename web/src/pages/{ => user}/MFA.tsx (100%) rename web/src/pages/{ => user}/ResetPassword.tsx (100%) rename web/src/pages/{ => user}/SignIn.tsx (100%) rename web/src/pages/{ => user}/SignUp.tsx (98%) create mode 100644 web/src/routes/user.tsx diff --git a/cmd/gogs/internal/web/web.go b/cmd/gogs/internal/web/web.go index df46393f3..d98dc2c59 100644 --- a/cmd/gogs/internal/web/web.go +++ b/cmd/gogs/internal/web/web.go @@ -127,7 +127,6 @@ func Run(configPath string, portOverride int) error { }) m.Group("/user", func() { - m.Any("/activate", user.Activate) m.Any("/activate_email", user.ActivateEmail) m.Get("/email2user", user.Email2User) }) diff --git a/cmd/gogs/internal/web/webapi.go b/cmd/gogs/internal/web/webapi.go index c1d6de082..3876369d0 100644 --- a/cmd/gogs/internal/web/webapi.go +++ b/cmd/gogs/internal/web/webapi.go @@ -2,11 +2,13 @@ package web import ( stdctx "context" + "encoding/hex" "encoding/json" "net/http" "os" "reflect" "regexp" + "strconv" "strings" "time" @@ -27,7 +29,7 @@ import ( "gogs.io/gogs/internal/context" "gogs.io/gogs/internal/database" "gogs.io/gogs/internal/email" - "gogs.io/gogs/internal/route/user" + "gogs.io/gogs/internal/tool" "gogs.io/gogs/internal/userx" ) @@ -80,6 +82,33 @@ func webAPIBodyLimiter(c flamego.Context) { r.Body = http.MaxBytesReader(c.ResponseWriter(), r.Body, 4*1024) // 4 KiB } +func parseUserFromCode(ctx stdctx.Context, code string) (user *database.User) { + if len(code) <= tool.TimeLimitCodeLength { + return nil + } + + hexStr := code[tool.TimeLimitCodeLength:] + if b, err := hex.DecodeString(hexStr); err == nil { + if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil { + return user + } else if !database.IsErrUserNotExist(err) { + log.Error("parseUserFromCode: get user by name %q: %v", string(b), err) + } + } + return nil +} + +func verifyUserActiveCode(ctx stdctx.Context, code string) (user *database.User) { + if user = parseUserFromCode(ctx, code); user != nil { + prefix := code[:tool.TimeLimitCodeLength] + data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands + if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) { + return user + } + } + return nil +} + // webAPIValidator is the shared validator instance used by every webapi // binding. Registering the json-tag name function makes validation errors // carry the wire field name (e.g. "recoveryCode") via ve.Field(), so the @@ -159,6 +188,12 @@ func mountWebAPIRoutes(f *flamego.Flame) { Post(bindJSON(userMFARequest{}), postUserMFA) f.Post("/recovery", bindJSON(userMFARecoveryRequest{}), postUserMFARecovery) }) + f.Group("/activate", func() { + f.Combo(""). + Get(getUserActivate). + Post(postUserActivate) + f.Post("/complete", bindJSON(userActivateCompleteRequest{}), postUserActivateComplete) + }) f.Post("/sign-out", postUserSignOut) }) }, webAPIBodyLimiter) @@ -368,7 +403,7 @@ func getUserResetPassword(r *http.Request) (statusCode int, resp *getUserResetPa code := r.URL.Query().Get("code") return http.StatusOK, &getUserResetPasswordResponse{ EmailEnabled: conf.Email.Enabled, - Valid: code != "" && user.VerifyUserActiveCode(code) != nil, + Valid: code != "" && verifyUserActiveCode(r.Context(), code) != nil, }, nil } @@ -426,7 +461,7 @@ func postUserResetPassword(r *http.Request, ca cache.Cache, l i18n.Locale, req u } func postUserResetPasswordComplete(r *http.Request, l i18n.Locale, req userResetPasswordCompleteRequest) (statusCode int, resp any, err error) { - u := user.VerifyUserActiveCode(req.Code) + u := verifyUserActiveCode(r.Context(), req.Code) if u == nil { return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil } @@ -600,6 +635,88 @@ func getUserInfo(user *database.User) (statusCode int, resp *userInfo, err error nil } +type getUserActivateResponse struct { + Email string `json:"email,omitempty"` + CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"` +} + +func getUserActivate(u *database.User) (statusCode int, resp any, err error) { + if u == nil { + return http.StatusUnauthorized, nil, nil + } + // An already-active and authenticated user has no business on the activation page. + if u.IsActive { + return http.StatusNotFound, nil, nil + } + return http.StatusOK, &getUserActivateResponse{ + Email: u.Email, + CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60, + }, nil +} + +type postUserActivateResponse struct { + RateLimited bool `json:"rateLimited,omitempty"` + CodeLifetimeHours int `json:"codeLifetimeHours,omitempty"` +} + +func postUserActivate(r *http.Request, u *database.User, mc *macaron.Context, ca cache.Cache, l i18n.Locale) (statusCode int, resp any, err error) { + if u == nil { + return http.StatusUnauthorized, nil, nil + } + if u.IsActive { + return http.StatusNotFound, nil, nil + } + if !conf.Auth.RequireEmailConfirmation { + return http.StatusForbidden, &bindingErrorResponse{Error: l.Tr("auth.disable_register_mail")}, nil + } + + ctx := r.Context() + if _, err := ca.Get(ctx, userx.MailResendCacheKey(u.ID)); err == nil { + return http.StatusOK, &postUserActivateResponse{ + RateLimited: true, + CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60, + }, nil + } else if !errors.Is(err, os.ErrNotExist) { + log.Error("postUserActivate: get mail resend cache for user %q: %v", u.Name, err) + } + + if err := email.SendActivateAccountMail(mc, database.NewMailerUser(u)); err != nil { + log.Error("postUserActivate: send activation mail to user %q: %v", u.Name, err) + } + if err := ca.Set(ctx, userx.MailResendCacheKey(u.ID), 1, 180*time.Second); err != nil { + log.Error("postUserActivate: put mail resend cache for user %q: %v", u.Name, err) + } + return http.StatusOK, &postUserActivateResponse{CodeLifetimeHours: conf.Auth.ActivateCodeLives / 60}, nil +} + +type userActivateCompleteRequest struct { + Code string `json:"code" validate:"required"` +} + +func postUserActivateComplete(r *http.Request, sess session.Session, mc *macaron.Context, l i18n.Locale, req userActivateCompleteRequest) (statusCode int, resp any, err error) { + target := verifyUserActiveCode(r.Context(), req.Code) + if target == nil { + return http.StatusBadRequest, &bindingErrorResponse{Error: l.Tr("auth.invalid_code")}, nil + } + + v := true + if err := database.Handle.Users().Update( + r.Context(), + target.ID, + database.UpdateUserOptions{ + GenerateNewRands: true, + IsActivated: &v, + }, + ); err != nil { + log.Error("postUserActivateComplete: update user %q: %v", target.Name, err) + return http.StatusInternalServerError, nil, errors.Wrap(err, "update user") + } + + log.Trace("User activated: %s", target.Name) + completeSignIn(sess, mc, target) + return http.StatusNoContent, nil, nil +} + type postUserSignOutResponse struct { RedirectTo string `json:"redirectTo,omitempty"` } diff --git a/conf/locale/locale_en-US.ini b/conf/locale/locale_en-US.ini index a67be812f..038ff6948 100644 --- a/conf/locale/locale_en-US.ini +++ b/conf/locale/locale_en-US.ini @@ -194,12 +194,18 @@ forgot_password= Forgot Password forget_password = Forgot password? sign_up_now = Create a new account confirmation_email_sent = 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 +activate_your_account = Activate your account prohibit_login = Login Prohibited prohibit_login_desc = Your account is prohibited from logging in. Please contact the site admin. -resent_limit_prompt = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again. +resend_rate_limited = Sorry, you already requested an activation email recently. Please wait 3 minutes then try again. has_unconfirmed_mail = Hi %s, you have an unconfirmed email address (%s). If you haven't received a confirmation email or need to receive a new one, please click the button below. -resend_mail = Click here to resend your activation email +send_activation_email = Send activation email +check_activation_email = Please check your email and click the activation link to finish creating your account. +activation_email_pending = Your email address {email} is not yet confirmed. Click below to send a new activation email valid for {hours} hours. +activation_email_sent = A new activation email has been sent to {email}. Please check your inbox within {hours} hours. +sending_activation_email = Sending activation email... +send_activation_email_failed = Could not send activation email, please try again. +activating_account = Activating your account... send_reset_email = Send password reset email reset_password_email_submitting = Sending password reset email... reset_password_email_failed = Could not send password reset email, please try again. diff --git a/internal/context/auth.go b/internal/context/auth.go index 6094547c0..a8c26faa1 100644 --- a/internal/context/auth.go +++ b/internal/context/auth.go @@ -80,8 +80,9 @@ func Toggle(options *ToggleOptions) macaron.Handler { c.RedirectSubpath("/user/sign-in") return } else if !c.User.IsActive && conf.Auth.RequireEmailConfirmation { - c.Title("auth.active_your_account") - c.Success("user/auth/activate") + // Inactive users get bounced to the React activation page, which + // is responsible for offering a resend and showing status. + c.RedirectSubpath("/user/activate") return } } diff --git a/internal/route/home.go b/internal/route/home.go index 5f8a3e49b..f917e3c28 100644 --- a/internal/route/home.go +++ b/internal/route/home.go @@ -20,8 +20,7 @@ const ( func Home(c *context.Context) { if c.IsLogged { if !c.User.IsActive && conf.Auth.RequireEmailConfirmation { - c.Data["Title"] = c.Tr("auth.active_your_account") - c.Success(user.TmplUserAuthActivate) + c.RedirectSubpath("/user/activate") } else { user.Dashboard(c) } diff --git a/internal/route/user/auth.go b/internal/route/user/auth.go deleted file mode 100644 index b292075d9..000000000 --- a/internal/route/user/auth.go +++ /dev/null @@ -1,149 +0,0 @@ -package user - -import ( - gocontext "context" - "encoding/hex" - "strconv" - - log "unknwon.dev/clog/v2" - - "gogs.io/gogs/internal/conf" - "gogs.io/gogs/internal/context" - "gogs.io/gogs/internal/database" - "gogs.io/gogs/internal/email" - "gogs.io/gogs/internal/tool" - "gogs.io/gogs/internal/userx" -) - -const TmplUserAuthActivate = "user/auth/activate" - -// parseUserFromCode returns user by username encoded in code. -// It returns nil if code or username is invalid. -func parseUserFromCode(code string) (user *database.User) { - if len(code) <= tool.TimeLimitCodeLength { - return nil - } - - // Use tail hex username to query user - hexStr := code[tool.TimeLimitCodeLength:] - if b, err := hex.DecodeString(hexStr); err == nil { - if user, err = database.Handle.Users().GetByUsername(gocontext.TODO(), string(b)); user != nil { - return user - } else if !database.IsErrUserNotExist(err) { - log.Error("Failed to get user by name %q: %v", string(b), err) - } - } - - return nil -} - -// VerifyUserActiveCode verifies an account activation or password reset code. -func VerifyUserActiveCode(code string) (user *database.User) { - minutes := conf.Auth.ActivateCodeLives - - if user = parseUserFromCode(code); user != nil { - // time limit code - prefix := code[:tool.TimeLimitCodeLength] - data := strconv.FormatInt(user.ID, 10) + user.Email + user.LowerName + user.Password + user.Rands - - if tool.VerifyTimeLimitCode(data, minutes, prefix) { - return user - } - } - return nil -} - -// verify active code when active account -func verifyActiveEmailCode(code, email string) *database.EmailAddress { - minutes := conf.Auth.ActivateCodeLives - - if user := parseUserFromCode(code); user != nil { - // time limit code - prefix := code[:tool.TimeLimitCodeLength] - data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands - - if tool.VerifyTimeLimitCode(data, minutes, prefix) { - emailAddress, err := database.Handle.Users().GetEmail(gocontext.TODO(), user.ID, email, false) - if err == nil { - return emailAddress - } - } - } - return nil -} - -func Activate(c *context.Context) { - code := c.Query("code") - if code == "" { - c.Data["IsActivatePage"] = true - if c.User.IsActive { - c.NotFound() - return - } - // Resend confirmation email. - if conf.Auth.RequireEmailConfirmation { - if c.Cache.IsExist(userx.MailResendCacheKey(c.User.ID)) { - c.Data["ResendLimited"] = true - } else { - c.Data["Hours"] = conf.Auth.ActivateCodeLives / 60 - if err := email.SendActivateAccountMail(c.Context, database.NewMailerUser(c.User)); err != nil { - log.Error("Failed to send activate account mail: %v", err) - } - - if err := c.Cache.Put(userx.MailResendCacheKey(c.User.ID), 1, 180); err != nil { - log.Error("Failed to put cache key 'mail resend': %v", err) - } - } - } else { - c.Data["ServiceNotEnabled"] = true - } - c.Success(TmplUserAuthActivate) - return - } - - // Verify code. - if user := VerifyUserActiveCode(code); user != nil { - v := true - err := database.Handle.Users().Update( - c.Req.Context(), - user.ID, - database.UpdateUserOptions{ - GenerateNewRands: true, - IsActivated: &v, - }, - ) - if err != nil { - c.Error(err, "update user") - return - } - - log.Trace("User activated: %s", user.Name) - - _ = c.Session.Set("uid", user.ID) - _ = c.Session.Set("uname", user.Name) - c.RedirectSubpath("/") - return - } - - c.Data["IsActivateFailed"] = true - c.Success(TmplUserAuthActivate) -} - -func ActivateEmail(c *context.Context) { - code := c.Query("code") - emailAddr := c.Query("email") - - // Verify code. - if email := verifyActiveEmailCode(code, emailAddr); email != nil { - err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email) - if err != nil { - c.Error(err, "activate email") - return - } - - log.Trace("Email activated: %s", email.Email) - c.Flash.Success(c.Tr("settings.add_email_success")) - } - - c.RedirectSubpath("/user/settings/email") -} diff --git a/internal/route/user/setting.go b/internal/route/user/setting.go index b642047ab..a5237e52e 100644 --- a/internal/route/user/setting.go +++ b/internal/route/user/setting.go @@ -4,11 +4,13 @@ import ( "bytes" gocontext "context" "encoding/base64" + "encoding/hex" "fmt" "html/template" "image/png" "io" "net/http" + "strconv" "github.com/cockroachdb/errors" "github.com/pquerna/otp" @@ -240,6 +242,54 @@ func SettingsEmails(c *context.Context) { c.Success(tmplUserSettingsEmail) } +func parseUserFromCode(ctx gocontext.Context, code string) (user *database.User) { + if len(code) <= tool.TimeLimitCodeLength { + return nil + } + + hexStr := code[tool.TimeLimitCodeLength:] + if b, err := hex.DecodeString(hexStr); err == nil { + if user, err = database.Handle.Users().GetByUsername(ctx, string(b)); user != nil { + return user + } else if !database.IsErrUserNotExist(err) { + log.Error("parseUserFromCode: get user by name %q: %v", string(b), err) + } + } + return nil +} + +func verifyActiveEmailCode(ctx gocontext.Context, code, email string) *database.EmailAddress { + if user := parseUserFromCode(ctx, code); user != nil { + prefix := code[:tool.TimeLimitCodeLength] + data := strconv.FormatInt(user.ID, 10) + email + user.LowerName + user.Password + user.Rands + if tool.VerifyTimeLimitCode(data, conf.Auth.ActivateCodeLives, prefix) { + emailAddress, err := database.Handle.Users().GetEmail(ctx, user.ID, email, false) + if err == nil { + return emailAddress + } + } + } + return nil +} + +func ActivateEmail(c *context.Context) { + code := c.Query("code") + emailAddr := c.Query("email") + + if email := verifyActiveEmailCode(c.Req.Context(), code, emailAddr); email != nil { + err := database.Handle.Users().MarkEmailActivated(c.Req.Context(), email.UserID, email.Email) + if err != nil { + c.Error(err, "activate email") + return + } + + log.Trace("Email activated: %s", email.Email) + c.Flash.Success(c.Tr("settings.add_email_success")) + } + + c.RedirectSubpath("/user/settings/email") +} + func SettingsEmailPost(c *context.Context, f form.AddEmail) { c.Title("settings.emails") c.PageIs("SettingsEmails") diff --git a/templates/user/auth/activate.tmpl b/templates/user/auth/activate.tmpl deleted file mode 100644 index e44bb4ea7..000000000 --- a/templates/user/auth/activate.tmpl +++ /dev/null @@ -1,38 +0,0 @@ -{{template "base/head" .}} -
-
-
-
- {{.CSRFTokenHTML}} -

- {{.i18n.Tr "auth.active_your_account"}} -

-
- {{template "base/alert" .}} - {{if .IsActivatePage}} - {{if .ServiceNotEnabled}} -

{{.i18n.Tr "auth.disable_register_mail"}}

- {{else if .ResendLimited}} -

{{.i18n.Tr "auth.resent_limit_prompt"}}

- {{else}} -

{{.i18n.Tr "auth.confirmation_email_sent" .LoggedUser.Email .Hours | Str2HTML}}

- {{end}} - {{else}} - {{if .IsSendRegisterMail}} -

{{.i18n.Tr "auth.confirmation_email_sent" .Email .Hours | Str2HTML}}

- {{else if .IsActivateFailed}} -

{{.i18n.Tr "auth.invalid_code"}}

- {{else}} -

{{.i18n.Tr "auth.has_unconfirmed_mail" .LoggedUser.Name .LoggedUser.Email | Str2HTML}}

-
-
- -
- {{end}} - {{end}} -
-
-
-
-
-{{template "base/footer" .}} diff --git a/web/scripts/extract-locales.mjs b/web/scripts/extract-locales.mjs index b9a84e692..acde3d3cc 100644 --- a/web/scripts/extract-locales.mjs +++ b/web/scripts/extract-locales.mjs @@ -63,7 +63,6 @@ const REUSED_KEYS = [ "disable_register_prompt", "reset_password_resend_limited", "non_local_account", - "confirmation_email_sent", "create_new_account", "register_hepler_msg", "sign_up", @@ -98,6 +97,15 @@ const REUSED_KEYS = [ "mfa_verifying", "mfa_session_expired", "mfa_verify_failed", + "activate_your_account", + "resend_rate_limited", + "send_activation_email", + "check_activation_email", + "activation_email_pending", + "activation_email_sent", + "sending_activation_email", + "send_activation_email_failed", + "activating_account", ]; // Lightweight INI parser: handles `key = value` and `key=value`, ignores diff --git a/web/src/locales/en-US.json b/web/src/locales/en-US.json index 677952fe8..01866fed2 100644 --- a/web/src/locales/en-US.json +++ b/web/src/locales/en-US.json @@ -49,7 +49,6 @@ "disable_register_prompt": "Sorry, registration has been disabled. Please contact the site administrator.", "reset_password_resend_limited": "You already requested a password reset email recently. Please wait 3 minutes then try again.", "non_local_account": "Non-local accounts cannot change passwords through Gogs.", - "confirmation_email_sent": "A new confirmation email has been sent to %s, please check your inbox within the next %d hours to complete the registration process.", "create_new_account": "Create new account", "register_hepler_msg": "Already have an account? Sign in now!", "sign_up": "Sign up", @@ -83,5 +82,14 @@ "mfa_verify": "Verify", "mfa_verifying": "Verifying...", "mfa_session_expired": "Your sign-in session has expired. Please sign in again.", - "mfa_verify_failed": "Verification failed. Please try again." + "mfa_verify_failed": "Verification failed. Please try again.", + "activate_your_account": "Activate your account", + "resend_rate_limited": "Sorry, you already requested an activation email recently. Please wait 3 minutes then try again.", + "send_activation_email": "Send activation email", + "check_activation_email": "Please check your email and click the activation link to finish creating your account.", + "activation_email_pending": "Your email address {email} is not yet confirmed. Click below to send a new activation email valid for {hours} hours.", + "activation_email_sent": "A new activation email has been sent to {email}. Please check your inbox within {hours} hours.", + "sending_activation_email": "Sending activation email...", + "send_activation_email_failed": "Could not send activation email, please try again.", + "activating_account": "Activating your account..." } diff --git a/web/src/pages/user/Activate.tsx b/web/src/pages/user/Activate.tsx new file mode 100644 index 000000000..a3fd7e7eb --- /dev/null +++ b/web/src/pages/user/Activate.tsx @@ -0,0 +1,174 @@ +import { getRouteApi } from "@tanstack/react-router"; +import { useEffect, useRef, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { usePageTitle } from "@/lib/page-title"; +import { subUrl } from "@/lib/url"; +import { useUserInfo } from "@/lib/use-user-info"; + +export interface ActivatePage { + code: string; + email: string; + codeLifetimeHours: number; +} + +interface ActivateResponse { + rateLimited?: boolean; + codeLifetimeHours?: number; +} + +interface ActivateErrorResponse { + error?: string; +} + +const route = getRouteApi("/user/activate"); + +export function Activate() { + const { t } = useTranslation(); + const { code, email, codeLifetimeHours } = route.useLoaderData(); + const authenticated = useUserInfo() !== null; + usePageTitle(t("activate_your_account")); + + const isVerifying = code !== ""; + const [verifyFailed, setVerifyFailed] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [resent, setResent] = useState(null); + const [formError, setFormError] = useState(null); + const verifyOnceRef = useRef(false); + + useEffect(() => { + if (!isVerifying || verifyOnceRef.current) return; + verifyOnceRef.current = true; + void (async () => { + try { + const res = await fetch(subUrl("/api/web/user/activate/complete"), { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code }), + }); + if (!res.ok) { + setVerifyFailed(true); + return; + } + window.location.assign(subUrl("/")); + } catch { + setVerifyFailed(true); + } + })(); + }, [isVerifying, code]); + + function onResend(event: React.FormEvent) { + event.preventDefault(); + setFormError(null); + setSubmitting(true); + void (async () => { + try { + const res = await fetch(subUrl("/api/web/user/activate"), { + method: "POST", + credentials: "same-origin", + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as ActivateErrorResponse; + setFormError(body.error ?? t("send_activation_email_failed")); + setSubmitting(false); + return; + } + setResent((await res.json()) as ActivateResponse); + setSubmitting(false); + } catch { + setFormError(t("send_activation_email_failed")); + setSubmitting(false); + } + })(); + } + + return ( +
+ + + {t("activate_your_account")} + + {renderContent()} + +
+ ); + + function renderContent() { + if (isVerifying) { + if (verifyFailed) { + return ( +
+

+ {t("invalid_code")} +

+ +
+ ); + } + return ( +

+ {t("activating_account")} +

+ ); + } + + if (!authenticated) { + return ( +
+

{t("check_activation_email")}

+ +
+ ); + } + + if (resent) { + return ( +

+ {resent.rateLimited ? ( + t("resend_rate_limited") + ) : ( + , hours: }} + /> + )} +

+ ); + } + + return ( +
+
+ {formError && ( +
+ {formError} +
+ )} +
+

+ , hours: }} + /> +

+ +
+
+ + ); + } +} diff --git a/web/src/pages/MFA.tsx b/web/src/pages/user/MFA.tsx similarity index 100% rename from web/src/pages/MFA.tsx rename to web/src/pages/user/MFA.tsx diff --git a/web/src/pages/ResetPassword.tsx b/web/src/pages/user/ResetPassword.tsx similarity index 100% rename from web/src/pages/ResetPassword.tsx rename to web/src/pages/user/ResetPassword.tsx diff --git a/web/src/pages/SignIn.tsx b/web/src/pages/user/SignIn.tsx similarity index 100% rename from web/src/pages/SignIn.tsx rename to web/src/pages/user/SignIn.tsx diff --git a/web/src/pages/SignUp.tsx b/web/src/pages/user/SignUp.tsx similarity index 98% rename from web/src/pages/SignUp.tsx rename to web/src/pages/user/SignUp.tsx index f9db11ee0..17eb02049 100644 --- a/web/src/pages/SignUp.tsx +++ b/web/src/pages/user/SignUp.tsx @@ -1,6 +1,6 @@ import { getRouteApi, useNavigate } from "@tanstack/react-router"; import { useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import { Trans, useTranslation } from "react-i18next"; import { PasswordInput } from "@/components/PasswordInput"; import { Button } from "@/components/ui/button"; @@ -140,10 +140,11 @@ export function SignUp() { return (

- {t("confirmation_email_sent") - .replace(/<[^>]+>/g, "") - .replace("%s", sent.email!) - .replace("%d", String(sent.hours))} + , hours: }} + />