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:
11
DESIGN.md
11
DESIGN.md
@@ -102,8 +102,15 @@
|
|||||||
- 验证邮件包含一次性验证链接。
|
- 验证邮件包含一次性验证链接。
|
||||||
- 验证 token 只保存 hash,并带过期时间和使用状态。
|
- 验证 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。
|
- 登录成功后返回明文 session token 给前端;数据库只保存 session token hash。
|
||||||
- 前端将登录 token 保存在 `localStorage` 的 `pokopia_auth_token`。
|
|
||||||
- 用户可退出登录,退出时删除对应 session。
|
- 用户可退出登录,退出时删除对应 session。
|
||||||
- 对外用户字段只包含必要信息:
|
- 对外用户字段只包含必要信息:
|
||||||
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
- 当前用户:`id`、`email`、`displayName`、`emailVerified`
|
||||||
@@ -534,6 +541,8 @@ API 暴露边界:
|
|||||||
- `POST /api/auth/register`
|
- `POST /api/auth/register`
|
||||||
- `POST /api/auth/verify-email`
|
- `POST /api/auth/verify-email`
|
||||||
- `POST /api/auth/login`
|
- `POST /api/auth/login`
|
||||||
|
- `POST /api/auth/request-password-reset`
|
||||||
|
- `POST /api/auth/reset-password`
|
||||||
- `GET /api/auth/me`
|
- `GET /api/auth/me`
|
||||||
- `POST /api/auth/logout`
|
- `POST /api/auth/logout`
|
||||||
|
|
||||||
|
|||||||
@@ -130,6 +130,18 @@ CREATE TABLE IF NOT EXISTS email_verification_tokens (
|
|||||||
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
|
||||||
ON email_verification_tokens(user_id);
|
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 (
|
CREATE TABLE IF NOT EXISTS user_sessions (
|
||||||
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import { systemMessage } from './systemWordingQueries.ts';
|
|||||||
const scrypt = promisify(scryptCallback);
|
const scrypt = promisify(scryptCallback);
|
||||||
const passwordKeyLength = 64;
|
const passwordKeyLength = 64;
|
||||||
const verificationTokenHours = 24;
|
const verificationTokenHours = 24;
|
||||||
const sessionDays = 30;
|
const passwordResetTokenHours = 1;
|
||||||
|
const rememberedSessionDays = 30;
|
||||||
|
const sessionOnlySessionDays = 1;
|
||||||
const defaultLocale = 'en';
|
const defaultLocale = 'en';
|
||||||
|
|
||||||
type DbClient = PoolClient;
|
type DbClient = PoolClient;
|
||||||
@@ -35,11 +37,19 @@ type AuthMessageKey =
|
|||||||
| 'emailAlreadyRegistered'
|
| 'emailAlreadyRegistered'
|
||||||
| 'checkVerificationEmail'
|
| 'checkVerificationEmail'
|
||||||
| 'emailVerified'
|
| 'emailVerified'
|
||||||
|
| 'checkPasswordResetEmail'
|
||||||
|
| 'passwordResetComplete'
|
||||||
| 'invalidCredentials'
|
| 'invalidCredentials'
|
||||||
| 'verifyEmailFirst'
|
| 'verifyEmailFirst'
|
||||||
|
| 'invalidResetToken'
|
||||||
| 'emailSubject'
|
| 'emailSubject'
|
||||||
| 'emailHtml'
|
| 'emailHtml'
|
||||||
| 'emailText';
|
| 'emailText'
|
||||||
|
| 'passwordResetSubject'
|
||||||
|
| 'passwordResetHtml'
|
||||||
|
| 'passwordResetText';
|
||||||
|
|
||||||
|
type AuthTokenMessageKey = 'invalidToken' | 'invalidResetToken';
|
||||||
|
|
||||||
export type AuthUser = {
|
export type AuthUser = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -65,11 +75,17 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
|||||||
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
emailAlreadyRegistered: 'server.auth.emailAlreadyRegistered',
|
||||||
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
checkVerificationEmail: 'server.auth.checkVerificationEmail',
|
||||||
emailVerified: 'server.auth.emailVerified',
|
emailVerified: 'server.auth.emailVerified',
|
||||||
|
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||||
|
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||||
invalidCredentials: 'server.auth.invalidCredentials',
|
invalidCredentials: 'server.auth.invalidCredentials',
|
||||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||||
|
invalidResetToken: 'server.auth.invalidResetToken',
|
||||||
emailSubject: 'email.auth.verificationSubject',
|
emailSubject: 'email.auth.verificationSubject',
|
||||||
emailHtml: 'email.auth.verificationHtml',
|
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);
|
return systemMessage(locale || defaultLocale, messageKeys[key], params);
|
||||||
@@ -109,9 +125,13 @@ async function cleanPassword(value: unknown, locale: string): Promise<string> {
|
|||||||
return value;
|
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) {
|
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();
|
return value.trim();
|
||||||
@@ -187,13 +207,21 @@ function getEmailConfig() {
|
|||||||
return { apiKey, from };
|
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 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);
|
url.searchParams.set('token', token);
|
||||||
return url.toString();
|
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> {
|
async function sendVerificationEmail(email: string, token: string, locale: string): Promise<void> {
|
||||||
const { apiKey, from } = getEmailConfig();
|
const { apiKey, from } = getEmailConfig();
|
||||||
const verificationUrl = buildVerificationUrl(token);
|
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) {
|
export async function registerUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
const displayName = await cleanDisplayName(payload.displayName, 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) {
|
export async function loginUser(payload: Record<string, unknown>, locale = defaultLocale) {
|
||||||
const email = await cleanEmail(payload.email, locale);
|
const email = await cleanEmail(payload.email, locale);
|
||||||
const password = await cleanPassword(payload.password, locale);
|
const password = await cleanPassword(payload.password, locale);
|
||||||
|
const sessionDays = payload.rememberMe === true ? rememberedSessionDays : sessionOnlySessionDays;
|
||||||
const user = await queryOne<LoginUserRow>(
|
const user = await queryOne<LoginUserRow>(
|
||||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE email = $1',
|
||||||
[email]
|
[email]
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import cors from '@fastify/cors';
|
import cors from '@fastify/cors';
|
||||||
import Fastify from 'fastify';
|
import Fastify from 'fastify';
|
||||||
import type { FastifyReply, FastifyRequest } 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 { initializeDatabase, pool } from './db.ts';
|
||||||
import {
|
import {
|
||||||
cleanLocale,
|
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/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) => {
|
app.get('/api/auth/me', async (request, reply) => {
|
||||||
const token = getBearerToken(request.headers.authorization);
|
const token = getBearerToken(request.headers.authorization);
|
||||||
const user = token ? await getUserBySessionToken(token) : null;
|
const user = token ? await getUserBySessionToken(token) : null;
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const iconEvent: AppIcon = 'mdi:calendar-star';
|
|||||||
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
export const iconHabitat: AppIcon = 'mdi:pine-tree';
|
||||||
export const iconInfo: AppIcon = 'mdi:information-outline';
|
export const iconInfo: AppIcon = 'mdi:information-outline';
|
||||||
export const iconItem: AppIcon = 'mdi:bag-personal-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 iconLife: AppIcon = 'mdi:post-outline';
|
||||||
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
export const iconClothes: AppIcon = 'mdi:tshirt-crew-outline';
|
||||||
export const iconLogin: AppIcon = 'mdi:login';
|
export const iconLogin: AppIcon = 'mdi:login';
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import DailyChecklistView from '../views/DailyChecklistView.vue';
|
|||||||
import LifeView from '../views/LifeView.vue';
|
import LifeView from '../views/LifeView.vue';
|
||||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||||
import AdminView from '../views/AdminView.vue';
|
import AdminView from '../views/AdminView.vue';
|
||||||
|
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||||
import LoginView from '../views/LoginView.vue';
|
import LoginView from '../views/LoginView.vue';
|
||||||
import RegisterView from '../views/RegisterView.vue';
|
import RegisterView from '../views/RegisterView.vue';
|
||||||
|
import ResetPasswordView from '../views/ResetPasswordView.vue';
|
||||||
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
import VerifyEmailView from '../views/VerifyEmailView.vue';
|
||||||
import { api, getAuthToken, setAuthToken } from '../services/api';
|
import { api, getAuthToken, setAuthToken } from '../services/api';
|
||||||
|
|
||||||
@@ -45,6 +47,8 @@ export const router = createRouter({
|
|||||||
{ path: '/life', component: LifeView },
|
{ path: '/life', component: LifeView },
|
||||||
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
{ path: '/admin', component: AdminView, meta: { requiresVerified: true } },
|
||||||
{ path: '/login', component: LoginView },
|
{ path: '/login', component: LoginView },
|
||||||
|
{ path: '/forgot-password', component: ForgotPasswordView },
|
||||||
|
{ path: '/reset-password', component: ResetPasswordView },
|
||||||
{ path: '/register', component: RegisterView },
|
{ path: '/register', component: RegisterView },
|
||||||
{ path: '/verify-email', component: VerifyEmailView }
|
{ path: '/verify-email', component: VerifyEmailView }
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export interface AuthUser {
|
|||||||
export interface LoginPayload {
|
export interface LoginPayload {
|
||||||
email: string;
|
email: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
rememberMe?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterPayload extends LoginPayload {
|
export interface RegisterPayload extends LoginPayload {
|
||||||
@@ -418,26 +419,39 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
|||||||
return query ? `?${query}` : '';
|
return query ? `?${query}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthToken(): string | null {
|
function authStorage(type: 'local' | 'session'): Storage | null {
|
||||||
if (typeof localStorage === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem(authTokenKey);
|
return type === 'local' ? window.localStorage : window.sessionStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthToken(token: string | null): void {
|
export function getAuthToken(): string | null {
|
||||||
if (typeof localStorage === 'undefined') {
|
const sessionToken = authStorage('session')?.getItem(authTokenKey);
|
||||||
return;
|
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) {
|
if (token) {
|
||||||
localStorage.setItem(authTokenKey, token);
|
if (options.persistent === false) {
|
||||||
|
session?.setItem(authTokenKey, token);
|
||||||
|
local?.removeItem(authTokenKey);
|
||||||
} else {
|
} 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));
|
window.dispatchEvent(new Event(authChangeEvent));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onAuthTokenChange(callback: () => void): () => void {
|
export function onAuthTokenChange(callback: () => void): () => void {
|
||||||
@@ -548,6 +562,10 @@ export const api = {
|
|||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
sendJson<{ message: string; user: AuthUser }>('/api/auth/verify-email', 'POST', { token }),
|
||||||
login: (payload: LoginPayload) => sendJson<AuthResponse>('/api/auth/login', 'POST', payload),
|
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'),
|
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||||
logout: () => postEmpty('/api/auth/logout'),
|
logout: () => postEmpty('/api/auth/logout'),
|
||||||
options: () => getJson<Options>('/api/options'),
|
options: () => getJson<Options>('/api/options'),
|
||||||
|
|||||||
@@ -3784,6 +3784,27 @@ button:disabled,
|
|||||||
gap: 14px;
|
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 {
|
.auth-switch {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|||||||
59
frontend/src/views/ForgotPasswordView.vue
Normal file
59
frontend/src/views/ForgotPasswordView.vue
Normal 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>
|
||||||
@@ -13,6 +13,7 @@ const router = useRouter();
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const email = ref('');
|
const email = ref('');
|
||||||
const password = ref('');
|
const password = ref('');
|
||||||
|
const rememberMe = ref(false);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
|
||||||
@@ -23,9 +24,10 @@ async function submitLogin() {
|
|||||||
try {
|
try {
|
||||||
const response = await api.login({
|
const response = await api.login({
|
||||||
email: email.value,
|
email: email.value,
|
||||||
password: password.value
|
password: password.value,
|
||||||
|
rememberMe: rememberMe.value
|
||||||
});
|
});
|
||||||
setAuthToken(response.token);
|
setAuthToken(response.token, { persistent: rememberMe.value });
|
||||||
|
|
||||||
const redirect =
|
const redirect =
|
||||||
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
|
||||||
@@ -44,7 +46,7 @@ async function submitLogin() {
|
|||||||
<section class="auth-page">
|
<section class="auth-page">
|
||||||
<div class="auth-panel">
|
<div class="auth-panel">
|
||||||
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
<PageHeader :title="t('auth.loginTitle')" :subtitle="t('auth.loginSubtitle')">
|
||||||
<template #kicker>Trainer Pass</template>
|
<template #kicker>{{ t('auth.accountAccess') }}</template>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<form class="auth-form" @submit.prevent="submitLogin">
|
<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" />
|
<input id="login-password" v-model="password" autocomplete="current-password" required type="password" />
|
||||||
</div>
|
</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>
|
<StatusMessage v-if="errorMessage" variant="danger">{{ errorMessage }}</StatusMessage>
|
||||||
|
|
||||||
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
<button class="ui-button ui-button--primary" :disabled="busy" type="submit">
|
||||||
|
|||||||
98
frontend/src/views/ResetPasswordView.vue
Normal file
98
frontend/src/views/ResetPasswordView.vue
Normal 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>
|
||||||
@@ -62,13 +62,18 @@ export const systemWordingMessages = {
|
|||||||
register: 'Register'
|
register: 'Register'
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
accountAccess: 'Trainer Pass',
|
||||||
email: 'Email',
|
email: 'Email',
|
||||||
password: 'Password',
|
password: 'Password',
|
||||||
|
newPassword: 'New password',
|
||||||
|
confirmPassword: 'Confirm password',
|
||||||
displayName: 'Display name',
|
displayName: 'Display name',
|
||||||
loginTitle: 'Log in',
|
loginTitle: 'Log in',
|
||||||
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
|
loginSubtitle: 'Use a verified email to enter Pokopia Wiki.',
|
||||||
loggingIn: 'Logging in',
|
loggingIn: 'Logging in',
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
|
rememberMe: 'Remember me',
|
||||||
|
forgotPassword: 'Forgot password?',
|
||||||
noAccount: 'No account yet?',
|
noAccount: 'No account yet?',
|
||||||
registerTitle: 'Register',
|
registerTitle: 'Register',
|
||||||
registerSubtitle: 'Verify your email after creating an account.',
|
registerSubtitle: 'Verify your email after creating an account.',
|
||||||
@@ -76,6 +81,17 @@ export const systemWordingMessages = {
|
|||||||
sending: 'Sending',
|
sending: 'Sending',
|
||||||
sendVerification: 'Send verification email',
|
sendVerification: 'Send verification email',
|
||||||
hasAccount: 'Already have an account?',
|
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',
|
verifyTitle: 'Email verification',
|
||||||
verifySubtitle: 'You can log in after verification is complete.',
|
verifySubtitle: 'You can log in after verification is complete.',
|
||||||
verifyingEmail: 'Verifying email',
|
verifyingEmail: 'Verifying email',
|
||||||
@@ -523,8 +539,11 @@ export const systemWordingMessages = {
|
|||||||
emailAlreadyRegistered: 'This email is already registered',
|
emailAlreadyRegistered: 'This email is already registered',
|
||||||
checkVerificationEmail: 'Please check your verification email',
|
checkVerificationEmail: 'Please check your verification email',
|
||||||
emailVerified: 'Email verified',
|
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',
|
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: {
|
validation: {
|
||||||
nameRequired: 'Name is required',
|
nameRequired: 'Name is required',
|
||||||
@@ -590,7 +609,11 @@ export const systemWordingMessages = {
|
|||||||
verificationSubject: 'Verify your Pokopia Wiki email',
|
verificationSubject: 'Verify your Pokopia Wiki email',
|
||||||
verificationHtml:
|
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>',
|
'<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: '注册'
|
register: '注册'
|
||||||
},
|
},
|
||||||
auth: {
|
auth: {
|
||||||
|
accountAccess: 'Trainer Pass',
|
||||||
email: '邮箱',
|
email: '邮箱',
|
||||||
password: '密码',
|
password: '密码',
|
||||||
|
newPassword: '新密码',
|
||||||
|
confirmPassword: '确认密码',
|
||||||
displayName: '显示名',
|
displayName: '显示名',
|
||||||
loginTitle: '登录',
|
loginTitle: '登录',
|
||||||
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
|
loginSubtitle: '使用已验证邮箱进入 Pokopia Wiki',
|
||||||
loggingIn: '登录中',
|
loggingIn: '登录中',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
|
rememberMe: '记住我',
|
||||||
|
forgotPassword: '忘记密码?',
|
||||||
noAccount: '还没有账号?',
|
noAccount: '还没有账号?',
|
||||||
registerTitle: '注册',
|
registerTitle: '注册',
|
||||||
registerSubtitle: '创建账号后需要完成邮箱验证',
|
registerSubtitle: '创建账号后需要完成邮箱验证',
|
||||||
@@ -665,6 +693,17 @@ export const systemWordingMessages = {
|
|||||||
sending: '发送中',
|
sending: '发送中',
|
||||||
sendVerification: '发送验证邮件',
|
sendVerification: '发送验证邮件',
|
||||||
hasAccount: '已有账号?',
|
hasAccount: '已有账号?',
|
||||||
|
requestResetTitle: '重置密码',
|
||||||
|
requestResetSubtitle: '向账号邮箱发送密码重置链接。',
|
||||||
|
sendResetLink: '发送重置链接',
|
||||||
|
requestResetFailed: '密码重置请求失败',
|
||||||
|
resetTitle: '设置新密码',
|
||||||
|
resetSubtitle: '使用邮件中的重置链接更新密码。',
|
||||||
|
resetPassword: '重置密码',
|
||||||
|
resetting: '重置中',
|
||||||
|
resetFailed: '密码重置失败',
|
||||||
|
passwordMismatch: '两次输入的密码不一致',
|
||||||
|
invalidPasswordReset: '密码重置链接无效或已过期',
|
||||||
verifyTitle: '邮箱验证',
|
verifyTitle: '邮箱验证',
|
||||||
verifySubtitle: '完成验证后即可登录',
|
verifySubtitle: '完成验证后即可登录',
|
||||||
verifyingEmail: '正在验证邮箱',
|
verifyingEmail: '正在验证邮箱',
|
||||||
@@ -1112,8 +1151,11 @@ export const systemWordingMessages = {
|
|||||||
emailAlreadyRegistered: '该邮箱已注册',
|
emailAlreadyRegistered: '该邮箱已注册',
|
||||||
checkVerificationEmail: '请查收验证邮件',
|
checkVerificationEmail: '请查收验证邮件',
|
||||||
emailVerified: '邮箱已验证',
|
emailVerified: '邮箱已验证',
|
||||||
|
checkPasswordResetEmail: '如果该邮箱已注册,系统会发送密码重置链接。',
|
||||||
|
passwordResetComplete: '密码已更新,请使用新密码登录。',
|
||||||
invalidCredentials: '邮箱或密码不正确',
|
invalidCredentials: '邮箱或密码不正确',
|
||||||
verifyEmailFirst: '请先完成邮箱验证'
|
verifyEmailFirst: '请先完成邮箱验证',
|
||||||
|
invalidResetToken: '密码重置链接无效或已过期'
|
||||||
},
|
},
|
||||||
validation: {
|
validation: {
|
||||||
nameRequired: '请输入名称',
|
nameRequired: '请输入名称',
|
||||||
@@ -1178,7 +1220,10 @@ export const systemWordingMessages = {
|
|||||||
auth: {
|
auth: {
|
||||||
verificationSubject: '验证你的 Pokopia Wiki 邮箱',
|
verificationSubject: '验证你的 Pokopia Wiki 邮箱',
|
||||||
verificationHtml: '<p>请点击下面的链接完成邮箱验证:</p><p><a href="{url}">验证邮箱</a></p><p>链接将在 {hours} 小时后失效。</p>',
|
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} 小时后失效。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user