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}
+
+
+
+
+
+
+
+
+ )
+}
+
+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}
+
+
+
+
+
+
+
+ {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}
+
+
+
+
+
+
+
+
+
+ )
+}
+
+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}
+
+
+
+
+
+
+
+
+ )
+}
+
+// 重置密码确认组件
+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}
+
+
+
+
+ )
+}
+
+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 (
-
-
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",