Skip to content

Commit

Permalink
Merge pull request #74 from Enterprize1/73-user-password-can-be-chang…
Browse files Browse the repository at this point in the history
…ed-by-arbitrary-user-without-confirmation

Limit ability to manage users to admins
  • Loading branch information
Enterprize1 authored Nov 10, 2024
2 parents c547598 + 12e053b commit 535240f
Show file tree
Hide file tree
Showing 10 changed files with 150 additions and 35 deletions.
21 changes: 11 additions & 10 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ model Email {
subject String
headers String
body String
allowExternalImages Boolean @default(false)
allowExternalImages Boolean @default(false)
backofficeIdentifier String
StudyEmail StudyEmail[]
ParticipationEmail ParticipationEmail[]
Expand Down Expand Up @@ -91,16 +91,17 @@ enum TimerMode {
}

model StudyEmail {
id String @id @default(uuid()) @db.Uuid
studyId String @db.Uuid
study Study @relation(fields: [studyId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "study_email_study_id_study_id_fk")
emailId String @db.Uuid
email Email @relation(fields: [emailId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "study_email_email_id_email_id_fk")
id String @id @default(uuid()) @db.Uuid
studyId String @db.Uuid
study Study @relation(fields: [studyId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "study_email_study_id_study_id_fk")
emailId String @db.Uuid
email Email @relation(fields: [emailId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "study_email_email_id_email_id_fk")
}

model User {
id String @id @default(uuid()) @db.Uuid
email String @unique(map: "email_idx")
password String
created_at DateTime? @db.Timestamptz(3)
id String @id @default(uuid()) @db.Uuid
email String @unique(map: "email_idx")
password String
created_at DateTime? @db.Timestamptz(3)
canManageUsers Boolean @default(true)
}
1 change: 1 addition & 0 deletions prisma/seed.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async function main() {
data: {
email: 'admin@example.com',
password: await bcrypt.hash('123456', 10),
canManageUsers: true,
},
});

Expand Down
14 changes: 8 additions & 6 deletions src/app/admin/(loggedin)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,11 +58,13 @@ export default function LoggedinLayout({children}: {children: React.ReactNode})
{t('admin.sidebar.studies')}
</SidebarNavLink>
</li>
<li>
<SidebarNavLink Icon={UserGroupIcon} href='/admin/users'>
{t('admin.sidebar.users')}
</SidebarNavLink>
</li>
{session.data?.user?.canManageUsers && (
<li>
<SidebarNavLink Icon={UserGroupIcon} href='/admin/users'>
{t('admin.sidebar.users')}
</SidebarNavLink>
</li>
)}
</ul>
</li>
</ul>
Expand Down Expand Up @@ -116,7 +118,7 @@ export default function LoggedinLayout({children}: {children: React.ReactNode})
)}
onClick={async () => {
if (session.data?.user?.name) {
router.push(`admin/users/${session.data.user.name}`);
router.push(`/admin/users/${session.data.user.name}`);
}
}}
>
Expand Down
34 changes: 25 additions & 9 deletions src/app/admin/(loggedin)/users/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,25 @@ import {useRouter} from 'next/navigation';
import {useTranslation} from 'react-i18next';
import {Headline} from '~/components/headline';
import {toast} from 'react-toastify';
import { CheckboxField } from '~/components/forms/fields/CheckboxField';
import { useSession } from 'next-auth/react';

export default function Page({params: {id}}: {params: {id: string}}) {
const builder = useFormBuilder<{email: string; password: string}>({
const builder = useFormBuilder<{email: string; password: string; canManageUsers: boolean}>({
defaultValues: {
email: '',
password: '',
canManageUsers: false,
},
});
const addUser = trpc.user.add.useMutation();
const updateUser = trpc.user.update.useMutation();
const {t} = useTranslation(undefined, {keyPrefix: 'admin.users.edit'});
const {t} = useTranslation(undefined, {keyPrefix: 'admin.users'});

const isCreate = id === 'create';
const getMail = trpc.user.get.useQuery(id, {enabled: !isCreate});
const router = useRouter();
const session = useSession();

useEffect(() => {
if (isCreate) return;
Expand All @@ -31,7 +35,7 @@ export default function Page({params: {id}}: {params: {id: string}}) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [getMail.data, isCreate]);

const onSubmit = async (data: {email: string; password: string}) => {
const onSubmit = async (data: {email: string; password: string; canManageUsers: boolean}) => {
try {
if (isCreate) {
await addUser.mutateAsync(data);
Expand All @@ -41,30 +45,42 @@ export default function Page({params: {id}}: {params: {id: string}}) {

router.push('/admin/users');
} catch (e) {
toast.error(t('errorDuringSave'));
toast.error(t('edit.errorDuringSave'));
}
};

return (
<div className='max-w-6xl'>
<Headline size={1} className='mb-4'>
{isCreate ? t('createUser') : t('editUser')}
{isCreate ? t('edit.createUser') : t('edit.editUser')}
</Headline>
<Form builder={builder} onSubmit={onSubmit} className='my-2 flex flex-col gap-x-8 gap-y-2'>
<InputField label={t('email')} on={builder.fields.email} rules={{required: true}} />
<InputField label={t('edit.email')} on={builder.fields.email} rules={{required: true}} />
<InputField
label={t('password')}
label={t('edit.password')}
on={builder.fields.password}
rules={{required: isCreate}}
helperText={isCreate ? undefined : t('passwordHint')}
helperText={isCreate ? undefined : t('edit.passwordHint')}
type='password'
/>
{session.data?.user?.canManageUsers && (
<div>
<CheckboxField
label={t('common.canManageUsers')}
on={builder.fields.canManageUsers}
disabled={session.data.user.id === id}
/>
<div className="text-sm text-gray-500 ml-8">
{t('common.canManageUsersHelperText')}
</div>
</div>
)}

<button
type='submit'
className='mt-4 flex justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
>
{t('save')}
{t('edit.save')}
</button>
</Form>
</div>
Expand Down
23 changes: 14 additions & 9 deletions src/app/admin/(loggedin)/users/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,30 @@ import {useTranslation} from 'react-i18next';
import {toast} from 'react-toastify';
import {useConfirm} from 'material-ui-confirm';
import {Headline} from '~/components/headline';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';

export default function Page() {
const {data, refetch} = trpc.user.getAll.useQuery();
const {mutateAsync: deleteUser} = trpc.user.delete.useMutation();
const {t} = useTranslation(undefined, {keyPrefix: 'admin.users.list'});
const {t} = useTranslation(undefined, {keyPrefix: 'admin.users'});
const confirm = useConfirm();

const deleteClick = useCallback(
async (id: string) => {
try {
await confirm({
description: t('deleteConfirm'),
description: t('list.deleteConfirm'),
});
} catch (e) {
return;
}

try {
await deleteUser(id);
toast.success(t('deletedSuccess'));
toast.success(t('list.deletedSuccess'));
refetch();
} catch (e) {
toast.error(t('deletedError'));
toast.error(t('list.deletedError'));
}
},
[confirm, deleteUser, refetch, t],
Expand All @@ -39,19 +40,23 @@ export default function Page() {
const usersColumns: SimpleTableColumn<Omit<User, 'password'>>[] = useMemo(
() => [
{
header: t('email'),
header: t('list.email'),
cell: (u) => u.email,
},
{
header: t('common.canManageUsers'),
cell: (e) => e.canManageUsers ? <CheckCircleIcon className="h-5 w-5 text-green-600" /> : <XCircleIcon className="h-5 w-5 text-red-600" />,
},
{
header: '',
cell: (e) => {
return (
<div className='ml-auto flex w-max gap-2'>
<Link href={'/admin/users/' + e.id} className='text-indigo-600'>
{t('edit')}
{t('list.edit')}
</Link>
<button className='text-red-600' type='button' onClick={() => deleteClick(e.id)}>
{t('delete')}
{t('list.delete')}
</button>
</div>
);
Expand All @@ -64,12 +69,12 @@ export default function Page() {
return (
<div className='max-w-6xl'>
<div className='mb-2 flex content-center justify-between'>
<Headline size={1}>{t('users')}</Headline>
<Headline size={1}>{t('list.users')}</Headline>
<Link
className='flex justify-center self-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600'
href='/admin/users/create'
>
{t('create')}
{t('list.create')}
</Link>
</div>
<SimpleTable columns={usersColumns} items={data ?? []} />
Expand Down
4 changes: 4 additions & 0 deletions src/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@
}
},
"users": {
"common": {
"canManageUsers": "Kann Nutzende verwalten",
"canManageUsersHelperText": "Neue Nutzer:innen einladen, bearbeiten und Passwörter ändern"
},
"list": {
"edit": "Bearbeiten",
"delete": "Löschen",
Expand Down
4 changes: 4 additions & 0 deletions src/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@
}
},
"users": {
"common": {
"canManageUsers": "Can manage users",
"canManageUsersHelperText": "Invite new users, edit and change passwords"
},
"list": {
"edit": "Edit",
"delete": "Delete",
Expand Down
70 changes: 70 additions & 0 deletions src/server/api/routers/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,39 @@ import bcrypt from 'bcrypt';

export const userRouter = createTRPCRouter({
getAll: protectedProcedure.query(async ({ctx}) => {
const currentUser = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.name! }
});

if (!currentUser?.canManageUsers) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to manage users',
});
}

return (await ctx.prisma.user.findMany()).map((user) => {
return {
id: user.id,
email: user.email,
created_at: user.created_at,
canManageUsers: user.canManageUsers,
};
});
}),
get: protectedProcedure.input(z.string().uuid()).query(async ({ctx, input}) => {
const currentUser = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.name! }
});

// Allow viewing own user or require canManageUsers permission
if (input !== ctx.session.user.name && !currentUser?.canManageUsers) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to manage users',
});
}

const user = await ctx.prisma.user.findUnique({
where: {
id: input,
Expand All @@ -31,21 +55,35 @@ export const userRouter = createTRPCRouter({
id: user.id,
email: user.email,
created_at: user.created_at,
canManageUsers: user.canManageUsers,
};
}),
add: protectedProcedure
.input(
z.object({
email: z.string(),
password: z.string(),
canManageUsers: z.boolean(),
}),
)
.mutation(async ({ctx, input}) => {
const currentUser = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.name! }
});

if (!currentUser?.canManageUsers) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to manage users',
});
}

return ctx.prisma.user.create({
data: {
email: input.email,
password: await bcrypt.hash(input.password, 10),
created_at: new Date(),
canManageUsers: input.canManageUsers,
},
});
}),
Expand All @@ -55,20 +93,52 @@ export const userRouter = createTRPCRouter({
id: z.string().uuid(),
email: z.string(),
password: z.string(),
canManageUsers: z.boolean(),
}),
)
.mutation(async ({ctx, input}) => {
const currentUser = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.name! }
});

if (!currentUser?.canManageUsers) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to manage users',
});
}

// Don't allow changing own manage users permission
if (input.id === ctx.session.user.name) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You cannot modify your own user management permissions',
});
}

return ctx.prisma.user.update({
where: {
id: input.id,
},
data: {
email: input.email,
canManageUsers: input.canManageUsers,
...(input.password ? {password: await bcrypt.hash(input.password, 10)} : {}), // Only update password if it's provided (otherwise it will be set to null
},
});
}),
delete: protectedProcedure.input(z.string().uuid()).mutation(async ({ctx, input}) => {
const currentUser = await ctx.prisma.user.findUnique({
where: { id: ctx.session.user.name! }
});

if (!currentUser?.canManageUsers) {
throw new TRPCError({
code: 'FORBIDDEN',
message: 'You do not have permission to manage users',
});
}

const user = await ctx.prisma.user.findUnique({
where: {
id: input,
Expand Down
1 change: 1 addition & 0 deletions src/server/api/trpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ const enforceUserIsAuthed = t.middleware(({ctx, next}) => {
if (!ctx.session || !ctx.session.user) {
throw new TRPCError({code: 'UNAUTHORIZED'});
}

return next({
ctx: {
// infers the `session` as non-nullable
Expand Down
Loading

0 comments on commit 535240f

Please sign in to comment.