diff --git a/.claude/launch.json b/.claude/launch.json index 0d48481b..7e402aa7 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -10,7 +10,10 @@ { "name": "backend", "runtimeExecutable": "sh", - "runtimeArgs": ["-c", "docker compose -f apps/backend/docker-compose.yml up -d && pnpm --filter plotwist-api run dev"], + "runtimeArgs": [ + "-c", + "docker compose -f apps/backend/docker-compose.yml up -d && pnpm --filter plotwist-api run dev" + ], "port": 3333 } ] diff --git a/.claude/settings.local.json b/.claude/settings.local.json index be519bd8..f0305852 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,12 @@ "Bash(source ~/.zshrc)", "Bash(npx @biomejs/biome check --changed)", "Bash(npx @biomejs/biome check src/config.ts src/infra/adapters/open-ai.ts src/domain/services/user-stats/get-user-ai-recommendations.ts src/domain/services/user-stats/get-user-ai-recommendations.spec.ts)", - "Bash(pnpm run:*)" + "Bash(pnpm run:*)", + "Bash(make run:*)", + "Bash(curl -s http://localhost:3333/healthcheck)", + "Bash(make test:*)" ] - } + }, + "enableAllProjectMcpServers": true, + "enabledMcpjsonServers": ["grafana"] } diff --git a/.gitignore b/.gitignore index 15c7db30..0b9e4788 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ coverage out/ build dist +*.tsbuildinfo # Debug diff --git a/apps/backend/src/domain/services/recommendations/get-received-recommendations.ts b/apps/backend/src/domain/services/recommendations/get-received-recommendations.ts index 70599be4..a889c6b9 100644 --- a/apps/backend/src/domain/services/recommendations/get-received-recommendations.ts +++ b/apps/backend/src/domain/services/recommendations/get-received-recommendations.ts @@ -1,7 +1,7 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' -import { selectReceivedRecommendations } from '@/infra/db/repositories/recommendations-repository' import { getTMDBDataService } from '@/domain/services/tmdb/get-tmdb-data' +import { selectReceivedRecommendations } from '@/infra/db/repositories/recommendations-repository' export async function getReceivedRecommendationsService( userId: string, diff --git a/apps/backend/src/domain/services/recommendations/send-recommendation.ts b/apps/backend/src/domain/services/recommendations/send-recommendation.ts index 20ded501..733592de 100644 --- a/apps/backend/src/domain/services/recommendations/send-recommendation.ts +++ b/apps/backend/src/domain/services/recommendations/send-recommendation.ts @@ -8,7 +8,9 @@ export type SendRecommendationInput = { message: string | null } -export async function sendRecommendationService(input: SendRecommendationInput) { +export async function sendRecommendationService( + input: SendRecommendationInput +) { const recommendation = await insertRecommendation(input) return { recommendation } } diff --git a/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.spec.ts b/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.spec.ts index 3e27e304..d8c76027 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.spec.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.spec.ts @@ -168,7 +168,6 @@ describe('get user ai recommendations', () => { mediaType: 'TV_SHOW', }) } - // Cold start path is triggered when candidate pool < 3 (1 movie + 1 TV = 2) // Mock search.multi to resolve both titles correctly ;(tmdb.search.multi as Mock).mockImplementation((title: string) => { diff --git a/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.ts b/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.ts index 6dfee0fd..07c3c3ab 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-ai-recommendations.ts @@ -283,9 +283,9 @@ async function detectIsAnimeFanFromWatched( tvSeeds.map(async item => { try { const details = await tmdb.tv.details(item.tmdbId, language) - return ( - (details as { genres?: { id: number }[] }).genres ?? [] - ).some(g => g.id === ANIME_GENRE_ID) + return ((details as { genres?: { id: number }[] }).genres ?? []).some( + g => g.id === ANIME_GENRE_ID + ) } catch { return false } diff --git a/apps/backend/src/domain/services/users/request-password-reset.spec.ts b/apps/backend/src/domain/services/users/request-password-reset.spec.ts new file mode 100644 index 00000000..892193dd --- /dev/null +++ b/apps/backend/src/domain/services/users/request-password-reset.spec.ts @@ -0,0 +1,40 @@ +import { faker } from '@faker-js/faker' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { makeUser } from '@/test/factories/make-user' +import { requestPasswordResetService } from './request-password-reset' +import * as sendPasswordResetEmail from './send-password-reset-email' + +describe('requestPasswordReset', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it('should return generic response and send email when user exists', async () => { + const user = await makeUser() + const sendEmailSpy = vi + .spyOn(sendPasswordResetEmail, 'sendPasswordResetEmailService') + .mockResolvedValueOnce(undefined) + + const result = await requestPasswordResetService({ login: user.email }) + + expect(result).toEqual({ status: 'password_reset_email_sent' }) + expect(sendEmailSpy).toHaveBeenCalledOnce() + expect(sendEmailSpy).toHaveBeenCalledWith( + expect.objectContaining({ email: user.email }) + ) + }) + + it('should return same generic response when user does not exist', async () => { + const sendEmailSpy = vi.spyOn( + sendPasswordResetEmail, + 'sendPasswordResetEmailService' + ) + + const result = await requestPasswordResetService({ + login: faker.internet.email(), + }) + + expect(result).toEqual({ status: 'password_reset_email_sent' }) + expect(sendEmailSpy).not.toHaveBeenCalled() + }) +}) diff --git a/apps/backend/src/domain/services/users/request-password-reset.ts b/apps/backend/src/domain/services/users/request-password-reset.ts new file mode 100644 index 00000000..ea915965 --- /dev/null +++ b/apps/backend/src/domain/services/users/request-password-reset.ts @@ -0,0 +1,20 @@ +import { generateMagicLinkTokenService } from '@/domain/services/magic-link/generate-magic-link' +import { findUserByEmailOrUsername } from '@/infra/db/repositories/login-repository' +import { sendPasswordResetEmailService } from './send-password-reset-email' + +type RequestPasswordResetInput = { + login: string +} + +export async function requestPasswordResetService({ + login, +}: RequestPasswordResetInput) { + const user = await findUserByEmailOrUsername(login) + + if (user) { + const { token } = await generateMagicLinkTokenService(user.id) + await sendPasswordResetEmailService({ email: user.email, token }) + } + + return { status: 'password_reset_email_sent' as const } +} diff --git a/apps/backend/src/domain/services/users/send-password-reset-email.ts b/apps/backend/src/domain/services/users/send-password-reset-email.ts new file mode 100644 index 00000000..c61711d1 --- /dev/null +++ b/apps/backend/src/domain/services/users/send-password-reset-email.ts @@ -0,0 +1,28 @@ +import { config } from '@/config' +import type { EmailMessage } from '@/domain/entities/email-message' +import { emailServiceFactory } from '@/infra/factories/resend-factory' + +type SendPasswordResetEmailServiceInput = { + email: string + token: string +} + +export async function sendPasswordResetEmailService({ + email, + token, +}: SendPasswordResetEmailServiceInput) { + const link = `${config.app.CLIENT_URL}/reset-password?token=${token}` + + const html = `Click the link to reset your password: Reset password` + const subject = 'Reset your Plotwist password' + + const emailService = emailServiceFactory('Resend') + + const emailMessage: EmailMessage = { + to: [email], + subject, + html, + } + + await emailService.sendEmail(emailMessage) +} diff --git a/apps/backend/src/domain/services/users/update-user-password.spec.ts b/apps/backend/src/domain/services/users/update-user-password.spec.ts new file mode 100644 index 00000000..c1ff80b7 --- /dev/null +++ b/apps/backend/src/domain/services/users/update-user-password.spec.ts @@ -0,0 +1,53 @@ +import { faker } from '@faker-js/faker' +import { describe, expect, it } from 'vitest' +import { InvalidTokenError } from '@/domain/errors/invalid-token-error' +import { makeMagicToken } from '@/test/factories/make-magic-token' +import { makeUser } from '@/test/factories/make-user' +import { updatePasswordService } from './update-user-password' + +describe('updateUserPassword', () => { + it('should update the password with a valid token', async () => { + const user = await makeUser() + const { token } = await makeMagicToken(user.id) + const newPassword = faker.internet.password() + + const result = await updatePasswordService({ password: newPassword, token }) + + expect(result).toEqual({ status: 'password_set' }) + }) + + it('should return InvalidTokenError for a non-existent token', async () => { + const result = await updatePasswordService({ + password: faker.internet.password(), + token: 'non-existent-token', + }) + + expect(result).toBeInstanceOf(InvalidTokenError) + }) + + it('should return InvalidTokenError for an already-used token', async () => { + const user = await makeUser() + const { token } = await makeMagicToken(user.id, { used: true }) + + const result = await updatePasswordService({ + password: faker.internet.password(), + token, + }) + + expect(result).toBeInstanceOf(InvalidTokenError) + }) + + it('should return InvalidTokenError for an expired token', async () => { + const user = await makeUser() + const { token } = await makeMagicToken(user.id, { + expiresAt: new Date(Date.now() - 1000), + }) + + const result = await updatePasswordService({ + password: faker.internet.password(), + token, + }) + + expect(result).toBeInstanceOf(InvalidTokenError) + }) +}) diff --git a/apps/backend/src/domain/services/users/update-user-password.ts b/apps/backend/src/domain/services/users/update-user-password.ts index bea4a7b9..5e7529aa 100644 --- a/apps/backend/src/domain/services/users/update-user-password.ts +++ b/apps/backend/src/domain/services/users/update-user-password.ts @@ -15,7 +15,15 @@ export async function updatePasswordService({ }: UpdatePasswordInput) { const [tokenRecord] = await selectMagicToken(token) - if (!token) { + if (!tokenRecord) { + return new InvalidTokenError() + } + + if (tokenRecord.used) { + return new InvalidTokenError() + } + + if (tokenRecord.expiresAt < new Date()) { return new InvalidTokenError() } diff --git a/apps/backend/src/infra/db/repositories/user-item-repository.ts b/apps/backend/src/infra/db/repositories/user-item-repository.ts index 70da90c3..3afbfc24 100644 --- a/apps/backend/src/infra/db/repositories/user-item-repository.ts +++ b/apps/backend/src/infra/db/repositories/user-item-repository.ts @@ -246,7 +246,9 @@ export async function selectWatchedItemsWithAvgRating( tmdbId: schema.userItems.tmdbId, mediaType: schema.userItems.mediaType, addedAt: schema.userItems.addedAt, - avgRating: sql`AVG(${schema.reviews.rating})::numeric(3,1)::text`, + avgRating: sql< + string | null + >`AVG(${schema.reviews.rating})::numeric(3,1)::text`, }) .from(schema.userItems) .leftJoin( diff --git a/apps/backend/src/infra/db/schema/index.ts b/apps/backend/src/infra/db/schema/index.ts index ea69f52e..e4eef9c6 100644 --- a/apps/backend/src/infra/db/schema/index.ts +++ b/apps/backend/src/infra/db/schema/index.ts @@ -16,8 +16,8 @@ import { uuid, varchar, } from 'drizzle-orm/pg-core' -import { userPreferences } from './user-preferences' import { achievements, userAchievements } from './achievements' +import { userPreferences } from './user-preferences' export const subscriptionStatusEnum = pgEnum('subscription_status', [ 'ACTIVE', @@ -776,5 +776,5 @@ export const schema = { userAchievements, } -export * from './user-preferences' export * from './achievements' +export * from './user-preferences' diff --git a/apps/backend/src/infra/http/controllers/recommendations-controller.ts b/apps/backend/src/infra/http/controllers/recommendations-controller.ts index 1c310b52..f53858c6 100644 --- a/apps/backend/src/infra/http/controllers/recommendations-controller.ts +++ b/apps/backend/src/infra/http/controllers/recommendations-controller.ts @@ -1,14 +1,14 @@ import type { FastifyRedis } from '@fastify/redis' -import type { FastifyReply, FastifyRequest } from 'fastify' import type { Language } from '@plotwist_app/tmdb' -import { sendRecommendationService } from '@/domain/services/recommendations/send-recommendation' +import type { FastifyReply, FastifyRequest } from 'fastify' import { getReceivedRecommendationsService } from '@/domain/services/recommendations/get-received-recommendations' import { respondRecommendationService } from '@/domain/services/recommendations/respond-recommendation' +import { sendRecommendationService } from '@/domain/services/recommendations/send-recommendation' import { - sendRecommendationBodySchema, getRecommendationsQuerySchema, respondRecommendationBodySchema, respondRecommendationParamsSchema, + sendRecommendationBodySchema, } from '../schemas/recommendations' export async function sendRecommendationController( diff --git a/apps/backend/src/infra/http/controllers/user-controller.ts b/apps/backend/src/infra/http/controllers/user-controller.ts index dcccbe12..da2d32c7 100644 --- a/apps/backend/src/infra/http/controllers/user-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-controller.ts @@ -9,6 +9,7 @@ import { getUserById } from '@/domain/services/users/get-by-id' import { getUserByUsername } from '@/domain/services/users/get-user-by-username' import { isEmailAvailable } from '@/domain/services/users/is-email-available' import { checkAvailableUsername } from '@/domain/services/users/is-username-available' +import { requestPasswordResetService } from '@/domain/services/users/request-password-reset' import { searchUsersByUsername } from '@/domain/services/users/search-users-by-username' import { updateUserService } from '@/domain/services/users/update-user' import { updatePasswordService } from '@/domain/services/users/update-user-password' @@ -18,6 +19,7 @@ import { getUserByIdParamsSchema, getUserByUsernameParamsSchema, isEmailAvailableQuerySchema, + requestPasswordResetBodySchema, searchUsersByUsernameQuerySchema, updateUserBodySchema, updateUserPasswordBodySchema, @@ -138,9 +140,23 @@ export async function updateUserPasswordController( reply: FastifyReply ) { const { password, token } = updateUserPasswordBodySchema.parse(request.body) - const { status } = await updatePasswordService({ password, token }) + const result = await updatePasswordService({ password, token }) - return reply.status(200).send({ status: status }) + if (result instanceof DomainError) { + return reply.status(result.status).send({ message: result.message }) + } + + return reply.status(200).send({ status: result.status }) +} + +export async function requestPasswordResetController( + request: FastifyRequest, + reply: FastifyReply +) { + const { login } = requestPasswordResetBodySchema.parse(request.body) + const { status } = await requestPasswordResetService({ login }) + + return reply.status(200).send({ status }) } export async function updateUserPreferencesController( diff --git a/apps/backend/src/infra/http/controllers/user-favorites-controller.ts b/apps/backend/src/infra/http/controllers/user-favorites-controller.ts index ec2685bb..49a7a768 100644 --- a/apps/backend/src/infra/http/controllers/user-favorites-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-favorites-controller.ts @@ -1,15 +1,15 @@ import type { FastifyReply, FastifyRequest } from 'fastify' import { - toggleFavoriteBodySchema, - getUserFavoritesQuerySchema, - checkFavoriteQuerySchema, -} from '../schemas/user-favorites' -import { - insertFavorite, deleteFavorite, - selectFavoritesByUser, + insertFavorite, selectFavorite, + selectFavoritesByUser, } from '@/infra/db/repositories/user-favorites-repository' +import { + checkFavoriteQuerySchema, + getUserFavoritesQuerySchema, + toggleFavoriteBodySchema, +} from '../schemas/user-favorites' export async function toggleFavoriteController( request: FastifyRequest, diff --git a/apps/backend/src/infra/http/routes/index.ts b/apps/backend/src/infra/http/routes/index.ts index 99c7800a..ee8fb862 100644 --- a/apps/backend/src/infra/http/routes/index.ts +++ b/apps/backend/src/infra/http/routes/index.ts @@ -10,8 +10,8 @@ import { config } from '@/config' import { setInitialCounterValue } from '@/domain/services/shared-urls/set-initial-counter-value' import { sharedUrlCounterFactory } from '@/infra/factories/shared-url-counter-factory' import { registerRateLimit } from '../rate-limit' +import { achievementsRoutes } from './achievements' import { feedbackRoutes } from './feedback' -import { userFavoritesRoutes } from './user-favorites' import { followsRoutes } from './follow' import { healthCheck } from './healthcheck' import { imagesRoutes } from './images' @@ -20,6 +20,7 @@ import { likesRoutes } from './likes' import { listItemRoute } from './list-item' import { listsRoute } from './lists' import { loginRoute } from './login' +import { userRecommendationsRoutes } from './recommendations' import { reviewRepliesRoute } from './review-replies' import { reviewsRoute } from './reviews' import { sharedUrlRoute } from './shared-url' @@ -29,12 +30,11 @@ import { subscriptionsRoutes } from './subscriptions' import { tmdbProxyRoutes } from './tmdb-proxy' import { userActivitiesRoutes } from './user-activities' import { userEpisodesRoutes } from './user-episodes' +import { userFavoritesRoutes } from './user-favorites' import { userItemsRoutes } from './user-items' import { userStatsRoutes } from './user-stats' import { usersRoute } from './users' import { watchEntriesRoutes } from './watch-entries' -import { userRecommendationsRoutes } from './recommendations' -import { achievementsRoutes } from './achievements' import { webhookRoutes } from './webhook' export function routes(app: FastifyInstance) { diff --git a/apps/backend/src/infra/http/routes/recommendations.ts b/apps/backend/src/infra/http/routes/recommendations.ts index 6fc3f358..512d80b3 100644 --- a/apps/backend/src/infra/http/routes/recommendations.ts +++ b/apps/backend/src/infra/http/routes/recommendations.ts @@ -2,16 +2,16 @@ import type { FastifyInstance } from 'fastify' import type { ZodTypeProvider } from 'fastify-type-provider-zod' import { - sendRecommendationController, getReceivedRecommendationsController, respondRecommendationController, + sendRecommendationController, } from '../controllers/recommendations-controller' import { verifyJwt } from '../middlewares/verify-jwt' import { - sendRecommendationBodySchema, getRecommendationsQuerySchema, respondRecommendationBodySchema, respondRecommendationParamsSchema, + sendRecommendationBodySchema, } from '../schemas/recommendations' const TAGS = ['Recommendations'] diff --git a/apps/backend/src/infra/http/routes/user-favorites.ts b/apps/backend/src/infra/http/routes/user-favorites.ts index 62e91b14..f61a9bb7 100644 --- a/apps/backend/src/infra/http/routes/user-favorites.ts +++ b/apps/backend/src/infra/http/routes/user-favorites.ts @@ -2,18 +2,18 @@ import type { FastifyInstance } from 'fastify' import type { ZodTypeProvider } from 'fastify-type-provider-zod' import { - toggleFavoriteController, - getUserFavoritesController, checkFavoriteController, + getUserFavoritesController, + toggleFavoriteController, } from '../controllers/user-favorites-controller' import { verifyJwt } from '../middlewares/verify-jwt' import { - toggleFavoriteBodySchema, - toggleFavoriteResponseSchema, - getUserFavoritesQuerySchema, - getUserFavoritesResponseSchema, checkFavoriteQuerySchema, checkFavoriteResponseSchema, + getUserFavoritesQuerySchema, + getUserFavoritesResponseSchema, + toggleFavoriteBodySchema, + toggleFavoriteResponseSchema, } from '../schemas/user-favorites' const TAGS = ['User Favorites'] diff --git a/apps/backend/src/infra/http/routes/users.ts b/apps/backend/src/infra/http/routes/users.ts index d85fd83f..30339d5d 100644 --- a/apps/backend/src/infra/http/routes/users.ts +++ b/apps/backend/src/infra/http/routes/users.ts @@ -10,6 +10,7 @@ import { getUserPreferencesController, isEmailAvailableController, isUsernameAvailableController, + requestPasswordResetController, searchUsersByUsernameController, updateUserController, updateUserPasswordController, @@ -30,6 +31,8 @@ import { getUserPreferencesResponseSchema, isEmailAvailableQuerySchema, isEmailAvailableResponseSchema, + requestPasswordResetBodySchema, + requestPasswordResetResponseSchema, searchUsersByUsernameQuerySchema, searchUsersByUsernameResponseSchema, updateUserBodySchema, @@ -231,4 +234,19 @@ export async function usersRoute(app: FastifyInstance) { handler: searchUsersByUsernameController, }) ) + + app.after(() => + app.withTypeProvider().route({ + method: 'POST', + url: '/users/password-reset/request', + schema: { + description: 'Request a password reset email', + operationId: 'requestPasswordReset', + tags: [usersTag], + body: requestPasswordResetBodySchema, + response: requestPasswordResetResponseSchema, + }, + handler: requestPasswordResetController, + }) + ) } diff --git a/apps/backend/src/infra/http/schemas/users.ts b/apps/backend/src/infra/http/schemas/users.ts index bfc439fb..c999a2aa 100644 --- a/apps/backend/src/infra/http/schemas/users.ts +++ b/apps/backend/src/infra/http/schemas/users.ts @@ -129,10 +129,20 @@ export const updateUserSchema = { } export const updateUserPasswordBodySchema = z.object({ - password: z.string(), + password: z.string().min(8), token: z.string(), }) +export const requestPasswordResetBodySchema = z.object({ + login: z.string().min(1), +}) + +export const requestPasswordResetResponseSchema = { + 200: z.object({ + status: z.enum(['password_reset_email_sent']), + }), +} + export const updateUserPasswordResponseSchema = { 200: z.object({ status: z.enum(['password_set']), diff --git a/apps/backend/src/test/factories/make-magic-token.ts b/apps/backend/src/test/factories/make-magic-token.ts new file mode 100644 index 00000000..84c89915 --- /dev/null +++ b/apps/backend/src/test/factories/make-magic-token.ts @@ -0,0 +1,22 @@ +import { randomBytes } from 'node:crypto' +import type { InsertMagicTokenModel } from '@/domain/entities/magic-token' +import { insertMagicToken } from '@/infra/db/repositories/magic-tokens' + +type Overrides = Partial> + +const FIFTEEN_MINUTES = () => new Date(Date.now() + 15 * 60000) + +export async function makeMagicToken( + userId: string, + overrides: Overrides = {} +) { + const values: InsertMagicTokenModel = { + userId, + token: randomBytes(32).toString('hex'), + expiresAt: FIFTEEN_MINUTES(), + ...overrides, + } + + await insertMagicToken(values) + return values +} diff --git a/apps/web/package.json b/apps/web/package.json index 3bdd5f0e..7316e7cc 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -118,4 +118,4 @@ "typescript": "^5.9.3", "vitest": "^4.0.16" } -} \ No newline at end of file +} diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index b42827ec..52876997 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -58,7 +58,8 @@ "password_length": "Ihr Passwort muss mindestens 8 Zeichen lang sein.", "login_success": "Anmeldung erfolgreich. Willkommen! 🎉", "invalid_login_credentials": "Ungültige Anmeldeinformationen", - "try_again": "Erneut versuchen" + "try_again": "Erneut versuchen", + "forgot_password": "Passwort vergessen?" }, "sign_up_form": { "email_label": "E-Mail", @@ -656,6 +657,7 @@ "password_required": "Bitte geben Sie Ihr Passwort ein.", "password_length": "Ihr Passwort muss mindestens 8 Zeichen lang sein.", "request_password_reset_form_response": "Wenn die eingegebene E-Mail-Adresse mit einem Konto verknüpft ist, erhalten Sie eine E-Mail mit Anweisungen zum Zurücksetzen Ihres Passworts.", + "request_password_reset_error": "Etwas ist schiefgelaufen. Bitte versuchen Sie es erneut.", "invalid_reset_password_code": "Ungültiger Passwort-Zurücksetzungscode.", "unexpected_error": "Ein unerwarteter Fehler ist aufgetreten.", "reset_password_success": "Passwort erfolgreich zurückgesetzt.", diff --git a/apps/web/public/dictionaries/en-US.json b/apps/web/public/dictionaries/en-US.json index 88afd901..eed57da5 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -58,7 +58,8 @@ "password_length": "Your password must be at least 8 characters long.", "login_success": "Login successful. Welcome! 🎉", "invalid_login_credentials": "Invalid login credentials.", - "try_again": "Try again" + "try_again": "Try again", + "forgot_password": "Forgot your password?" }, "sign_up_form": { "email_label": "E-mail", @@ -657,6 +658,7 @@ "password_required": "Please enter your password.", "password_length": "Your password must be at least 8 characters long.", "request_password_reset_form_response": "If the email you entered is associated with an account, you will receive an email with instructions to reset your password.", + "request_password_reset_error": "Something went wrong. Please try again.", "invalid_reset_password_code": "Invalid reset password code.", "unexpected_error": "An unexpected error occurred.", "reset_password_success": "Password reset successfully.", diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 93d25c20..69ff408b 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -58,7 +58,8 @@ "password_length": "Tu contraseña debe tener al menos 8 caracteres.", "login_success": "Inicio de sesión exitoso. ¡Bienvenido! 🎉", "invalid_login_credentials": "Credenciales de inicio de sesión inválidas", - "try_again": "Intentar de nuevo" + "try_again": "Intentar de nuevo", + "forgot_password": "¿Olvidaste tu contraseña?" }, "sign_up_form": { "email_label": "Correo electrónico", @@ -659,6 +660,7 @@ "password_required": "Por favor, introduce tu contraseña.", "password_length": "Tu contraseña debe tener al menos 8 caracteres.", "request_password_reset_form_response": "Si el correo electrónico que ingresaste está asociado a una cuenta, recibirás un correo con las instrucciones para restablecer tu contraseña.", + "request_password_reset_error": "Algo salió mal. Por favor, inténtalo de nuevo.", "invalid_reset_password_code": "Código de restablecimiento de contraseña no válido.", "unexpected_error": "Ocurrió un error inesperado.", "reset_password_success": "Contraseña restablecida con éxito.", diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index 557bef8e..6f877628 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -58,7 +58,8 @@ "password_length": "Votre mot de passe doit comporter au moins 8 caractères.", "login_success": "Connexion réussie. Bienvenue ! 🎉", "invalid_login_credentials": "Identifiants de connexion invalides", - "try_again": "Réessayer" + "try_again": "Réessayer", + "forgot_password": "Mot de passe oublié ?" }, "sign_up_form": { "email_label": "E-mail", @@ -662,6 +663,7 @@ "password_required": "Veuillez entrer votre mot de passe.", "password_length": "Votre mot de passe doit contenir au moins 8 caractères.", "request_password_reset_form_response": "Si l'adresse e-mail que vous avez entrée est associée à un compte, vous recevrez un e-mail avec des instructions pour réinitialiser votre mot de passe.", + "request_password_reset_error": "Une erreur s'est produite. Veuillez réessayer.", "invalid_reset_password_code": "Code de réinitialisation de mot de passe non valide.", "unexpected_error": "Une erreur inattendue s'est produite.", "reset_password_success": "Mot de passe réinitialisé avec succès.", diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index 5f7945c9..31e0dfe5 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -58,7 +58,8 @@ "password_length": "La tua password deve essere lunga almeno 8 caratteri.", "login_success": "Accesso effettuato con successo. Benvenuto! 🎉", "invalid_login_credentials": "Credenziali di accesso non valide", - "try_again": "Riprova" + "try_again": "Riprova", + "forgot_password": "Hai dimenticato la password?" }, "sign_up_form": { "email_label": "E-mail", @@ -659,6 +660,7 @@ "password_required": "Inserisci la tua password.", "password_length": "La tua password deve contenere almeno 8 caratteri.", "request_password_reset_form_response": "Se l'indirizzo e-mail che hai inserito è associato a un account, riceverai un'e-mail con le istruzioni per reimpostare la tua password.", + "request_password_reset_error": "Qualcosa è andato storto. Per favore riprova.", "invalid_reset_password_code": "Codice di reimpostazione della password non valido.", "unexpected_error": "Si è verificato un errore imprevisto.", "reset_password_success": "Password reimpostata con successo.", diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index b0a174ed..bbb4f3c3 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -58,7 +58,8 @@ "password_length": "パスワードは最低8文字必要です。", "login_success": "ログイン成功!お帰りなさい!", "invalid_login_credentials": "無効なログイン情報です。", - "try_again": "もう一度試す" + "try_again": "もう一度試す", + "forgot_password": "パスワードをお忘れですか?" }, "sign_up_form": { "email_label": "Eメールアドレス", @@ -662,6 +663,7 @@ "password_required": "パスワードを入力してください。", "password_length": "パスワードは8文字以上でなければなりません。", "request_password_reset_form_response": "入力したメールアドレスがアカウントに関連付けられている場合、パスワードをリセットする手順を記載したメールが届きます。", + "request_password_reset_error": "エラーが発生しました。もう一度お試しください。", "invalid_reset_password_code": "無効なパスワードリセットコードです。", "unexpected_error": "予期しないエラーが発生しました。", "reset_password_success": "パスワードが正常にリセットされました。", diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index c680f46e..d7587f50 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -58,7 +58,8 @@ "password_length": "Sua senha deve ter pelo menos 8 caracteres.", "login_success": "Login bem-sucedido. Bem-vindo! 🎉", "invalid_login_credentials": "Credenciais de login inválidas", - "try_again": "Tente novamente" + "try_again": "Tente novamente", + "forgot_password": "Esqueceu sua senha?" }, "sign_up_form": { "email_label": "E-mail", @@ -660,6 +661,7 @@ "password_required": "Por favor, insira sua senha.", "password_length": "Sua senha deve ter pelo menos 8 caracteres.", "request_password_reset_form_response": "Se o e-mail que você inseriu estiver associado a uma conta, você receberá um e-mail com instruções para redefinir sua senha.", + "request_password_reset_error": "Algo deu errado. Por favor, tente novamente.", "invalid_reset_password_code": "Código de redefinição de senha inválido.", "unexpected_error": "Ocorreu um erro inesperado.", "reset_password_success": "Senha redefinida com sucesso.", diff --git a/apps/web/src/actions/auth/request-password-reset.ts b/apps/web/src/actions/auth/request-password-reset.ts new file mode 100644 index 00000000..7c40a115 --- /dev/null +++ b/apps/web/src/actions/auth/request-password-reset.ts @@ -0,0 +1,18 @@ +'use server' + +import { requestPasswordReset as requestPasswordResetApi } from '@/api/users' + +type RequestPasswordReset = { + login: string +} + +export async function requestPasswordReset({ login }: RequestPasswordReset) { + try { + const res = await requestPasswordResetApi({ login }) + if (res.status !== 200) { + return { error: 'request_password_reset_error' } + } + } catch { + return { error: 'request_password_reset_error' } + } +} diff --git a/apps/web/src/api/endpoints.schemas.ts b/apps/web/src/api/endpoints.schemas.ts index 375323dc..634f6335 100644 --- a/apps/web/src/api/endpoints.schemas.ts +++ b/apps/web/src/api/endpoints.schemas.ts @@ -332,6 +332,22 @@ export type GetUsersSearch200 = { users: GetUsersSearch200UsersItem[]; }; +export type RequestPasswordResetBody = { + /** @minLength 1 */ + login: string; +}; + +export type RequestPasswordReset200Status = typeof RequestPasswordReset200Status[keyof typeof RequestPasswordReset200Status]; + + +export const RequestPasswordReset200Status = { + password_reset_email_sent: 'password_reset_email_sent', +} as const; + +export type RequestPasswordReset200 = { + status: RequestPasswordReset200Status; +}; + export type PostListBodyVisibility = typeof PostListBodyVisibility[keyof typeof PostListBodyVisibility]; @@ -2366,6 +2382,7 @@ export type GetUserIdAiRecommendations200RecommendationsItem = { reason: string; mediaType: string; year?: number; + tmdbId?: number; }; export type GetUserIdAiRecommendations200 = { @@ -3412,7 +3429,6 @@ export type GetAchievements200AchievementsItem = { id: string; slug: string; icon: string; - color: string; target: number; category: GetAchievements200AchievementsItemCategory; level: number; @@ -3517,7 +3533,6 @@ export type GetAdminAchievements200AchievementsItem = { id: string; slug: string; icon: string; - color: string; target: number; category: GetAdminAchievements200AchievementsItemCategory; level: number; @@ -3581,11 +3596,6 @@ export type PostAdminAchievementsBody = { slug: string; /** @minLength 1 */ icon: string; - /** - * @minLength 1 - * @maxLength 10 - */ - color: string; /** @maximum 9007199254740991 */ target: number; category: PostAdminAchievementsBodyCategory; @@ -3643,7 +3653,6 @@ export type PostAdminAchievements201Achievement = { id: string; slug: string; icon: string; - color: string; target: number; category: PostAdminAchievements201AchievementCategory; level: number; @@ -3704,7 +3713,6 @@ export type GetAdminAchievementsId200Achievement = { id: string; slug: string; icon: string; - color: string; target: number; category: GetAdminAchievementsId200AchievementCategory; level: number; @@ -3768,11 +3776,6 @@ export type PutAdminAchievementsIdBody = { slug?: string; /** @minLength 1 */ icon?: string; - /** - * @minLength 1 - * @maxLength 10 - */ - color?: string; /** @maximum 9007199254740991 */ target?: number; category?: PutAdminAchievementsIdBodyCategory; @@ -3830,7 +3833,6 @@ export type PutAdminAchievementsId200Achievement = { id: string; slug: string; icon: string; - color: string; target: number; category: PutAdminAchievementsId200AchievementCategory; level: number; @@ -3891,7 +3893,6 @@ export type DeleteAdminAchievementsId200Achievement = { id: string; slug: string; icon: string; - color: string; target: number; category: DeleteAdminAchievementsId200AchievementCategory; level: number; diff --git a/apps/web/src/api/tmdb-proxy.ts b/apps/web/src/api/tmdb-proxy.ts new file mode 100644 index 00000000..6d5e877c --- /dev/null +++ b/apps/web/src/api/tmdb-proxy.ts @@ -0,0 +1,190 @@ +/** + * Generated by orval v8.5.3 🍺 + * Do not edit manually. + * Plotwist + * OpenAPI spec version: 0.1.0 + */ +import { + useQuery, + useSuspenseQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseQueryOptions, + UseQueryResult, + UseSuspenseQueryOptions, + UseSuspenseQueryResult +} from '@tanstack/react-query'; + +import { customFetch } from '../services/api-client'; +import type { ErrorType } from '../services/api-client'; + + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * Proxy TMDB API requests with Redis caching + */ +export type getTmdbResponse200 = { + data: void + status: 200 +} + +export type getTmdbResponseSuccess = (getTmdbResponse200) & { + headers: Headers; +}; +; + +export type getTmdbResponse = (getTmdbResponseSuccess) + +export const getGetTmdbUrl = (: string,) => { + + + + + return `/tmdb/${}` +} + +export const getTmdb = async (: string, options?: RequestInit): Promise => { + + return customFetch(getGetTmdbUrl(), + { + ...options, + method: 'GET' + + + } +);} + + + + + +export const getGetTmdbQueryKey = (: string,) => { + return [ + `/tmdb/${}` + ] as const; + } + + +export const getGetTmdbQueryOptions = >, TError = ErrorType>(: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTmdbQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTmdb({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, enabled: !!(), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTmdbQueryResult = NonNullable>> +export type GetTmdbQueryError = ErrorType + + +export function useGetTmdb>, TError = ErrorType>( + : string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTmdb>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTmdb>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } + +export function useGetTmdb>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTmdbQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + +export const getGetTmdbSuspenseQueryOptions = >, TError = ErrorType>(: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTmdbQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTmdb({ signal, ...requestOptions }); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseSuspenseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTmdbSuspenseQueryResult = NonNullable>> +export type GetTmdbSuspenseQueryError = ErrorType + + +export function useGetTmdbSuspense>, TError = ErrorType>( + : string, options: { query:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseSuspenseQueryResult & { queryKey: DataTag } +export function useGetTmdbSuspense>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseSuspenseQueryResult & { queryKey: DataTag } +export function useGetTmdbSuspense>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseSuspenseQueryResult & { queryKey: DataTag } + +export function useGetTmdbSuspense>, TError = ErrorType>( + : string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseSuspenseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTmdbSuspenseQueryOptions(options) + + const query = useSuspenseQuery(queryOptions, queryClient) as UseSuspenseQueryResult & { queryKey: DataTag }; + + return { ...query, queryKey: queryOptions.queryKey }; +} + + + + diff --git a/apps/web/src/api/users.ts b/apps/web/src/api/users.ts index b215e4d5..61f6e00d 100644 --- a/apps/web/src/api/users.ts +++ b/apps/web/src/api/users.ts @@ -49,6 +49,8 @@ import type { PostUsersCreate409, PostUsersCreate500, PostUsersCreateBody, + RequestPasswordReset200, + RequestPasswordResetBody, UpdateUserPreferences200, UpdateUserPreferencesBody } from './endpoints.schemas'; @@ -1609,3 +1611,83 @@ export function useGetUsersSearchSuspense { + + + + + return `/users/password-reset/request` +} + +export const requestPasswordReset = async (requestPasswordResetBody: RequestPasswordResetBody, options?: RequestInit): Promise => { + + return customFetch(getRequestPasswordResetUrl(), + { + ...options, + method: 'POST', + headers: { 'Content-Type': 'application/json', ...options?.headers }, + body: JSON.stringify( + requestPasswordResetBody,) + } +);} + + + + +export const getRequestPasswordResetMutationOptions = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: BodyType}, TContext> => { + +const mutationKey = ['requestPasswordReset']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: BodyType}> = (props) => { + const {data} = props ?? {}; + + return requestPasswordReset(data,requestOptions) + } + + + + + + + return { mutationFn, ...mutationOptions }} + + export type RequestPasswordResetMutationResult = NonNullable>> + export type RequestPasswordResetMutationBody = BodyType + export type RequestPasswordResetMutationError = ErrorType + + export const useRequestPasswordReset = , + TContext = unknown>(options?: { mutation?:UseMutationOptions>, TError,{data: BodyType}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: BodyType}, + TContext + > => { + return useMutation(getRequestPasswordResetMutationOptions(options), queryClient); + } + \ No newline at end of file diff --git a/apps/web/src/app/[lang]/admin/achievements/_components/achievement-form.tsx b/apps/web/src/app/[lang]/admin/achievements/_components/achievement-form.tsx index 662ac469..db407f55 100644 --- a/apps/web/src/app/[lang]/admin/achievements/_components/achievement-form.tsx +++ b/apps/web/src/app/[lang]/admin/achievements/_components/achievement-form.tsx @@ -111,7 +111,7 @@ export function AchievementForm({ achievement }: Props) { ? { slug: achievement.slug, icon: achievement.icon, - color: (achievement as Record).color as string ?? '', + color: ((achievement as Record).color as string) ?? '', target: achievement.target, category: achievement.category as 'general' | 'saga', level: achievement.level, diff --git a/apps/web/src/app/[lang]/forgot-password/_components/forgot-password-form.tsx b/apps/web/src/app/[lang]/forgot-password/_components/forgot-password-form.tsx index 33c598ad..e6bf7045 100644 --- a/apps/web/src/app/[lang]/forgot-password/_components/forgot-password-form.tsx +++ b/apps/web/src/app/[lang]/forgot-password/_components/forgot-password-form.tsx @@ -12,14 +12,20 @@ import { } from '@plotwist/ui/components/ui/form' import { Input } from '@plotwist/ui/components/ui/input' import { useForm } from 'react-hook-form' +import { toast } from 'sonner' +import type { requestPasswordReset } from '@/actions/auth/request-password-reset' import { useLanguage } from '@/context/language' import { type ForgotPasswordFormValues, forgotPasswordFormSchema, } from './forgot-password-form.schema' -export const ForgotPasswordForm = () => { +type ForgotPasswordFormProps = { + onRequest: typeof requestPasswordReset +} + +export const ForgotPasswordForm = ({ onRequest }: ForgotPasswordFormProps) => { const { dictionary } = useLanguage() const form = useForm({ @@ -30,7 +36,15 @@ export const ForgotPasswordForm = () => { }) async function onSubmit(values: ForgotPasswordFormValues) { - console.log({ values }) + const result = await onRequest({ login: values.login }) + + if (result?.error) { + toast.error(dictionary.request_password_reset_error) + return + } + + toast.success(dictionary.request_password_reset_form_response) + form.reset() } return ( diff --git a/apps/web/src/app/[lang]/forgot-password/page.tsx b/apps/web/src/app/[lang]/forgot-password/page.tsx index 0f0d5538..db865e3b 100644 --- a/apps/web/src/app/[lang]/forgot-password/page.tsx +++ b/apps/web/src/app/[lang]/forgot-password/page.tsx @@ -1,4 +1,5 @@ import type { Metadata } from 'next' +import { requestPasswordReset } from '@/actions/auth/request-password-reset' import { AnimatedLink } from '@/components/animated-link' import { Pattern } from '@/components/pattern' import type { PageProps } from '@/types/languages' @@ -40,7 +41,7 @@ const ForgotPasswordPage = async (props: PageProps) => { {dictionary.forgot_your_password} - + diff --git a/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx b/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx index 022647de..5284f0d4 100644 --- a/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx +++ b/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx @@ -24,6 +24,7 @@ import { TooltipTrigger, } from '@plotwist/ui/components/ui/tooltip' import { Eye, EyeOff } from 'lucide-react' +import Link from 'next/link' import { useState } from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' @@ -159,6 +160,15 @@ export const SignInForm = ({ onSignIn }: SignInFormProps) => { )} /> +
+ + {dictionary.login_form.forgot_password} + +
+