0
Fork 0
mirror of https://github.com/stonith404/pingvin-share.git synced 2025-02-19 01:55:48 -05:00

feat: invite new user with email

This commit is contained in:
Elias Schneider 2023-02-21 08:51:04 +01:00
parent 759c55f625
commit f9840505b8
No known key found for this signature in database
GPG key ID: 07E623B294202B6C
10 changed files with 111 additions and 29 deletions

View file

@ -135,9 +135,27 @@ const configVariables: Prisma.ConfigCreateInput[] = [
"Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧", "Hey!\nYou requested a password reset. Click this link to reset your password: {url}\nThe link expires in a hour.\nPingvin Share 🐧",
category: "email", category: "email",
}, },
{ {
order: 13, order: 13,
key: "INVITE_EMAIL_SUBJECT",
description:
"Subject of the email which gets sent when an admin invites an user.",
type: "string",
value: "Pingvin Share invite",
category: "email",
},
{
order: 14,
key: "INVITE_EMAIL_MESSAGE",
description:
"Message which gets sent when an admin invites an user. {url} will be replaced with the invite URL and {password} with the password.",
type: "text",
value:
"Hey!\nYou were invited to Pingvin Share. Click this link to accept the invite: {url}\nYour password is: {password}\nPingvin Share 🐧",
category: "email",
},
{
order: 15,
key: "SMTP_ENABLED", key: "SMTP_ENABLED",
description: description:
"Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.", "Whether SMTP is enabled. Only set this to true if you entered the host, port, email, user and password of your SMTP server.",
@ -147,7 +165,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
secret: false, secret: false,
}, },
{ {
order: 14, order: 16,
key: "SMTP_HOST", key: "SMTP_HOST",
description: "Host of the SMTP server", description: "Host of the SMTP server",
type: "string", type: "string",
@ -155,7 +173,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 15, order: 17,
key: "SMTP_PORT", key: "SMTP_PORT",
description: "Port of the SMTP server", description: "Port of the SMTP server",
type: "number", type: "number",
@ -163,7 +181,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 16, order: 18,
key: "SMTP_EMAIL", key: "SMTP_EMAIL",
description: "Email address which the emails get sent from", description: "Email address which the emails get sent from",
type: "string", type: "string",
@ -171,7 +189,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 17, order: 19,
key: "SMTP_USERNAME", key: "SMTP_USERNAME",
description: "Username of the SMTP server", description: "Username of the SMTP server",
type: "string", type: "string",
@ -179,7 +197,7 @@ const configVariables: Prisma.ConfigCreateInput[] = [
category: "smtp", category: "smtp",
}, },
{ {
order: 18, order: 20,
key: "SMTP_PASSWORD", key: "SMTP_PASSWORD",
description: "Password of the SMTP server", description: "Password of the SMTP server",
type: "string", type: "string",

View file

@ -73,6 +73,20 @@ export class EmailService {
}); });
} }
async sendInviteEmail(recipientEmail: string, password: string) {
const loginUrl = `${this.config.get("APP_URL")}/auth/signIn`;
await this.getTransporter().sendMail({
from: `"Pingvin Share" <${this.config.get("SMTP_EMAIL")}>`,
to: recipientEmail,
subject: this.config.get("INVITE_EMAIL_SUBJECT"),
text: this.config
.get("INVITE_EMAIL_MESSAGE")
.replaceAll("{url}", loginUrl)
.replaceAll("{password}", password),
});
}
async sendTestMail(recipientEmail: string) { async sendTestMail(recipientEmail: string) {
try { try {
await this.getTransporter().sendMail({ await this.getTransporter().sendMail({

View file

@ -1,12 +1,15 @@
import { Expose, plainToClass } from "class-transformer"; import { plainToClass } from "class-transformer";
import { Allow } from "class-validator"; import { Allow, IsOptional, MinLength } from "class-validator";
import { UserDTO } from "./user.dto"; import { UserDTO } from "./user.dto";
export class CreateUserDTO extends UserDTO { export class CreateUserDTO extends UserDTO {
@Expose()
@Allow() @Allow()
isAdmin: boolean; isAdmin: boolean;
@MinLength(8)
@IsOptional()
password: string;
from(partial: Partial<CreateUserDTO>) { from(partial: Partial<CreateUserDTO>) {
return plainToClass(CreateUserDTO, partial, { return plainToClass(CreateUserDTO, partial, {
excludeExtraneousValues: true, excludeExtraneousValues: true,

View file

@ -1,8 +1,10 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { EmailModule } from "src/email/email.module";
import { UserController } from "./user.controller"; import { UserController } from "./user.controller";
import { UserSevice } from "./user.service"; import { UserSevice } from "./user.service";
@Module({ @Module({
imports:[EmailModule],
providers: [UserSevice], providers: [UserSevice],
controllers: [UserController], controllers: [UserController],
}) })

View file

@ -1,13 +1,17 @@
import { BadRequestException, Injectable } from "@nestjs/common"; import { BadRequestException, Injectable } from "@nestjs/common";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime";
import * as argon from "argon2"; import * as argon from "argon2";
import { EmailService } from "src/email/email.service";
import { PrismaService } from "src/prisma/prisma.service"; import { PrismaService } from "src/prisma/prisma.service";
import { CreateUserDTO } from "./dto/createUser.dto"; import { CreateUserDTO } from "./dto/createUser.dto";
import { UpdateUserDto } from "./dto/updateUser.dto"; import { UpdateUserDto } from "./dto/updateUser.dto";
@Injectable() @Injectable()
export class UserSevice { export class UserSevice {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private emailService: EmailService
) {}
async list() { async list() {
return await this.prisma.user.findMany(); return await this.prisma.user.findMany();
@ -18,7 +22,17 @@ export class UserSevice {
} }
async create(dto: CreateUserDTO) { async create(dto: CreateUserDTO) {
const hash = await argon.hash(dto.password); let hash: string;
// The password can be undefined if the user is invited by an admin
if (!dto.password) {
const randomPassword = crypto.randomUUID();
hash = await argon.hash(randomPassword);
this.emailService.sendInviteEmail(dto.email, randomPassword);
} else {
hash = await argon.hash(dto.password);
}
try { try {
return await this.prisma.user.create({ return await this.prisma.user.create({
data: { data: {

View file

@ -1,7 +1,7 @@
import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core"; import { ActionIcon, Box, Group, Skeleton, Table } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { TbCheck, TbEdit, TbTrash } from "react-icons/tb"; import { TbCheck, TbEdit, TbTrash } from "react-icons/tb";
import User from "../../types/user.type"; import User from "../../../types/user.type";
import showUpdateUserModal from "./showUpdateUserModal"; import showUpdateUserModal from "./showUpdateUserModal";
const ManageUserTable = ({ const ManageUserTable = ({

View file

@ -10,38 +10,44 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import * as yup from "yup";
import userService from "../../services/user.service"; import userService from "../../../services/user.service";
import toast from "../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showCreateUserModal = ( const showCreateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
smtpEnabled: boolean,
getUsers: () => void getUsers: () => void
) => { ) => {
return modals.openModal({ return modals.openModal({
title: <Title order={5}>Create user</Title>, title: <Title order={5}>Create user</Title>,
children: <Body modals={modals} getUsers={getUsers} />, children: (
<Body modals={modals} smtpEnabled={smtpEnabled} getUsers={getUsers} />
),
}); });
}; };
const Body = ({ const Body = ({
modals, modals,
smtpEnabled,
getUsers, getUsers,
}: { }: {
modals: ModalsContextProps; modals: ModalsContextProps;
smtpEnabled: boolean;
getUsers: () => void; getUsers: () => void;
}) => { }) => {
const form = useForm({ const form = useForm({
initialValues: { initialValues: {
username: "", username: "",
email: "", email: "",
password: "", password: undefined,
isAdmin: false, isAdmin: false,
setPasswordManually: false,
}, },
validate: yupResolver( validate: yupResolver(
yup.object().shape({ yup.object().shape({
email: yup.string().email(), email: yup.string().email(),
username: yup.string().min(3), username: yup.string().min(3),
password: yup.string().min(8), password: yup.string().min(8).optional(),
}) })
), ),
}); });
@ -62,14 +68,34 @@ const Body = ({
<Stack> <Stack>
<TextInput label="Username" {...form.getInputProps("username")} /> <TextInput label="Username" {...form.getInputProps("username")} />
<TextInput label="Email" {...form.getInputProps("email")} /> <TextInput label="Email" {...form.getInputProps("email")} />
<PasswordInput {smtpEnabled && (
label="New password"
{...form.getInputProps("password")}
/>
<Switch <Switch
mt="xs"
labelPosition="left"
label="Set password manually"
description="If not checked, the user will receive an email with a link to set their password."
{...form.getInputProps("setPasswordManually", {
type: "checkbox",
})}
/>
)}
{form.values.setPasswordManually || !smtpEnabled && (
<PasswordInput
label="Password"
{...form.getInputProps("password")}
/>
)}
<Switch
styles={{
body: {
display: "flex",
justifyContent: "space-between",
},
}}
mt="xs" mt="xs"
labelPosition="left" labelPosition="left"
label="Admin privileges" label="Admin privileges"
description="If checked, the user will be able to access the admin panel."
{...form.getInputProps("isAdmin", { type: "checkbox" })} {...form.getInputProps("isAdmin", { type: "checkbox" })}
/> />
<Group position="right"> <Group position="right">

View file

@ -11,9 +11,9 @@ import {
import { useForm, yupResolver } from "@mantine/form"; import { useForm, yupResolver } from "@mantine/form";
import { ModalsContextProps } from "@mantine/modals/lib/context"; import { ModalsContextProps } from "@mantine/modals/lib/context";
import * as yup from "yup"; import * as yup from "yup";
import userService from "../../services/user.service"; import userService from "../../../services/user.service";
import User from "../../types/user.type"; import User from "../../../types/user.type";
import toast from "../../utils/toast.util"; import toast from "../../../utils/toast.util";
const showUpdateUserModal = ( const showUpdateUserModal = (
modals: ModalsContextProps, modals: ModalsContextProps,
@ -90,7 +90,7 @@ const Body = ({
</form> </form>
<Accordion> <Accordion>
<Accordion.Item sx={{ borderBottom: "none" }} value="changePassword"> <Accordion.Item sx={{ borderBottom: "none" }} value="changePassword">
<Accordion.Control>Change password</Accordion.Control> <Accordion.Control px={0}>Change password</Accordion.Control>
<Accordion.Panel> <Accordion.Panel>
<form <form
onSubmit={passwordForm.onSubmit(async (values) => { onSubmit={passwordForm.onSubmit(async (values) => {

View file

@ -2,9 +2,10 @@ import { Button, Group, Space, Text, Title } from "@mantine/core";
import { useModals } from "@mantine/modals"; import { useModals } from "@mantine/modals";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { TbPlus } from "react-icons/tb"; import { TbPlus } from "react-icons/tb";
import ManageUserTable from "../../components/admin/ManageUserTable"; import ManageUserTable from "../../components/admin/users/ManageUserTable";
import showCreateUserModal from "../../components/admin/showCreateUserModal"; import showCreateUserModal from "../../components/admin/users/showCreateUserModal";
import Meta from "../../components/Meta"; import Meta from "../../components/Meta";
import useConfig from "../../hooks/config.hook";
import userService from "../../services/user.service"; import userService from "../../services/user.service";
import User from "../../types/user.type"; import User from "../../types/user.type";
import toast from "../../utils/toast.util"; import toast from "../../utils/toast.util";
@ -12,6 +13,8 @@ import toast from "../../utils/toast.util";
const Users = () => { const Users = () => {
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const config = useConfig();
const modals = useModals(); const modals = useModals();
const getUsers = () => { const getUsers = () => {
@ -54,7 +57,9 @@ const Users = () => {
User management User management
</Title> </Title>
<Button <Button
onClick={() => showCreateUserModal(modals, getUsers)} onClick={() =>
showCreateUserModal(modals, config.get("SMTP_ENABLED"), getUsers)
}
leftIcon={<TbPlus size={20} />} leftIcon={<TbPlus size={20} />}
> >
Create Create

View file

@ -9,7 +9,7 @@ type User = {
export type CreateUser = { export type CreateUser = {
username: string; username: string;
email: string; email: string;
password: string; password?: string;
isAdmin?: boolean; isAdmin?: boolean;
}; };