Compare commits

..

6 commits

Author SHA1 Message Date
7e6b541979 chore(deps): update dependency prettier to v3.6.0
Some checks failed
docker-build / docker (push) Failing after 2m34s
container-scan / Container Scan (pull_request) Successful in 3m23s
docker-build / docker (pull_request) Failing after 2m56s
2025-06-23 10:01:26 +00:00
c98a72f2f1 Merge pull request 'feat(api): implement missing user update and delete endpoints' (#102)
All checks were successful
container-scan / Container Scan (push) Successful in 4m58s
docker-build / docker (push) Successful in 7m17s
Reviewed-on: #102
Reviewed-by: Maximilian Liebmann <lima@noreply.git.dominikstahl.dev>
2025-06-23 09:37:13 +00:00
29f2a01ac6
style: format code
All checks were successful
container-scan / Container Scan (pull_request) Successful in 3m10s
docker-build / docker (pull_request) Successful in 7m25s
2025-06-23 10:45:56 +02:00
4cf5ce26ff
feat(api): implement DELETE method for /api/user/me endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 32s
docker-build / docker (pull_request) Successful in 7m22s
2025-06-23 10:44:26 +02:00
16b878a2e9
feat(api): stricter user data api types checking 2025-06-23 10:40:28 +02:00
280fa57e45
feat(api): implement /api/user/me/password endpoint
Some checks failed
container-scan / Container Scan (pull_request) Failing after 33s
docker-build / docker (pull_request) Failing after 5m11s
add an endpoint to allow the user to change his password
2025-06-23 10:00:19 +02:00
7 changed files with 3973 additions and 4 deletions

View file

@ -0,0 +1,122 @@
import { auth } from '@/auth';
import { prisma } from '@/prisma';
import { updateUserPasswordServerSchema } from '../validation';
import {
returnZodTypeCheckedResponse,
userAuthenticated,
} from '@/lib/apiHelpers';
import { FullUserResponseSchema } from '../../validation';
import {
ErrorResponseSchema,
ZodErrorResponseSchema,
} from '@/app/api/validation';
import bcrypt from 'bcryptjs';
export const PATCH = auth(async function PATCH(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const body = await req.json();
const parsedBody = updateUserPasswordServerSchema.safeParse(body);
if (!parsedBody.success)
return returnZodTypeCheckedResponse(
ZodErrorResponseSchema,
{
success: false,
message: 'Invalid request data',
errors: parsedBody.error.issues,
},
{ status: 400 },
);
const { current_password, new_password } = parsedBody.data;
const dbUser = await prisma.user.findUnique({
where: {
id: authCheck.user.id,
},
include: {
accounts: true,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
if (!dbUser.password_hash)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User does not have a password set',
},
{ status: 400 },
);
if (
dbUser.accounts.length === 0 ||
dbUser.accounts[0].provider !== 'credentials'
)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Credentials login is not enabled for this user',
},
{ status: 400 },
);
const isCurrentPasswordValid = await bcrypt.compare(
current_password,
dbUser.password_hash || '',
);
if (!isCurrentPasswordValid)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'Current password is incorrect',
},
{ status: 401 },
);
const hashedNewPassword = await bcrypt.hash(new_password, 10);
const updatedUser = await prisma.user.update({
where: {
id: dbUser.id,
},
data: {
password_hash: hashedNewPassword,
},
select: {
id: true,
name: true,
first_name: true,
last_name: true,
email: true,
image: true,
timezone: true,
created_at: true,
updated_at: true,
},
});
return returnZodTypeCheckedResponse(FullUserResponseSchema, {
success: true,
user: updatedUser,
});
});

View file

@ -0,0 +1,43 @@
import { OpenAPIRegistry } from '@asteasolutions/zod-to-openapi';
import { FullUserResponseSchema } from '../../validation';
import { updateUserPasswordServerSchema } from '../validation';
import {
invalidRequestDataResponse,
notAuthenticatedResponse,
serverReturnedDataValidationErrorResponse,
userNotFoundResponse,
} from '@/lib/defaultApiResponses';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({
method: 'patch',
path: '/api/user/me/password',
description: 'Update the password of the currently authenticated user',
request: {
body: {
description: 'User password update request body',
required: true,
content: {
'application/json': {
schema: updateUserPasswordServerSchema,
},
},
},
},
responses: {
200: {
description: 'User information updated successfully',
content: {
'application/json': {
schema: FullUserResponseSchema,
},
},
},
...invalidRequestDataResponse,
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
}

View file

@ -8,6 +8,7 @@ import {
import { FullUserResponseSchema } from '../validation'; import { FullUserResponseSchema } from '../validation';
import { import {
ErrorResponseSchema, ErrorResponseSchema,
SuccessResponseSchema,
ZodErrorResponseSchema, ZodErrorResponseSchema,
} from '@/app/api/validation'; } from '@/app/api/validation';
@ -117,3 +118,43 @@ export const PATCH = auth(async function PATCH(req) {
{ status: 200 }, { status: 200 },
); );
}); });
export const DELETE = auth(async function DELETE(req) {
const authCheck = userAuthenticated(req);
if (!authCheck.continue)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
authCheck.response,
authCheck.metadata,
);
const dbUser = await prisma.user.findUnique({
where: {
id: authCheck.user.id,
},
});
if (!dbUser)
return returnZodTypeCheckedResponse(
ErrorResponseSchema,
{
success: false,
message: 'User not found',
},
{ status: 404 },
);
await prisma.user.delete({
where: {
id: authCheck.user.id,
},
});
return returnZodTypeCheckedResponse(
SuccessResponseSchema,
{
success: true,
message: 'User deleted successfully',
},
{ status: 200 },
);
});

View file

@ -7,6 +7,7 @@ import {
serverReturnedDataValidationErrorResponse, serverReturnedDataValidationErrorResponse,
userNotFoundResponse, userNotFoundResponse,
} from '@/lib/defaultApiResponses'; } from '@/lib/defaultApiResponses';
import { SuccessResponseSchema } from '../../validation';
export default function registerSwaggerPaths(registry: OpenAPIRegistry) { export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
registry.registerPath({ registry.registerPath({
@ -60,4 +61,24 @@ export default function registerSwaggerPaths(registry: OpenAPIRegistry) {
}, },
tags: ['User'], tags: ['User'],
}); });
registry.registerPath({
method: 'delete',
path: '/api/user/me',
description: 'Delete the currently authenticated user',
responses: {
200: {
description: 'User deleted successfully',
content: {
'application/json': {
schema: SuccessResponseSchema,
},
},
},
...notAuthenticatedResponse,
...userNotFoundResponse,
...serverReturnedDataValidationErrorResponse,
},
tags: ['User'],
});
} }

View file

@ -4,6 +4,8 @@ import {
lastNameSchema, lastNameSchema,
newUserEmailServerSchema, newUserEmailServerSchema,
newUserNameServerSchema, newUserNameServerSchema,
passwordSchema,
timezoneSchema,
} from '@/app/api/user/validation'; } from '@/app/api/user/validation';
// ---------------------------------------- // ----------------------------------------
@ -16,6 +18,16 @@ export const updateUserServerSchema = zod.object({
first_name: firstNameSchema.optional(), first_name: firstNameSchema.optional(),
last_name: lastNameSchema.optional(), last_name: lastNameSchema.optional(),
email: newUserEmailServerSchema.optional(), email: newUserEmailServerSchema.optional(),
image: zod.string().optional(), image: zod.url().optional(),
timezone: zod.string().optional(), timezone: timezoneSchema.optional(),
}); });
export const updateUserPasswordServerSchema = zod
.object({
current_password: zod.string().min(1, 'Current password is required'),
new_password: passwordSchema,
confirm_new_password: passwordSchema,
})
.refine((data) => data.new_password === data.confirm_new_password, {
message: 'New password and confirm new password must match',
});

View file

@ -1,6 +1,7 @@
import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi'; import { extendZodWithOpenApi } from '@asteasolutions/zod-to-openapi';
import { prisma } from '@/prisma'; import { prisma } from '@/prisma';
import zod from 'zod/v4'; import zod from 'zod/v4';
import { allTimeZones } from '@/lib/timezones';
extendZodWithOpenApi(zod); extendZodWithOpenApi(zod);
@ -107,6 +108,15 @@ export const passwordSchema = zod
'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character', 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character',
); );
// ----------------------------------------
//
// Timezone Validation
//
// ----------------------------------------
export const timezoneSchema = zod.enum(allTimeZones).openapi('Timezone', {
description: 'Valid timezone from the list of supported timezones',
});
// ---------------------------------------- // ----------------------------------------
// //
// User Schema Validation (for API responses) // User Schema Validation (for API responses)
@ -119,8 +129,11 @@ export const FullUserSchema = zod
first_name: zod.string().nullish(), first_name: zod.string().nullish(),
last_name: zod.string().nullish(), last_name: zod.string().nullish(),
email: zod.email(), email: zod.email(),
image: zod.string().nullish(), image: zod.url().nullish(),
timezone: zod.string(), timezone: zod
.string()
.refine((i) => (allTimeZones as string[]).includes(i))
.nullish(),
created_at: zod.date(), created_at: zod.date(),
updated_at: zod.date(), updated_at: zod.date(),
}) })

3717
src/lib/timezones.ts Normal file

File diff suppressed because it is too large Load diff