feat(auth): add password reset and remember me options

Add password reset request and reset endpoints with email verification
Add "Remember me" option to login for persistent sessions
Create frontend views for forgot and reset password flows
This commit is contained in:
2026-05-02 22:13:10 +08:00
parent 97f06794a8
commit 4a42756e2e
12 changed files with 456 additions and 26 deletions

View File

@@ -102,8 +102,15 @@
- 验证邮件包含一次性验证链接。
- 验证 token 只保存 hash并带过期时间和使用状态。
- 只有邮箱已验证的用户可以登录。
- 用户可请求重置密码:
- 重置请求只接收邮箱,并始终返回泛化成功信息,避免暴露邮箱是否已注册。
- 重置邮件包含一次性重置链接。
- 重置 token 只保存 hash并带过期时间和使用状态。
- 密码重置成功后不自动登录,并删除该用户已有 session。
- 登录页提供 Remember me
- 未勾选时前端将登录 token 保存在 `sessionStorage``pokopia_auth_token`,服务端 session 有效期为 1 天。
- 勾选时前端将登录 token 保存在 `localStorage``pokopia_auth_token`,服务端 session 有效期为 30 天。
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
- 前端将登录 token 保存在 `localStorage``pokopia_auth_token`
- 用户可退出登录,退出时删除对应 session。
- 对外用户字段只包含必要信息:
- 当前用户:`id``email``displayName``emailVerified`
@@ -534,6 +541,8 @@ API 暴露边界:
- `POST /api/auth/register`
- `POST /api/auth/verify-email`
- `POST /api/auth/login`
- `POST /api/auth/request-password-reset`
- `POST /api/auth/reset-password`
- `GET /api/auth/me`
- `POST /api/auth/logout`

View File

@@ -130,6 +130,18 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens (
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens(user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash text NOT NULL UNIQUE,
expires_at timestamptz NOT NULL,
used_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens(user_id);
CREATE TABLE IF NOT EXISTS user_sessions (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,

View File

@@ -7,7 +7,9 @@ import { systemMessage } from './systemWordingQueries.ts';
const scrypt = promisify(scryptCallback);
const passwordKeyLength = 64;
const verificationTokenHours = 24;
const sessionDays = 30;
const passwordResetTokenHours = 1;
const rememberedSessionDays = 30;
const sessionOnlySessionDays = 1;
const defaultLocale = 'en';
type DbClient = PoolClient;
@@ -35,11 +37,19 @@ type AuthMessageKey =
| 'emailAlreadyRegistered'
| 'checkVerificationEmail'
| 'emailVerified'
| 'checkPasswordResetEmail'
| 'passwordResetComplete'
| 'invalidCredentials'
| 'verifyEmailFirst'
| 'invalidResetToken'
| 'emailSubject'
| 'emailHtml'
| 'emailText';
| 'emailText'
| 'passwordResetSubject'
| 'passwordResetHtml'
| 'passwordResetText';
type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken';
export type AuthUser = {
id: number;
@@ -65,11 +75,17 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
checkVerificationEmail: 'server.auth.checkVerificationEmail',
emailVerified: 'server.auth.emailVerified',
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
passwordResetComplete: 'server.auth.passwordResetComplete',
invalidCredentials: 'server.auth.invalidCredentials',
verifyEmailFirst: 'server.auth.verifyEmailFirst',
invalidResetToken: 'server.auth.invalidResetToken',
emailSubject: 'email.auth.verificationSubject',
emailHtml: 'email.auth.verificationHtml',
emailText: 'email.auth.verificationText'
emailText: 'email.auth.verificationText',
passwordResetSubject: 'email.auth.passwordResetSubject',
passwordResetHtml: 'email.auth.passwordResetHtml',
passwordResetText: 'email.auth.passwordResetText'
};
return systemMessage(locale || defaultLocale, messageKeys[key], params);
@@ -109,9 +125,13 @@ async function cleanPassword(value: unknown, locale: string): Promise<string> {
return value;
}
async function cleanToken(value: unknown, locale: string): Promise<string> {
async function cleanToken(
value: unknown,
locale: string,
messageKey: AuthTokenMessageKey = 'invalidToken'
): Promise<string> {
if (typeof value !== 'string' || value.trim().length < 32) {
throw statusError(await authMessage(locale, 'invalidToken'), 400);
throw statusError(await authMessage(locale, messageKey), 400);
}
return value.trim();
@@ -187,13 +207,21 @@ function getEmailConfig() {
return { apiKey, from };
}
function buildVerificationUrl(token: string): string {
function buildTokenUrl(pathname: string, token: string): string {
const origin = process.env.APP_ORIGIN ?? process.env.FRONTEND_ORIGIN ?? 'http://localhost:3000';
const url = new URL('/verify-email', origin);
const url = new URL(pathname, origin);
url.searchParams.set('token', token);
return url.toString();
}
function buildVerificationUrl(token: string): string {
return buildTokenUrl('/verify-email', token);
}
function buildPasswordResetUrl(token: string): string {
return buildTokenUrl('/reset-password', token);
}
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
const { apiKey, from } = getEmailConfig();
const verificationUrl = buildVerificationUrl(token);
@@ -221,6 +249,33 @@ async function sendVerificationEmail(email: string, token: string, locale: strin
}
}
async function sendPasswordResetEmail(email: string, token: string, locale: string): Promise<void> {
const { apiKey, from } = getEmailConfig();
const resetUrl = buildPasswordResetUrl(token);
const subject = await authMessage(locale, 'passwordResetSubject');
const html = await authMessage(locale, 'passwordResetHtml', { url: resetUrl, hours: passwordResetTokenHours });
const text = await authMessage(locale, 'passwordResetText', { url: resetUrl, hours: passwordResetTokenHours });
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
from,
to: [email],
subject,
html,
text
})
});
if (!response.ok) {
const responseText = await response.text();
throw new Error(`Resend email failed with ${response.status}: ${responseText.slice(0, 500)}`);
}
}
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = await cleanEmail(payload.email, locale);
const displayName = await cleanDisplayName(payload.displayName, locale);
@@ -324,9 +379,90 @@ export async function verifyEmail(payload: Record<string, unknown>, locale = def
});
}
export async function requestPasswordReset(payload: Record<string, unknown>, locale = defaultLocale) {
const email = await cleanEmail(payload.email, locale);
const user = await queryOne<UserRow>(
'SELECT id, email, display_name, email_verified_at FROM users WHERE email = $1',
[email]
);
if (user) {
const resetToken = createPlainToken();
const resetTokenHash = hashToken(resetToken);
await withTransaction(async (client) => {
await client.query('DELETE FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL', [user.id]);
await client.query(
`
INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, now() + ($3 * interval '1 hour'))
`,
[user.id, resetTokenHash, passwordResetTokenHours]
);
});
try {
await sendPasswordResetEmail(email, resetToken, locale);
} catch (error) {
console.error('Password reset email failed', error);
}
}
return { message: await authMessage(locale, 'checkPasswordResetEmail') };
}
export async function resetPassword(payload: Record<string, unknown>, locale = defaultLocale) {
const token = await cleanToken(payload.token, locale, 'invalidResetToken');
const password = await cleanPassword(payload.password, locale);
const passwordHash = await hashPassword(password);
const tokenHash = hashToken(token);
return withTransaction(async (client) => {
const tokenRow = await clientQueryOne<{ id: number; user_id: number }>(
client,
`
SELECT id, user_id
FROM password_reset_tokens
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > now()
FOR UPDATE
`,
[tokenHash]
);
if (!tokenRow) {
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
}
const user = await clientQueryOne<UserRow>(
client,
`
UPDATE users
SET password_hash = $1, updated_at = now()
WHERE id = $2
RETURNING id, email, display_name, email_verified_at
`,
[passwordHash, tokenRow.user_id]
);
if (!user) {
throw statusError(await authMessage(locale, 'invalidResetToken'), 400);
}
await client.query('UPDATE password_reset_tokens SET used_at = now() WHERE user_id = $1 AND used_at IS NULL', [
user.id
]);
await client.query('DELETE FROM user_sessions WHERE user_id = $1', [user.id]);
return { message: await authMessage(locale, 'passwordResetComplete') };
});
}
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
const email = await cleanEmail(payload.email, locale);
const password = await cleanPassword(payload.password, locale);
const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays;
const user = await queryOne<LoginUserRow>(
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
[email]

View File

@@ -1,7 +1,16 @@
import cors from '@fastify/cors';
import Fastify from 'fastify';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { getUserBySessionToken, loginUser, logoutSession, registerUser, verifyEmail, type AuthUser } from './auth.ts';
import {
getUserBySessionToken,
loginUser,
logoutSession,
registerUser,
requestPasswordReset,
resetPassword,
verifyEmail,
type AuthUser
} from './auth.ts';
import { initializeDatabase, pool } from './db.ts';
import {
cleanLocale,
@@ -170,6 +179,14 @@ app.post('/api/auth/verify-email', async (request) => verifyEmail(request.body a
app.post('/api/auth/login', async (request) => loginUser(request.body as Record<string, unknown>, requestLocale(request)));
app.post('/api/auth/request-password-reset', async (request) =>
requestPasswordReset(request.body as Record<string, unknown>, requestLocale(request))
);
app.post('/api/auth/reset-password', async (request) =>
resetPassword(request.body as Record<string, unknown>, requestLocale(request))
);
app.get('/api/auth/me', async (request, reply) => {
const token = getBearerToken(request.headers.authorization);
const user = token ? await getUserBySessionToken(token) : null;

View File

@@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star';
export const iconHabitat: AppIcon = 'mdi:pine-tree';
export const iconInfo: AppIcon = 'mdi:information-outline';
export const iconItem: AppIcon = 'mdi:bag-personal-outline';
export const iconKey: AppIcon = 'mdi:key-outline';
export const iconLife: AppIcon = 'mdi:post-outline';
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
export const iconLogin: AppIcon = 'mdi:login';

View File

@@ -11,8 +11,10 @@ import DailyChecklistView from '../views/DailyChecklistView.vue';
import LifeView from '../views/LifeView.vue';
import ComingSoonView from '../views/ComingSoonView.vue';
import AdminView from '../views/AdminView.vue';
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
import LoginView from '../views/LoginView.vue';
import RegisterView from '../views/RegisterView.vue';
import ResetPasswordView from '../views/ResetPasswordView.vue';
import VerifyEmailView from '../views/VerifyEmailView.vue';
import { api, getAuthToken, setAuthToken } from '../services/api';
@@ -45,6 +47,8 @@ export const router = createRouter({
{ path: '/life', component: LifeView },
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
{ path: '/login', component: LoginView },
{ path: '/forgot-password', component: ForgotPasswordView },
{ path: '/reset-password', component: ResetPasswordView },
{ path: '/register', component: RegisterView },
{ path: '/verify-email', component: VerifyEmailView }
],

View File

@@ -277,6 +277,7 @@ export interface AuthUser {
export interface LoginPayload {
email: string;
password: string;
rememberMe?: boolean;
}
export interface RegisterPayload extends LoginPayload {
@@ -418,27 +419,40 @@ export function buildQuery(params: Record<string, string | number | undefined>):
return query ? `?${query}` : '';
}
export function getAuthToken(): string | null {
if (typeof localStorage === 'undefined') {
function authStorage(type: 'local' | 'session'): Storage | null {
if (typeof window === 'undefined') {
return null;
}
return localStorage.getItem(authTokenKey);
return type === 'local' ? window.localStorage : window.sessionStorage;
}
export function setAuthToken(token: string | null): void {
if (typeof localStorage === 'undefined') {
return;
export function getAuthToken(): string | null {
const sessionToken = authStorage('session')?.getItem(authTokenKey);
return sessionToken ?? authStorage('local')?.getItem(authTokenKey) ?? null;
}
export function setAuthToken(token: string | null, options: { persistent?: boolean } = {}): void {
const local = authStorage('local');
const session = authStorage('session');
if (token) {
localStorage.setItem(authTokenKey, token);
if (options.persistent === false) {
session?.setItem(authTokenKey, token);
local?.removeItem(authTokenKey);
} else {
localStorage.removeItem(authTokenKey);
local?.setItem(authTokenKey, token);
session?.removeItem(authTokenKey);
}
} else {
local?.removeItem(authTokenKey);
session?.removeItem(authTokenKey);
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new Event(authChangeEvent));
}
}
export function onAuthTokenChange(callback: () => void): () => void {
window.addEventListener(authChangeEvent, callback);
@@ -548,6 +562,10 @@ export const api = {
verifyEmail: (token: string) =>
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
requestPasswordReset: (payload: { email: string }) =>
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
logout: () => postEmpty('/api/auth/logout'),
options: () => getJson<Options>('/api/options'),

View File

@@ -3784,6 +3784,27 @@ button:disabled,
gap: 14px;
}
.auth-options {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 14px;
font-weight: 800;
}
.auth-options__remember {
margin: 0;
min-width: 0;
}
.auth-options a {
color: var(--pokemon-blue-deep);
font-weight: 900;
white-space: nowrap;
}
.auth-switch {
margin: 0;
color: var(--muted);

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconMail } from '../icons';
import { api } from '../services/api';
const { t } = useI18n();
const email = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
async function submitResetRequest() {
busy.value = true;
message.value = '';
errorMessage.value = '';
try {
const response = await api.requestPasswordReset({ email: email.value });
message.value = response.message;
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.requestResetFailed');
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.requestResetTitle')" :subtitle="t('auth.requestResetSubtitle')">
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form class="auth-form" @submit.prevent="submitResetRequest">
<div class="field">
<label for="forgot-password-email">{{ t('auth.email') }}</label>
<input id="forgot-password-email" v-model="email" autocomplete="email" required type="email" />
</div>
<StatusMessage v-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
<Icon :icon="iconMail" class="ui-icon" aria-hidden="true" />
{{ busy ? t('auth.sending') : t('auth.sendResetLink') }}
</button>
</form>
<p class="auth-switch">
<RouterLink to="/login">{{ t('auth.goLogin') }}</RouterLink>
</p>
</div>
</section>
</template>

View File

@@ -13,6 +13,7 @@ const router = useRouter();
const { t } = useI18n();
const email = ref('');
const password = ref('');
const rememberMe = ref(false);
const busy = ref(false);
const errorMessage = ref('');
@@ -23,9 +24,10 @@ async function submitLogin() {
try {
const response = await api.login({
email: email.value,
password: password.value
password: password.value,
rememberMe: rememberMe.value
});
setAuthToken(response.token);
setAuthToken(response.token, { persistent: rememberMe.value });
const redirect =
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
@@ -44,7 +46,7 @@ async function submitLogin() {
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
<template #kicker>Trainer Pass</template>
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form class="auth-form" @submit.prevent="submitLogin">
@@ -58,6 +60,14 @@ async function submitLogin() {
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
</div>
<div class="auth-options">
<label class="check-row auth-options__remember">
<input v-model="rememberMe" type="checkbox" />
{{ t('auth.rememberMe') }}
</label>
<RouterLink to="/forgot-password">{{ t('auth.forgotPassword') }}</RouterLink>
</div>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">

View File

@@ -0,0 +1,98 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue';
import { iconKey, iconLogin } from '../icons';
import { api } from '../services/api';
const { t } = useI18n();
const route = useRoute();
const password = ref('');
const confirmPassword = ref('');
const busy = ref(false);
const message = ref('');
const errorMessage = ref('');
const token = computed(() => (typeof route.query.token === 'string' ? route.query.token : ''));
async function submitPasswordReset() {
message.value = '';
errorMessage.value = '';
if (!token.value) {
errorMessage.value = t('auth.invalidPasswordReset');
return;
}
if (password.value !== confirmPassword.value) {
errorMessage.value = t('auth.passwordMismatch');
return;
}
busy.value = true;
try {
const response = await api.resetPassword({ token: token.value, password: password.value });
message.value = response.message;
password.value = '';
confirmPassword.value = '';
} catch (error) {
errorMessage.value = error instanceof Error && error.message ? error.message : t('auth.resetFailed');
} finally {
busy.value = false;
}
}
</script>
<template>
<section class="auth-page">
<div class="auth-panel">
<PageHeader :title="t('auth.resetTitle')" :subtitle="t('auth.resetSubtitle')">
<template #kicker>{{ t('auth.accountAccess') }}</template>
</PageHeader>
<form v-if="!message" class="auth-form" @submit.prevent="submitPasswordReset">
<div class="field">
<label for="reset-password">{{ t('auth.newPassword') }}</label>
<input
id="reset-password"
v-model="password"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<div class="field">
<label for="reset-password-confirm">{{ t('auth.confirmPassword') }}</label>
<input
id="reset-password-confirm"
v-model="confirmPassword"
autocomplete="new-password"
minlength="8"
required
type="password"
/>
</div>
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
<Icon :icon="iconKey" class="ui-icon" aria-hidden="true" />
{{ busy ? t('auth.resetting') : t('auth.resetPassword') }}
</button>
</form>
<StatusMessage v-else variant="success">{{ message }}</StatusMessage>
<RouterLink v-if="message" class="ui-button ui-button--ghost" to="/login">
<Icon :icon="iconLogin" class="ui-icon" aria-hidden="true" />
{{ t('auth.goLogin') }}
</RouterLink>
</div>
</section>
</template>

View File

@@ -62,13 +62,18 @@ export const systemWordingMessages = {
register: 'Register'
},
auth: {
accountAccess: 'Trainer Pass',
email: 'Email',
password: 'Password',
newPassword: 'New password',
confirmPassword: 'Confirm password',
displayName: 'Display name',
loginTitle: 'Log in',
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
loggingIn: 'Logging in',
loginFailed: 'Login failed',
rememberMe: 'Remember me',
forgotPassword: 'Forgot password?',
noAccount: 'No account yet?',
registerTitle: 'Register',
registerSubtitle: 'Verify your email after creating an account.',
@@ -76,6 +81,17 @@ export const systemWordingMessages = {
sending: 'Sending',
sendVerification: 'Send verification email',
hasAccount: 'Already have an account?',
requestResetTitle: 'Reset password',
requestResetSubtitle: 'Send a password reset link to your account email.',
sendResetLink: 'Send reset link',
requestResetFailed: 'Password reset request failed',
resetTitle: 'Choose a new password',
resetSubtitle: 'Use the reset link from your email to update your password.',
resetPassword: 'Reset password',
resetting: 'Resetting',
resetFailed: 'Password reset failed',
passwordMismatch: 'Passwords do not match',
invalidPasswordReset: 'The password reset link is invalid or expired.',
verifyTitle: 'Email verification',
verifySubtitle: 'You can log in after verification is complete.',
verifyingEmail: 'Verifying email',
@@ -523,8 +539,11 @@ export const systemWordingMessages = {
emailAlreadyRegistered: 'This email is already registered',
checkVerificationEmail: 'Please check your verification email',
emailVerified: 'Email verified',
checkPasswordResetEmail: 'If an account uses this email, a password reset link will be sent.',
passwordResetComplete: 'Password updated. You can log in with the new password.',
invalidCredentials: 'Email or password is incorrect',
verifyEmailFirst: 'Please complete email verification first'
verifyEmailFirst: 'Please complete email verification first',
invalidResetToken: 'The password reset link is invalid or expired'
},
validation: {
nameRequired: 'Name is required',
@@ -590,7 +609,11 @@ export const systemWordingMessages = {
verificationSubject: 'Verify your Pokopia Wiki email',
verificationHtml:
'<p>Open the link below to verify your email:</p><p><a href="{url}">Verify email</a></p><p>The link expires in {hours} hours.</p>',
verificationText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.'
verificationText: 'Open this link to verify your Pokopia Wiki email: {url}\nThe link expires in {hours} hours.',
passwordResetSubject: 'Reset your Pokopia Wiki password',
passwordResetHtml:
'<p>Open the link below to reset your password:</p><p><a href="{url}">Reset password</a></p><p>The link expires in {hours} hours.</p>',
passwordResetText: 'Open this link to reset your Pokopia Wiki password: {url}\nThe link expires in {hours} hours.'
}
},
},
@@ -651,13 +674,18 @@ export const systemWordingMessages = {
register: '注册'
},
auth: {
accountAccess: 'Trainer Pass',
email: '邮箱',
password: '密码',
newPassword: '新密码',
confirmPassword: '确认密码',
displayName: '显示名',
loginTitle: '登录',
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
loggingIn: '登录中',
loginFailed: '登录失败',
rememberMe: '记住我',
forgotPassword: '忘记密码?',
noAccount: '还没有账号?',
registerTitle: '注册',
registerSubtitle: '创建账号后需要完成邮箱验证',
@@ -665,6 +693,17 @@ export const systemWordingMessages = {
sending: '发送中',
sendVerification: '发送验证邮件',
hasAccount: '已有账号?',
requestResetTitle: '重置密码',
requestResetSubtitle: '向账号邮箱发送密码重置链接。',
sendResetLink: '发送重置链接',
requestResetFailed: '密码重置请求失败',
resetTitle: '设置新密码',
resetSubtitle: '使用邮件中的重置链接更新密码。',
resetPassword: '重置密码',
resetting: '重置中',
resetFailed: '密码重置失败',
passwordMismatch: '两次输入的密码不一致',
invalidPasswordReset: '密码重置链接无效或已过期',
verifyTitle: '邮箱验证',
verifySubtitle: '完成验证后即可登录',
verifyingEmail: '正在验证邮箱',
@@ -1112,8 +1151,11 @@ export const systemWordingMessages = {
emailAlreadyRegistered: '该邮箱已注册',
checkVerificationEmail: '请查收验证邮件',
emailVerified: '邮箱已验证',
checkPasswordResetEmail: '如果该邮箱已注册,系统会发送密码重置链接。',
passwordResetComplete: '密码已更新,请使用新密码登录。',
invalidCredentials: '邮箱或密码不正确',
verifyEmailFirst: '请先完成邮箱验证'
verifyEmailFirst: '请先完成邮箱验证',
invalidResetToken: '密码重置链接无效或已过期'
},
validation: {
nameRequired: '请输入名称',
@@ -1178,7 +1220,10 @@ export const systemWordingMessages = {
auth: {
verificationSubject: '验证你的 Pokopia Wiki 邮箱',
verificationHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。'
verificationText: '请打开以下链接完成 Pokopia Wiki 邮箱验证:{url}\n链接将在 {hours} 小时后失效。',
passwordResetSubject: '重置你的 Pokopia Wiki 密码',
passwordResetHtml: '<p>请点击下面的链接重置密码:</p><p><a href="{url}">重置密码</a></p><p>链接将在 {hours} 小时后失效。</p>',
passwordResetText: '请打开以下链接重置 Pokopia Wiki 密码:{url}\n链接将在 {hours} 小时后失效。'
}
}
}