-
Notifications
You must be signed in to change notification settings - Fork 512
CDX-12: console: support expirations on app passwords #38444
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b0d18d8
47d5d2a
5d1e506
c0de456
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,176 @@ | ||
| // Copyright Materialize, Inc. and contributors. All rights reserved. | ||
| // | ||
| // Use of this software is governed by the Business Source License | ||
| // included in the LICENSE file. | ||
| // | ||
| // As of the Change Date specified in that file, in accordance with | ||
| // the Business Source License, use of this software will be governed | ||
| // by the Apache License, Version 2.0. | ||
|
|
||
| import { screen, waitFor, within } from "@testing-library/react"; | ||
| import userEvent from "@testing-library/user-event"; | ||
| import { http, HttpResponse } from "msw"; | ||
| import React from "react"; | ||
|
|
||
| import { UserApiToken } from "~/api/frontegg/types"; | ||
| import server from "~/api/mocks/server"; | ||
| import { dummyValidUser } from "~/external-library-wrappers/__mocks__/frontegg"; | ||
| import { renderComponent } from "~/test/utils"; | ||
|
|
||
| import AppPasswordsPage from "./AppPasswordsPage"; | ||
|
|
||
| const DAY_MS = 24 * 60 * 60 * 1000; | ||
|
|
||
| const buildToken = (props: Partial<UserApiToken>): UserApiToken => ({ | ||
| type: "personal", | ||
| clientId: "11111111-1111-1111-1111-111111111111", | ||
| createdAt: "2026-01-01T00:00:00Z", | ||
| description: "Personal laptop", | ||
| metadata: {}, | ||
| ...props, | ||
| }); | ||
|
|
||
| const ROLE = { id: "role-id", key: "Admin", name: "Admin" }; | ||
|
|
||
| /** Stubs every request the page makes, plus both create endpoints, and returns | ||
| * the body of the last create request against each. */ | ||
| const mockFrontegg = (tokens: UserApiToken[]) => { | ||
| const created: { | ||
| personal?: Record<string, unknown>; | ||
| service?: Record<string, unknown>; | ||
| } = {}; | ||
| const capture = | ||
| (kind: "personal" | "service") => | ||
| async ({ request }: { request: Request }) => { | ||
| created[kind] = (await request.json()) as Record<string, unknown>; | ||
| return HttpResponse.json({ | ||
| ...buildToken({ clientId: "22222222-2222-2222-2222-222222222222" }), | ||
| secret: "33333333-3333-3333-3333-333333333333", | ||
| }); | ||
| }; | ||
| server.use( | ||
| http.get("*/frontegg/identity/resources/users/api-tokens/v1", () => | ||
| HttpResponse.json(tokens), | ||
| ), | ||
| http.get("*/frontegg/identity/resources/tenants/api-tokens/v1", () => | ||
| HttpResponse.json([]), | ||
| ), | ||
| http.get("*/frontegg/team/resources/roles/v1", () => | ||
| HttpResponse.json({ items: [ROLE] }), | ||
| ), | ||
| http.post( | ||
| "*/frontegg/identity/resources/users/api-tokens/v1", | ||
| capture("personal"), | ||
| ), | ||
| http.post( | ||
| "*/frontegg/identity/resources/tenants/api-tokens/v1", | ||
| capture("service"), | ||
| ), | ||
| ); | ||
| return created; | ||
| }; | ||
|
|
||
| const renderPage = (openNewModal = false) => | ||
| renderComponent(<AppPasswordsPage user={dummyValidUser} />, { | ||
| initialRouterEntries: openNewModal | ||
| ? [{ pathname: "/", state: { new: true } }] | ||
| : ["/"], | ||
| }); | ||
|
|
||
| const findRow = (description: string) => | ||
| screen.findByRole("row", { name: description }); | ||
|
|
||
| describe("AppPasswordsPage", () => { | ||
| it("renders passwords without an expiration as never expiring", async () => { | ||
| mockFrontegg([buildToken({ description: "Legacy password" })]); | ||
| await renderPage(); | ||
|
|
||
| expect( | ||
| within(await findRow("Legacy password")).getByText("Never"), | ||
| ).toBeVisible(); | ||
| }); | ||
|
|
||
| it("flags expired and soon to expire passwords", async () => { | ||
| mockFrontegg([ | ||
| buildToken({ | ||
| clientId: "aaaaaaaa-1111-1111-1111-111111111111", | ||
| description: "Stale password", | ||
| expires: new Date(Date.now() - DAY_MS).toISOString(), | ||
| }), | ||
| buildToken({ | ||
| clientId: "bbbbbbbb-1111-1111-1111-111111111111", | ||
| description: "Almost stale password", | ||
| expires: new Date(Date.now() + 3 * DAY_MS).toISOString(), | ||
| }), | ||
| buildToken({ | ||
| clientId: "cccccccc-1111-1111-1111-111111111111", | ||
| description: "Fresh password", | ||
| expires: new Date(Date.now() + 30 * DAY_MS).toISOString(), | ||
| }), | ||
| ]); | ||
| await renderPage(); | ||
|
|
||
| expect( | ||
| within(await findRow("Stale password")).getByText("Expired"), | ||
| ).toBeVisible(); | ||
| expect( | ||
| within(await findRow("Almost stale password")).getByText("Expiring soon"), | ||
| ).toBeVisible(); | ||
| const freshRow = within(await findRow("Fresh password")); | ||
| expect(freshRow.queryByText("Expired")).not.toBeInTheDocument(); | ||
| expect(freshRow.queryByText("Expiring soon")).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it("defaults new passwords to a 90 day expiration", async () => { | ||
| const created = mockFrontegg([]); | ||
| await renderPage(true); | ||
| const user = userEvent.setup(); | ||
|
|
||
| await user.type(await screen.findByLabelText("Name"), "New password"); | ||
| await user.click(screen.getByRole("button", { name: "Create Password" })); | ||
|
|
||
| await waitFor(() => | ||
| expect(created.personal).toMatchObject({ | ||
| description: "New password", | ||
| expiresInMinutes: 90 * 24 * 60, | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it("omits the expiration when no expiration is selected", async () => { | ||
| const created = mockFrontegg([]); | ||
| await renderPage(true); | ||
| const user = userEvent.setup(); | ||
|
|
||
| await user.type(await screen.findByLabelText("Name"), "New password"); | ||
| await user.selectOptions(screen.getByLabelText("Expiration"), "never"); | ||
| await user.click(screen.getByRole("button", { name: "Create Password" })); | ||
|
|
||
| await waitFor(() => expect(created.personal).toBeDefined()); | ||
| expect(created.personal).not.toHaveProperty("expiresInMinutes"); | ||
| }); | ||
|
|
||
| it("sends the expiration on the service password endpoint too", async () => { | ||
| const created = mockFrontegg([]); | ||
| await renderPage(true); | ||
| const user = userEvent.setup(); | ||
|
|
||
| await user.click(await screen.findByRole("radio", { name: /Service/ })); | ||
| await user.type(screen.getByLabelText("Name"), "Service password"); | ||
| // The User field's label is not wired to its input, so go by position. | ||
| await user.type(screen.getAllByRole("textbox")[1], "svc"); | ||
| await user.click(screen.getByPlaceholderText("Select...")); | ||
| await user.click(await screen.findByRole("option", { name: ROLE.name })); | ||
| await user.selectOptions(screen.getByLabelText("Expiration"), "30d"); | ||
| await user.click(screen.getByRole("button", { name: "Create Password" })); | ||
|
|
||
| await waitFor(() => | ||
| expect(created.service).toMatchObject({ | ||
| description: "Service password", | ||
| metadata: { user: "svc" }, | ||
| roleIds: [ROLE.id], | ||
| expiresInMinutes: 30 * 24 * 60, | ||
| }), | ||
| ); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,6 +27,7 @@ import { | |
| ModalOverlay, | ||
| Radio, | ||
| RadioGroup, | ||
| Select, | ||
| Stack, | ||
| Tab, | ||
| Table, | ||
|
|
@@ -54,11 +55,11 @@ import { hasTenantApiTokenPermissions } from "~/api/auth"; | |
| import { ApiToken } from "~/api/frontegg/types"; | ||
| import Alert from "~/components/Alert"; | ||
| import { AppErrorBoundary } from "~/components/AppErrorBoundary"; | ||
| import ConnectDrawer from "~/components/connect/ConnectDrawer"; | ||
| import { SecretCopyableBox } from "~/components/copyableComponents"; | ||
| import TaggedMultiSelect from "~/components/Dropdown/TaggedComboBox"; | ||
| import { LoadingContainer } from "~/components/LoadingContainer"; | ||
| import { Modal } from "~/components/Modal"; | ||
| import StatusPill from "~/components/StatusPill"; | ||
| import { User } from "~/external-library-wrappers/frontegg"; | ||
| import { | ||
| MainContentContainer, | ||
|
|
@@ -70,15 +71,24 @@ import { | |
| useListApiTokens, | ||
| useTeamRoles, | ||
| } from "~/queries/frontegg"; | ||
| import ConnectionIcon from "~/svg/ConnectionIcon"; | ||
| import { MaterializeTheme } from "~/theme"; | ||
| import { | ||
| formatDate, | ||
| FRIENDLY_DATETIME_FORMAT_NO_SECONDS, | ||
| } from "~/utils/dateFormat"; | ||
| import { DATE_FORMAT, formatDate } from "~/utils/dateFormat"; | ||
| import { toBase64 } from "~/utils/format"; | ||
| import { obfuscateSecret } from "~/utils/format"; | ||
|
|
||
| const EXPIRES_IN_OPTIONS = { | ||
| "30d": { label: "30 days", minutes: 30 * 24 * 60 }, | ||
| "60d": { label: "60 days", minutes: 60 * 24 * 60 }, | ||
| "90d": { label: "90 days", minutes: 90 * 24 * 60 }, | ||
| never: { label: "No expiration", minutes: undefined }, | ||
| } as const; | ||
|
|
||
| type ExpiresInOption = keyof typeof EXPIRES_IN_OPTIONS; | ||
|
|
||
| const DEFAULT_EXPIRES_IN: ExpiresInOption = "90d"; | ||
|
|
||
| const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000; | ||
|
|
||
| const AppPasswordsPage = ({ user }: { user: User }) => { | ||
| const { isOpen, onOpen, onClose } = useDisclosure(); | ||
| const location = useLocation(); | ||
|
|
@@ -139,13 +149,15 @@ const AppPasswordsInner = (props: { | |
| user: string; | ||
| name: string; | ||
| roles: { name: string; id: string }[]; | ||
| expiresIn: ExpiresInOption; | ||
| }>({ | ||
| mode: "onChange", | ||
| defaultValues: { | ||
| type: "personal", | ||
| name: "", | ||
| user: "", | ||
| roles: [], | ||
| expiresIn: DEFAULT_EXPIRES_IN, | ||
| }, | ||
| }); | ||
|
|
||
|
|
@@ -190,6 +202,7 @@ const AppPasswordsInner = (props: { | |
| description: data.name, | ||
| user: data.user, | ||
| roleIds: data.roles.map((r) => r.id), | ||
| expiresInMinutes: EXPIRES_IN_OPTIONS[data.expiresIn].minutes, | ||
| }); | ||
| reset(); | ||
| props.closeNewModal(); | ||
|
|
@@ -255,6 +268,24 @@ const AppPasswordsInner = (props: { | |
| you need to revoke it in the future. | ||
| </FormHelperText> | ||
| </FormControl> | ||
| <FormControl> | ||
| <FormLabel htmlFor="expiresIn" fontSize="sm"> | ||
| Expiration | ||
| </FormLabel> | ||
| <Select {...register("expiresIn")} id="expiresIn" size="sm"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: Prefer to use |
||
| {Object.entries(EXPIRES_IN_OPTIONS).map( | ||
| ([value, { label }]) => ( | ||
| <option key={value} value={value}> | ||
| {label} | ||
| </option> | ||
| ), | ||
| )} | ||
| </Select> | ||
| <FormHelperText> | ||
| The app password stops working once it expires. Expiration | ||
| cannot be changed after creation. | ||
| </FormHelperText> | ||
| </FormControl> | ||
| {watchType == "service" && ( | ||
| <> | ||
| <FormControl isInvalid={!!formState.errors.user}> | ||
|
|
@@ -357,6 +388,28 @@ const AppPasswordsInner = (props: { | |
| ); | ||
| }; | ||
|
|
||
| const ExpiresCell = ({ expires }: { expires?: string }) => { | ||
| const { colors } = useTheme<MaterializeTheme>(); | ||
|
|
||
| if (!expires) { | ||
| return <Text color={colors.gray["500"]}>Never</Text>; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Prefer |
||
| } | ||
|
|
||
| const msUntilExpiry = new Date(expires).getTime() - Date.now(); | ||
|
|
||
| return ( | ||
| <HStack flexWrap="wrap"> | ||
| <Text whiteSpace="nowrap"> | ||
| {formatDate(new Date(expires), DATE_FORMAT)} | ||
| </Text> | ||
| {msUntilExpiry <= 0 && <StatusPill status="expired" colorScheme="red" />} | ||
| {msUntilExpiry > 0 && msUntilExpiry <= EXPIRING_SOON_MS && ( | ||
| <StatusPill status="expiring soon" colorScheme="yellow" /> | ||
| )} | ||
| </HStack> | ||
| ); | ||
| }; | ||
|
|
||
| type ApiTokensTableProps = BoxProps & { | ||
| tokens: ApiToken[]; | ||
| user: User; | ||
|
|
@@ -382,6 +435,7 @@ const ApiTokensTableProps = ({ | |
| <Th>User</Th> | ||
| <Th>Roles</Th> | ||
| <Th>Created at</Th> | ||
| <Th>Expires</Th> | ||
| <Th /> | ||
| </Tr> | ||
| </Thead> | ||
|
|
@@ -439,28 +493,28 @@ const ApiTokensTableProps = ({ | |
| <Td | ||
| borderBottomWidth="1px" | ||
| borderBottomColor={colors.border.primary} | ||
| whiteSpace="nowrap" | ||
| > | ||
| {" "} | ||
| {formatDate( | ||
| new Date(token.createdAt), | ||
| FRIENDLY_DATETIME_FORMAT_NO_SECONDS, | ||
| )} | ||
| {formatDate(new Date(token.createdAt), DATE_FORMAT)} | ||
| </Td> | ||
| <Td | ||
| borderBottomWidth="1px" | ||
| borderBottomColor={colors.border.primary} | ||
| > | ||
| <HStack> | ||
| <ConnectAppPasswordButton userStr={userStr} /> | ||
| <DeleteAppPasswordModal token={token} /> | ||
| </HStack> | ||
| <ExpiresCell expires={token.expires} /> | ||
| </Td> | ||
| <Td | ||
| borderBottomWidth="1px" | ||
| borderBottomColor={colors.border.primary} | ||
| > | ||
| <DeleteAppPasswordModal token={token} /> | ||
| </Td> | ||
| </Tr> | ||
| ); | ||
| })} | ||
| {tokens.length === 0 && ( | ||
| <Tr> | ||
| <Td colSpan={6}>No app passwords yet.</Td> | ||
| <Td colSpan={7}>No app passwords yet.</Td> | ||
| </Tr> | ||
| )} | ||
| </Tbody> | ||
|
|
@@ -545,28 +599,4 @@ const SecretBox = ({ | |
| ); | ||
| }; | ||
|
|
||
| const ConnectAppPasswordButton = ({ userStr }: { userStr: string }) => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we are removing this, can we move the service account password creation to the new connect modal:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think that makes sense, but let's not do that in this PR. |
||
| const { isOpen, onOpen, onClose } = useDisclosure(); | ||
|
|
||
| return ( | ||
| <> | ||
| <Button | ||
| onClick={onOpen} | ||
| title="Connect to Materialize" | ||
| size="sm" | ||
| colorScheme="primary" | ||
| variant="outline" | ||
| leftIcon={<ConnectionIcon />} | ||
| > | ||
| Connect | ||
| </Button> | ||
| <ConnectDrawer | ||
| onClose={onClose} | ||
| isOpen={isOpen} | ||
| forAppPassword={{ user: userStr }} | ||
| /> | ||
| </> | ||
| ); | ||
| }; | ||
|
|
||
| export default AppPasswordsPage; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Will this also apply to Service accounts as well?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but it's really just the default for the dropdown