diff --git a/.gitignore b/.gitignore index 235d988a..dc5798c7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,9 @@ /node_modules /public/dll /public/downloads +/public/assets .idea .vscode /log -/app/config/webpack-assets.json \ No newline at end of file +/app/config/webpack-assets.json +package-lock.json \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..48082f72 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +12 diff --git a/app/controllers/home.js b/app/controllers/home.js index 2feea69e..1f993232 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -12,10 +12,18 @@ const cacheControl = (ctx) => { } const renderLandingPage = async (ctx) => { - const clientId = await network.github.getVerify() + let clientId = null + let loginLink = '#' + try { + clientId = await network.github.getVerify() + loginLink = `https://github.com/login/oauth/authorize?scope=user:email&client_id=${clientId}` + } catch (error) { + logger.warn(`[GitHub Service] Service unavailable: ${error.message}`) + // Fallback for local development without GitHub service + loginLink = '#github-service-unavailable' + } cacheControl(ctx) - const loginLink = `https://github.com/login/oauth/authorize?scope=user:email&client_id=${clientId}` logger.info(`[LoginLink] ${loginLink}`) const { messageCode, messageType } = ctx.request.query @@ -28,6 +36,31 @@ const renderLandingPage = async (ctx) => { }) } +const renderSignupPage = async (ctx) => { + let clientId = null + let loginLink = '#' + try { + clientId = await network.github.getVerify() + loginLink = `https://github.com/login/oauth/authorize?scope=user:email&client_id=${clientId}` + } catch (error) { + logger.warn(`[GitHub Service] Service unavailable: ${error.message}`) + // Fallback for local development without GitHub service + loginLink = '#github-service-unavailable' + } + + cacheControl(ctx) + logger.info(`[LoginLink] ${loginLink}`) + + const { messageCode, messageType } = ctx.request.query + + await ctx.render('user/signup', { + loginLink, + messageCode, + messageType, + title: ctx.__('signupPage.title') + }) +} + const render500Page = async (ctx) => { cacheControl(ctx) await ctx.render('error/500', { @@ -103,15 +136,45 @@ const combineStat = stats => stats.reduce((pre, cur) => { }, {}) const statistic = async (ctx) => { - const [ - users, - githubFields, - resumeFields - ] = await Promise.all([ - network.user.getUserCount(), - network.stat.getStat({ type: 'github' }), - network.stat.getStat({ type: 'resume' }) - ]) + let users = 0 + let githubFields = [] + let resumeFields = [] + + try { + const [ + usersResult, + githubResult, + resumeResult + ] = await Promise.all([ + network.user.getUserCount().catch(() => { + logger.warn('User service not available, using mock data') + return 1234 + }), + network.stat.getStat({ type: 'github' }).catch(() => { + logger.warn('Stat service not available for github, using mock data') + return [ + { action: 'pageview', count: 5678 }, + { action: 'share', count: 234 } + ] + }), + network.stat.getStat({ type: 'resume' }).catch(() => { + logger.warn('Stat service not available for resume, using mock data') + return [ + { action: 'pageview', count: 3456 }, + { action: 'download', count: 567 } + ] + }) + ]) + + users = usersResult + githubFields = githubResult + resumeFields = resumeResult + } catch (error) { + logger.error('Error fetching statistics, using fallback data:', error.message) + users = 1234 + githubFields = [{ action: 'pageview', count: 5678 }] + resumeFields = [{ action: 'pageview', count: 3456 }] + } const github = combineStat(githubFields || []) const resume = combineStat(resumeFields || []) @@ -175,6 +238,64 @@ const getIcon = async (ctx, next) => { await next() } +/* ===================== Email Authentication Pages ===================== */ + +const renderForgotPasswordPage = async (ctx) => { + const { messageCode, messageType } = ctx.request.query + + cacheControl(ctx) + await ctx.render('user/forgot-password', { + messageCode, + messageType, + title: ctx.__('forgotPasswordPage.title') + }) +} + +const renderResetPasswordPage = async (ctx) => { + const { token } = ctx.request.query + const { messageCode, messageType } = ctx.request.query + + cacheControl(ctx) + await ctx.render('user/reset-password', { + token, + messageCode, + messageType, + title: ctx.__('resetPasswordPage.title') + }) +} + +const renderVerifyEmailPage = async (ctx) => { + const { token } = ctx.request.query + if (!token) { + cacheControl(ctx) + await ctx.render('user/verify-email', { + success: false, + message: '验证链接无效', + title: ctx.__('verifyEmailPage.title') + }) + return + } + + try { + // 调用验证API + const result = await network.user.verifyEmail({ token }) + cacheControl(ctx) + await ctx.render('user/verify-email', { + success: result.success, + message: result.message, + title: ctx.__('verifyEmailPage.title') + }) + } catch (error) { + logger.error('[EMAIL:VERIFY] Verification failed:', error) + cacheControl(ctx) + await ctx.render('user/verify-email', { + success: false, + message: '验证失败,请稍后重试', + title: ctx.__('verifyEmailPage.title') + }) + } +} + export default { getIcon, statistic, @@ -184,5 +305,10 @@ export default { render500Page, renderDashboard, renderLandingPage, - renderInitialPage + renderSignupPage, + renderInitialPage, + // email auth pages + renderForgotPasswordPage, + renderResetPasswordPage, + renderVerifyEmailPage } diff --git a/app/controllers/user.js b/app/controllers/user.js index 65689e11..eb91afe2 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -3,6 +3,8 @@ import network from '../services/network' import getCacheKey from './helper/cacheKey' import logger from '../utils/logger' import notify from '../services/notify' +import { hashPassword, verifyPassword, generateResetToken, validateEmail, validatePassword } from '../utils/password' +import emailService from '../utils/email' const clearCache = async (ctx, next) => { const cacheKey = getCacheKey(ctx) @@ -188,6 +190,295 @@ const voteNotify = async (ctx) => { } } +/* ===================== Email Authentication ===================== */ + +/** + * 邮箱注册 + */ +const registerByEmail = async (ctx) => { + const { email, password, name } = ctx.request.body + + // 验证邮箱格式 + if (!validateEmail(email)) { + return ctx.body = { + success: false, + message: '邮箱格式不正确' + } + } + + // 验证密码强度 + const passwordValidation = validatePassword(password) + if (!passwordValidation.valid) { + return ctx.body = { + success: false, + message: passwordValidation.errors.join(', ') + } + } + + try { + // 检查邮箱是否已存在 + const existingUser = await network.user.getUserByEmail(email) + if (existingUser) { + return ctx.body = { + success: false, + message: '该邮箱已被注册' + } + } + + // 加密密码 + const hashedPassword = await hashPassword(password) + + // 生成验证token + const verificationToken = generateResetToken() + + // 创建用户 + const userData = { + email, + name: name || email.split('@')[0], + password: hashedPassword, + loginType: 'email', + emailVerified: false, + verificationToken, + verificationTokenExpires: new Date(Date.now() + (24 * 60 * 60 * 1000)) // 24小时 + } + + const user = await network.user.createUserByEmail(userData) + // 发送验证邮件 + const emailSent = await emailService.sendVerificationEmail(email, verificationToken, name) + if (!emailSent) { + logger.warn(`[EMAIL] Failed to send verification email to ${email}`) + } + + logger.info(`[USER:REGISTER] ${email} registered successfully`) + ctx.body = { + success: true, + message: '注册成功!请检查你的邮箱并点击验证链接完成注册。', + result: { + email: user.email, + name: user.name, + emailVerified: user.emailVerified + } + } + } catch (error) { + logger.error(`[USER:REGISTER] ${email} registration failed:`, error) + ctx.body = { + success: false, + message: '注册失败,请稍后重试' + } + } +} + +/** + * 邮箱登录 + */ +const loginByEmail = async (ctx) => { + const { email, password } = ctx.request.body + + if (!validateEmail(email)) { + return ctx.body = { + success: false, + message: '邮箱格式不正确' + } + } + + if (!password) { + return ctx.body = { + success: false, + message: '密码不能为空' + } + } + + try { + // 获取用户信息 + const user = await network.user.getUserByEmail(email) + if (!user) { + return ctx.body = { + success: false, + message: '邮箱或密码错误' + } + } + + // 验证密码 + const isPasswordValid = await verifyPassword(password, user.password) + if (!isPasswordValid) { + return ctx.body = { + success: false, + message: '邮箱或密码错误' + } + } + + // 检查邮箱是否已验证 + if (!user.emailVerified) { + return ctx.body = { + success: false, + message: '请先验证你的邮箱地址' + } + } + + // 设置会话 + ctx.session.userId = user.userId + ctx.session.githubLogin = user.name || user.email.split('@')[0] // 兼容现有系统 + ctx.session.userEmail = user.email + ctx.session.loginType = 'email' + + logger.info(`[USER:LOGIN] ${email} logged in successfully`) + ctx.body = { + success: true, + message: '登录成功', + result: { + userId: user.userId, + name: user.name, + email: user.email, + loginType: 'email' + } + } + } catch (error) { + logger.error(`[USER:LOGIN] ${email} login failed:`, error) + ctx.body = { + success: false, + message: '登录失败,请稍后重试' + } + } +} + +/** + * 验证邮箱 + */ +const verifyEmail = async (ctx) => { + const { token } = ctx.request.query || ctx.request.body + + if (!token) { + return ctx.body = { + success: false, + message: '验证链接无效' + } + } + + try { + const result = await network.user.verifyEmail({ token }) + if (result.success) { + ctx.body = { + success: true, + message: '邮箱验证成功!现在你可以登录了。' + } + } else { + ctx.body = { + success: false, + message: result.message || '验证失败,链接可能已过期' + } + } + } catch (error) { + logger.error('[USER:VERIFY] Email verification failed:', error) + ctx.body = { + success: false, + message: '验证失败,请稍后重试' + } + } +} + +/** + * 发送密码重置邮件 + */ +const requestPasswordReset = async (ctx) => { + const { email } = ctx.request.body + + if (!validateEmail(email)) { + return ctx.body = { + success: false, + message: '邮箱格式不正确' + } + } + + try { + const user = await network.user.getUserByEmail(email) + if (!user) { + // 为了安全,即使用户不存在也返回成功 + return ctx.body = { + success: true, + message: '如果该邮箱已注册,你将收到密码重置邮件' + } + } + + // 生成重置token + const resetToken = generateResetToken() + // 保存重置token + await network.user.resetPassword({ + email, + resetToken, + resetTokenExpires: new Date(Date.now() + (24 * 60 * 60 * 1000)) // 24小时 + }) + + // 发送重置邮件 + const emailSent = await emailService.sendPasswordResetEmail(email, resetToken, user.name) + if (!emailSent) { + logger.warn(`[EMAIL] Failed to send password reset email to ${email}`) + } + + logger.info(`[USER:RESET] Password reset requested for ${email}`) + ctx.body = { + success: true, + message: '如果该邮箱已注册,你将收到密码重置邮件' + } + } catch (error) { + logger.error('[USER:RESET] Password reset request failed:', error) + ctx.body = { + success: false, + message: '请求失败,请稍后重试' + } + } +} + +/** + * 确认密码重置 + */ +const confirmPasswordReset = async (ctx) => { + const { token, newPassword } = ctx.request.body + + if (!token) { + return ctx.body = { + success: false, + message: '重置链接无效' + } + } + + // 验证新密码 + const passwordValidation = validatePassword(newPassword) + if (!passwordValidation.valid) { + return ctx.body = { + success: false, + message: passwordValidation.errors.join(', ') + } + } + + try { + // 加密新密码 + const hashedPassword = await hashPassword(newPassword) + const result = await network.user.confirmPasswordReset({ + token, + newPassword: hashedPassword + }) + + if (result.success) { + logger.info(`[USER:RESET] Password reset completed for user ${result.userId}`) + ctx.body = { + success: true, + message: '密码重置成功!现在你可以使用新密码登录了。' + } + } else { + ctx.body = { + success: false, + message: result.message || '重置失败,链接可能已过期' + } + } + } catch (error) { + logger.error('[USER:RESET] Password reset confirmation failed:', error) + ctx.body = { + success: false, + message: '重置失败,请稍后重试' + } + } +} + export default { // user logout, @@ -197,6 +488,12 @@ export default { patchUserInfo, loginByGitHub, initialFinished, + // email auth + registerByEmail, + loginByEmail, + verifyEmail, + requestPasswordReset, + confirmPasswordReset, // notify markNotifies, voteNotify, diff --git a/app/routes/index.js b/app/routes/index.js index 5b23c635..39f5cbbd 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -42,6 +42,33 @@ router.get( user.checkIfLogin(), Home.renderInitialPage ) + +// Email authentication routes +router.get( + '/login', + user.checkNotLogin(), + Home.renderLandingPage +) +router.get( + '/signup', + user.checkNotLogin(), + Home.renderSignupPage +) +router.get( + '/forgot-password', + user.checkNotLogin(), + Home.renderForgotPasswordPage +) +router.get( + '/reset-password', + user.checkNotLogin(), + Home.renderResetPasswordPage +) +router.get( + '/verify-email', + Home.renderVerifyEmailPage +) + router.get( '/:login', user.checkValidateUser(), diff --git a/app/routes/user.js b/app/routes/user.js index 00357363..99e8935f 100644 --- a/app/routes/user.js +++ b/app/routes/user.js @@ -48,6 +48,44 @@ router.get( User.loginByGitHub ) +// Email authentication routes +router.post( + '/register', + check.body('email'), + check.body('password'), + User.registerByEmail +) + +router.post( + '/login/email', + check.body('email'), + check.body('password'), + User.loginByEmail +) + +router.get( + '/verify-email', + User.verifyEmail +) + +router.post( + '/verify-email', + User.verifyEmail +) + +router.post( + '/reset-password', + check.body('email'), + User.requestPasswordReset +) + +router.post( + '/confirm-reset-password', + check.body('token'), + check.body('newPassword'), + User.confirmPasswordReset +) + router.get( '/notifies', user.checkIfLogin(), diff --git a/app/services/network/lib/user.js b/app/services/network/lib/user.js index beb0f45d..796ba207 100644 --- a/app/services/network/lib/user.js +++ b/app/services/network/lib/user.js @@ -68,3 +68,41 @@ export const getResumeCount = () => ({ useCache: true, url: '/resume/count' }) + +/* =========================================================== */ +/* Email Authentication APIs */ + +export const createUserByEmail = data => ({ + body: { data }, + method: 'post', + url: '/user/email' +}) + +export const loginByEmail = data => ({ + body: { data }, + method: 'post', + url: '/user/login/email' +}) + +export const verifyEmail = data => ({ + body: { data }, + method: 'post', + url: '/user/verify-email' +}) + +export const resetPassword = data => ({ + body: { data }, + method: 'post', + url: '/user/reset-password' +}) + +export const confirmPasswordReset = data => ({ + body: { data }, + method: 'post', + url: '/user/confirm-reset-password' +}) + +export const getUserByEmail = email => ({ + qs: { email }, + url: '/user/email/info' +}) diff --git a/app/templates/user/forgot-password.html b/app/templates/user/forgot-password.html new file mode 100644 index 00000000..21831c98 --- /dev/null +++ b/app/templates/user/forgot-password.html @@ -0,0 +1,29 @@ +{% extends "layouts/base.html" %} + +{% block css %} + + +{% endblock %} + +{% block body %} + {% if messageCode %} + {% include "layouts/message.html" %} + {% endif %} +
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/app/templates/user/reset-password.html b/app/templates/user/reset-password.html new file mode 100644 index 00000000..fd0b49c8 --- /dev/null +++ b/app/templates/user/reset-password.html @@ -0,0 +1,29 @@ +{% extends "layouts/base.html" %} + +{% block css %} + + +{% endblock %} + +{% block body %} + {% if messageCode %} + {% include "layouts/message.html" %} + {% endif %} +
+{% endblock %} + +{% block js %} + +{% endblock %} diff --git a/app/templates/user/signup.html b/app/templates/user/signup.html new file mode 100644 index 00000000..dea30a5e --- /dev/null +++ b/app/templates/user/signup.html @@ -0,0 +1,35 @@ +{% extends "layouts/base.html" %} + +{% block css %} + + +{% endblock %} + +{% block body %} + {% if messageCode %} + {% include "layouts/message.html" %} + {% endif %} +
+{% endblock %} + +{% block js %} + + +{% endblock %} diff --git a/app/templates/user/verify-email.html b/app/templates/user/verify-email.html new file mode 100644 index 00000000..a0425812 --- /dev/null +++ b/app/templates/user/verify-email.html @@ -0,0 +1,73 @@ +{% extends "layouts/base.html" %} + +{% block css %} + + +{% endblock %} + +{% block body %} +
+
+ {% if success %} +
+

✅ 邮箱验证成功

+

+ 恭喜!你的邮箱已成功验证。现在你可以使用邮箱登录 Hacknical 了。 +

+ 立即登录 +
+ {% else %} +
+

❌ 验证失败

+

+ {{ message || '验证链接无效或已过期,请重新注册或申请验证邮件。' }} +

+ 返回登录 + 重新注册 +
+ {% endif %} +
+
+{% endblock %} diff --git a/app/utils/email.js b/app/utils/email.js new file mode 100644 index 00000000..5a0e917a --- /dev/null +++ b/app/utils/email.js @@ -0,0 +1,195 @@ +import nodemailer from 'nodemailer' +import config from 'config' +import logger from './logger' + +class EmailService { + constructor() { + this.transporter = null + this.senderConfig = {} + this.init() + } + + init() { + let emailConfig = {} + let senderConfig = {} + try { + // 尝试从新的messenger配置中读取 + const messengerEmail = config.get('services.messenger.email') || {} + if (messengerEmail.config) { + emailConfig = messengerEmail.config + senderConfig = messengerEmail.sender || {} + } else { + // 兼容旧的配置 + emailConfig = config.get('services.email') || {} + } + } catch (e) { + // 如果配置不存在,使用空对象 + emailConfig = {} + } + // 如果没有配置邮件服务,使用默认的SMTP配置 + const defaultConfig = { + host: process.env.SMTP_HOST || 'smtp.gmail.com', + port: process.env.SMTP_PORT || 587, + secure: false, // true for 465, false for other ports + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS + } + } + + try { + this.transporter = nodemailer.createTransport({ + ...defaultConfig, + ...emailConfig + }) + // 保存发件人配置 + this.senderConfig = senderConfig + logger.info('[EMAIL] Email service initialized') + } catch (error) { + logger.error('[EMAIL] Failed to initialize email service:', error) + } + } + + /** + * 发送邮件 + * @param {object} options - 邮件选项 + * @param {string} options.to - 收件人 + * @param {string} options.subject - 主题 + * @param {string} options.html - HTML内容 + * @param {string} options.text - 文本内容 + * @returns {Promise} 发送结果 + */ + async sendEmail({ + to, + subject, + html, + text + }) { + if (!this.transporter) { + logger.warn('[EMAIL] Email service not configured') + return false + } + + try { + // 使用配置的发件人信息 + const fromAddress = this.senderConfig.address || process.env.SMTP_USER || 'noreply@hacknical.com' + const fromName = this.senderConfig.name || 'Hacknical' + const from = `${fromName} <${fromAddress}>` + + const info = await this.transporter.sendMail({ + from, + to, + subject, + text, + html + }) + + logger.info(`[EMAIL] Email sent: ${info.messageId}`) + return true + } catch (error) { + logger.error('[EMAIL] Failed to send email:', error) + return false + } + } + + /** + * 发送注册验证邮件 + * @param {string} email - 邮箱地址 + * @param {string} token - 验证token + * @param {string} name - 用户名 + * @returns {Promise} 发送结果 + */ + async sendVerificationEmail(email, token, name = '') { + const verifyUrl = `${config.get('url') || 'http://localhost:7001'}/api/user/verify-email?token=${token}` + const html = ` +
+

欢迎注册 Hacknical

+

你好${name ? ` ${name}` : ''},

+

感谢你注册 Hacknical!请点击下面的按钮验证你的邮箱地址:

+
+ + 验证邮箱 + +
+

或者复制以下链接到浏览器打开:

+

${verifyUrl}

+

+ 此链接24小时内有效。如果这不是你的操作,请忽略此邮件。 +

+
+ ` + + const text = ` + 欢迎注册 Hacknical + + 你好${name ? ` ${name}` : ''}, + + 感谢你注册 Hacknical!请访问以下链接验证你的邮箱地址: + ${verifyUrl} + + 此链接24小时内有效。如果这不是你的操作,请忽略此邮件。 + ` + + return await this.sendEmail({ + to: email, + subject: '验证你的 Hacknical 账号', + html, + text + }) + } + + /** + * 发送密码重置邮件 + * @param {string} email - 邮箱地址 + * @param {string} token - 重置token + * @param {string} name - 用户名 + * @returns {Promise} 发送结果 + */ + async sendPasswordResetEmail(email, token, name = '') { + const resetUrl = `${config.get('url') || 'http://localhost:7001'}/reset-password?token=${token}` + const html = ` +
+

重置 Hacknical 密码

+

你好${name ? ` ${name}` : ''},

+

我们收到了重置你账号密码的请求。请点击下面的按钮重置密码:

+
+ + 重置密码 + +
+

或者复制以下链接到浏览器打开:

+

${resetUrl}

+

+ 此链接24小时内有效。如果这不是你的操作,请忽略此邮件,你的密码不会被更改。 +

+
+ ` + + const text = ` + 重置 Hacknical 密码 + + 你好${name ? ` ${name}` : ''}, + + 我们收到了重置你账号密码的请求。请访问以下链接重置密码: + ${resetUrl} + + 此链接24小时内有效。如果这不是你的操作,请忽略此邮件,你的密码不会被更改。 + ` + + return await this.sendEmail({ + to: email, + subject: '重置你的 Hacknical 密码', + html, + text + }) + } +} + +// 创建邮件服务实例 +const emailService = new EmailService() + +export default emailService diff --git a/app/utils/password.js b/app/utils/password.js new file mode 100644 index 00000000..e03fd424 --- /dev/null +++ b/app/utils/password.js @@ -0,0 +1,80 @@ +import crypto from 'crypto' +import bcrypt from 'bcrypt' + +const SALT_ROUNDS = 12 + +/** + * 密码加密 + * @param {string} password - 明文密码 + * @returns {Promise} 加密后的密码 + */ +export const hashPassword = async (password) => { + return await bcrypt.hash(password, SALT_ROUNDS) +} + +/** + * 验证密码 + * @param {string} password - 明文密码 + * @param {string} hashedPassword - 加密后的密码 + * @returns {Promise} 是否匹配 + */ +export const verifyPassword = async (password, hashedPassword) => { + return await bcrypt.compare(password, hashedPassword) +} + +/** + * 生成随机token + * @param {number} length - token长度 + * @returns {string} 随机token + */ +export const generateToken = (length = 32) => { + return crypto.randomBytes(length).toString('hex') +} + +/** + * 生成密码重置token + * @returns {string} 重置token + */ +export const generateResetToken = () => { + return generateToken(32) +} + +/** + * 验证邮箱格式 + * @param {string} email - 邮箱地址 + * @returns {boolean} 是否为有效邮箱 + */ +export const validateEmail = (email) => { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + return emailRegex.test(email) +} + +/** + * 验证密码强度 + * @param {string} password - 密码 + * @returns {object} 验证结果 + */ +export const validatePassword = (password) => { + const result = { + valid: true, + errors: [] + } + + if (!password || password.length < 6) { + result.valid = false + result.errors.push('密码长度不能少于6位') + } + + if (password.length > 128) { + result.valid = false + result.errors.push('密码长度不能超过128位') + } + + // 检查是否包含至少一个字母和一个数字 + if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(password)) { + result.valid = false + result.errors.push('密码必须包含至少一个字母和一个数字') + } + + return result +} diff --git a/app/utils/uploader.js b/app/utils/uploader.js index f7fb3702..a503411c 100644 --- a/app/utils/uploader.js +++ b/app/utils/uploader.js @@ -2,18 +2,40 @@ import fs from 'fs' import path from 'path' import config from 'config' -import oss from 'ali-oss' import logger from './logger' const g = (key, defaultValue) => process.env[key] || defaultValue || '' -const store = oss({ - accessKeyId: g('HACKNICAL_ALI_ACCESS_ID'), - accessKeySecret: g('HACKNICAL_ALI_ACCESS_KEY'), - bucket: config.get('services.oss.bucket'), - region: config.get('services.oss.region'), - internal: false -}) +// 在开发环境中,如果没有配置 OSS,则使用 null +let store = null; +let OSSClient = null; + +// 只有在有 OSS 配置时才导入 +const accessKeyId = g('HACKNICAL_ALI_ACCESS_ID'); +const accessKeySecret = g('HACKNICAL_ALI_ACCESS_KEY'); + +if (accessKeyId && accessKeySecret) { + try { + // eslint-disable-next-line import/no-extraneous-dependencies + import('ali-oss').then((aliOSS) => { + OSSClient = aliOSS.default; + store = OSSClient({ + accessKeyId, + accessKeySecret, + bucket: config.get('services.oss.bucket'), + region: config.get('services.oss.region'), + internal: false + }); + logger.info('[OSS] OSS client initialized successfully'); + }).catch((err) => { + logger.error('[OSS] Failed to initialize OSS client:', err.message); + }); + } catch (err) { + logger.error('[OSS] Failed to initialize OSS client:', err.message); + } +} else { + logger.warn('[OSS] OSS credentials not configured, file upload will be disabled'); +} const nextTick = (func, ...params) => process.nextTick(async () => { @@ -25,6 +47,10 @@ const nextTick = (func, ...params) => }) export const uploadFile = ({ filePath, prefix = '' }) => { + if (!store) { + logger.warn('[OSS] OSS client not available, skipping file upload'); + return; + } if (!fs.statSync(filePath).isFile()) return const filename = filePath.split('/').slice(-1)[0] @@ -49,12 +75,22 @@ export const uploadFolder = ({ folderPath, prefix = '' }) => { } } -export const getUploadUrl = ({ filePath, expires = 60, mimeType }) => - store.signatureUrl(filePath, { +export const getUploadUrl = ({ filePath, expires = 60, mimeType }) => { + if (!store) { + logger.warn('[OSS] OSS client not available, returning empty URL'); + return ''; + } + return store.signatureUrl(filePath, { expires, method: 'PUT', 'Content-Type': mimeType - }) + }); +} -export const getOssObjectUrl = ({ filePath, baseUrl = '' }) => - store.generateObjectUrl(filePath, baseUrl) +export const getOssObjectUrl = ({ filePath, baseUrl = '' }) => { + if (!store) { + logger.warn('[OSS] OSS client not available, returning empty URL'); + return ''; + } + return store.generateObjectUrl(filePath, baseUrl); +} diff --git a/config/localdev.json b/config/localdev.json index 38eb5a06..a099a966 100644 --- a/config/localdev.json +++ b/config/localdev.json @@ -6,6 +6,7 @@ "redis": { "host": "127.0.0.1", "port": 6379, + "password": "", "db": 1 }, "besticon": { @@ -48,7 +49,24 @@ "channel": "hacknical" }, "email": { - "type": "sendcloud", + "type": "smtp", + "config": { + "host": "smtp.qq.com", + "port": 587, + "secure": false, + "auth": { + "user": "", + "pass": "" + } + }, + "sender": { + "address": "", + "name": "Hacknical App" + }, + "receiver": { + "address": "", + "name": " " + }, "channel": "hacknical_app", "template": "hacknical_welcome" } @@ -65,7 +83,8 @@ "source": "redis", "config": { "host": "127.0.0.1", - "port": 6379 + "port": 6379, + "password": "" }, "options": {}, "channels": { diff --git a/frontend/api/user.js b/frontend/api/user.js index 962ed919..29246fd5 100644 --- a/frontend/api/user.js +++ b/frontend/api/user.js @@ -13,6 +13,13 @@ const markNotifies = messageIds => API.patch('/user/notifies', { messageIds }) const getNotifies = () => API.get('/user/notifies') const voteNotify = (messageId, data) => API.patch(`/user/notifies/${messageId}`, data) +// Email authentication APIs +const registerByEmail = data => API.post('/user/register', data) +const loginByEmail = data => API.post('/user/login/email', data) +const verifyEmail = token => API.get(`/user/verify-email?token=${token}`) +const requestPasswordReset = data => API.post('/user/reset-password', data) +const confirmPasswordReset = data => API.post('/user/confirm-reset-password', data) + export default { logout, initialed, @@ -22,5 +29,11 @@ export default { // notify markNotifies, getNotifies, - voteNotify + voteNotify, + // email auth + registerByEmail, + loginByEmail, + verifyEmail, + requestPasswordReset, + confirmPasswordReset } diff --git a/frontend/entries/forgot-password.js b/frontend/entries/forgot-password.js new file mode 100644 index 00000000..6dcbe803 --- /dev/null +++ b/frontend/entries/forgot-password.js @@ -0,0 +1,13 @@ +import 'particles.js' +import renderApp from 'PAGES/forgot-password' +import { REMOTE_ASSETS } from 'UTILS/constant' + +$(() => { + particlesJS.load( + 'forgot-password', + REMOTE_ASSETS.PARTICLES_JS, + Function.prototype + ) + + renderApp('forgot-password', {}) +}) diff --git a/frontend/entries/reset-password.js b/frontend/entries/reset-password.js new file mode 100644 index 00000000..ed4e537a --- /dev/null +++ b/frontend/entries/reset-password.js @@ -0,0 +1,13 @@ +import 'particles.js' +import renderApp from 'PAGES/reset-password' +import { REMOTE_ASSETS } from 'UTILS/constant' + +$(() => { + particlesJS.load( + 'reset-password', + REMOTE_ASSETS.PARTICLES_JS, + Function.prototype + ) + + renderApp('reset-password', {}) +}) diff --git a/frontend/entries/signup.js b/frontend/entries/signup.js new file mode 100644 index 00000000..9f86f3bb --- /dev/null +++ b/frontend/entries/signup.js @@ -0,0 +1,16 @@ +import 'particles.js' +import renderApp from 'PAGES/login' +import { REMOTE_ASSETS } from 'UTILS/constant' + +$(() => { + particlesJS.load( + 'login', + REMOTE_ASSETS.PARTICLES_JS, + Function.prototype + ) + + renderApp('login', { + loginLink: window.loginLink, + isMobile: window.isMobile === 'true' || window.isMobile === true + }) +}) diff --git a/frontend/pages/forgot-password/components/PasswordResetForm.jsx b/frontend/pages/forgot-password/components/PasswordResetForm.jsx new file mode 100644 index 00000000..5c5fa5de --- /dev/null +++ b/frontend/pages/forgot-password/components/PasswordResetForm.jsx @@ -0,0 +1,131 @@ +import React, { useState } from 'react' +import cx from 'classnames' +import API from 'API' +import Icon from 'COMPONENTS/Icon' +import { Input } from 'light-ui' +import styles from '../styles/login.css' +import locales from 'LOCALES' + +const { emailAuth: emailText } = locales('login') + +const ForgotPasswordForm = ({ onBackToLogin }) => { + const [form, setForm] = useState({ + email: '' + }) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + const [message, setMessage] = useState('') + + const handleInputChange = (field) => { + return (value) => { + setForm({ ...form, [field]: value }) + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + } + + const validateForm = () => { + const newErrors = {} + if (!form.email) { + newErrors.email = '请输入邮箱地址' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { + newErrors.email = '邮箱格式不正确' + } + return newErrors + } + + const handleSubmit = async (e) => { + e.preventDefault() + const formErrors = validateForm() + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + setLoading(true) + setMessage('') + setErrors({}) + + try { + const response = await API.user.requestPasswordReset(form) + if (response.success) { + setMessage(response.message || '重置邮件已发送,请检查你的邮箱') + } else { + setMessage(response.message || '发送失败') + } + } catch (error) { + setMessage('网络错误,请稍后重试') + } finally { + setLoading(false) + } + } + + return ( +
+
+

{emailText.forgotPasswordTitle}

+

{emailText.forgotPasswordSubtitle}

+
+ +
+
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)} + /> + {errors.email && ( +
{errors.email}
+ )} +
+ + {message && ( +
+ {message} +
+ )} + + +
+ +
+ +
+
+ ) +} + +export { ForgotPasswordForm } diff --git a/frontend/pages/forgot-password/index.js b/frontend/pages/forgot-password/index.js new file mode 100644 index 00000000..252545a7 --- /dev/null +++ b/frontend/pages/forgot-password/index.js @@ -0,0 +1,24 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { ForgotPasswordForm } from './components/PasswordResetForm' +import styles from './styles/login.css' + +const ForgotPasswordPage = () => { + return ( +
+ window.location.href = '/login'} + /> +
+ ) +} + +const renderApp = (domId, props = {}) => { + const DOM = document.getElementById(domId) + ReactDOM.render( + , + DOM + ) +} + +export default renderApp diff --git a/frontend/pages/forgot-password/styles/login.css b/frontend/pages/forgot-password/styles/login.css new file mode 100644 index 00000000..9aed1cec --- /dev/null +++ b/frontend/pages/forgot-password/styles/login.css @@ -0,0 +1,547 @@ +@import 'open-color/open-color.css'; + +:root { + --modalTriangleWidth: 8px; + --modalTriangleHeight: 8px; + --modalTriangleOffset: 6px; + --tipso-triangle-back: rgba(212, 212, 212, 0.3); + + --speed: .2s; + --easing: cubic-bezier(.55,0,.1,1); + --modalContentOpacity: 0; + --scale: scale(0.8); + --scaleActive: scale(1); +} + +.baseLink { + display: block; + cursor: pointer; + user-select: none; + text-decoration: none; + pointer-events: auto; +} + +.topbar { + position: fixed; + top: 0; + left: 0; + right: 0; + height: 50px; + background-color: transparent; + display: flex; + flex-direction: row; + justify-content: flex-end; + align-items: center; + color: var(--oc-white); + font-size: 14px; + padding: 0 25px; + pointer-events: none; + display: flex; + align-items: center; + + width: 80%; + margin: auto; + justify-content: flex-start; +} + +.topbarLink { + composes: baseLink; + color: var(--oc-gray-1); + margin: 0 10px; + transition: color 0.3s; + + &:hover { + color: var(--oc-white); + text-decoration: underline; + } +} + +.topbarSelector { + display: inline-block; + height: 2em; + line-height: 2em; + overflow: hidden; + + &:hover { + overflow: visible; + } + + & a { + line-height: 2em; + } +} + +.loginPannel { + color: var(--oc-white); + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + pointer-events: auto; + + width: 80%; + justify-content: flex-start; + align-items: flex-start; +} + +.loginIntro { + font-size: 1rem; + color: var(--oc-gray-1); +} + +.logo { + font-size: 10rem; +} + +.githubLoginLink { + composes: baseLink; + color: var(--oc-gray-8); + padding: 10px 15px; +} + +.loginButtonsContainer { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 25px; + margin-bottom: 25px; + pointer-events: auto; +} + +.loginButtonsContainer button { + min-width: 160px; + height: 44px; + font-size: 14px; +} + +.githubLoginLink { + composes: baseLink; + color: var(--oc-gray-8); + padding: 10px 15px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.emailLoginLink { + composes: baseLink; + color: var(--oc-gray-8); + padding: 10px 15px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.statisticContainer { + margin-top: 25px; + position: relative; + + &:hover { + & .statistic { + opacity: 1; + } + + & .statisticModal { + z-index: var(--zIndex99); + visibility: visible; + opacity: 1; + transform: var(--scaleActive) translateY(-50%) translateX(20px); + + &.statisticModalBottom { + transform: var(--scaleActive) translateY(10px) translateX(-50%) !important; + } + } + } +} + +/* loading */ +@-webkit-keyframes ball-beat { + 50% { + opacity: 0.2; + transform: scale(0.75); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes ball-beat { + 50% { + opacity: 0.2; + transform: scale(0.75); + } + 100% { + opacity: 1; + transform: scale(1); + } +} + +.statisticLoading { + opacity: 0.5; +} + +.statisticLoading > div { + background-color: var(--oc-gray-5); + width: 15px; + height: 15px; + border-radius: 100%; + margin: 2px; + animation-fill-mode: both; + display: inline-block; + animation: ball-beat 0.7s 0s infinite linear; +} + +.statisticLoading > div:nth-child(2n-1) { + animation-delay: -0.35s !important; +} + + +.statistic { + font-family: 'Geo', 'PingFangSC-Light', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', monospace, sans-serif; + + /* font-family: 'Open Sans', 'PingFangSC-Light', 'PingFang SC', 'Helvetica Neue', 'Microsoft YaHei', monospace, sans-serif; */ + color: var(--oc-gray-1); + opacity: 0.5; + pointer-events: auto; + cursor: pointer; + transition: opacity 0.2s; + + & span { + font-size: 3.5em; + } +} + +.statisticModal { + position: absolute; + top: 50%; + left: 100%; + max-width: 250px; + background-color: var(--bg); + border-radius: 2px; + box-shadow: var(--shadow3); + + z-index: var(--zIndex0); + opacity: var(--modalContentOpacity); + visibility: hidden; + backface-visibility: hidden; + transform: var(--scale) translateY(-50%) translateX(10px); + transition: all var(--speed) var(--easing); + + color: var(--oc-gray-7); + text-align: left; + padding: 10px 20px; + min-width: 190px; + line-height: 1.5em; + + &::before, + &::after { + width: 0; + height: 0; + top: 50%; + content: ''; + display: block; + position: absolute; + transform: translateY(-50%); + border-top: var(--modalTriangleWidth) solid transparent; + border-bottom: var(--modalTriangleWidth) solid transparent; + } + + &::after { + z-index: var(--zIndex1); + left: calc(0 - var(--modalTriangleHeight)); + border-right: var(--modalTriangleHeight) solid var(--oc-white); + } + + &::before { + z-index: var(--zIndexHidden); + left: calc(0 - var(--modalTriangleOffset)); + border-right: var(--modalTriangleHeight) solid var(--tipso-triangle-back); + } +} + +.statisticModalBottom { + top: 100%; + left: 50%; + transform: var(--scaleActive) translateY(10px) translateX(-50%) !important; + + &::before, + &::after { + left: 50%; + transform: translateX(-50%); + border-top: none; + border-left: var(--modalTriangleWidth) solid transparent; + border-right: var(--modalTriangleWidth) solid transparent; + } + + &::after { + z-index: var(--zIndex1); + top: -var(--modalTriangleHeight); + border-bottom: var(--modalTriangleHeight) solid var(--oc-white); + } + + &::before { + z-index: var(--zIndexHidden); + top: -var(--modalTriangleOffset); + border-bottom: var(--modalTriangleHeight) solid var(--tipso-triangle-back); + } +} + +@media (max-width: 800px) { + .statistic { + & span { + font-size: 2em; + } + } +} + +@media (max-width: 500px) { + .logo { + font-size: 4rem; + } + + .statistic { + & span { + font-size: 1.8em; + } + } + + .loginIntro { + font-size: 0.8rem; + } +} + +/* Email Authentication Styles */ +.emailAuthContainer { + width: 100%; + max-width: 400px; + margin: 0 auto; + padding: 20px; + color: var(--oc-gray-1); + pointer-events: auto; +} + +.authHeader { + text-align: center; + margin-bottom: 30px; +} + +.authTitle { + font-size: 2rem; + color: var(--oc-gray-1); + margin-bottom: 10px; + font-weight: 300; +} + +.authSubtitle { + font-size: 1rem; + color: var(--oc-gray-4); + margin: 0; +} + +.authForm { + width: 100%; +} + +.inputGroup { + margin-bottom: 20px; +} + +.authInput { + width: 100%; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--oc-gray-6); + color: var(--oc-gray-1); + padding: 12px 16px; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s ease; + pointer-events: auto; +} + +.authInput:focus { + border-color: var(--oc-blue-5); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + outline: none; +} + +.authInput.inputError { + border-color: var(--oc-red-5); +} + +.authInput.inputError:focus { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); +} + +.errorText { + color: var(--oc-red-5); + font-size: 12px; + margin-top: 5px; +} + +.helpText { + color: var(--oc-gray-5); + font-size: 12px; + margin-top: 5px; +} + +.message { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 20px; + font-size: 14px; + text-align: center; +} + +.successMessage { + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid var(--oc-green-5); + color: var(--oc-green-4); +} + +.errorMessage { + background-color: rgba(239, 68, 68, 0.2); + border: 1px solid var(--oc-red-5); + color: var(--oc-red-4); +} + +.authButton { + width: 100%; + margin-bottom: 20px; + padding: 12px 0; + font-size: 16px; + font-weight: 500; + pointer-events: auto; +} + +.classicButton { + background: linear-gradient(135deg, #2d3748, #4a5568); + border: 1px solid #4a5568; + border-radius: 6px; + color: white; + cursor: pointer; + outline: none; + transition: all 0.2s ease; +} + +.classicButton:hover { + background: linear-gradient(135deg, #4a5568, #718096); + border-color: #718096; +} + +.classicButton:active { + transform: translateY(1px); +} + +.classicButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.classicButton:disabled:hover { + background: linear-gradient(135deg, #2d3748, #4a5568); + border-color: #4a5568; + transform: none; +} + +.authLinks { + text-align: center; + margin-top: 20px; + pointer-events: auto; +} + +.linkButton { + background: none; + border: none; + color: var(--oc-blue-4); + cursor: pointer; + font-size: 14px; + text-decoration: underline; + margin: 0 10px; + padding: 5px 0; + transition: color 0.3s ease; +} + +.linkButton:hover { + color: var(--oc-blue-3); +} + +.authSeparator { + margin: 20px 0; + text-align: center; + position: relative; +} + +.authSeparator::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--oc-gray-6); +} + +.authSeparator span { + background: #252525; + padding: 0 15px; + color: var(--oc-gray-4); + font-size: 14px; + position: relative; + z-index: 1; +} + +.githubLoginContainer { + text-align: center; + pointer-events: auto; +} + +.loginButtonsContainer { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 25px; + margin-bottom: 25px; + pointer-events: auto; +} + +/* Responsive */ +@media (max-width: 500px) { + .emailAuthContainer { + padding: 15px; + max-width: 100%; + } + + .authTitle { + font-size: 1.5rem; + } + + .authInput { + padding: 10px 12px; + } +} + +@media (max-width: 300px) { + .logo { + font-size: 3rem; + } + + .statistic { + & span { + font-size: 1.5em; + } + } +} diff --git a/frontend/pages/login/components/EmailLoginForm.jsx b/frontend/pages/login/components/EmailLoginForm.jsx new file mode 100644 index 00000000..e6c44451 --- /dev/null +++ b/frontend/pages/login/components/EmailLoginForm.jsx @@ -0,0 +1,172 @@ +import React, { useState } from 'react' +import cx from 'classnames' +import API from 'API' +import Icon from 'COMPONENTS/Icon' +import { Input } from 'light-ui' +import styles from '../styles/login.css' +import locales from 'LOCALES' + +const { emailAuth: emailText } = locales('login') + +const EmailLoginForm = ({ onSwitchToRegister, onBackToGithub }) => { + const [form, setForm] = useState({ + email: '', + password: '' + }) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + const [message, setMessage] = useState('') + + const handleInputChange = field => (value) => { + setForm({ ...form, [field]: value }) + // 清除相关错误 + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + + const validateForm = () => { + const newErrors = {} + if (!form.email) { + newErrors.email = '请输入邮箱地址' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { + newErrors.email = '邮箱格式不正确' + } + if (!form.password) { + newErrors.password = '请输入密码' + } + return newErrors + } + + const handleSubmit = async (e) => { + e.preventDefault() + const formErrors = validateForm() + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + setLoading(true) + setMessage('') + setErrors({}) + + try { + const response = await API.user.loginByEmail(form) + if (response.success) { + setMessage('登录成功!正在跳转...') + setTimeout(() => { + window.location.href = '/dashboard' + }, 1000) + } else { + setMessage(response.message || '登录失败') + } + } catch (error) { + setMessage('网络错误,请稍后重试') + } finally { + setLoading(false) + } + } + + return ( +
+
+

{emailText.loginTitle}

+

{emailText.loginSubtitle}

+
+ +
+
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)} + /> + {errors.email && ( +
{errors.email}
+ )} +
+ +
+ value && value.length >= 6} + /> + {errors.password && ( +
{errors.password}
+ )} +
+ + {message && ( +
+ {message} +
+ )} + + +
+ +
+ +
+ {emailText.or} +
+ + +
+
+ ) +} + +export default EmailLoginForm diff --git a/frontend/pages/login/components/EmailRegisterForm.jsx b/frontend/pages/login/components/EmailRegisterForm.jsx new file mode 100644 index 00000000..9db2028a --- /dev/null +++ b/frontend/pages/login/components/EmailRegisterForm.jsx @@ -0,0 +1,208 @@ +import React, { useState } from 'react' +import cx from 'classnames' +import API from 'API' +import Icon from 'COMPONENTS/Icon' +import { Input } from 'light-ui' +import styles from '../styles/login.css' +import locales from 'LOCALES' + +const { emailAuth: emailText } = locales('login') + +const EmailRegisterForm = ({ onSwitchToLogin, onBackToGithub }) => { + const [form, setForm] = useState({ + email: '', + password: '', + confirmPassword: '', + name: '' + }) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + const [message, setMessage] = useState('') + + const handleInputChange = field => (value) => { + setForm({ ...form, [field]: value }) + // 清除相关错误 + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + + const validateForm = () => { + const newErrors = {} + if (!form.email) { + newErrors.email = '请输入邮箱地址' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { + newErrors.email = '邮箱格式不正确' + } + if (!form.password) { + newErrors.password = '请输入密码' + } else if (form.password.length < 6) { + newErrors.password = '密码长度不能少于6位' + } else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(form.password)) { + newErrors.password = '密码必须包含至少一个字母和一个数字' + } + if (!form.confirmPassword) { + newErrors.confirmPassword = '请确认密码' + } else if (form.password !== form.confirmPassword) { + newErrors.confirmPassword = '两次输入的密码不一致' + } + return newErrors + } + + const handleSubmit = async (e) => { + e.preventDefault() + const formErrors = validateForm() + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + setLoading(true) + setMessage('') + setErrors({}) + + try { + const response = await API.user.registerByEmail({ + email: form.email, + password: form.password, + name: form.name || form.email.split('@')[0] + }) + if (response.success) { + setMessage(response.message || '注册成功!请检查你的邮箱进行验证。') + } else { + setMessage(response.message || '注册失败') + } + } catch (error) { + setMessage('网络错误,请稍后重试') + } finally { + setLoading(false) + } + } + + return ( +
+
+

{emailText.registerTitle}

+

{emailText.registerSubtitle}

+
+ +
+
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)} + /> + {errors.email && ( +
{errors.email}
+ )} +
+ +
+ +
{emailText.nameOptional}
+
+ +
+ value && value.length >= 6} + /> + {errors.password && ( +
{errors.password}
+ )} +
{emailText.passwordHelp}
+
+ +
+ value && value.length >= 6} + /> + {errors.confirmPassword && ( +
{errors.confirmPassword}
+ )} +
+ + {message && ( +
+ {message} +
+ )} + + +
+ +
+ + +
+
+ ) +} + +export default EmailRegisterForm diff --git a/frontend/pages/login/components/PasswordResetForm.jsx b/frontend/pages/login/components/PasswordResetForm.jsx new file mode 100644 index 00000000..080fbe55 --- /dev/null +++ b/frontend/pages/login/components/PasswordResetForm.jsx @@ -0,0 +1,298 @@ +import React, { useState } from 'react' +import cx from 'classnames' +import API from 'API' +import Icon from 'COMPONENTS/Icon' +import { Input } from 'light-ui' +import styles from '../styles/login.css' +import locales from 'LOCALES' + +const { emailAuth: emailText } = locales('login') + +const ForgotPasswordForm = ({ onBackToLogin }) => { + const [form, setForm] = useState({ + email: '' + }) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + const [message, setMessage] = useState('') + + const handleInputChange = field => (value) => { + setForm({ ...form, [field]: value }) + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + + const validateForm = () => { + const newErrors = {} + if (!form.email) { + newErrors.email = '请输入邮箱地址' + } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) { + newErrors.email = '邮箱格式不正确' + } + return newErrors + } + + const handleSubmit = async (e) => { + e.preventDefault() + const formErrors = validateForm() + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + setLoading(true) + setMessage('') + setErrors({}) + + try { + const response = await API.user.requestPasswordReset(form) + if (response.success) { + setMessage(response.message || '重置邮件已发送,请检查你的邮箱') + } else { + setMessage(response.message || '发送失败') + } + } catch (error) { + setMessage('网络错误,请稍后重试') + } finally { + setLoading(false) + } + } + + return ( +
+
+

{emailText.forgotPasswordTitle}

+

{emailText.forgotPasswordSubtitle}

+
+ +
+
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)} + /> + {errors.email && ( +
{errors.email}
+ )} +
+ + {message && ( +
+ {message} +
+ )} + + +
+ +
+ +
+
+ ) +} + +// 重置密码确认组件 +const ResetPasswordForm = () => { + const [form, setForm] = useState({ + newPassword: '', + confirmPassword: '' + }) + const [loading, setLoading] = useState(false) + const [errors, setErrors] = useState({}) + const [message, setMessage] = useState('') + + // 从URL中获取token + const urlParams = new URLSearchParams(window.location.search) + const token = urlParams.get('token') + + const handleInputChange = field => (value) => { + setForm({ ...form, [field]: value }) + if (errors[field]) { + setErrors({ ...errors, [field]: '' }) + } + } + + const validateForm = () => { + const newErrors = {} + if (!form.newPassword) { + newErrors.newPassword = '请输入新密码' + } else if (form.newPassword.length < 6) { + newErrors.newPassword = '密码长度不能少于6位' + } else if (!/(?=.*[a-zA-Z])(?=.*\d)/.test(form.newPassword)) { + newErrors.newPassword = '密码必须包含至少一个字母和一个数字' + } + if (!form.confirmPassword) { + newErrors.confirmPassword = '请确认新密码' + } else if (form.newPassword !== form.confirmPassword) { + newErrors.confirmPassword = '两次输入的密码不一致' + } + return newErrors + } + + const handleSubmit = async (e) => { + e.preventDefault() + if (!token) { + setMessage('重置链接无效') + return + } + const formErrors = validateForm() + if (Object.keys(formErrors).length > 0) { + setErrors(formErrors) + return + } + + setLoading(true) + setMessage('') + setErrors({}) + + try { + const response = await API.user.confirmPasswordReset({ + token, + newPassword: form.newPassword + }) + if (response.success) { + setMessage(response.message || '密码重置成功!') + setTimeout(() => { + window.location.href = '/login' + }, 2000) + } else { + setMessage(response.message || '重置失败') + } + } catch (error) { + setMessage('网络错误,请稍后重试') + } finally { + setLoading(false) + } + } + + if (!token) { + return ( +
+
+

重置链接无效

+

请重新申请密码重置

+
+
+ +
+
+ ) + } + + return ( +
+
+

{emailText.resetPasswordTitle}

+

{emailText.resetPasswordSubtitle}

+
+ +
+
+ value && value.length >= 6} + /> + {errors.newPassword && ( +
{errors.newPassword}
+ )} +
{emailText.passwordHelp}
+
+ +
+ value && value.length >= 6} + /> + {errors.confirmPassword && ( +
{errors.confirmPassword}
+ )} +
+ + {message && ( +
+ {message} +
+ )} + + +
+
+ ) +} + +export { ForgotPasswordForm, ResetPasswordForm } diff --git a/frontend/pages/login/container/EnhancedLoginPanel.jsx b/frontend/pages/login/container/EnhancedLoginPanel.jsx new file mode 100644 index 00000000..4e6a0576 --- /dev/null +++ b/frontend/pages/login/container/EnhancedLoginPanel.jsx @@ -0,0 +1,218 @@ +import React, { useState } from 'react' +import cx from 'classnames' +import API from 'API' +import Icon from 'COMPONENTS/Icon' +import styles from '../styles/login.css' +import locales, { getLocale } from 'LOCALES' +import LogoText from 'COMPONENTS/LogoText' +import Terminal from 'COMPONENTS/Terminal' +import { ClassicButton } from 'light-ui' +import EmailLoginForm from '../components/EmailLoginForm' +import EmailRegisterForm from '../components/EmailRegisterForm' + +const { + login: loginText, + statistic: statisticText +} = locales('login') +const locale = getLocale() + +const LOGIN_MODES = { + GITHUB: 'github', + EMAIL_LOGIN: 'email_login', + EMAIL_REGISTER: 'email_register' +} + +const EnhancedLoginPanel = ({ loginLink, ...props }) => { + const [currentMode, setCurrentMode] = useState(LOGIN_MODES.GITHUB) + const [loading, setLoading] = useState(true) + const [statistic, setStatistic] = useState({}) + const [languages, setLanguages] = useState([]) + + React.useEffect(() => { + getLanguages() + getStatistic() + }, []) + + const getLanguages = async () => { + try { + const langs = await API.home.languages() + setLanguages(langs || []) + } catch (error) { + console.error('Failed to fetch languages:', error) + } + } + + const getStatistic = async () => { + try { + const stats = await API.home.statistic() + const { + users, + github = {}, + resume = {} + } = (stats || {}) + + const usersCount = Number(users || 0) + const githubPageview = (github && github.pageview) || 0 + const resumePageview = (resume && resume.pageview) || 0 + const resumeCount = (resume && resume.count) || 0 + const resumeDownload = (resume && resume.download) || 0 + const resumeNum = Number(resumeCount) + Number(resumeDownload) + + setStatistic({ + usersCount, + githubPageview, + resumePageview, + resumeNum + }) + } catch (error) { + console.error('Failed to fetch statistics:', error) + } finally { + setLoading(false) + } + } + + const renderLanguages = () => { + return languages.map((language, index) => { + const { locale: lang, text, url } = language + return ( + + {text} + + ) + }) + } + + const renderStatistic = () => { + if (loading) return null + + const { + usersCount, + githubPageview, + resumePageview, + resumeNum + } = statistic + + return ( +
+ + {usersCount} + + {statisticText.developers} +
+ + {githubPageview} + + {statisticText.githubPageview} +
+ + {resumePageview} + + {statisticText.resumePageview} +
+ + {resumeNum} + + {statisticText.resumes} +
+
+ ) + } + + const renderGithubLogin = () => ( +
+ + window.location = loginLink} + buttonContainerClassName={styles.loginButton} + > + + +   + {loginText.loginButton} + + + +
+ 或者 +
+ + setCurrentMode(LOGIN_MODES.EMAIL_LOGIN)} + buttonContainerClassName={styles.emailLoginButton} + > + +   + 使用邮箱登录 + + + +
+ ) + + const renderCurrentMode = () => { + switch (currentMode) { + case LOGIN_MODES.EMAIL_LOGIN: + return ( + setCurrentMode(LOGIN_MODES.EMAIL_REGISTER)} + onBackToGithub={() => setCurrentMode(LOGIN_MODES.GITHUB)} + /> + ) + case LOGIN_MODES.EMAIL_REGISTER: + return ( + setCurrentMode(LOGIN_MODES.EMAIL_LOGIN)} + onBackToGithub={() => setCurrentMode(LOGIN_MODES.GITHUB)} + /> + ) + default: + return renderGithubLogin() + } + } + + return ( +
+ + +
+ {renderCurrentMode()} + +
+ {renderStatistic()} +
+
+
+ ) +} + +export default EnhancedLoginPanel diff --git a/frontend/pages/login/container/index.jsx b/frontend/pages/login/container/index.jsx index 14c7d071..3363c21d 100644 --- a/frontend/pages/login/container/index.jsx +++ b/frontend/pages/login/container/index.jsx @@ -11,22 +11,41 @@ import CountByStep from 'COMPONENTS/Count/CountByStep' import LogoText from 'COMPONENTS/LogoText' import Terminal from 'COMPONENTS/Terminal' import { ClassicButton } from 'light-ui' +import EmailLoginForm from '../components/EmailLoginForm' +import EmailRegisterForm from '../components/EmailRegisterForm' const { login: loginText, - statistic: statisticText + statistic: statisticText, + emailAuth: emailText } = locales('login') const locale = getLocale() const DEFAULT_NUM = 0 +const LOGIN_MODES = { + GITHUB: 'github', + EMAIL_LOGIN: 'email_login', + EMAIL_REGISTER: 'email_register' +} class LoginPanel extends React.PureComponent { constructor(props) { super(props) + + // Determine initial login mode based on current URL + const currentPath = window.location.pathname + let initialMode = LOGIN_MODES.GITHUB // Default to GitHub login to show all options + if (currentPath === '/signup') { + initialMode = LOGIN_MODES.EMAIL_REGISTER + } else if (currentPath === '/login') { + initialMode = LOGIN_MODES.EMAIL_LOGIN + } + this.state = { loading: true, statistic: {}, - languages: [] + languages: [], + loginMode: initialMode } this.heartBeat = null this.getStatistic = this.getStatistic.bind(this) @@ -77,6 +96,10 @@ class LoginPanel extends React.PureComponent { }) } + switchLoginMode = (mode) => { + this.setState({ loginMode: mode }) + } + renderModal() { const { isMobile } = this.props const { loading, statistic } = this.state @@ -187,16 +210,81 @@ class LoginPanel extends React.PureComponent { }) } - render() { + renderMainContent() { const { loginLink } = this.props + const { loginMode } = this.state + + switch (loginMode) { + case LOGIN_MODES.EMAIL_LOGIN: + return ( + this.switchLoginMode(LOGIN_MODES.EMAIL_REGISTER)} + onBackToGithub={() => this.switchLoginMode(LOGIN_MODES.GITHUB)} + /> + ) + case LOGIN_MODES.EMAIL_REGISTER: + return ( + this.switchLoginMode(LOGIN_MODES.EMAIL_LOGIN)} + onBackToGithub={() => this.switchLoginMode(LOGIN_MODES.GITHUB)} + /> + ) + default: + return ( +
+ + +
+ window.location = loginLink} + buttonContainerClassName={styles.loginButton} + > + + +   + {loginText.loginButton} + + + this.switchLoginMode(LOGIN_MODES.EMAIL_LOGIN)} + buttonContainerClassName={styles.emailLoginButton} + > + + +   + {emailText.loginTitle} + + +
+ + +
+ {this.renderLoading()} + {this.renderStatistic()} + {this.renderModal()} +
+
+ ) + } + } + + render() { return (
{this.renderLanguages()}
- + {loginText.topbarLogin}
- - window.location = loginLink} - buttonContainerClassName={styles.loginButton} - > - - -   - {loginText.loginButton} - - - -
- {this.renderLoading()} - {this.renderStatistic()} - {this.renderModal()} -
+ {this.renderMainContent()}
) diff --git a/frontend/pages/login/styles/login.css b/frontend/pages/login/styles/login.css index c96a42c7..9aed1cec 100644 --- a/frontend/pages/login/styles/login.css +++ b/frontend/pages/login/styles/login.css @@ -82,7 +82,7 @@ justify-content: center; align-items: center; text-align: center; - pointer-events: none; + pointer-events: auto; width: 80%; justify-content: flex-start; @@ -98,15 +98,50 @@ font-size: 10rem; } -.loginButton { +.githubLoginLink { + composes: baseLink; + color: var(--oc-gray-8); + padding: 10px 15px; +} + +.loginButtonsContainer { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; margin-top: 25px; margin-bottom: 25px; + pointer-events: auto; +} + +.loginButtonsContainer button { + min-width: 160px; + height: 44px; + font-size: 14px; } .githubLoginLink { composes: baseLink; color: var(--oc-gray-8); padding: 10px 15px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.emailLoginLink { + composes: baseLink; + color: var(--oc-gray-8); + padding: 10px 15px; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; } .statisticContainer { @@ -288,6 +323,217 @@ } } +/* Email Authentication Styles */ +.emailAuthContainer { + width: 100%; + max-width: 400px; + margin: 0 auto; + padding: 20px; + color: var(--oc-gray-1); + pointer-events: auto; +} + +.authHeader { + text-align: center; + margin-bottom: 30px; +} + +.authTitle { + font-size: 2rem; + color: var(--oc-gray-1); + margin-bottom: 10px; + font-weight: 300; +} + +.authSubtitle { + font-size: 1rem; + color: var(--oc-gray-4); + margin: 0; +} + +.authForm { + width: 100%; +} + +.inputGroup { + margin-bottom: 20px; +} + +.authInput { + width: 100%; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--oc-gray-6); + color: var(--oc-gray-1); + padding: 12px 16px; + border-radius: 4px; + font-size: 14px; + transition: border-color 0.3s ease; + pointer-events: auto; +} + +.authInput:focus { + border-color: var(--oc-blue-5); + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2); + outline: none; +} + +.authInput.inputError { + border-color: var(--oc-red-5); +} + +.authInput.inputError:focus { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); +} + +.errorText { + color: var(--oc-red-5); + font-size: 12px; + margin-top: 5px; +} + +.helpText { + color: var(--oc-gray-5); + font-size: 12px; + margin-top: 5px; +} + +.message { + padding: 12px 16px; + border-radius: 4px; + margin-bottom: 20px; + font-size: 14px; + text-align: center; +} + +.successMessage { + background-color: rgba(34, 197, 94, 0.2); + border: 1px solid var(--oc-green-5); + color: var(--oc-green-4); +} + +.errorMessage { + background-color: rgba(239, 68, 68, 0.2); + border: 1px solid var(--oc-red-5); + color: var(--oc-red-4); +} + +.authButton { + width: 100%; + margin-bottom: 20px; + padding: 12px 0; + font-size: 16px; + font-weight: 500; + pointer-events: auto; +} + +.classicButton { + background: linear-gradient(135deg, #2d3748, #4a5568); + border: 1px solid #4a5568; + border-radius: 6px; + color: white; + cursor: pointer; + outline: none; + transition: all 0.2s ease; +} + +.classicButton:hover { + background: linear-gradient(135deg, #4a5568, #718096); + border-color: #718096; +} + +.classicButton:active { + transform: translateY(1px); +} + +.classicButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.classicButton:disabled:hover { + background: linear-gradient(135deg, #2d3748, #4a5568); + border-color: #4a5568; + transform: none; +} + +.authLinks { + text-align: center; + margin-top: 20px; + pointer-events: auto; +} + +.linkButton { + background: none; + border: none; + color: var(--oc-blue-4); + cursor: pointer; + font-size: 14px; + text-decoration: underline; + margin: 0 10px; + padding: 5px 0; + transition: color 0.3s ease; +} + +.linkButton:hover { + color: var(--oc-blue-3); +} + +.authSeparator { + margin: 20px 0; + text-align: center; + position: relative; +} + +.authSeparator::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--oc-gray-6); +} + +.authSeparator span { + background: #252525; + padding: 0 15px; + color: var(--oc-gray-4); + font-size: 14px; + position: relative; + z-index: 1; +} + +.githubLoginContainer { + text-align: center; + pointer-events: auto; +} + +.loginButtonsContainer { + display: flex; + justify-content: center; + align-items: center; + gap: 20px; + margin-top: 25px; + margin-bottom: 25px; + pointer-events: auto; +} + +/* Responsive */ +@media (max-width: 500px) { + .emailAuthContainer { + padding: 15px; + max-width: 100%; + } + + .authTitle { + font-size: 1.5rem; + } + + .authInput { + padding: 10px 12px; + } +} + @media (max-width: 300px) { .logo { font-size: 3rem; diff --git a/frontend/pages/reset-password/index.js b/frontend/pages/reset-password/index.js new file mode 100644 index 00000000..cf5608ed --- /dev/null +++ b/frontend/pages/reset-password/index.js @@ -0,0 +1,22 @@ +import React from 'react' +import ReactDOM from 'react-dom' +import { ResetPasswordForm } from '../login/components/PasswordResetForm' +import styles from '../login/styles/login.css' + +const ResetPasswordPage = () => { + return ( +
+ +
+ ) +} + +const renderApp = (domId, props = {}) => { + const DOM = document.getElementById(domId) + ReactDOM.render( + , + DOM + ) +} + +export default renderApp diff --git a/frontend/utils/locales/login/en.js b/frontend/utils/locales/login/en.js index fbc17230..ada33883 100644 --- a/frontend/utils/locales/login/en.js +++ b/frontend/utils/locales/login/en.js @@ -11,6 +11,44 @@ const datas = { loginText: 'USE GITHUB DATA TO MAKE A BETTER RESUME', topbarLogin: 'LOGIN', topbarAbout: 'ABOUT' + }, + emailAuth: { + // Login + loginTitle: 'Email Login', + loginSubtitle: 'Sign in with your email account', + loginButton: 'Login', + loggingIn: 'Logging in...', + // Register + registerTitle: 'Email Registration', + registerSubtitle: 'Create your Hacknical account', + registerButton: 'Register', + registering: 'Registering...', + // Forgot Password + forgotPasswordTitle: 'Forgot Password', + forgotPasswordSubtitle: 'Enter your email and we will send you a reset link', + sendResetEmail: 'Send Reset Email', + sending: 'Sending...', + // Reset Password + resetPasswordTitle: 'Reset Password', + resetPasswordSubtitle: 'Enter your new password', + resetPasswordButton: 'Reset Password', + resetting: 'Resetting...', + // Form Fields + emailPlaceholder: 'Email address', + passwordPlaceholder: 'Password', + newPasswordPlaceholder: 'New password', + confirmPasswordPlaceholder: 'Confirm password', + namePlaceholder: 'Name (optional)', + // Help Text + nameOptional: 'Email prefix will be used as name if not provided', + passwordHelp: 'At least 6 characters, including letters and numbers', + // Link Text + forgotPassword: 'Forgot password?', + noAccount: 'No account? Register now', + hasAccount: 'Have an account? Login now', + backToGithub: 'Back to GitHub login', + backToLogin: 'Back to login', + or: 'or' } } diff --git a/frontend/utils/locales/login/zh.js b/frontend/utils/locales/login/zh.js index 3de2ff83..39a25c7e 100644 --- a/frontend/utils/locales/login/zh.js +++ b/frontend/utils/locales/login/zh.js @@ -11,6 +11,44 @@ const datas = { loginText: '用 GitHub 数据辅助你进行简历投递', topbarLogin: '登录', topbarAbout: '关于' + }, + emailAuth: { + // 登录 + loginTitle: '邮箱登录', + loginSubtitle: '使用你的邮箱账号登录', + loginButton: '登录', + loggingIn: '登录中...', + // 注册 + registerTitle: '邮箱注册', + registerSubtitle: '创建你的 Hacknical 账号', + registerButton: '注册', + registering: '注册中...', + // 忘记密码 + forgotPasswordTitle: '忘记密码', + forgotPasswordSubtitle: '输入你的邮箱地址,我们将发送重置链接', + sendResetEmail: '发送重置邮件', + sending: '发送中...', + // 重置密码 + resetPasswordTitle: '重置密码', + resetPasswordSubtitle: '输入你的新密码', + resetPasswordButton: '重置密码', + resetting: '重置中...', + // 表单字段 + emailPlaceholder: '邮箱地址', + passwordPlaceholder: '密码', + newPasswordPlaceholder: '新密码', + confirmPasswordPlaceholder: '确认密码', + namePlaceholder: '姓名(可选)', + // 帮助文本 + nameOptional: '不填写将使用邮箱前缀作为姓名', + passwordHelp: '至少6位,包含字母和数字', + // 链接文本 + forgotPassword: '忘记密码?', + noAccount: '没有账号?立即注册', + hasAccount: '已有账号?立即登录', + backToGithub: '返回 GitHub 登录', + backToLogin: '返回登录', + or: '或者' } } diff --git a/package.json b/package.json index 704ab193..e1d6eb99 100644 --- a/package.json +++ b/package.json @@ -145,6 +145,7 @@ "assets-webpack-plugin": "^3.5.1", "babel-core": "^6.26.0", "babel-polyfill": "^6.26.0", + "bcrypt": "^6.0.0", "cache-manager": "^2.9.0", "config": "^1.21.0", "eventemitter3": "^3.1.0", @@ -167,6 +168,7 @@ "log4js": "^1.1.1", "moment": "^2.17.0", "mq-utils": "^0.1.0", + "nodemailer": "^7.0.5", "nunjucks": "^3.0.1", "phantom": "^4.0.1", "rc-times": "0.0.6",