#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