diff --git a/console/src/access/AppPasswordsPage.test.tsx b/console/src/access/AppPasswordsPage.test.tsx new file mode 100644 index 0000000000000..a420ec9d635cf --- /dev/null +++ b/console/src/access/AppPasswordsPage.test.tsx @@ -0,0 +1,192 @@ +// 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 => ({ + 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; + service?: Record; + } = {}; + const capture = + (kind: "personal" | "service") => + async ({ request }: { request: Request }) => { + created[kind] = (await request.json()) as Record; + 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(, { + 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("posts the selected expiration", async () => { + const created = mockFrontegg([]); + await renderPage(true); + const user = userEvent.setup(); + + await user.type(await screen.findByLabelText("Name"), "Long lived"); + await user.selectOptions(screen.getByLabelText("Expiration"), "365d"); + await user.click(screen.getByRole("button", { name: "Create Password" })); + + await waitFor(() => + expect(created.personal).toMatchObject({ + expiresInMinutes: 365 * 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, + }), + ); + }); +}); diff --git a/console/src/access/AppPasswordsPage.tsx b/console/src/access/AppPasswordsPage.tsx index f1f4f05135a1f..051d9d95c56a1 100644 --- a/console/src/access/AppPasswordsPage.tsx +++ b/console/src/access/AppPasswordsPage.tsx @@ -54,11 +54,12 @@ 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 SimpleSelect from "~/components/SimpleSelect"; +import StatusPill from "~/components/StatusPill"; import { User } from "~/external-library-wrappers/frontegg"; import { MainContentContainer, @@ -70,15 +71,25 @@ 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 }, + "365d": { label: "365 days", minutes: 365 * 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,6 +150,7 @@ const AppPasswordsInner = (props: { user: string; name: string; roles: { name: string; id: string }[]; + expiresIn: ExpiresInOption; }>({ mode: "onChange", defaultValues: { @@ -146,6 +158,7 @@ const AppPasswordsInner = (props: { name: "", user: "", roles: [], + expiresIn: DEFAULT_EXPIRES_IN, }, }); @@ -190,6 +203,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 +269,28 @@ const AppPasswordsInner = (props: { you need to revoke it in the future. + + + Expiration + + + {Object.entries(EXPIRES_IN_OPTIONS).map( + ([value, { label }]) => ( + + ), + )} + + + The app password stops working once it expires. Expiration + cannot be changed after creation. + + {watchType == "service" && ( <> @@ -357,6 +393,28 @@ const AppPasswordsInner = (props: { ); }; +const ExpiresCell = ({ expires }: { expires?: string }) => { + const { colors } = useTheme(); + + if (!expires) { + return Never; + } + + const msUntilExpiry = new Date(expires).getTime() - Date.now(); + + return ( + + + {formatDate(new Date(expires), DATE_FORMAT)} + + {msUntilExpiry <= 0 && } + {msUntilExpiry > 0 && msUntilExpiry <= EXPIRING_SOON_MS && ( + + )} + + ); +}; + type ApiTokensTableProps = BoxProps & { tokens: ApiToken[]; user: User; @@ -382,6 +440,7 @@ const ApiTokensTableProps = ({ User Roles Created at + Expires @@ -419,7 +478,7 @@ const ApiTokensTableProps = ({ borderBottomColor={colors.border.primary} > {token.type === "personal" ? ( - {userStr} + {userStr} ) : ( userStr )} @@ -429,7 +488,7 @@ const ApiTokensTableProps = ({ borderBottomColor={colors.border.primary} > {token.type === "personal" ? ( - + {tokenRoles.join(", ")} ) : ( @@ -439,28 +498,28 @@ const ApiTokensTableProps = ({ - {" "} - {formatDate( - new Date(token.createdAt), - FRIENDLY_DATETIME_FORMAT_NO_SECONDS, - )} + {formatDate(new Date(token.createdAt), DATE_FORMAT)} - - - - + + + + ); })} {tokens.length === 0 && ( - No app passwords yet. + No app passwords yet. )} @@ -545,28 +604,4 @@ const SecretBox = ({ ); }; -const ConnectAppPasswordButton = ({ userStr }: { userStr: string }) => { - const { isOpen, onOpen, onClose } = useDisclosure(); - - return ( - <> - - - - ); -}; - export default AppPasswordsPage; diff --git a/console/src/api/frontegg/index.ts b/console/src/api/frontegg/index.ts index ef58acc035caf..a63186bd8cc9e 100644 --- a/console/src/api/frontegg/index.ts +++ b/console/src/api/frontegg/index.ts @@ -59,7 +59,7 @@ export async function fetchUserApiTokens(requestOptions?: RequestInit) { } export async function createUserApiToken( - params: { description: string }, + params: { description: string; expiresInMinutes?: number }, requestOptions?: RequestInit, ) { const response = handleFronteggResponse( @@ -109,7 +109,15 @@ export async function fetchTenantApiTokens(requestOptions?: RequestInit) { } export async function createTenantApiToken( - { user, ...params }: { description: string; user: string; roleIds: string[] }, + { + user, + ...params + }: { + description: string; + user: string; + roleIds: string[]; + expiresInMinutes?: number; + }, requestOptions?: RequestInit, ) { const response = handleFronteggResponse( diff --git a/console/src/api/frontegg/types.ts b/console/src/api/frontegg/types.ts index fe219fd1e1503..149e370504cf3 100644 --- a/console/src/api/frontegg/types.ts +++ b/console/src/api/frontegg/types.ts @@ -13,6 +13,8 @@ export interface UserApiToken { createdAt: string; description: string; metadata: Record; + /** Frontegg omits this for tokens created without an expiration. */ + expires?: string; } export interface NewUserApiToken extends UserApiToken { @@ -27,6 +29,8 @@ export interface TenantApiToken { metadata: Record; user: string; roleIds: string[]; + /** Frontegg omits this for tokens created without an expiration. */ + expires?: string; } export interface NewTenantApiToken extends TenantApiToken { diff --git a/console/src/queries/frontegg.ts b/console/src/queries/frontegg.ts index 73097834d1a95..6c3f907bafa8a 100644 --- a/console/src/queries/frontegg.ts +++ b/console/src/queries/frontegg.ts @@ -81,12 +81,14 @@ type CreateApiTokenVariables = | { type: "personal"; description: string; + expiresInMinutes?: number; } | { type: "service"; description: string; user: string; roleIds: string[]; + expiresInMinutes?: number; }; function formatAppPassword({ clientId, secret }: NewApiToken) { diff --git a/console/src/test/utils.tsx b/console/src/test/utils.tsx index b050ee5d43be6..7d86cefa15cc6 100644 --- a/console/src/test/utils.tsx +++ b/console/src/test/utils.tsx @@ -18,6 +18,7 @@ import React, { ReactElement } from "react"; import { BrowserRouter, MemoryRouter, + MemoryRouterProps, Route, useLocation, } from "react-router-dom"; @@ -134,7 +135,7 @@ export const renderComponent = async ( element: ReactElement, options: { initializeState?: InitializeStateFn; - initialRouterEntries?: string[]; + initialRouterEntries?: MemoryRouterProps["initialEntries"]; queryClient?: QueryClient; } = {}, ) => { @@ -161,7 +162,7 @@ export interface ProviderWrapperProps { } | { type: "MEMORY_ROUTER"; - initialRouterEntries?: string[]; + initialRouterEntries?: MemoryRouterProps["initialEntries"]; }; queryClient?: QueryClient;