studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
221 lines (184 loc) • 6.72 kB
text/typescript
/** biome-ignore-all lint/style/noNonNullAssertion: This file uses valid use cases for non-null assertion */
import { Password, User } from 'studiocms:auth/lib';
import { developerConfig } from 'studiocms:config';
import { apiResponseLogger } from 'studiocms:logger';
import { Notifications } from 'studiocms:notifier';
import { SDKCore } from 'studiocms:sdk';
import type { tsUsersUpdate } from 'studiocms:sdk/types';
import { z } from 'astro/zod';
import {
AllResponse,
createEffectAPIRoutes,
createJsonResponse,
Effect,
genLogger,
OptionsResponse,
readAPIContextJson,
} from '../../../effect.js';
type UserBasicUpdate = Omit<tsUsersUpdate, 'id'>;
type UserPasswordUpdate = {
currentPassword: string | null;
newPassword: string;
confirmNewPassword: string;
};
type BasicUserProfileUpdate = {
mode: 'basic';
data: UserBasicUpdate;
};
type PasswordProfileUpdate = {
mode: 'password';
data: UserPasswordUpdate;
};
type AvatarProfileUpdate = {
mode: 'avatar';
};
type UserProfileUpdate = BasicUserProfileUpdate | PasswordProfileUpdate | AvatarProfileUpdate;
export const { POST, OPTIONS, ALL } = createEffectAPIRoutes(
{
POST: (ctx) =>
genLogger('studiocms/routes/api/dashboard/profile.POST')(function* () {
const [pass, userHelper, notify, sdk] = yield* Effect.all([
Password,
User,
Notifications,
SDKCore,
]);
// Check if demo mode is enabled
if (developerConfig.demoMode !== false) {
return apiResponseLogger(403, 'Demo mode is enabled, this action is not allowed.');
}
// Get user data
const userData = ctx.locals.StudioCMS.security?.userSessionData;
// Check if user is logged in
if (!userData?.isLoggedIn) {
return apiResponseLogger(401, 'Unauthorized');
}
// Check if user has permission
if (!ctx.locals.StudioCMS.security?.userPermissionLevel.isVisitor) {
return apiResponseLogger(403, 'Unauthorized');
}
// Get Json Data
const userProfileUpdate = yield* readAPIContextJson<UserProfileUpdate>(ctx);
switch (userProfileUpdate.mode) {
case 'basic': {
const { data: r } = userProfileUpdate;
const data = r;
if (!data.name) {
return apiResponseLogger(400, 'Invalid form data, name is required');
}
if (!data.email) {
return apiResponseLogger(400, 'Invalid form data, email is required');
}
if (!data.username) {
return apiResponseLogger(400, 'Invalid form data, username is required');
}
// If the username is invalid, return an error
const verifyUsernameResponse = yield* userHelper.verifyUsernameInput(data.username);
if (verifyUsernameResponse !== true) {
return apiResponseLogger(400, verifyUsernameResponse);
}
// If the email is invalid, return an error
const checkEmail = z.coerce
.string()
.email({ message: 'Email address is invalid' })
.safeParse(data.email);
if (!checkEmail.success)
return apiResponseLogger(400, `Invalid email: ${checkEmail.error.message}`);
const { usernameSearch, emailSearch } =
yield* sdk.AUTH.user.searchUsersForUsernameOrEmail(data.username, checkEmail.data);
if (userData.user?.username !== data.username) {
if (usernameSearch.length > 0)
return apiResponseLogger(400, 'Invalid username: Username is already in use');
}
if (userData.user?.email !== data.email) {
if (emailSearch.length > 0)
return apiResponseLogger(400, 'Invalid email: Email is already in use');
}
yield* sdk.AUTH.user.update(userData.user!.id!, data);
return apiResponseLogger(200, 'User profile updated successfully');
}
case 'password': {
const { data: r } = userProfileUpdate;
const data = r;
const { currentPassword, newPassword, confirmNewPassword } = data;
if (!currentPassword) {
if (userData.user?.password) {
return apiResponseLogger(400, 'Invalid form data, current password is required');
}
}
if (!newPassword) {
return apiResponseLogger(400, 'Invalid form data, new password is required');
}
// Verify the current password matches the stored hash (when one exists)
if (currentPassword && userData.user?.password) {
const isValid = yield* pass.verifyPasswordHash(
userData.user.password,
currentPassword
);
if (!isValid) {
return apiResponseLogger(400, 'Invalid current password');
}
}
if (!confirmNewPassword) {
return apiResponseLogger(400, 'Invalid form data, confirm new password is required');
}
if (newPassword !== confirmNewPassword) {
return apiResponseLogger(
400,
'Invalid form data, new password and confirm new password do not match'
);
}
// If the password is invalid, return an error
const verifyPasswordResponse = yield* pass.verifyPasswordStrength(newPassword);
if (verifyPasswordResponse !== true) {
return apiResponseLogger(400, verifyPasswordResponse);
}
const userUpdate = {
password: yield* pass.hashPassword(newPassword),
};
if (userData.user)
yield* Effect.all([
sdk.AUTH.user.update(userData.user.id, userUpdate),
notify.sendUserNotification('account_updated', userData.user.id),
notify.sendAdminNotification('user_updated', userData.user.username),
]);
return apiResponseLogger(200, 'User password updated successfully');
}
case 'avatar': {
if (!userData.user?.email) {
return apiResponseLogger(400, 'User email required');
}
if (userData.user)
yield* userHelper
.createUserAvatar(userData.user.email)
.pipe(
Effect.flatMap((newAvatar) =>
Effect.all([
sdk.AUTH.user.update(userData.user!.id, { avatar: newAvatar }),
notify.sendUserNotification('account_updated', userData.user!.id),
notify.sendAdminNotification('user_updated', userData.user!.username),
])
)
);
return apiResponseLogger(200, 'User Avatar updated successfully');
}
default:
return apiResponseLogger(400, 'Invalid form data, mode is required or unsupported');
}
}).pipe(Notifications.Provide),
OPTIONS: () => Effect.try(() => OptionsResponse({ allowedMethods: ['POST'] })),
ALL: () => Effect.try(() => AllResponse()),
},
{
cors: { methods: ['POST', 'OPTIONS'] },
onError: (error) => {
console.error('API Error:', error);
return createJsonResponse(
{ error: 'Internal Server Error' },
{
status: 500,
}
);
},
}
);