feat(profile): add password change and activity filters
Implement password change API and UI in the Account tab Add secondary filters for contributions, reactions, and comments Display referral summary in the profile header
This commit is contained in:
@@ -53,9 +53,11 @@ type AuthMessageKey =
|
||||
| 'emailVerified'
|
||||
| 'checkPasswordResetEmail'
|
||||
| 'passwordResetComplete'
|
||||
| 'passwordChanged'
|
||||
| 'invalidCredentials'
|
||||
| 'verifyEmailFirst'
|
||||
| 'invalidResetToken'
|
||||
| 'currentPasswordInvalid'
|
||||
| 'invalidReferralCode'
|
||||
| 'emailSubject'
|
||||
| 'emailHtml'
|
||||
@@ -171,9 +173,11 @@ function authMessage(locale: string, key: AuthMessageKey, params: Record<string,
|
||||
emailVerified: 'server.auth.emailVerified',
|
||||
checkPasswordResetEmail: 'server.auth.checkPasswordResetEmail',
|
||||
passwordResetComplete: 'server.auth.passwordResetComplete',
|
||||
passwordChanged: 'server.auth.passwordChanged',
|
||||
invalidCredentials: 'server.auth.invalidCredentials',
|
||||
verifyEmailFirst: 'server.auth.verifyEmailFirst',
|
||||
invalidResetToken: 'server.auth.invalidResetToken',
|
||||
currentPasswordInvalid: 'server.auth.currentPasswordInvalid',
|
||||
invalidReferralCode: 'server.auth.invalidReferralCode',
|
||||
emailSubject: 'email.auth.verificationSubject',
|
||||
emailHtml: 'email.auth.verificationHtml',
|
||||
@@ -1007,6 +1011,40 @@ export async function updateCurrentUser(
|
||||
return (await publicUserById(user.id)) ?? toPublicUser(user);
|
||||
}
|
||||
|
||||
export async function changeCurrentUserPassword(
|
||||
userId: number,
|
||||
payload: Record<string, unknown>,
|
||||
currentSessionToken: string,
|
||||
locale = defaultLocale
|
||||
): Promise<{ message: string }> {
|
||||
const currentPassword = typeof payload.currentPassword === 'string' ? payload.currentPassword : '';
|
||||
const nextPassword = await cleanPassword(payload.password, locale);
|
||||
|
||||
if (!currentPassword) {
|
||||
throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400);
|
||||
}
|
||||
|
||||
const user = await queryOne<LoginUserRow>(
|
||||
'SELECT id, email, display_name, email_verified_at, password_hash FROM users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (!user || !(await verifyPassword(currentPassword, user.password_hash))) {
|
||||
throw statusError(await authMessage(locale, 'currentPasswordInvalid'), 400);
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(nextPassword);
|
||||
const currentSessionHash = hashToken(currentSessionToken);
|
||||
|
||||
await withTransaction(async (client) => {
|
||||
await client.query('UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2', [passwordHash, user.id]);
|
||||
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 AND token_hash <> $2', [user.id, currentSessionHash]);
|
||||
});
|
||||
|
||||
return { message: await authMessage(locale, 'passwordChanged') };
|
||||
}
|
||||
|
||||
export async function getReferralSummary(userId: number): Promise<ReferralSummary> {
|
||||
return withTransaction(async (client) => {
|
||||
const code = await ensureReferralCode(client, userId);
|
||||
|
||||
@@ -2205,6 +2205,28 @@ function cleanLifeReactionType(value: unknown): LifeReactionType {
|
||||
return value;
|
||||
}
|
||||
|
||||
function cleanLifeReactionFilter(value: QueryValue): LifeReactionType | null {
|
||||
const reactionType = asString(value);
|
||||
if (!reactionType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cleanLifeReactionType(reactionType);
|
||||
}
|
||||
|
||||
function cleanUserCommentActivitySourceFilter(value: QueryValue): UserCommentActivitySource | null {
|
||||
const source = asString(value);
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (source !== 'life' && source !== 'discussion') {
|
||||
throw validationError('server.validation.invalidField');
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
function lifePostProjection(locale = defaultLocale): string {
|
||||
const tagName = localizedName('life-tags', 'lt', locale);
|
||||
|
||||
@@ -2691,9 +2713,15 @@ export async function listUserReactionActivities(
|
||||
|
||||
const cursor = decodeLifePostCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const reactionType = cleanLifeReactionFilter(paramsQuery.reactionType);
|
||||
const params: unknown[] = [user.id];
|
||||
const conditions = ['lpr.user_id = $1', 'lp.deleted_at IS NULL'];
|
||||
|
||||
if (reactionType) {
|
||||
params.push(reactionType);
|
||||
conditions.push(`lpr.reaction_type = $${params.length}`);
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.id);
|
||||
conditions.push(`(lpr.updated_at, lpr.post_id) < ($${params.length - 1}::timestamptz, $${params.length}::integer)`);
|
||||
@@ -2765,19 +2793,28 @@ export async function listUserCommentActivities(
|
||||
|
||||
const cursor = decodeUserCommentActivityCursor(paramsQuery.cursor);
|
||||
const limit = cleanLifePostLimit(paramsQuery.limit);
|
||||
const sourceFilter = cleanUserCommentActivitySourceFilter(paramsQuery.source);
|
||||
const pokemonName = localizedName('pokemon', 'p', locale);
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const recipeItemName = localizedName('items', 'recipe_item', locale);
|
||||
const habitatName = localizedName('habitats', 'h', locale);
|
||||
const params: unknown[] = [user.id];
|
||||
let cursorClause = '';
|
||||
const outerConditions: string[] = [];
|
||||
|
||||
if (sourceFilter) {
|
||||
params.push(sourceFilter);
|
||||
outerConditions.push(`source = $${params.length}`);
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
params.push(cursor.createdAt, cursor.source, cursor.id);
|
||||
cursorClause = `WHERE (created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`;
|
||||
outerConditions.push(
|
||||
`(created_at, source, id) < ($${params.length - 2}::timestamptz, $${params.length - 1}::text, $${params.length}::integer)`
|
||||
);
|
||||
}
|
||||
|
||||
params.push(limit + 1);
|
||||
const outerWhere = outerConditions.length ? `WHERE ${outerConditions.join(' AND ')}` : '';
|
||||
const rows = await query<{
|
||||
id: number;
|
||||
source: UserCommentActivitySource;
|
||||
@@ -2849,7 +2886,7 @@ export async function listUserCommentActivities(
|
||||
target_title AS "targetTitle",
|
||||
target_excerpt AS "targetExcerpt"
|
||||
FROM activity
|
||||
${cursorClause}
|
||||
${outerWhere}
|
||||
ORDER BY created_at DESC, source DESC, id DESC
|
||||
LIMIT $${params.length}
|
||||
`,
|
||||
|
||||
@@ -5,6 +5,7 @@ import Fastify from 'fastify';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import {
|
||||
changeCurrentUserPassword,
|
||||
createPermission,
|
||||
createRole,
|
||||
deletePermission,
|
||||
@@ -301,6 +302,18 @@ app.patch('/api/auth/me', async (request, reply) => {
|
||||
return { user: await updateCurrentUser(user.id, payload, requestLocale(request)) };
|
||||
});
|
||||
|
||||
app.patch('/api/auth/me/password', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
if (!user || !token) {
|
||||
return reply.code(401).send({ message: await serverMessage(requestLocale(request), 'loginRequired') });
|
||||
}
|
||||
|
||||
const payload = request.body && typeof request.body === 'object' ? (request.body as Record<string, unknown>) : {};
|
||||
return changeCurrentUserPassword(user.id, payload, token, requestLocale(request));
|
||||
});
|
||||
|
||||
app.get('/api/auth/referral', async (request, reply) => {
|
||||
const token = getBearerToken(request.headers.authorization);
|
||||
const user = token ? await getUserBySessionToken(token) : null;
|
||||
|
||||
Reference in New Issue
Block a user