feat(web): migrate account activation page to React (#8296)

This commit is contained in:
ᴊᴏᴇ ᴄʜᴇɴ
2026-05-24 22:35:41 -04:00
committed by GitHub
parent 44f0222a71
commit adea243ee8
17 changed files with 508 additions and 296 deletions
-1
View File
@@ -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)
})
+120 -3
View File
@@ -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"`
}
+9 -3
View File
@@ -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 <b>%s</b>, 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 (<b>%s</b>). If you haven't received a confirmation email or need to receive a new one, please click the button below.
resend_mail = Click here to resend your activation email
send_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>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.
activation_email_sent = A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} 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.
+3 -2
View File
@@ -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
}
}
+1 -2
View File
@@ -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)
}
-149
View File
@@ -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")
}
+50
View File
@@ -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")
-38
View File
@@ -1,38 +0,0 @@
{{template "base/head" .}}
<div class="user activate">
<div class="ui middle very relaxed page grid">
<div class="column">
<form class="ui form" action="{{AppSubURL}}/user/activate" method="post">
{{.CSRFTokenHTML}}
<h2 class="ui top attached header">
{{.i18n.Tr "auth.active_your_account"}}
</h2>
<div class="ui attached segment">
{{template "base/alert" .}}
{{if .IsActivatePage}}
{{if .ServiceNotEnabled}}
<p class="center">{{.i18n.Tr "auth.disable_register_mail"}}</p>
{{else if .ResendLimited}}
<p class="center">{{.i18n.Tr "auth.resent_limit_prompt"}}</p>
{{else}}
<p>{{.i18n.Tr "auth.confirmation_email_sent" .LoggedUser.Email .Hours | Str2HTML}}</p>
{{end}}
{{else}}
{{if .IsSendRegisterMail}}
<p>{{.i18n.Tr "auth.confirmation_email_sent" .Email .Hours | Str2HTML}}</p>
{{else if .IsActivateFailed}}
<p>{{.i18n.Tr "auth.invalid_code"}}</p>
{{else}}
<p>{{.i18n.Tr "auth.has_unconfirmed_mail" .LoggedUser.Name .LoggedUser.Email | Str2HTML}}</p>
<div class="ui divider"></div>
<div class="text right">
<button class="ui blue button">{{.i18n.Tr "auth.resend_mail"}}</button>
</div>
{{end}}
{{end}}
</div>
</form>
</div>
</div>
</div>
{{template "base/footer" .}}
+9 -1
View File
@@ -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
+10 -2
View File
@@ -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 <b>%s</b>, 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>{email}</email> is not yet confirmed. Click below to send a new activation email valid for <hours>{hours} hours</hours>.",
"activation_email_sent": "A new activation email has been sent to <email>{email}</email>. Please check your inbox within <hours>{hours} 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..."
}
+174
View File
@@ -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<ActivateResponse | null>(null);
const [formError, setFormError] = useState<string | null>(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<HTMLFormElement>) {
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 (
<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("activate_your_account")}</CardTitle>
</CardHeader>
<CardContent className="pt-2">{renderContent()}</CardContent>
</Card>
</main>
);
function renderContent() {
if (isVerifying) {
if (verifyFailed) {
return (
<div className="flex flex-col gap-4 text-center">
<p role="alert" className="text-sm text-(--color-destructive)">
{t("invalid_code")}
</p>
<Button variant="link" size="inline" asChild className="self-center">
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
</Button>
</div>
);
}
return (
<p role="status" className="text-center text-sm text-(--color-foreground)">
{t("activating_account")}
</p>
);
}
if (!authenticated) {
return (
<div className="flex flex-col gap-4 text-center">
<p className="text-sm text-(--color-foreground)">{t("check_activation_email")}</p>
<Button variant="link" size="inline" asChild className="self-center">
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
</Button>
</div>
);
}
if (resent) {
return (
<p role="status" className="text-center text-sm text-(--color-foreground)">
{resent.rateLimited ? (
t("resend_rate_limited")
) : (
<Trans
i18nKey="activation_email_sent"
values={{ email, hours: resent.codeLifetimeHours }}
components={{ email: <b />, hours: <b /> }}
/>
)}
</p>
);
}
return (
<form onSubmit={onResend} noValidate>
<fieldset disabled={submitting} className="contents">
{formError && (
<div
role="alert"
className="mb-4 rounded-md border border-(--color-destructive) bg-(--color-destructive)/10 px-3 py-2 text-sm text-(--color-destructive)"
>
{formError}
</div>
)}
<div className="flex flex-col gap-4">
<p className="text-sm text-(--color-foreground)">
<Trans
i18nKey="activation_email_pending"
values={{ email, hours: codeLifetimeHours }}
components={{ email: <b />, hours: <b /> }}
/>
</p>
<Button type="submit" disabled={submitting} className="w-full">
{submitting ? t("sending_activation_email") : t("send_activation_email")}
</Button>
</div>
</fieldset>
</form>
);
}
}
@@ -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 (
<div className="flex flex-col gap-4 text-center">
<p role="status" className="text-sm text-(--color-foreground)">
{t("confirmation_email_sent")
.replace(/<[^>]+>/g, "")
.replace("%s", sent.email!)
.replace("%d", String(sent.hours))}
<Trans
i18nKey="activation_email_sent"
values={{ email: sent.email, hours: sent.hours }}
components={{ email: <b />, hours: <b /> }}
/>
</p>
<Button variant="link" size="inline" asChild className="self-center">
<a href={subUrl("/user/sign-in")}>{t("back_to_sign_in")}</a>
+3 -90
View File
@@ -1,25 +1,13 @@
import {
Outlet,
RouterProvider,
createRootRouteWithContext,
createRoute,
createRouter,
redirect,
} from "@tanstack/react-router";
import { Outlet, RouterProvider, createRootRouteWithContext, createRoute, createRouter } from "@tanstack/react-router";
import { Footer } from "@/components/Footer";
import { Navbar } from "@/components/Navbar";
import { webContext } from "@/lib/context";
import { loaderResponseError } from "@/lib/loader-error";
import { subUrl } from "@/lib/url";
import type { UserInfo } from "@/lib/user-info";
import { Landing } from "@/pages/Landing";
import { MFA } from "@/pages/MFA";
import { NotFound } from "@/pages/NotFound";
import { ResetPassword, type ResetPasswordPage } from "@/pages/ResetPassword";
import { ServerError } from "@/pages/ServerError";
import { SignIn, type SignInPage } from "@/pages/SignIn";
import { SignUp, type SignUpPage } from "@/pages/SignUp";
import { createUserRoutes } from "@/routes/user";
interface RouterContext {
user: UserInfo | null;
@@ -46,82 +34,7 @@ const landingRoute = createRoute({
component: Landing,
});
function requireUnauthenticated({ context }: { context: RouterContext }) {
if (!context.user) return;
// Bounce authenticated visits to "/" via full navigation so the server-rendered
// dashboard handler runs.
window.location.assign(subUrl("/"));
// The thrown redirect is a sentinel to halt loader execution;
// the document-level navigation above is what actually moves the user.
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
throw redirect({ to: "/", replace: true });
}
const signInRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/sign-in",
beforeLoad: requireUnauthenticated,
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 signUpRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/sign-up",
beforeLoad: requireUnauthenticated,
loader: async (): Promise<SignUpPage> => {
const res = await fetch(subUrl("/api/web/user/sign-up"), { credentials: "same-origin" });
if (!res.ok) {
throw await loaderResponseError(res);
}
return (await res.json()) as SignUpPage;
},
component: SignUp,
});
const resetPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/reset-password",
beforeLoad: requireUnauthenticated,
loader: async (): Promise<ResetPasswordPage> => {
const code = new URLSearchParams(window.location.search).get("code") ?? "";
const url = code
? subUrl("/api/web/user/reset-password") + "?code=" + encodeURIComponent(code)
: subUrl("/api/web/user/reset-password");
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) {
return { code, emailEnabled: false, valid: false };
}
const data = (await res.json()) as { emailEnabled: boolean; valid: boolean };
return { code, emailEnabled: data.emailEnabled, valid: data.valid };
},
component: ResetPassword,
});
const mfaRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/mfa",
loader: async (): Promise<{ pending: boolean }> => {
const res = await fetch(subUrl("/api/web/user/mfa"), { credentials: "same-origin" });
if (res.status === 404) {
// No pending MFA challenge — there is nothing to verify here, so fall
// through to the server-rendered home, which will redirect to sign-in
// for anonymous visitors and to the dashboard for signed-in ones.
window.location.assign(subUrl("/"));
return { pending: false };
}
return { pending: res.ok };
},
component: MFA,
});
const routeTree = rootRoute.addChildren([landingRoute, signInRoute, signUpRoute, resetPasswordRoute, mfaRoute]);
const routeTree = rootRoute.addChildren([landingRoute, ...createUserRoutes(rootRoute)]);
function makeRouter(context: RouterContext) {
return createRouter({
+123
View File
@@ -0,0 +1,123 @@
import { type AnyRoute, createRoute, redirect } from "@tanstack/react-router";
import { loaderResponseError } from "@/lib/loader-error";
import { subUrl } from "@/lib/url";
import type { UserInfo } from "@/lib/user-info";
import { Activate, type ActivatePage } from "@/pages/user/Activate";
import { MFA } from "@/pages/user/MFA";
import { ResetPassword, type ResetPasswordPage } from "@/pages/user/ResetPassword";
import { SignIn, type SignInPage } from "@/pages/user/SignIn";
import { SignUp, type SignUpPage } from "@/pages/user/SignUp";
interface RouterContext {
user: UserInfo | null;
}
function requireUnauthenticated({ context }: { context: RouterContext }) {
if (!context.user) return;
// Bounce authenticated visits to "/" via full navigation so the server-rendered
// dashboard handler runs.
window.location.assign(subUrl("/"));
// The thrown redirect is a sentinel to halt loader execution;
// the document-level navigation above is what actually moves the user.
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
throw redirect({ to: "/", replace: true });
}
export function createUserRoutes(rootRoute: AnyRoute) {
const signInRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/sign-in",
beforeLoad: requireUnauthenticated,
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 signUpRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/sign-up",
beforeLoad: requireUnauthenticated,
loader: async (): Promise<SignUpPage> => {
const res = await fetch(subUrl("/api/web/user/sign-up"), { credentials: "same-origin" });
if (!res.ok) {
throw await loaderResponseError(res);
}
return (await res.json()) as SignUpPage;
},
component: SignUp,
});
const resetPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/reset-password",
beforeLoad: requireUnauthenticated,
loader: async (): Promise<ResetPasswordPage> => {
const code = new URLSearchParams(window.location.search).get("code") ?? "";
const url = code
? subUrl("/api/web/user/reset-password") + "?code=" + encodeURIComponent(code)
: subUrl("/api/web/user/reset-password");
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok) {
return { code, emailEnabled: false, valid: false };
}
const data = (await res.json()) as { emailEnabled: boolean; valid: boolean };
return { code, emailEnabled: data.emailEnabled, valid: data.valid };
},
component: ResetPassword,
});
const activateRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/activate",
loader: async ({ context }): Promise<ActivatePage> => {
const code = new URLSearchParams(window.location.search).get("code") ?? "";
const routerContext = context as RouterContext;
if (!routerContext.user) {
if (code !== "") {
return { code, email: "", codeLifetimeHours: 0 };
}
// eslint-disable-next-line @typescript-eslint/only-throw-error -- TanStack's redirect() returns a sentinel that must be thrown.
throw redirect({ to: "/user/sign-in", replace: true });
}
const res = await fetch(subUrl("/api/web/user/activate"), { credentials: "same-origin" });
if (res.status === 404) {
// Already-active user hit a stale activation link. Send them home via
// a full navigation so the server-rendered dashboard handler decides
// where to land.
window.location.assign(subUrl("/"));
return { code, email: "", codeLifetimeHours: 0 };
}
if (!res.ok) {
throw await loaderResponseError(res);
}
const data = (await res.json()) as Omit<ActivatePage, "code">;
return { code, ...data };
},
component: Activate,
});
const mfaRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/user/mfa",
loader: async (): Promise<{ pending: boolean }> => {
const res = await fetch(subUrl("/api/web/user/mfa"), { credentials: "same-origin" });
if (res.status === 404) {
// No pending MFA challenge. Fall through to the server-rendered home,
// which will redirect to sign-in for anonymous visitors and to the
// dashboard for signed-in ones.
window.location.assign(subUrl("/"));
return { pending: false };
}
return { pending: res.ok };
},
component: MFA,
});
return [signInRoute, signUpRoute, resetPasswordRoute, activateRoute, mfaRoute];
}