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/components/TermsAndConditions.tsx b/frontend/src/components/TermsAndConditions.tsx new file mode 100644 index 000000000..b9c25dc47 --- /dev/null +++ b/frontend/src/components/TermsAndConditions.tsx @@ -0,0 +1,27 @@ +import { FunctionComponent } from 'react' +import CloseIcon from './alert/CloseIcon' + +interface Props { + close: () => void +} + +const TermsAndConditions: FunctionComponent = ({ close }) => { + return ( +
+
+ { + close() + }} + /> +
+
+

Terms & Conditions

+ Placeholder text for terms and conitions +
+
+ ) +} + +export default TermsAndConditions diff --git a/frontend/src/hooks/useUser.tsx b/frontend/src/hooks/useUser.tsx new file mode 100644 index 000000000..e5e61a4f1 --- /dev/null +++ b/frontend/src/hooks/useUser.tsx @@ -0,0 +1,23 @@ +import { headers, SERVER_URL, throwOnProblem } from './useAuth' + +const updateTermsAndConditions = ({ + id, + termsAndConditionsAcceptedAt, +}: { + id: number + termsAndConditionsAcceptedAt: Date +}) => + fetch(`${SERVER_URL}/user/termsandcond/${id}`, { + credentials: 'include', + method: 'PATCH', + headers: { + ...headers, + }, + body: JSON.stringify(termsAndConditionsAcceptedAt), + }).then(throwOnProblem(`Updating terms and conditions failed!`)) + +export const useUser = () => { + return { + updateTermsAndConditions, + } +} diff --git a/frontend/src/pages/groups/GroupCreatePage.tsx b/frontend/src/pages/groups/GroupCreatePage.tsx index f3263bdf2..7d116a5c5 100644 --- a/frontend/src/pages/groups/GroupCreatePage.tsx +++ b/frontend/src/pages/groups/GroupCreatePage.tsx @@ -1,6 +1,7 @@ import { FunctionComponent } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../../hooks/useAuth' +import { useUser } from '../../hooks/useUser' import LayoutWithNav from '../../layouts/LayoutWithNav' import { AllGroupsDocument, @@ -12,8 +13,9 @@ import { setEmptyFieldsToUndefined } from '../../utils/setEmptyFieldsToUndefined import GroupForm from './GroupForm' const GroupCreatePage: FunctionComponent = () => { - const { refreshMe } = useAuth() + const { refreshMe, me: profile } = useAuth() const navigate = useNavigate() + const user = useUser() const [addGroup, { loading: mutationIsLoading, error: mutationError }] = useCreateGroupMutation() @@ -30,6 +32,14 @@ const GroupCreatePage: FunctionComponent = () => { refetchQueries: [{ query: AllGroupsDocument }], }) + // updated user with termsAndConditionsAcceptedAt time + if (profile != undefined) { + user.updateTermsAndConditions({ + id: profile.id, + termsAndConditionsAcceptedAt: input.termsAndConditionsAcceptedAt, + }) + } + // Because we cache the association between a group captain and their // group, we need to refresh that association when they create their first // group. @@ -65,6 +75,7 @@ const GroupCreatePage: FunctionComponent = () => { onSubmit={onSubmit} isLoading={mutationIsLoading} submitButtonLabel="Create group" + renderTermsAndConditions={true} /> diff --git a/frontend/src/pages/groups/GroupEditPage.tsx b/frontend/src/pages/groups/GroupEditPage.tsx index 38682697f..727bf5d8f 100644 --- a/frontend/src/pages/groups/GroupEditPage.tsx +++ b/frontend/src/pages/groups/GroupEditPage.tsx @@ -75,6 +75,7 @@ const GroupEditPage: FunctionComponent = () => { submitButtonLabel="Save changes" onSubmit={onSubmit} defaultValues={originalGroupData} + renderTermsAndConditions={false} /> diff --git a/frontend/src/pages/groups/GroupForm.tsx b/frontend/src/pages/groups/GroupForm.tsx index c1936c976..6404fa61c 100644 --- a/frontend/src/pages/groups/GroupForm.tsx +++ b/frontend/src/pages/groups/GroupForm.tsx @@ -3,12 +3,14 @@ import { PropsWithChildren, ReactNode, useEffect, + useState, } from 'react' import { useForm } from 'react-hook-form' import Button from '../../components/Button' import SelectField from '../../components/forms/SelectField' import TextArea from '../../components/forms/TextArea' import TextField from '../../components/forms/TextField' +import TermsAndConditions from '../../components/TermsAndConditions' import { GROUP_TYPE_OPTIONS } from '../../data/constants' import { useAuth } from '../../hooks/useAuth' import { useCountries } from '../../hooks/useCountries' @@ -16,6 +18,7 @@ import { useRegions } from '../../hooks/useRegions' import { GroupCreateInput, GroupQuery, GroupType } from '../../types/api-types' import { formatRegion } from '../../utils/format' import { stripIdAndTypename } from '../../utils/types' +import TermsAndCondCheckbox from './TermsAndCondCheckbox' interface Props { /** @@ -35,6 +38,11 @@ interface Props { * The callback triggered when the user submits the form */ onSubmit: (input: GroupCreateInput) => void + /** + * If true, checkbox for terms and conditions will be displayed + * We don't want to display checkbox terms and conditions on update page + */ + renderTermsAndConditions: boolean } /** @@ -44,6 +52,10 @@ const GroupForm: FunctionComponent> = (props) => { const { me: profile } = useAuth() const countries = useCountries() const regions = useRegions() + const [showTermsAndCond, setShowTermsAndCond] = useState(false) + const [timeTcChecked, setTimeTcChecked] = useState(null) + + console.log(profile) const { register, @@ -73,6 +85,10 @@ const GroupForm: FunctionComponent> = (props) => { input.groupType = GroupType.Regular } + if (timeTcChecked !== null) { + //input.termsAndConditionsAcceptedAt = timeTcChecked + } + props.onSubmit({ ...input, // FIXME: for some reasons, servingRegions will be `false` in no item is selected @@ -81,8 +97,32 @@ const GroupForm: FunctionComponent> = (props) => { }) }) + function handleTcChange() { + if (timeTcChecked === null) { + setTimeTcChecked(new Date()) + } else { + setTimeTcChecked(null) + } + } + + let termsAndConditions + if (props.renderTermsAndConditions) { + termsAndConditions = ( + + ) + } else { + termsAndConditions = null + } + return (
+ {showTermsAndCond ? ( + setShowTermsAndCond(false)} /> + ) : null}
> = (props) => { register={register} errors={errors} /> + {termsAndConditions}
@@ -199,7 +240,10 @@ const GroupForm: FunctionComponent> = (props) => { variant="primary" type="submit" className="mt-6" - disabled={props.isLoading} + disabled={ + props.isLoading || + (timeTcChecked === null && props.renderTermsAndConditions) + } > {props.submitButtonLabel} diff --git a/frontend/src/pages/groups/TermsAndCondCheckbox.tsx b/frontend/src/pages/groups/TermsAndCondCheckbox.tsx new file mode 100644 index 000000000..ebaea7b9a --- /dev/null +++ b/frontend/src/pages/groups/TermsAndCondCheckbox.tsx @@ -0,0 +1,33 @@ +import { FunctionComponent } from 'react' +import CheckboxField from '../../components/forms/CheckboxField' + +interface Props { + timeTcChecked: Date | null + handleTcChange: () => void + setShowTermsAndCond: (tc: boolean) => void +} + +const TermsAndCondCheckbox: FunctionComponent = ({ + timeTcChecked, + handleTcChange, + setShowTermsAndCond, +}) => { + return ( + + ) +} + +export default TermsAndCondCheckbox 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)) + }) +})