diff --git a/apps/backend/config/sync/admin-role.strapi-super-admin.json b/apps/backend/config/sync/admin-role.strapi-super-admin.json index 954adf13a..5b79696ca 100644 --- a/apps/backend/config/sync/admin-role.strapi-super-admin.json +++ b/apps/backend/config/sync/admin-role.strapi-super-admin.json @@ -21,7 +21,8 @@ "codeinjection_head", "codeinjection_foot", "slug_id" - ] + ], + "locales": ["en"] }, "conditions": [] }, @@ -29,14 +30,18 @@ "action": "plugin::content-manager.explorer.delete", "actionParameters": {}, "subject": "api::post.post", - "properties": {}, + "properties": { + "locales": ["en"] + }, "conditions": [] }, { "action": "plugin::content-manager.explorer.publish", "actionParameters": {}, "subject": "api::post.post", - "properties": {}, + "properties": { + "locales": ["en"] + }, "conditions": [] }, { @@ -57,7 +62,8 @@ "codeinjection_head", "codeinjection_foot", "slug_id" - ] + ], + "locales": ["en"] }, "conditions": [] }, @@ -79,7 +85,8 @@ "codeinjection_head", "codeinjection_foot", "slug_id" - ] + ], + "locales": ["en"] }, "conditions": [] }, @@ -483,6 +490,34 @@ "properties": {}, "conditions": [] }, + { + "action": "plugin::i18n.locale.create", + "actionParameters": {}, + "subject": null, + "properties": {}, + "conditions": [] + }, + { + "action": "plugin::i18n.locale.delete", + "actionParameters": {}, + "subject": null, + "properties": {}, + "conditions": [] + }, + { + "action": "plugin::i18n.locale.read", + "actionParameters": {}, + "subject": null, + "properties": {}, + "conditions": [] + }, + { + "action": "plugin::i18n.locale.update", + "actionParameters": {}, + "subject": null, + "properties": {}, + "conditions": [] + }, { "action": "plugin::upload.assets.copy-link", "actionParameters": {}, diff --git a/apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##i18n.locale.json b/apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##i18n.locale.json new file mode 100644 index 000000000..617fa5b5b --- /dev/null +++ b/apps/backend/config/sync/core-store.plugin_content_manager_configuration_content_types##plugin##i18n.locale.json @@ -0,0 +1,129 @@ +{ + "key": "plugin_content_manager_configuration_content_types::plugin::i18n.locale", + "value": { + "uid": "plugin::i18n.locale", + "settings": { + "bulkable": true, + "filterable": true, + "searchable": true, + "pageSize": 10, + "mainField": "name", + "defaultSortBy": "name", + "defaultSortOrder": "ASC" + }, + "metadatas": { + "id": { + "edit": {}, + "list": { + "label": "id", + "searchable": true, + "sortable": true + } + }, + "name": { + "edit": { + "label": "name", + "description": "", + "placeholder": "", + "visible": true, + "editable": true + }, + "list": { + "label": "name", + "searchable": true, + "sortable": true + } + }, + "code": { + "edit": { + "label": "code", + "description": "", + "placeholder": "", + "visible": true, + "editable": true + }, + "list": { + "label": "code", + "searchable": true, + "sortable": true + } + }, + "createdAt": { + "edit": { + "label": "createdAt", + "description": "", + "placeholder": "", + "visible": false, + "editable": true + }, + "list": { + "label": "createdAt", + "searchable": true, + "sortable": true + } + }, + "updatedAt": { + "edit": { + "label": "updatedAt", + "description": "", + "placeholder": "", + "visible": false, + "editable": true + }, + "list": { + "label": "updatedAt", + "searchable": true, + "sortable": true + } + }, + "createdBy": { + "edit": { + "label": "createdBy", + "description": "", + "placeholder": "", + "visible": false, + "editable": true, + "mainField": "firstname" + }, + "list": { + "label": "createdBy", + "searchable": true, + "sortable": true + } + }, + "updatedBy": { + "edit": { + "label": "updatedBy", + "description": "", + "placeholder": "", + "visible": false, + "editable": true, + "mainField": "firstname" + }, + "list": { + "label": "updatedBy", + "searchable": true, + "sortable": true + } + } + }, + "layouts": { + "list": ["id", "name", "code", "createdAt"], + "edit": [ + [ + { + "name": "name", + "size": 6 + }, + { + "name": "code", + "size": 6 + } + ] + ] + } + }, + "type": "object", + "environment": null, + "tag": null +} diff --git a/apps/backend/config/sync/user-role.authenticated.json b/apps/backend/config/sync/user-role.authenticated.json index 3228601f4..9763115b7 100644 --- a/apps/backend/config/sync/user-role.authenticated.json +++ b/apps/backend/config/sync/user-role.authenticated.json @@ -60,6 +60,9 @@ { "action": "plugin::users-permissions.auth.changePassword" }, + { + "action": "plugin::users-permissions.auth.invitation" + }, { "action": "plugin::users-permissions.role.find" }, diff --git a/apps/backend/src/extensions/users-permissions/strapi-server.js b/apps/backend/src/extensions/users-permissions/strapi-server.js index 63e564d1f..c548de5a5 100644 --- a/apps/backend/src/extensions/users-permissions/strapi-server.js +++ b/apps/backend/src/extensions/users-permissions/strapi-server.js @@ -1,3 +1,5 @@ +const DASHBOARD_URL = process.env.DASHBOARD_URL ?? "http://localhost:3000"; + module.exports = (plugin) => { plugin.controllers.user.updateMe = async (ctx) => { if (!ctx.state.user || !ctx.state.user.id) { @@ -25,6 +27,7 @@ module.exports = (plugin) => { }); }; + // TODO: find out if unshift is necessary or push will work. plugin.routes["content-api"].routes.unshift({ method: "PUT", path: "/users/me", @@ -35,5 +38,41 @@ module.exports = (plugin) => { }, }); + plugin.controllers.auth.invitation = async (ctx) => { + if (!ctx.state.user || !ctx.state.user.id) { + return (ctx.response.status = 401); + } + + const { email } = await strapi + .query("plugin::users-permissions.user") + .update({ + where: { id: ctx.request.params.id }, + data: { + provider: "auth0", + password: null, + }, + }); + + await strapi.plugins.email.services.email.send({ + to: email, + from: "support@freecodecamp.org", + subject: "Invitation Link", + text: `Here is your invitation link: ${DASHBOARD_URL}/api/auth/signin`, + }); + + ctx.response.status = 200; + ctx.response.body = { + status: "success", + }; + }; + plugin.routes["content-api"].routes.unshift({ + method: "PUT", + path: "/auth/invitation/:id", + handler: "auth.invitation", + config: { + prefix: "", + policies: [], + }, + }); return plugin; }; diff --git a/apps/backend/tests/app.test.js b/apps/backend/tests/app.test.js index b6e6899e6..b4f93de0c 100644 --- a/apps/backend/tests/app.test.js +++ b/apps/backend/tests/app.test.js @@ -26,5 +26,6 @@ it("strapi is defined", () => { }); require("./user"); +require("./auth"); require("./custom-post"); require("./post"); diff --git a/apps/backend/tests/auth/index.js b/apps/backend/tests/auth/index.js new file mode 100644 index 000000000..246693051 --- /dev/null +++ b/apps/backend/tests/auth/index.js @@ -0,0 +1,129 @@ +"use strict"; +const request = require("supertest"); +const { + getUser, + getUserJWT, + deleteUser, + getAllRoles, + getUserByRole, +} = require("../helpers/helpers"); + +// user mock data +const mockUserData = { + username: "tester", + email: "tester@strapi.com", + provider: "local", + password: "1234abc", + confirmed: true, + blocked: null, +}; + +describe("auth", () => { + describe("invitation", () => { + let mockUser; + let sendEmailSpy; + let editorToken; + + beforeEach(async () => { + sendEmailSpy = jest + .spyOn(strapi.plugins.email.services.email, "send") + .mockImplementation(jest.fn()); + mockUser = + await strapi.plugins["users-permissions"].services.user.add( + mockUserData, + ); + editorToken = await getUserJWT("editor-user"); + }); + + afterEach(() => { + deleteUser(mockUserData.username); + jest.clearAllMocks(); + }); + + it("should set the user's provider to auth0 and delete the password", async () => { + // There are subtle differences between what services.user.add and + // getUser return, so we use getUser for a fair comparison. + const user = await getUser(mockUserData.username); + + const res = await request(strapi.server.httpServer) + .put("/api/auth/invitation/" + user.id) + .auth(editorToken, { type: "bearer" }); + + expect(res.body).toEqual({ status: "success" }); + expect(res.status).toEqual(200); + const updatedUser = await getUser(user.username); + expect(updatedUser).toEqual({ + ...user, + provider: "auth0", + updatedAt: updatedUser.updatedAt, + password: null, + }); + }); + + it("should email the invited user", async () => { + const res = await request(strapi.server.httpServer) + .put("/api/auth/invitation/" + mockUser.id) + .auth(editorToken, { type: "bearer" }); + + expect(res.body).toEqual({ status: "success" }); + expect(res.status).toEqual(200); + expect(sendEmailSpy).toHaveBeenCalledTimes(1); + expect(sendEmailSpy).toHaveBeenCalledWith({ + to: mockUserData.email, + from: "support@freecodecamp.org", + subject: "Invitation Link", + text: `Here is your invitation link: http://localhost:3000/api/auth/signin`, + }); + }); + + // This should happen irrespective of what we do with their password. i.e. + // we null it, but even if we didn't they should not be able to login. + it("should prevent email password login", async () => { + const loginResponse = await request(strapi.server.httpServer) + .post("/api/auth/local") + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); + expect(loginResponse.status).toEqual(200); + + await request(strapi.server.httpServer) + .put("/api/auth/invitation/" + mockUser.id) + .auth(editorToken, { type: "bearer" }); + + const deniedLoginResponse = await request(strapi.server.httpServer) + .post("/api/auth/local") + .send({ + identifier: mockUserData.email, + password: mockUserData.password, + }); + + expect(deniedLoginResponse.body.error.message).toEqual( + "Invalid identifier or password", + ); + expect(deniedLoginResponse.status).toEqual(400); + }); + + // There is only one role for now, but this is designed to make sure we get + // warned before allowing new roles to invite users. + it("should reject requests from other role(s)", async () => { + const allRoles = await getAllRoles(); + const forbiddenRoles = allRoles.filter( + ({ type }) => type !== "authenticated", + ); + + for (const role of forbiddenRoles) { + const user = await getUserByRole(role.id); + const token = await getUserJWT(user.username); + const res = await request(strapi.server.httpServer) + .put("/api/auth/invitation/" + mockUser.id) + .auth(token, { type: "bearer" }); + if (res.status !== 403) { + throw new Error( + `Expected ${role.name} to be forbidden but it returned status: ${res.status}`, + ); + } + } + }); + }); +}); diff --git a/apps/backend/tests/helpers/helpers.js b/apps/backend/tests/helpers/helpers.js index 41f356f04..0f9897909 100644 --- a/apps/backend/tests/helpers/helpers.js +++ b/apps/backend/tests/helpers/helpers.js @@ -10,6 +10,22 @@ const getUser = async (username) => { } }; +const getUserByRole = async (roleId) => + await strapi.db + .query("plugin::users-permissions.user") + .findOne({ where: { role: roleId }, populate: ["role"] }); + +const deleteUser = async (username) => { + try { + return await strapi.db.query("plugin::users-permissions.user").delete({ + where: { username }, + }); + } catch (e) { + console.error(e); + throw new Error(`Failed to delete User for ${username}`); + } +}; + const getPost = async (slug) => { try { return await strapi.db.query("api::post.post").findOne({ @@ -47,9 +63,17 @@ const getRoleId = async (roleName) => { } }; +const getAllRoles = async () => + await strapi.db.query("plugin::users-permissions.role").findMany({ + where: { $not: { type: "public" } }, + }); + module.exports = { + deleteUser, getUser, getPost, getUserJWT, getRoleId, + getUserByRole, + getAllRoles, }; diff --git a/apps/backend/tests/user/index.js b/apps/backend/tests/user/index.js index d0cd96c35..f8feacd04 100644 --- a/apps/backend/tests/user/index.js +++ b/apps/backend/tests/user/index.js @@ -1,4 +1,9 @@ const request = require("supertest"); +const { + deleteUser, + getUserByRole, + getAllRoles, +} = require("../helpers/helpers"); // user mock data const mockUserData = { @@ -13,6 +18,9 @@ const mockUserData = { describe("user", () => { // Example test taken from https://docs.strapi.io/dev-docs/testing // This test should pass if the test environment is set up properly + afterAll(async () => { + await deleteUser(mockUserData.username); + }); it("should login user and return jwt token", async () => { /** Creates a new user and save it to the database */ await strapi.plugins["users-permissions"].services.user.add({ @@ -33,4 +41,18 @@ describe("user", () => { expect(data.body.jwt).toBeDefined(); }); }); + + // This just ensures that the test environment is set up with all types of + // user. + it("should have a user for each of the roles", async () => { + const roles = await getAllRoles(); + for (const role of roles) { + const user = await getUserByRole(role.id); + expect(user).toMatchObject({ + role: { + name: role.name, + }, + }); + } + }); });