diff --git a/db/migrations/001-t&c.ts b/db/migrations/001-t&c.ts new file mode 100644 index 000000000..d1928d4bf --- /dev/null +++ b/db/migrations/001-t&c.ts @@ -0,0 +1,19 @@ +import { DataTypes, QueryInterface } from 'sequelize' + +export const up = async (queryInterface: QueryInterface) => { + await queryInterface.addColumn( + `UserAccounts`, + 'termsAndConditionsAcceptedAt', + { + allowNull: true, + type: DataTypes.TIME, + }, + ) +} + +export const down = async (queryInterface: QueryInterface) => { + await queryInterface.removeColumn( + `UserAccounts`, + 'termsAndConditionsAcceptedAt', + ) +} diff --git a/frontend/src/pages/groups/TermsAndCondCheckbox.tsx b/frontend/src/pages/groups/TermsAndCondCheckbox.tsx index b79c9f778..ebaea7b9a 100644 --- a/frontend/src/pages/groups/TermsAndCondCheckbox.tsx +++ b/frontend/src/pages/groups/TermsAndCondCheckbox.tsx @@ -24,7 +24,7 @@ const TermsAndCondCheckbox: FunctionComponent = ({ className="cursor-pointer text-blue-500" onClick={() => setShowTermsAndCond(true)} > -  I accept terms and conditions + I accept terms and conditions ) diff --git a/schema.graphql b/schema.graphql index 6c3aee32b..d4ea10722 100644 --- a/schema.graphql +++ b/schema.graphql @@ -42,6 +42,7 @@ input GroupCreateInput { primaryContact: ContactInfoInput! website: String servingRegions: [ID!] + termsAndConditionsAcceptedAt: DateTime } input GroupUpdateInput { diff --git a/src/models/user_account.ts b/src/models/user_account.ts index bb80de939..5761839aa 100644 --- a/src/models/user_account.ts +++ b/src/models/user_account.ts @@ -20,6 +20,7 @@ export interface UserAccountAttributes { isAdmin?: boolean isConfirmed?: boolean name: string + termsAndConditionsAcceptedAt?: Date } export interface UserAccountCreationAttributes @@ -57,6 +58,9 @@ export default class UserAccount extends Model< @Column public isConfirmed!: boolean + @Column + public termsAndConditionsAcceptedAt!: Date + @CreatedAt @Column public readonly createdAt!: Date diff --git a/src/routes/user/update.ts b/src/routes/user/update.ts index 559ab27fd..54c4bc1a8 100644 --- a/src/routes/user/update.ts +++ b/src/routes/user/update.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express' import { AuthContext } from '../../authenticateRequest' import { errorsToProblemDetail } from '../../input-validation/errorsToProblemDetail' import { validateIdInput } from '../../input-validation/idInputSchema' +import { DateTime } from '../../input-validation/types' import { validateWithJSONSchema } from '../../input-validation/validateWithJSONSchema' import UserAccount from '../../models/user_account' import { HTTPStatusCode } from '../../rest/response/HttpStatusCode' @@ -15,6 +16,7 @@ const adminUpdateUserInput = Type.Object( isAdmin: Type.Optional(Type.Boolean()), email: Type.Optional(emailInput), name: Type.Optional(Type.String({ minLength: 1, maxLength: 255 })), + termsAndConditionsAcceptedAt: Type.Optional(DateTime), }, { additionalProperties: false }, ) @@ -51,7 +53,78 @@ export const adminUpdateUser = async (request: Request, response: Response) => { status: HTTPStatusCode.NotFound, }) - await user.update(validInput.value) + let input + if (validInput.value.termsAndConditionsAcceptedAt != undefined) { + input = { + ...validInput.value, + termsAndConditionsAcceptedAt: new Date( + validInput.value.termsAndConditionsAcceptedAt, + ), + } + } else { + input = { + ...validInput.value, + termsAndConditionsAcceptedAt: undefined, + } + } + + await user.update(input) + + return response.status(HTTPStatusCode.NoContent).end() +} + +const updateUserTermsAndCondInput = Type.Object( + { + termsAndConditionsAcceptedAt: DateTime, + }, + { additionalProperties: false }, +) + +const validateUpdateUserTermsAndCondInput = validateWithJSONSchema( + updateUserTermsAndCondInput, +) + +export const updateTermsAndConditions = async ( + request: Request, + response: Response, +) => { + const id = parseInt(request.params.id, 10) + // Validate user id and update input + const validId = validateIdInput({ id }) + if ('errors' in validId) { + return respondWithProblem(response, errorsToProblemDetail(validId.errors)) + } + + const validInput = validateUpdateUserTermsAndCondInput(request.body) + if ('errors' in validInput) { + return respondWithProblem( + response, + errorsToProblemDetail(validInput.errors), + ) + } + + // Check if user updates itself terms and conditions + const authContext = request.user as AuthContext + if (authContext.userId != id) { + return response.sendStatus(HTTPStatusCode.Forbidden).end() + } + + // Find user + const user = await UserAccount.findByPk(validId.value.id) + if (user === null) { + return respondWithProblem(response, { + title: `User with id ${validId.value.id} not found!`, + status: HTTPStatusCode.NotFound, + }) + } + + let input = { + termsAndConditionsAcceptedAt: new Date( + validInput.value.termsAndConditionsAcceptedAt, + ), + } + + await user.update(input) return response.status(HTTPStatusCode.NoContent).end() } diff --git a/src/server/feat/backend.ts b/src/server/feat/backend.ts index adcd891f7..47197f311 100644 --- a/src/server/feat/backend.ts +++ b/src/server/feat/backend.ts @@ -16,7 +16,10 @@ import setNewPasswordUsingTokenAndEmail from '../../routes/password/new' import sendVerificationTokenByEmail from '../../routes/password/token' import registerUser from '../../routes/register' import confirmRegistrationByEmail from '../../routes/register/confirm' -import { adminUpdateUser } from '../../routes/user/update' +import { + adminUpdateUser, + updateTermsAndConditions, +} from '../../routes/user/update' import { adminListUsers } from '../../routes/users' import sendShipmentExportCsv from '../../sendShipmentExportCsv' import { addRequestId } from '../addRequestId' @@ -90,6 +93,7 @@ export const backend = ({ app.get('/users', cookieAuth, adminListUsers) app.patch('/user/:id', cookieAuth, adminUpdateUser) + app.patch('/user/termsandcond/:id', cookieAuth, updateTermsAndConditions) app.use(compression()) diff --git a/src/tests/updateUser.test.ts b/src/tests/updateUser.test.ts new file mode 100644 index 000000000..a13f2d868 --- /dev/null +++ b/src/tests/updateUser.test.ts @@ -0,0 +1,114 @@ +import { json } from 'body-parser' +import cookieParser from 'cookie-parser' +import express, { Express } from 'express' +import { createServer, Server } from 'http' +import passport from 'passport' +import request, { SuperTest, Test } from 'supertest' +import { v4 } from 'uuid' +import { + authCookie as getAuthCookie, + authCookieName, + cookieAuthStrategy, +} from '../authenticateRequest' +import UserAccount from '../models/user_account' +import { HTTPStatusCode } from '../rest/response/HttpStatusCode' +import login from '../routes/login' +import { hashPassword } from '../routes/register' +import { updateTermsAndConditions } from '../routes/user/update' +import { tokenCookieRx } from './helpers/auth' + +jest.setTimeout(15 * 1000) + +const cookieAuth = passport.authenticate('cookie', { session: false }) +passport.use(cookieAuthStrategy) + +const password = '2DhE.sf!f9Z3u8x' + +describe('User update API', () => { + let app: Express + let httpServer: Server + let r: SuperTest + + const getExpressCookie = getAuthCookie(1800) + + beforeAll(async () => { + app = express() + app.use(cookieParser(process.env.COOKIE_SECRET ?? 'cookie-secret')) + app.use(json()) + app.use(passport.initialize()) + app.post('/auth/login', login(getExpressCookie)) + app.patch('/user/termsandcond/:id', cookieAuth, updateTermsAndConditions) + httpServer = createServer(app) + await new Promise((resolve) => + httpServer.listen(8888, '127.0.0.1', undefined, resolve), + ) + r = request('http://127.0.0.1:8888') + }) + afterAll(async () => { + httpServer.close() + }) + + const getCookieForUserAccount = async ({ + email, + password, + }: { + email: string + password: string + }) => { + const res = await r + .post('/auth/login') + .send({ + email, + password, + }) + .expect(HTTPStatusCode.NoContent) + .expect('set-cookie', tokenCookieRx) + + return tokenCookieRx.exec(res.header['set-cookie'])?.[1] as string + } + + const userEmail = `some-user${v4()}@example.com` + let userId: number + + /** Create and log in admin */ + beforeAll(async () => { + await UserAccount.create({ + email: userEmail, + isAdmin: false, // not an admin + name: 'Some User', + passwordHash: hashPassword(password, 1), + isConfirmed: true, + }) + + const user = await UserAccount.findOneByEmail(userEmail) + if (user?.id != undefined) { + userId = user?.id + } + }) + + describe('users should be allowed to update its termsAndConditionsAcceptedAt', () => { + let userAuthCookie: string + beforeAll(async () => { + userAuthCookie = await getCookieForUserAccount({ + email: userEmail, + password, + }) + }) + test('probaj-ovo', () => + r + .patch(`/user/termsandcond/${userId as number}`) + .set('Cookie', [`${authCookieName}=${userAuthCookie}`]) + .send({ + termsAndConditionsAcceptedAt: new Date(), + }) + .expect(HTTPStatusCode.NoContent)) + test('users should not be allowed to update other users termsAndConditionsAcceptedAt', () => + r + .patch('/user/termsandcond/999') + .set('Cookie', [`${authCookieName}=${userAuthCookie}`]) + .send({ + termsAndConditionsAcceptedAt: new Date(), + }) + .expect(HTTPStatusCode.Forbidden)) + }) +})