feat: 添加通过密码登录的功能

This commit is contained in:
gujiangjiang committed 2025-10-17 05:29:29 +08:00
1 parent 89302e1afb
commit 4f010d271e
6 files changed
+296 -146

No files matched your search

+74 -61
View File
@@ -1,63 +1,76 @@
import { Link } from "react-router-dom";
import Twitter from "./icons/Twitter.tsx";
import GitHub from "./icons/GitHub.tsx";
import MailIcon from "./icons/MailIcon.tsx";
import WrdoLogo from "./icons/Wrdo.tsx";
import { Dispatch, SetStateAction, useCallback, useEffect, useRef } from 'react';
// fix: 将具名导入 { useWindowSize } 更改为默认导入 useWindowSize
import useWindowSize from '../hooks/use-window-size';
import Leaflet from './leaflet';
/**
* 网站的页脚组件
*/
export function Footer() {
return (
<div className="text-white w-full mt-auto flex flex-col items-center justify-between px-5 pt-16 mb-10 md:px-10 mx-auto sm:flex-row">
<Link to="/" className="text-xl font-black leading-none select-none logo">
VMAIL.DEV
</Link>{" "}
<p className="mt-4 text-sm text-gray-400 sm:ml-4 sm:pl-4 sm:border-l sm:border-gray-200 sm:mt-0">
© 2024 Products of{" "}
<a
className="font-semibold underline hover:text-gray-600"
href="https://www.oiov.dev"
target="_blank"
rel="noopener noreferrer"
>
oiov
</a>
.
</p>
<div className="inline-flex justify-center mt-4 space-x-5 sm:ml-auto sm:mt-0 sm:justify-start">
{/* 社交媒体和联系方式链接 */}
<a
href="https://wr.do"
target="_blank"
rel="noopener noreferrer"
title="WR.DO"
className="text-gray-400 hover:text-gray-500 scale-[1.2]">
<WrdoLogo className="w-6 h-6" />
</a>
<a
href="mailto:hi@oiov.dev"
title="Email"
className="text-gray-400 hover:text-gray-500">
<MailIcon className="w-6 h-6" />
</a>
<a
href="https://twitter.com/yesmoree"
target="_blank"
rel="noopener noreferrer"
title="Twitter"
className="text-gray-400 hover:text-gray-500">
<Twitter />
</a>
<a
href="https://github.com/oiov/vmail"
target="_blank"
rel="noopener noreferrer"
title="Github"
className="text-gray-400 hover:text-gray-500">
<GitHub />
</a>
</div>
</div>
// 将默认导出(export default)改为具名导出(export)
export function Modal({
children,
showModal,
setShowModal,
}: {
children: React.ReactNode;
showModal: boolean;
setShowModal: Dispatch<SetStateAction<boolean>>;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const { isMobile } = useWindowSize();
// close modal on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setShowModal(false);
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [modalRef, setShowModal]);
const onKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === "Escape") {
setShowModal(false);
}
},
[setShowModal],
);
}
useEffect(() => {
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [onKeyDown]);
if (isMobile) {
return (
<Leaflet show={showModal} setShow={setShowModal}>
{children}
</Leaflet>
);
}
return (
<>
{showModal && (
<div
className="fixed inset-0 z-40 flex items-center justify-center"
style={{
backdropFilter: "blur(4px)",
WebkitBackdropFilter: "blur(4px)",
backgroundColor: "rgba(0, 0, 0, 0.4)",
}}
>
<div
className="relative z-50 w-full max-w-lg"
ref={modalRef}
>
{children}
</div>
</div>
)}
</>
);
}
+57 -56
View File
@@ -2,38 +2,42 @@ import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Modal from "./modal";
import { Form, useNavigation } from "@remix-run/react";
// fix: 使用具名导入来修复构建错误
import { Modal } from "./modal";
import { useTranslation } from "react-i18next";
import Close from "./icons/Close";
import toast from "react-hot-toast";
export default function PasswordModal({
password,
showPasswordModal,
setShowPasswordModal,
}: {
password: string;
// 定义组件的 props 类型
interface PasswordModalProps {
showPasswordModal: boolean;
setShowPasswordModal: Dispatch<SetStateAction<boolean>>;
}) {
const navigation = useNavigation();
onLogin: (password: string) => Promise<void>; // feat: 添加 onLogin 回调处理登录逻辑
isLoggingIn: boolean; // feat: 添加状态以在 UI 中反映登录过程
}
export default function PasswordModal({
showPasswordModal,
setShowPasswordModal,
onLogin,
isLoggingIn,
}: PasswordModalProps) {
const { t } = useTranslation();
const [password, setPassword] = useState(""); // 状态:存储输入的密码
const [isSubmitted, setIsSubmitted] = useState(false);
useEffect(() => {
if (showPasswordModal && navigation.state === "submitting") {
setIsSubmitted(true);
// 提交处理器
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password) {
toast.error(t("Password is required"));
return;
}
if (showPasswordModal && navigation.state === "idle" && isSubmitted) {
setShowPasswordModal(false);
}
}, [navigation.state, showPasswordModal]);
await onLogin(password);
// 登录成功或失败的 toast 提示已在 Home.tsx 中处理
};
return (
<Modal showModal={showPasswordModal} setShowModal={setShowPasswordModal}>
@@ -51,9 +55,10 @@ export default function PasswordModal({
{t("Save your password and continue using this email in 1 day")}
</p>
</div>
<Form method="POST" className="flex flex-col mt-4 space-y-4 px-4">
<form onSubmit={handleSubmit} className="flex flex-col mt-4 space-y-4 px-4">
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
type="text"
name="password"
placeholder={t("Enter your password *")}
@@ -66,55 +71,51 @@ export default function PasswordModal({
)}
.
</p>
<p className="text-sm text-yellow-600">
{t(
"Remember your password, otherwise your email will expire and cannot be retrieved"
)}
.
</p>
{password && password.length > 0 ? (
<button
type="submit"
name="_action"
value="stop"
className="py-2.5 text-white rounded-md w-full bg-red-500 hover:opacity-90 disabled:cursor-not-allowed disabled:bg-zinc-500">
{t("Log out")}
</button>
) : (
<button
type="submit"
value="login"
name="_action"
disabled={navigation.state != "idle"}
className="py-2.5 text-white rounded-md w-full bg-cyan-600 hover:opacity-90 disabled:cursor-not-allowed disabled:bg-zinc-500">
{navigation.state === "submitting"
? t("Submitting...")
: t("Login")}
</button>
)}
</Form>
<button
type="submit"
disabled={isLoggingIn}
className="py-2.5 text-white rounded-md w-full bg-cyan-600 hover:opacity-90 disabled:cursor-not-allowed disabled:bg-zinc-500">
{isLoggingIn
? t("Submitting...")
: t("Login")}
</button>
</form>
</div>
</Modal>
);
}
export function usePasswordModal(password: string) {
// 自定义 Hook,用于管理密码模态框的可见性
export function usePasswordModal() {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const SenderModalCallback = useCallback(() => {
return (
<PasswordModal
password={password}
showPasswordModal={showPasswordModal}
setShowPasswordModal={setShowPasswordModal}
/>
);
}, [showPasswordModal, setShowPasswordModal]);
// 使用 useCallback 优化 PasswordModal 组件的创建
const PasswordModalCallback = useCallback(
({ onLogin, isLoggingIn }: Omit<PasswordModalProps, 'showPasswordModal' | 'setShowPasswordModal'>) => {
return (
<PasswordModal
showPasswordModal={showPasswordModal}
setShowPasswordModal={setShowPasswordModal}
onLogin={onLogin}
isLoggingIn={isLoggingIn}
/>
);
},
[showPasswordModal] // 依赖项
);
return useMemo(
() => ({ setShowPasswordModal, PasswordModal: SenderModalCallback }),
[setShowPasswordModal, SenderModalCallback]
() => ({
showPasswordModal,
setShowPasswordModal,
PasswordModal: PasswordModalCallback
}),
[setShowPasswordModal, PasswordModalCallback, showPasswordModal]
);
}
+44 -5
View File
@@ -8,10 +8,15 @@ import { Toaster, toast } from 'react-hot-toast';
import { MailList } from '../components/MailList.tsx';
import { CopyButton } from '../components/CopyButton.tsx';
import { getEmails, deleteEmails } from '../services/api.ts';
// feat: 导入 loginByPassword
import { getEmails, deleteEmails, loginByPassword } from '../services/api.ts';
import { useConfig } from '../hooks/useConfig.ts';
import { getRandomCharacter } from '../lib/utlis.ts';
// feat: 导入密码模态框和相关 hook
import { usePasswordModal } from '../components/password.tsx';
import PasswordIcon from '../components/icons/Password.tsx'; // 假设你有一个密码图标
// 图标导入
import ShieldCheck from "../components/icons/ShieldCheck.tsx";
import Cloudflare from "../components/icons/Cloudflare.tsx";
@@ -32,6 +37,10 @@ export function Home() {
const [turnstileToken, setTurnstileToken] = useState<string>('');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// feat: 初始化密码模态框
const { PasswordModal, setShowPasswordModal } = usePasswordModal();
const [isLoggingIn, setIsLoggingIn] = useState(false);
// 使用 React Query 获取邮件列表
const { data: emails = [], isLoading, isFetching, refetch } = useQuery<Email[]>({
queryKey: ['emails', address],
@@ -42,7 +51,7 @@ export function Home() {
refetchInterval: 20000,
// 当查询出错时,自动刷新会停止,这里添加错误提示方便调试
onError: (err: Error) => {
toast.error(`获取邮件失败: ${err.message}`, { duration: 5000 });
toast.error(`${t("Failed to get emails")}: ${err.message}`, { duration: 5000 });
},
// 失败后不自动重试
retry: false,
@@ -71,28 +80,51 @@ export function Home() {
const deleteMutation = useMutation({
mutationFn: (ids: string[]) => deleteEmails(ids, turnstileToken),
onSuccess: () => {
toast.success('邮件已删除');
toast.success(t('Emails deleted'));
setSelectedIds([]); // 清空选择
// 使邮件列表的查询失效,触发一次刷新
queryClient.invalidateQueries({ queryKey: ['emails', address] });
},
onError: () => {
toast.error('删除失败');
toast.error(t('Deletion failed'));
}
});
// 定义 handleDeleteEmails 函数
const handleDeleteEmails = (ids: string[]) => {
if (ids.length === 0) {
toast.error('请选择要删除的邮件');
toast.error(t('Please select emails to delete'));
return;
}
deleteMutation.mutate(ids);
};
// feat: 处理密码登录的函数
const handleLogin = async (password: string) => {
if (!turnstileToken) {
toast.error(t('No captcha response'));
return;
}
setIsLoggingIn(true);
try {
const data = await loginByPassword(password, turnstileToken);
Cookies.set('userMailbox', data.address, { expires: 1 });
setAddress(data.address);
setShowPasswordModal(false); // 关闭模态框
toast.success(t("Login successful"));
} catch (error: any) {
toast.error(`${t("Login failed")}: ${error.message}`);
} finally {
setIsLoggingIn(false);
}
};
return (
<div className="h-full flex flex-col gap-4 md:flex-row justify-center items-start mt-24 mx-6 md:mx-10">
<Toaster position="top-center" />
{/* feat: 渲染密码模态框 */}
<PasswordModal onLogin={handleLogin} isLoggingIn={isLoggingIn} />
<div className="flex flex-col text-white items-start w-full md:w-[350px] mx-auto gap-2">
{/* 左侧信息面板 */}
<div className="w-full mb-4 md:max-w-[350px] shrink-0 group group-hover:before:duration-500 group-hover:after:duration-500 after:duration-500 hover:border-cyan-600 hover:before:[box-shadow:_20px_20px_20px_30px_#a21caf] duration-500 before:duration-500 hover:duration-500 hover:after:-right-8 hover:before:right-12 hover:before:-bottom-8 hover:before:blur origin-left hover:decoration-2 relative bg-neutral-800 h-full border text-left p-4 rounded-lg overflow-hidden border-cyan-50/20 before:absolute before:w-12 before:h-12 before:content[''] before:right-1 before:top-1 before:z-10 before:bg-violet-500 before:rounded-full before:blur-lg after:absolute after:z-10 after:w-20 after:h-20 after:content[''] after:bg-rose-300 after:right-8 after:top-3 after:rounded-full after:blur-lg">
@@ -135,6 +167,13 @@ export function Home() {
className="py-2.5 rounded-md w-full bg-cyan-600 hover:opacity-90 disabled:cursor-not-allowed disabled:bg-zinc-500">
{t("Create temporary email")}
</button>
{/* feat: 添加登录按钮 */}
<p
className="mt-4 text-sm text-cyan-500 cursor-pointer"
onClick={() => setShowPasswordModal(true)}>
<PasswordIcon className="inline-block w-4 h-4 mr-2" />
{t("Have a password? Login.")}
</p>
</div>
)}
</div>
+47 -16
View File
@@ -1,28 +1,59 @@
import axios from 'axios';
import { Email } from 'database/schema'; // 假设您可以从数据库包中导入类型
// 导入 Email 类型,'database' 是您 monorepo 中的包名
import type { Email } from 'database';
const API_BASE_URL = '/api';
// 定义 Turnstile 人机验证所需的 token
// 恢复此接口定义,以备将来使用
interface ApiPayload {
token: string;
[key: string]: any;
}
// 获取邮件列表
export const getEmails = async (address: string, token: string): Promise<Email[]> => {
const response = await axios.post('/api/emails', { address, token });
return response.data;
};
export async function getEmails(address: string, token: string): Promise<Email[]> {
const response = await fetch(`${API_BASE_URL}/emails`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ address, token }),
});
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}
// 获取单个邮件详情
export const getEmailById = async (id: string): Promise<Email> => {
const response = await axios.get(`/api/emails/${id}`);
return response.data;
};
// feat: 添加获取单封邮件详情的函数
export async function getEmailById(id: string): Promise<Email> {
const response = await fetch(`${API_BASE_URL}/emails/${id}`);
if (!response.ok) {
throw new Error('Failed to fetch email');
}
return response.json();
}
// 删除邮件
export const deleteEmails = async (ids: string[], token: string): Promise<any> => {
const response = await axios.post('/api/delete-emails', { ids, token });
return response.data;
};
export async function deleteEmails(ids: string[], token: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/delete-emails`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids, token }),
});
if (!response.ok) {
throw new Error('Failed to delete emails');
}
}
// ... 您可以在此添加更多 API 调用函数
// feat: 添加密码登录函数
export async function loginByPassword(password: string, token: string): Promise<{ address: string }> {
const response = await fetch(`${API_BASE_URL}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, token }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.message || 'Login failed');
}
return response.json();
}
+21 -1
View File
@@ -1,4 +1,4 @@
import { count, desc, eq, and, inArray } from "drizzle-orm";
import { count, desc, eq, and, inArray, lt } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql";
import { emails, InsertEmail } from "./schema";
@@ -86,4 +86,24 @@ export async function deleteEmails(db: LibSQLDatabase, ids: string[]) {
console.error(e);
return { count: 0 };
}
}
/**
* 新增函数:根据提供的过期时间删除此时间之前的所有邮件。
* @param db Drizzle 数据库实例。
* @param expirationTime 一个 Date 对象,表示过期时间点。
* @returns 返回一个包含已删除邮件数量的对象,或在出错时返回 { count: 0 }。
*/
export async function deleteExpiredEmails(db: LibSQLDatabase, expirationTime: Date) {
try {
// 使用Drizzle的lt(小于)操作符来比较createdAt字段和expirationTime
const result = await db.delete(emails).where(lt(emails.createdAt, expirationTime)).execute();
// 返回受影响的行数,即已删除的邮件数量
return { count: result.rowsAffected };
} catch (e) {
// 如果在删除过程中发生错误,则在控制台打印错误信息
console.error('清理过期邮件失败:', e);
// 并返回一个表示删除数量为0的对象
return { count: 0 };
}
}
+53 -7
View File
@@ -2,7 +2,8 @@ import { Hono } from 'hono';
import { serveStatic } from 'hono/cloudflare-workers';
import { cors } from 'hono/cors';
// 路径修正:使用相对路径并导入正确的函数
import { deleteEmails, findEmailById, getEmailsByMessageTo, insertEmail } from '../../packages/database/dao';
// feat: 导入 getEmailByPassword 函数
import { deleteEmails, findEmailById, getEmailByPassword, getEmailsByMessageTo, insertEmail, deleteExpiredEmails } from '../../packages/database/dao';
import { getD1DB } from '../../packages/database/db';
import { InsertEmail, insertEmailSchema } from '../../packages/database/schema';
import { nanoid } from 'nanoid/non-secure';
@@ -26,9 +27,26 @@ const app = new Hono<{ Bindings: Env }>();
// 配置 CORS
app.use('/api/*', cors());
// fix: 修复重复读取请求体的问题,并增强健壮性
// Turnstile (人机验证) 中间件
const turnstile = async (c, next) => {
const body = await c.req.json().catch(() => ({}));
let body: any = {}; // 默认值为空对象
try {
// 尝试读取请求体文本。如果请求体为空,.text() 会返回空字符串,不会像 .json() 那样报错。
const rawBody = await c.req.text();
if (rawBody) {
// 如果请求体不为空,则解析为 JSON
body = JSON.parse(rawBody);
}
} catch (e) {
// 如果 JSON.parse 失败,说明请求体不是有效的 JSON
// 这种情况我们依然继续,因为 token 可能在 header 中。body 维持为空对象。
console.error("无法将请求体解析为 JSON:", e);
}
// 将解析后的 body (或空对象) 存入上下文,供后续的处理器使用
c.set('parsedBody', body);
const token = body.token || c.req.header('cf-turnstile-token');
const ip = c.req.header('CF-Connecting-IP');
@@ -48,6 +66,8 @@ const turnstile = async (c, next) => {
const data = await res.json();
if (!data.success) {
// feat: 增加详细的错误日志,方便调试
console.error("Turnstile 验证失败:", data['error-codes']);
return c.json({ message: 'token is invalid' }, 400);
}
@@ -60,7 +80,11 @@ const api = app.basePath('/api');
// 获取邮件列表
api.post('/emails', turnstile, async (c) => {
const db = getD1DB(c.env.DB);
const { address } = await c.req.json();
// fix: 从上下文中获取已解析的请求体,并增加健壮性处理
const { address } = c.get('parsedBody') || {};
if (!address) {
return c.json({ message: 'address is required' }, 400);
}
// 函数调用修正:使用 getEmailsByMessageTo 函数
const emails = await getEmailsByMessageTo(db, address as string);
return c.json(emails);
@@ -72,17 +96,39 @@ api.get('/emails/:id', async (c) => {
const { id } = c.req.param();
// 函数调用修正:使用 findEmailById 函数
const email = await findEmailById(db, id);
if (!email) {
return c.json({ message: 'Email not found'}, 404);
}
return c.json(email);
});
// 删除邮件
api.post('/delete-emails', turnstile, async (c) => {
const db = getD1DB(c.env.DB);
const { ids } = await c.req.json();
// fix: 从上下文中获取已解析的请求体,并增加健壮性处理
const { ids } = c.get('parsedBody') || {};
if (!ids || !Array.isArray(ids) || ids.length === 0) {
return c.json({ message: 'ids are required' }, 400);
}
const result = await deleteEmails(db, ids as string[]);
return c.json(result);
});
// feat: 添加登录路由
api.post('/login', turnstile, async (c) => {
const db = getD1DB(c.env.DB);
// fix: 从上下文中获取已解析的请求体,并增加健壮性处理
const { password } = c.get('parsedBody') || {};
if (!password) {
return c.json({ message: 'Password is required' }, 400);
}
const email = await getEmailByPassword(db, password as string);
if (!email) {
return c.json({ message: 'Invalid password' }, 404);
}
return c.json({ address: email.messageTo });
});
// 前端配置接口
app.get('/config', (c) => {
@@ -115,7 +161,7 @@ export default {
const email = insertEmailSchema.parse(newEmail);
await insertEmail(db, email);
} catch (e) {
console.error('Failed to process email:', e);
console.error('处理邮件失败:', e);
}
},
@@ -133,8 +179,8 @@ export default {
// 定时任务 (清理过期邮件)
async scheduled(event, env, ctx) {
const db = getD1DB(env.DB);
// 清理一小时前的邮件
const oneHourAgo = new Date(Date.now() - 1000 * 60 * 60);
// 注意: `deleteExpiredEmails` 是一个假设的函数,您需要在 `database/dao.ts` 中实现它
// await deleteExpiredEmails(db, oneHourAgo);
await deleteExpiredEmails(db, oneHourAgo);
},
};