diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index fb3527c05f..3b952ef881 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -438,7 +438,7 @@ dataset: - system.operations - name: first_name data_categories: - - system.operations + - user.name.first - name: hashed_password data_categories: - system.operations @@ -450,7 +450,7 @@ dataset: - system.operations - name: last_name data_categories: - - system.operations + - user.name.last - name: password_reset_at data_categories: - system.operations @@ -462,8 +462,17 @@ dataset: data_categories: - system.operations - name: username + data_categories: + - user.account.username + - name: disabled data_categories: - system.operations + - name: disabled_reason + data_categories: + - system.operations + - name: email_address + data_categories: + - user.contact.email - name: fidesuserpermissions fields: - name: created_at @@ -2164,3 +2173,17 @@ dataset: data_categories: [system] - name: user_assigned_data_categories data_categories: [system] + - name: fides_user_invite + fields: + - name: created_at + data_categories: [system.operations] + - name: hashed_invite_code + data_categories: [system.operations] + - name: id + data_categories: [system.operations] + - name: salt + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: username + data_categories: [user.account.username] \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 809d2d046c..4b4411d128 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The types of changes are: - New messaging page. Allows managing messaging templates for different properties. [#5005](https://github.com/ethyca/fides/pull/5005) - Ability to configure "Enforcement Level" for Privacy Notices [#5025](https://github.com/ethyca/fides/pull/5025) - BE cleanup for property-specific messaging [#5006](https://github.com/ethyca/fides/pull/5006) +- Invite users via email flow [#4539](https://github.com/ethyca/fides/pull/4539) ### Changed - Navigation changes. 'Management' was renamed 'Settings'. Properties was moved to Settings section. [#5005](https://github.com/ethyca/fides/pull/5005) diff --git a/clients/admin-ui/cypress.config.ts b/clients/admin-ui/cypress.config.ts index 413b7a4e43..b41fed48c0 100644 --- a/clients/admin-ui/cypress.config.ts +++ b/clients/admin-ui/cypress.config.ts @@ -4,6 +4,15 @@ import { defineConfig } from "cypress"; export default defineConfig({ e2e: { baseUrl: "http://localhost:3000", + setupNodeEvents(on) { + on("before:browser:launch", (browser, launchOptions) => { + if (browser.family === "chromium") { + // No need for tests to be slowed down by animations! + launchOptions.args.push("--force-prefers-reduced-motion"); + } + return launchOptions; + }); + }, }, defaultCommandTimeout: 5000, diff --git a/clients/admin-ui/cypress/e2e/auth.cy.ts b/clients/admin-ui/cypress/e2e/auth.cy.ts index 94b28ef629..ea6809eb0e 100644 --- a/clients/admin-ui/cypress/e2e/auth.cy.ts +++ b/clients/admin-ui/cypress/e2e/auth.cy.ts @@ -1,6 +1,17 @@ import { SYSTEM_ROUTE } from "~/features/common/nav/v2/routes"; describe("User Authentication", () => { + const login = () => { + cy.fixture("login.json").then((body) => { + cy.intercept("POST", "/api/v1/login", body).as("postLogin"); + cy.intercept("/api/v1/user/*/permission", { + fixture: "user-management/permissions.json", + }).as("getUserPermission"); + }); + + cy.getByTestId("input-username").type("cypress-user@ethyca.com"); + cy.getByTestId("input-password").type("FakePassword123!{Enter}"); + }; describe("when the user not logged in", () => { it("redirects them to the login page", () => { cy.visit("/"); @@ -20,18 +31,20 @@ describe("User Authentication", () => { cy.getByTestId("Login"); cy.intercept("GET", "/api/v1/system", { body: [] }); - cy.fixture("login.json").then((body) => { - cy.intercept("POST", "/api/v1/login", body).as("postLogin"); - cy.intercept("/api/v1/user/*/permission", { - fixture: "user-management/permissions.json", - }).as("getUserPermission"); - }); - - cy.get("#email").type("cypress-user@ethyca.com"); - cy.get("#password").type("FakePassword123!{Enter}"); + login(); cy.getByTestId("Home"); }); + + it("can persist URL after logging in", () => { + cy.visit("/user-management"); + cy.location("pathname").should("eq", "/login"); + cy.location("search").should("eq", "?redirect=%2Fuser-management"); + + // Now log in + login(); + cy.location("pathname").should("eq", "/user-management"); + }); }); describe("when the user is logged in", () => { @@ -70,4 +83,36 @@ describe("User Authentication", () => { cy.location("pathname").should("eq", "/"); }); }); + + describe("invited user", () => { + beforeEach(() => { + cy.intercept("/api/v1/user/*/permission", { + fixture: "user-management/permissions.json", + }).as("getUserPermission"); + cy.fixture("login.json").then((body) => { + cy.intercept("POST", "/api/v1/user/accept-invite*", body).as( + "postAcceptInvite" + ); + }); + }); + it("can prefill email and render different copy for an invited user", () => { + const data = { username: "testuser", invite_code: "123" }; + const newPassword = "FakePassword123!"; + cy.visit("/login", { + qs: data, + }); + cy.getByTestId("input-username").should("be.disabled"); + cy.getByTestId("input-username").should("have.value", data.username); + cy.get("label").contains("Set new password"); + cy.getByTestId("input-password").type(newPassword); + cy.get("button").contains("Setup user").click(); + cy.wait("@postAcceptInvite").then((interception) => { + const { body, url } = interception.request; + expect(url).to.contain(data.invite_code); + expect(url).to.contain(data.username); + expect(body).to.eql({ new_password: newPassword }); + }); + cy.getByTestId("Home"); + }); + }); }); diff --git a/clients/admin-ui/cypress/e2e/user-management.cy.ts b/clients/admin-ui/cypress/e2e/user-management.cy.ts index 7befb3d8a0..7b1b002a86 100644 --- a/clients/admin-ui/cypress/e2e/user-management.cy.ts +++ b/clients/admin-ui/cypress/e2e/user-management.cy.ts @@ -94,6 +94,35 @@ describe("User management", () => { cy.getByTestId("user-systems-badge"); cy.contains("4"); }); + + it("can see invite sent field", () => { + cy.visit("/user-management"); + cy.wait("@getAllUsers"); + cy.getByTestId(`row-${USER_1_ID}`).within(() => { + cy.getByTestId("invite-sent-badge"); + }); + cy.getByTestId(`row-${CYPRESS_USER_ID}`).within(() => { + cy.getByTestId("invite-sent-badge").should("not.exist"); + }); + }); + }); + + describe("Create users", () => { + it("can set a user's password if email messaging is not configured", () => { + cy.visit(`/user-management/new`); + cy.getByTestId("input-password"); + }); + + it("cannot set a user's password if email messaging is enabled", () => { + cy.intercept("GET", "**/messaging/email-invite/status", { + body: { + enabled: true, + }, + }); + cy.visit(`/user-management/new`); + cy.getByTestId("input-email_address"); + cy.getByTestId("input-password").should("not.exist"); + }); }); describe("Password management", () => { diff --git a/clients/admin-ui/cypress/fixtures/user-management/user.json b/clients/admin-ui/cypress/fixtures/user-management/user.json index eb562a6366..c17f309c5a 100644 --- a/clients/admin-ui/cypress/fixtures/user-management/user.json +++ b/clients/admin-ui/cypress/fixtures/user-management/user.json @@ -1,6 +1,7 @@ { "id": "123", "username": "cypress-user@ethyca.com", + "email_address": "cypress-user@ethyca.com", "created_at": "2022-09-28T16:15:30.994Z", "first_name": "Cypress", "last_name": "User" diff --git a/clients/admin-ui/cypress/fixtures/user-management/users.json b/clients/admin-ui/cypress/fixtures/user-management/users.json index 0872065c0a..aec0def9d9 100644 --- a/clients/admin-ui/cypress/fixtures/user-management/users.json +++ b/clients/admin-ui/cypress/fixtures/user-management/users.json @@ -3,30 +3,39 @@ { "id": "fid_bad38cda-476b-4d25-9883-798ba1415f40", "username": "user_3", + "email_address": "user_3@ethyca.com", "created_at": "2023-01-26T16:24:50.718476+00:00", "first_name": "User", - "last_name": "Three" + "last_name": "Three", + "disabled": false }, { "id": "fid_560cceb5-f567-4d76-a905-1e16ada1c143", "username": "user_2", + "email_address": "user_2@ethyca.com", "created_at": "2023-01-26T16:23:56.023966+00:00", "first_name": "User", - "last_name": "Two" + "last_name": "Two", + "disabled": false }, { "id": "fid_ee8f54ce-19f7-4640-b311-1cc1e77e7166", "username": "user_1", + "email_address": "user_1@ethyca.com", "created_at": "2023-01-26T16:16:49.575653+00:00", "first_name": "User", - "last_name": "One" + "last_name": "One", + "disabled": true, + "disabled_reason": "pending_invite" }, { "id": "123", "username": "cypress-user@ethyca.com", + "email_address": "123@ethyca.com", "created_at": "2022-09-28T16:15:30.994Z", "first_name": "Cypress", - "last_name": "User" + "last_name": "User", + "disabled": false } ], "total": 4, diff --git a/clients/admin-ui/src/features/auth/ProtectedRoute.tsx b/clients/admin-ui/src/features/auth/ProtectedRoute.tsx index 43666e1002..54959e4f5d 100644 --- a/clients/admin-ui/src/features/auth/ProtectedRoute.tsx +++ b/clients/admin-ui/src/features/auth/ProtectedRoute.tsx @@ -9,6 +9,8 @@ import { useGetUserPermissionsQuery } from "~/features/user-management"; import { logout, selectToken, selectUser } from "./auth.slice"; +const REDIRECT_IGNORES = ["/", "/login"]; + const useProtectedRoute = (redirectUrl: string) => { const router = useRouter(); const dispatch = useAppDispatch(); @@ -28,7 +30,13 @@ const useProtectedRoute = (redirectUrl: string) => { dispatch(logout()); } if (typeof window !== "undefined") { - router.push(redirectUrl); + const query = REDIRECT_IGNORES.includes(window.location.pathname) + ? undefined + : { redirect: window.location.pathname }; + router.push({ + pathname: redirectUrl, + query, + }); } return { authenticated: false, hasAccess: false }; } diff --git a/clients/admin-ui/src/features/auth/auth.slice.ts b/clients/admin-ui/src/features/auth/auth.slice.ts index 008aaf6944..74a2df834c 100644 --- a/clients/admin-ui/src/features/auth/auth.slice.ts +++ b/clients/admin-ui/src/features/auth/auth.slice.ts @@ -69,12 +69,24 @@ const authApi = baseApi.injectEndpoints({ query: () => ({ url: `oauth/role` }), providesTags: ["Roles"], }), + acceptInvite: build.mutation< + LoginResponse, + LoginRequest & { inviteCode: string } + >({ + query: ({ username, password, inviteCode }) => ({ + url: "/user/accept-invite", + params: { username, invite_code: inviteCode }, + method: "POST", + body: { new_password: password }, + }), + }), }), }); export const { useLoginMutation, useLogoutMutation, + useAcceptInviteMutation, useGetRolesToScopesMappingQuery, } = authApi; export const { reducer } = authSlice; diff --git a/clients/admin-ui/src/features/common/Header.tsx b/clients/admin-ui/src/features/common/Header.tsx new file mode 100644 index 0000000000..e6807d8cf1 --- /dev/null +++ b/clients/admin-ui/src/features/common/Header.tsx @@ -0,0 +1,90 @@ +import { + Button, + Flex, + Link, + Menu, + MenuButton, + MenuDivider, + MenuItem, + MenuList, + QuestionIcon, + Stack, + Text, + UserIcon, +} from "fidesui"; +import { useRouter } from "next/router"; +import React from "react"; + +import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import { LOGIN_ROUTE } from "~/constants"; +import { logout, selectUser, useLogoutMutation } from "~/features/auth"; + +const useHeader = () => { + const { username } = useAppSelector(selectUser) ?? { username: "" }; + return { username }; +}; + +const Header: React.FC = () => { + const { username } = useHeader(); + const router = useRouter(); + const [logoutMutation] = useLogoutMutation(); + const dispatch = useAppDispatch(); + + const handleLogout = async () => { + await logoutMutation({}); + // Go to Login page first, then dispatch logout so that ProtectedRoute does not + // tack on a redirect URL. We don't need a redirect URL if we are just logging out! + router.push(LOGIN_ROUTE).then(() => { + dispatch(logout()); + }); + }; + + return ( + + + + + + {username && ( + + + + + + + {username} + + + + + Sign out + + + + )} + + + ); +}; + +export default Header; diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index 3f0725ad7a..8cdb5c501f 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -34,6 +34,7 @@ export const baseApi = createApi({ "Datasets", "Discovery Monitor Configs", "Discovery Monitor Results", + "Email Invite Status", "Fides Cloud Config", "Languages", "Locations", diff --git a/clients/admin-ui/src/features/common/features/features.slice.ts b/clients/admin-ui/src/features/common/features/features.slice.ts index 2b7ffaa1f0..bdecb7326b 100644 --- a/clients/admin-ui/src/features/common/features/features.slice.ts +++ b/clients/admin-ui/src/features/common/features/features.slice.ts @@ -26,7 +26,10 @@ type FeaturesState = { showNotificationBanner: boolean; }; -const initialState: FeaturesState = { flags: {}, showNotificationBanner: true }; +const initialState: FeaturesState = { + flags: {}, + showNotificationBanner: true, +}; export const featuresSlice = createSlice({ name: "features", @@ -84,7 +87,6 @@ export const selectShowNotificationBanner = createSelector( selectFeatures, (state) => state.showNotificationBanner ); - export const { setShowNotificationBanner } = featuresSlice.actions; export const useFlags = () => { diff --git a/clients/admin-ui/src/features/common/form/inputs.tsx b/clients/admin-ui/src/features/common/form/inputs.tsx index 81c6e03d45..c6eae5c137 100644 --- a/clients/admin-ui/src/features/common/form/inputs.tsx +++ b/clients/admin-ui/src/features/common/form/inputs.tsx @@ -69,6 +69,7 @@ export interface CustomInputProps { isRequired?: boolean; textColor?: string; inputRightElement?: React.ReactNode; + size?: string; } // We allow `undefined` here and leave it up to each component that uses this field @@ -96,6 +97,7 @@ export const TextInput = forwardRef( { isPassword, inputRightElement, + size, ...props }: InputProps & { isPassword: boolean; @@ -111,7 +113,7 @@ export const TextInput = forwardRef( setType(type === "password" ? "text" : "password"); return ( - + | undefined} @@ -534,6 +536,7 @@ export const CustomTextInput = ({ variant = "inline", isRequired = false, inputRightElement, + size, ...props }: CustomInputProps & StringField) => { const [initialField, meta] = useField(props); @@ -551,6 +554,7 @@ export const CustomTextInput = ({ placeholder={placeholder} isPassword={isPassword} inputRightElement={inputRightElement} + size={size} /> ); @@ -581,7 +585,12 @@ export const CustomTextInput = ({ {label ? ( -