studiocms
Version:
Astro Native CMS for AstroDB. Built from the ground up by the Astro community.
220 lines (184 loc) • 6.08 kB
text/typescript
import { User } from 'studiocms:auth/lib';
import { apiResponseLogger } from 'studiocms:logger';
import { Mailer } from 'studiocms:mailer';
import getTemplate from 'studiocms:mailer/templates';
import { Notifications } from 'studiocms:notifier';
import { SDKCore } from 'studiocms:sdk';
import type { AvailablePermissionRanks } from '@withstudiocms/auth-kit/types';
import type { APIContext } from 'astro';
import { z } from 'astro/zod';
import {
AllResponse,
appendSearchParamsToUrl,
createEffectAPIRoutes,
createJsonResponse,
Effect,
genLogger,
OptionsResponse,
pipe,
readAPIContextJson,
} from '../../../effect.js';
type JSONData = {
username: string | undefined;
email: string | undefined;
displayname: string | undefined;
rank: AvailablePermissionRanks | undefined;
originalUrl: string;
};
type Token = {
id: string;
userId: string;
token: string;
};
const generateResetUrl = (
{
locals: {
StudioCMS: {
routeMap: {
mainLinks: { dashboardIndex },
},
},
},
}: APIContext,
baseUrl: string,
{ id, userId, token }: Token
) => {
const resetURL = new URL(`${dashboardIndex}/password-reset`, baseUrl);
return pipe(
resetURL,
appendSearchParamsToUrl('userid', userId),
appendSearchParamsToUrl('token', token),
appendSearchParamsToUrl('id', id)
);
};
const noMailerError = (message: string, resetLink: URL) =>
`Failed to send email: ${message}. You can provide the following Reset link to your User: ${resetLink}`;
export const { POST, OPTIONS, ALL } = createEffectAPIRoutes(
{
POST: (ctx) =>
genLogger('studiocms/routes/api/dashboard/create-user-invite.POST')(function* () {
const [userHelper, mailer, notify, sdk] = yield* Effect.all([
User,
Mailer,
Notifications,
SDKCore,
]);
const siteConfig = ctx.locals.StudioCMS.siteConfig.data;
if (!siteConfig) {
return apiResponseLogger(500, 'Failed to get site config');
}
// Get user data
const userData = ctx.locals.StudioCMS.security?.userSessionData;
// Check if user is logged in
if (!userData?.isLoggedIn) {
return apiResponseLogger(403, 'Unauthorized');
}
// Check if user has permission
const isAuthorized = ctx.locals.StudioCMS.security?.userPermissionLevel.isAdmin;
if (!isAuthorized) {
return apiResponseLogger(403, 'Unauthorized');
}
const { username, email, displayname, rank, originalUrl } =
yield* readAPIContextJson<JSONData>(ctx);
// If the username, password, email, or display name is missing, return an error
if (!username) {
return apiResponseLogger(400, 'Missing field: Username is required');
}
if (!email) {
return apiResponseLogger(400, 'Missing field: Email is required');
}
if (!displayname) {
return apiResponseLogger(400, 'Missing field: Display name is required');
}
if (!rank) {
return apiResponseLogger(400, 'Missing field: Rank is required');
}
const userPerms = ctx.locals.StudioCMS.security?.userPermissionLevel;
if (rank === 'owner' && !userPerms?.isOwner) {
return apiResponseLogger(403, 'Unauthorized');
}
// If the email is invalid, return an error
const checkEmail = z.coerce
.string()
.email({ message: 'Email address is invalid' })
.safeParse(email);
if (!checkEmail.success) {
return apiResponseLogger(400, `Invalid email: ${checkEmail.error.message}`);
}
const [verifyUsernameResponse, { usernameSearch, emailSearch }] = yield* Effect.all([
userHelper.verifyUsernameInput(username),
sdk.AUTH.user.searchUsersForUsernameOrEmail(username, checkEmail.data),
]);
if (verifyUsernameResponse !== true) {
return apiResponseLogger(400, verifyUsernameResponse);
}
if (usernameSearch.length > 0) {
return apiResponseLogger(400, 'Invalid username: Username is already in use');
}
if (emailSearch.length > 0) {
return apiResponseLogger(400, 'Invalid email: Email is already in use');
}
// Creates a new user invite
const token = yield* sdk.AUTH.user
.create(
{
username,
email: checkEmail.data,
name: displayname,
createdAt: new Date(),
id: crypto.randomUUID(),
},
rank
)
.pipe(Effect.flatMap((newUser) => sdk.resetTokenBucket.new(newUser.id)));
if (!token) {
return apiResponseLogger(500, 'Failed to create reset token');
}
const resetLink = generateResetUrl(ctx, originalUrl, token);
yield* notify.sendAdminNotification('new_user', username);
if (siteConfig.enableMailer) {
const checkMailConnection = yield* mailer.verifyMailConnection;
if (!checkMailConnection) {
return apiResponseLogger(
500,
noMailerError('Failed to connect to mail server', resetLink)
);
}
if ('error' in checkMailConnection) {
return apiResponseLogger(
500,
noMailerError('Failed to connect to mail server', resetLink)
);
}
const htmlTemplate = getTemplate('userInvite');
const mailResponse = yield* mailer.sendMail({
to: checkEmail.data,
subject: `You have been invited to join ${siteConfig.title}!`,
html: htmlTemplate({ title: siteConfig.title, link: resetLink }),
});
if (!mailResponse) {
return apiResponseLogger(500, noMailerError('Failed to send email', resetLink));
}
if ('error' in mailResponse) {
return apiResponseLogger(500, noMailerError(mailResponse.error, resetLink));
}
return apiResponseLogger(200, 'User invite created and email sent');
}
return apiResponseLogger(200, resetLink.toString());
}).pipe(Mailer.Provide, 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,
}
);
},
}
);