Skip to content

Commit

Permalink
feat(api): support name/custom fields in jwt user creation
Browse files Browse the repository at this point in the history
  • Loading branch information
lukashroch committed Jun 26, 2024
1 parent 02e7c9e commit d2c0cf4
Show file tree
Hide file tree
Showing 13 changed files with 157 additions and 59 deletions.
4 changes: 2 additions & 2 deletions apps/admin/src/views/surveys/read.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@
</tr>
<tr>
<th>{{ $t('surveys.search.sortingAlgorithm') }}</th>
<td>{{ $t(`surveys.search.algorithms.${entry.searchSortingAlgorithm}`) }}</td>
<td>{{ $t(`surveys.search.algorithms.${entry.searchSettings.sortingAlgorithm}`) }}</td>
<th>{{ $t('surveys.search.matchScoreWeight') }}</th>
<td>{{ entry.searchMatchScoreWeight }}</td>
<td>{{ entry.searchSettings.matchScoreWeight }}</td>
</tr>
</tbody>
</v-simple-table>
Expand Down
12 changes: 6 additions & 6 deletions apps/api/__tests__/integration/admin/surveys/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,12 @@ export default () => {
// describe('POST /api/admin/surveys/:surveyId/respondents/:username/feedback', emailFeedback);

// Surveys respondents custom fields
describe('get /api/admin/surveys/:surveyId/respondents/:username/fields', respondentCustomFields.browse);
describe('post /api/admin/surveys/:surveyId/respondents/:username/fields', respondentCustomFields.store);
describe('get /api/admin/surveys/:surveyId/respondents/:username/fields/:field', respondentCustomFields.read);
describe('patch /api/admin/surveys/:surveyId/respondents/:username/fields/:field', respondentCustomFields.update);
describe('put /api/admin/surveys/:surveyId/respondents/:username/fields/:field', respondentCustomFields.upsert);
describe('delete /api/admin/surveys/:surveyId/respondents/:username/fields/:field', respondentCustomFields.destroy);
describe('get /api/admin/surveys/:surveyId/respondents/:username/custom-fields', respondentCustomFields.browse);
describe('post /api/admin/surveys/:surveyId/respondents/:username/custom-fields', respondentCustomFields.store);
describe('get /api/admin/surveys/:surveyId/respondents/:username/custom-fields/:field', respondentCustomFields.read);
describe('patch /api/admin/surveys/:surveyId/respondents/:username/custom-fields/:field', respondentCustomFields.update);
describe('put /api/admin/surveys/:surveyId/respondents/:username/custom-fields/:field', respondentCustomFields.upsert);
describe('delete /api/admin/surveys/:surveyId/respondents/:username/custom-fields/:field', respondentCustomFields.destroy);

// Surveys submissions
describe('get /api/admin/surveys/:surveyId/submissions', submissions.browse);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ export default () => {

respondent = await ioc.cradle.adminSurveyService.createRespondent(survey.id, mocker.system.respondent());

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/invalid-username/fields`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/invalid-username/custom-fields`;
});

it('missing authentication / authorization', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export default () => {
);
const field = respondentInput.customFields!.at(0)!.name;

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${field}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/fields/${field}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/fields/${field}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/invalid-field`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${field}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/custom-fields/${field}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/custom-fields/${field}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/invalid-field`;
});

it('missing authentication / authorization', async () => {
Expand Down Expand Up @@ -85,7 +85,7 @@ export default () => {
await suite.util.setSecurable({ ...securable, action: ['respondents'] });

const field2 = respondentInput.customFields!.at(1)!.name;
const url2 = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${field2}`;
const url2 = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${field2}`;

await suite.sharedTests.assertRecordDeleted('delete', url2);
});
Expand All @@ -95,7 +95,7 @@ export default () => {
await survey.update({ ownerId: suite.data.system.user.id });

const field3 = respondentInput.customFields!.at(2)!.name;
const url3 = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${field3}`;
const url3 = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${field3}`;

await suite.sharedTests.assertRecordDeleted('delete', url3);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@ export default () => {
respondent = await ioc.cradle.adminSurveyService.createRespondent(survey.id, input);
output = input.customFields!.at(0)!;

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${output.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/fields/${output.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/fields/${output.name}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/invalid-field`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${output.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/custom-fields/${output.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/custom-fields/${output.name}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/invalid-field`;
});

it('missing authentication / authorization', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export default () => {
input = mocker.system.customField();
output = { ...input };

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/fields`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/fields`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/custom-fields`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/custom-fields`;
});

it('missing authentication / authorization', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,10 @@ export default () => {
respondent = await ioc.cradle.adminSurveyService.createRespondent(survey.id, respondentInput);
input = { ...mocker.system.customField(), name: respondentInput.customFields!.at(0)!.name };

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${input.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/fields/${input.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/fields/${input.name}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/invalid-field`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${input.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/custom-fields/${input.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/custom-fields/${input.name}`;
invalidUrl = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/invalid-field`;
});

it('missing authentication / authorization', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ export default () => {
input = mocker.system.customField();
output = { ...input };

url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/fields/${input.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/fields/${input.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/fields/${input.name}`;
url = `${baseUrl}/${survey.id}/respondents/${respondent.username}/custom-fields/${input.name}`;
invalidSurveyUrl = `${baseUrl}/999999/respondents/${respondent.username}/custom-fields/${input.name}`;
invalidRespondentUrl = `${baseUrl}/${survey.id}/respondents/999999/custom-fields/${input.name}`;
});

it('missing authentication / authorization', async () => {
Expand Down
68 changes: 64 additions & 4 deletions apps/api/__tests__/integration/surveys/create-user.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ export default () => {

describe('for correct survey settings', () => {
beforeAll(async () => {
await suite.data.system.survey.update({ allowGenUsers: true, genUserKey: secret });
await suite.data.system.survey.update({
allowGenUsers: true,
genUserKey: secret,
userCustomFields: false,
userPersonalIdentifiers: false,
});
});

it(`should return 403 for invalid JWT secret`, async () => {
Expand Down Expand Up @@ -92,12 +97,14 @@ export default () => {
});

it(`should return 400 for invalid input data`, async () => {
await suite.sharedTests.assertInvalidInput('post', url, ['username', 'password', 'redirectUrl'], {
await suite.sharedTests.assertInvalidInput('post', url, ['username', 'password', 'name', 'customFields', 'redirectUrl'], {
input: {
token: jwt.sign({
username: false,
name: ['name'],
password: 'weak',
redirectUrl: 'not-url',
customFields: { field: 'value' },
}, secret, { expiresIn: '5m' }),
},
});
Expand All @@ -124,7 +131,7 @@ export default () => {
});

it('should return 200 and respondent record (with password)', async () => {
const tokenWithPassword = jwt.sign({
const token = jwt.sign({
username: 'userIdentifier002',
password: 'aPassword132456',
redirectUrl: 'https://redirect-me.here',
Expand All @@ -133,10 +140,63 @@ export default () => {
const { status, body } = await request(suite.app)
.post(url)
.set('Accept', 'application/json')
.send({ token: tokenWithPassword });
.send({ token });

expect(status).toBe(200);
expect(body).toContainAllKeys(['userId', 'username', 'authToken', 'redirectUrl']);
});

it('should return 200 and respondent record without name & custom fields', async () => {
const token = jwt.sign({
username: 'userIdentifier002',
password: 'aPassword132456',
name: 'myName',
customFields: [{ name: 'field01', value: 'value01' }],
}, secret, { expiresIn: '15m' });

const { status, body } = await request(suite.app)
.post(url)
.set('Accept', 'application/json')
.send({ token });

expect(status).toBe(200);
expect(body).toContainAllKeys(['userId', 'username', 'authToken']);
});

it('should return 200 and respondent record with name', async () => {
await suite.data.system.survey.update({ userCustomFields: false, userPersonalIdentifiers: true });

const token = jwt.sign({
username: 'userIdentifier002',
name: 'myName',
customFields: [{ name: 'field01', value: 'value01' }],
}, secret, { expiresIn: '15m' });

const { status, body } = await request(suite.app)
.post(url)
.set('Accept', 'application/json')
.send({ token });

expect(status).toBe(200);
expect(body).toContainAllKeys(['userId', 'username', 'authToken', 'name']);
});

it('should return 200 and respondent record with custom fields', async () => {
await suite.data.system.survey.update({ userCustomFields: true, userPersonalIdentifiers: false });

const token = jwt.sign({
username: 'userIdentifier002',
name: 'myName',
customFields: [{ name: 'field01', value: 'value01' }],
}, secret, { expiresIn: '15m' });

const { status, body } = await request(suite.app)
.post(url)
.set('Accept', 'application/json')
.send({ token });

expect(status).toBe(200);
expect(body).toContainAllKeys(['userId', 'username', 'authToken', 'customFields']);
});
});
};
54 changes: 44 additions & 10 deletions apps/api/src/services/survey/survey.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { z, ZodError } from 'zod';

import type { IoC } from '@intake24/api/ioc';
import type { Prompts } from '@intake24/common/prompts';
import type { JobParams, SurveyState } from '@intake24/common/types';
import type {
CreateUserResponse,
SurveyRatingRequest,
Expand All @@ -16,6 +15,7 @@ import type { FindOptions, Includeable, SubmissionScope } from '@intake24/db';
import { ForbiddenError, NotFoundError } from '@intake24/api/http/errors';
import { jwt } from '@intake24/api/util';
import { strongPassword } from '@intake24/common/schemas';
import { customField, type JobParams, type SurveyState } from '@intake24/common/types';
import { randomString } from '@intake24/common/util';
import {
GenUserCounter,
Expand All @@ -36,9 +36,10 @@ export type RespondentWithPassword = {

function surveyService({
adminSurveyService,
adminUserService,
logger: globalLogger,
scheduler,
}: Pick<IoC, 'adminSurveyService' | 'logger' | 'scheduler'>) {
}: Pick<IoC, 'adminSurveyService' | 'adminUserService' | 'logger' | 'scheduler'>) {
const logger = globalLogger.child({ service: 'SurveyService' });

/**
Expand Down Expand Up @@ -115,28 +116,61 @@ function surveyService({

try {
const decoded = await jwt.verify(token, genUserKey, { algorithms: ['HS256', 'HS512'] });
const { name, password, username, redirectUrl } = z
const { name, password, username, redirectUrl, customFields } = z
.object({
username: z.string().min(1).max(256),
password: strongPassword.optional(),
redirectUrl: z.string().url().optional(),
name: z.string().max(512).optional().transform(val => val || undefined),
name: z.string().min(1).max(512).nullish(),
customFields: customField.array().optional(),
})
.parse(decoded);

const alias = await UserSurveyAlias.findOne({ where: { surveyId, username } });
if (alias) {
const { userId, urlAuthToken: authToken } = alias;
const alias = await UserSurveyAlias.findOne(
{
include: [{
association: 'user',
attributes: ['id', 'name'],
required: true,
include: [{ association: 'customFields', attributes: ['name', 'value'] }],
}],
where: { surveyId, username },
},
);

if (alias?.user) {
const { userId, urlAuthToken: authToken, user } = alias;

const payload: CreateUserResponse = { userId, username, authToken, redirectUrl };

if (survey.userPersonalIdentifiers) {
await user.update({ name });
payload.name = name ?? user.name;
}

return { userId, username, authToken, redirectUrl };
if (survey.userCustomFields) {
if (customFields && user.customFields)
await adminUserService.updateUserCustomFields(userId, user.customFields, customFields);

payload.customFields = customFields ?? user.customFields;
}

return payload;
}

const { userId, urlAuthToken: authToken } = await adminSurveyService.createRespondent(
survey,
{ username, password, name },
{ username, password, name, customFields },
);

return { userId, username, authToken, redirectUrl };
const payload: CreateUserResponse = { userId, username, authToken, redirectUrl };
if (survey.userPersonalIdentifiers)
payload.name = name;

if (survey.userCustomFields)
payload.customFields = customFields;

return payload;
}
catch (err) {
if (err instanceof ZodError)
Expand Down
18 changes: 10 additions & 8 deletions docs/guides/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,24 @@ Tokens without standard claims limit their identifiability and may pose increase

- `username` - Unique respondent username within the survey
- `password` (optional) - password for `username:password` login, can be omitted if only authentication URL is intended to be used
- `redirectUrl` (optional) - redirect URL for user redirection after recall completion
- `name` (optional) - user's name for personalisation
- `customFields` (optional) - user's custom fields
- `redirectUrl` (optional) - redirect URL for user redirection after recall completion

#### JWT payload

```json
{
"iat": number,
"exp": number,
"aud": string,
"sub": string,
"iss": string,
"iat"?: number,
"exp"?: number,
"aud"?: string | string[],
"sub"?: string,
"iss"?: string,
"username": string,
"password"?: string
"password"?: string,
"name"?: string,
"customFields"?: [{"name": string, "value": string}],
"redirectUrl"?: string
"name"?: string
}
```

Expand Down
Loading

0 comments on commit d2c0cf4

Please sign in to comment.