#11 — email change flow: POST /user/setting/email validates the new address (format, domain filter, uniqueness) and current password, stores the pending address in KV, and mails a signed confirmation link to the new mailbox. GET /user/activate_email/:id verifies the signature and applies the change once (KV entry consumed). New settings dialog on the profile page plus a /session/activate_email confirmation page. #7 — Monaco code viewer remembers charset and text-type per extension (code_charset_/code_language_ session keys) and re-applies them on the next open of the same file type; the charset menu now shows the active encoding. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>pull/3582/head
parent
606ee032d3
commit
1914fcbcdf
@ -0,0 +1,82 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery } from "../../../util";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Box, Button, Typography } from "@mui/material";
|
||||
import PageTitle from "../../../router/PageTitle.tsx";
|
||||
import CheckmarkCircle from "../../Icons/CheckmarkCircle.tsx";
|
||||
import { setHeadlessFrameLoading } from "../../../redux/globalStateSlice.ts";
|
||||
import { sendEmailChangeActivate } from "../../../api/api.ts";
|
||||
|
||||
const ActivateEmailChange = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const query = useQuery();
|
||||
|
||||
const [success, setSuccess] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const sign = query.get("sign");
|
||||
const id = query.get("id");
|
||||
if (!sign || !id) {
|
||||
setSuccess(false);
|
||||
navigate("/session");
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setHeadlessFrameLoading(true));
|
||||
dispatch(sendEmailChangeActivate(id, decodeURIComponent(sign)))
|
||||
.then(() => {
|
||||
setSuccess(true);
|
||||
})
|
||||
.catch(() => {
|
||||
navigate("/session");
|
||||
})
|
||||
.finally(() => {
|
||||
dispatch(setHeadlessFrameLoading(false));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<PageTitle title={t("login.emailChangedTitle")} />
|
||||
<Box sx={{ overflow: "hidden" }}>
|
||||
{success && (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
py: 7,
|
||||
}}
|
||||
>
|
||||
<CheckmarkCircle fontSize={"large"} color={"success"} />
|
||||
<Typography
|
||||
variant={"h6"}
|
||||
sx={{
|
||||
color: (theme) => theme.palette.success.main,
|
||||
mt: 1,
|
||||
}}
|
||||
>
|
||||
{t("application:login.emailChanged")}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
onClick={() => navigate("/session")}
|
||||
sx={{ mt: 2 }}
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("login.backToSingIn")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActivateEmailChange;
|
||||
@ -0,0 +1,80 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSnackbar } from "notistack";
|
||||
import { useAppDispatch } from "../../../redux/hooks.ts";
|
||||
import React, { useState } from "react";
|
||||
import { DialogContent, FormControl, FormHelperText, Typography } from "@mui/material";
|
||||
import DraggableDialog from "../../Dialogs/DraggableDialog.tsx";
|
||||
import { DenseFilledTextField } from "../../Common/StyledComponents.tsx";
|
||||
import { sendRequestEmailChange } from "../../../api/api.ts";
|
||||
|
||||
export interface ChangeEmailDialogProps {
|
||||
open?: boolean;
|
||||
onClose: () => void;
|
||||
currentEmail?: string;
|
||||
}
|
||||
|
||||
const ChangeEmailDialog = ({ open, onClose, currentEmail }: ChangeEmailDialogProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { enqueueSnackbar } = useSnackbar();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
|
||||
const submit = () => {
|
||||
setLoading(true);
|
||||
dispatch(sendRequestEmailChange(email, password))
|
||||
.then(() => {
|
||||
enqueueSnackbar({ message: t("setting.emailChangeSent"), variant: "success" });
|
||||
setEmail("");
|
||||
setPassword("");
|
||||
onClose();
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<DraggableDialog
|
||||
title={t("setting.changeEmail")}
|
||||
showCancel
|
||||
onAccept={submit}
|
||||
loading={loading}
|
||||
dialogProps={{
|
||||
open: !!open,
|
||||
onClose: onClose,
|
||||
fullWidth: true,
|
||||
maxWidth: "xs",
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||
{t("setting.changeEmailDes", { email: currentEmail })}
|
||||
</Typography>
|
||||
<FormControl fullWidth sx={{ mb: 2 }}>
|
||||
<DenseFilledTextField
|
||||
required
|
||||
type="email"
|
||||
label={t("setting.newEmail")}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
autoComplete="email"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<DenseFilledTextField
|
||||
required
|
||||
type="password"
|
||||
label={t("login.password")}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<FormHelperText>{t("setting.changeEmailPasswordDes")}</FormHelperText>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
</DraggableDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangeEmailDialog;
|
||||
@ -0,0 +1,123 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/auth"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cluster/routes"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/email"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/serializer"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
emailChangeSessionKey = "email_change_"
|
||||
emailChangeTTL = 24 * 3600
|
||||
)
|
||||
|
||||
type (
|
||||
// RequestEmailChangeService starts the email-change flow: validates the new
|
||||
// address and the current password, stores the pending address in KV, and
|
||||
// sends a signed confirmation link to the new mailbox.
|
||||
RequestEmailChangeService struct {
|
||||
NewEmail string `json:"new_email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
RequestEmailChangeParamCtx struct{}
|
||||
)
|
||||
|
||||
func (s *RequestEmailChangeService) Request(c *gin.Context) error {
|
||||
dep := dependency.FromContext(c)
|
||||
u := inventory.UserFromContext(c)
|
||||
userClient := dep.UserClient()
|
||||
|
||||
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := inventory.CheckPassword(u, s.Password); err != nil {
|
||||
return serializer.NewError(serializer.CodeIncorrectPassword, "Incorrect password", err)
|
||||
}
|
||||
|
||||
if err := CheckEmailAllowed(dep.SettingProvider().EmailFilter(c), s.NewEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := userClient.GetByEmail(c, s.NewEmail); err == nil {
|
||||
return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
|
||||
}
|
||||
|
||||
// The pending address lives in KV — the signed link carries only the user
|
||||
// id, so the target address can't be tampered with.
|
||||
kv := dep.KV()
|
||||
if err := kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), s.NewEmail, emailChangeTTL); err != nil {
|
||||
return serializer.NewError(serializer.CodeInternalSetting, "Failed to store email change session", err)
|
||||
}
|
||||
|
||||
base := dep.SettingProvider().SiteURL(c)
|
||||
userID := hashid.EncodeUserID(dep.HashIDEncoder(), u.ID)
|
||||
ttl := time.Now().Add(emailChangeTTL * time.Second)
|
||||
activateURL, err := auth.SignURI(c, dep.GeneralAuth(), routes.MasterUserActivateEmailAPIUrl(base, userID).String(), &ttl)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeEncryptError, "Failed to sign the activation link", err)
|
||||
}
|
||||
|
||||
credential := activateURL.Query().Get("sign")
|
||||
finalURL := routes.MasterUserActivateEmailUrl(base)
|
||||
queries := finalURL.Query()
|
||||
queries.Add("id", userID)
|
||||
queries.Add("sign", credential)
|
||||
finalURL.RawQuery = queries.Encode()
|
||||
|
||||
title, body, err := email.NewActivationEmail(c, dep.SettingProvider(), u, finalURL.String())
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
|
||||
}
|
||||
|
||||
if err := dep.EmailClient(c).Send(c, s.NewEmail, title, body); err != nil {
|
||||
return serializer.NewError(serializer.CodeFailedSendEmail, "Failed to send confirmation email", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActivateEmailChange applies a pending email change once the signed link
|
||||
// from the confirmation mail is opened. The target address comes from KV so a
|
||||
// valid signature alone can't pick an arbitrary address.
|
||||
func ActivateEmailChange(c *gin.Context) error {
|
||||
uid := hashid.FromContext(c)
|
||||
dep := dependency.FromContext(c)
|
||||
userClient := dep.UserClient()
|
||||
kv := dep.KV()
|
||||
|
||||
pending, ok := kv.Get(fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
|
||||
if !ok {
|
||||
return serializer.NewError(serializer.CodeParamErr, "No pending email change", nil)
|
||||
}
|
||||
newEmail, ok := pending.(string)
|
||||
if !ok {
|
||||
return serializer.NewError(serializer.CodeInternalSetting, "Invalid pending email change", nil)
|
||||
}
|
||||
|
||||
target, err := userClient.GetByID(c, uid)
|
||||
if err != nil {
|
||||
return serializer.NewError(serializer.CodeUserNotFound, "User not found", err)
|
||||
}
|
||||
|
||||
if _, err := userClient.GetByEmail(c, newEmail); err == nil {
|
||||
return serializer.NewError(serializer.CodeEmailExisted, "Email already registered", nil)
|
||||
}
|
||||
|
||||
target.Email = newEmail
|
||||
if _, err := userClient.Upsert(c, target, "", ""); err != nil {
|
||||
return serializer.NewError(serializer.CodeDBError, "Failed to update email", err)
|
||||
}
|
||||
|
||||
kv.Delete("", fmt.Sprintf("%s%d", emailChangeSessionKey, uid))
|
||||
return nil
|
||||
}
|
||||
@ -0,0 +1,77 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/application/dependency"
|
||||
"github.com/cloudreve/Cloudreve/v4/ent/enttest"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory"
|
||||
"github.com/cloudreve/Cloudreve/v4/inventory/types"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/boolset"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/cache"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/hashid"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestActivateEmailChange verifies the signed-link confirmation path: pending
|
||||
// address comes from KV, the change applies once, and replays/concurrent
|
||||
// claims are rejected.
|
||||
func TestActivateEmailChange(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
client := enttest.Open(t, "sqlite3", "file:"+t.Name()+"?mode=memory&cache=shared")
|
||||
t.Cleanup(func() { require.NoError(t, client.Close()) })
|
||||
ctx := context.Background()
|
||||
|
||||
group := client.Group.Create().SetName("g").SetPermissions(&boolset.BooleanSet{}).SaveX(ctx)
|
||||
u := client.User.Create().
|
||||
SetEmail("old@example.com").
|
||||
SetNick("change").
|
||||
SetStatus("active").
|
||||
SetGroup(group).
|
||||
SetSettings(&types.UserSetting{}).
|
||||
SaveX(ctx)
|
||||
client.User.Create().
|
||||
SetEmail("taken@example.com").
|
||||
SetNick("taken").
|
||||
SetStatus("active").
|
||||
SetGroup(group).
|
||||
SetSettings(&types.UserSetting{}).
|
||||
SaveX(ctx)
|
||||
|
||||
kv := cache.NewMemoStore("", nil)
|
||||
dep := dependency.NewDependency(
|
||||
dependency.WithUserClient(inventory.NewUserClient(client)),
|
||||
dependency.WithKV(kv),
|
||||
)
|
||||
|
||||
newCtx := func(uid int) *gin.Context {
|
||||
engine := gin.New()
|
||||
engine.ContextWithFallback = true
|
||||
c := gin.CreateTestContextOnly(httptest.NewRecorder(), engine)
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
util.WithValue(c, dependency.DepCtx{}, dep)
|
||||
util.WithValue(c, hashid.ObjectIDCtx{}, uid)
|
||||
return c
|
||||
}
|
||||
|
||||
// No pending change → rejected.
|
||||
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
|
||||
|
||||
// Pending change applies.
|
||||
require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "new@example.com", 60))
|
||||
require.NoError(t, ActivateEmailChange(newCtx(u.ID)))
|
||||
require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
|
||||
|
||||
// Replay after success is rejected (KV entry consumed).
|
||||
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
|
||||
|
||||
// Address claimed between request and confirm is rejected.
|
||||
require.NoError(t, kv.Set(fmt.Sprintf("%s%d", emailChangeSessionKey, u.ID), "taken@example.com", 60))
|
||||
require.Error(t, ActivateEmailChange(newCtx(u.ID)))
|
||||
require.Equal(t, "new@example.com", client.User.GetX(ctx, u.ID).Email)
|
||||
}
|
||||
Loading…
Reference in new issue