From b0d18d865a6cdb696e268f96537deef3541d3ba6 Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Mon, 24 Aug 2026 22:52:08 -0500 Subject: [PATCH 1/8] console: support expirations on app passwords Frontegg's user and tenant API token endpoints accept `expiresInMinutes` on create and return `expires` on list, but the console never used either, so every app password was immortal and the list gave no signal about staleness. Add an Expiration select to the new app password modal (30/60/90 days or no expiration, defaulting to 90 days) and an Expires column to the list that renders "Never" for passwords without an expiration and flags expired and soon to expire ones with a status pill. CDX-12 --- console/src/access/AppPasswordsPage.test.tsx | 140 +++++++++++++++++++ console/src/access/AppPasswordsPage.tsx | 78 ++++++++++- console/src/api/frontegg/index.ts | 12 +- console/src/api/frontegg/types.ts | 4 + console/src/queries/frontegg.ts | 2 + console/src/test/utils.tsx | 5 +- 6 files changed, 236 insertions(+), 5 deletions(-) create mode 100644 console/src/access/AppPasswordsPage.test.tsx diff --git a/console/src/access/AppPasswordsPage.test.tsx b/console/src/access/AppPasswordsPage.test.tsx new file mode 100644 index 0000000000000..c1ccc480f0e8c --- /dev/null +++ b/console/src/access/AppPasswordsPage.test.tsx @@ -0,0 +1,140 @@ +// 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, +}); + +/** Stubs every request the page makes, plus the create endpoint, and returns + * the body of the last create request. */ +const mockFrontegg = (tokens: UserApiToken[]) => { + const createRequest: { body?: Record } = {}; + 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: [] }), + ), + http.post( + "*/frontegg/identity/resources/users/api-tokens/v1", + async ({ request }) => { + createRequest.body = (await request.json()) as Record; + return HttpResponse.json({ + ...buildToken({ clientId: "22222222-2222-2222-2222-222222222222" }), + secret: "33333333-3333-3333-3333-333333333333", + }); + }, + ), + ); + return createRequest; +}; + +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 createRequest = 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(createRequest.body).toMatchObject({ + description: "New password", + expiresInMinutes: 90 * 24 * 60, + }), + ); + }); + + it("omits the expiration when no expiration is selected", async () => { + const createRequest = 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(createRequest.body).toBeDefined()); + expect(createRequest.body).not.toHaveProperty("expiresInMinutes"); + }); +}); diff --git a/console/src/access/AppPasswordsPage.tsx b/console/src/access/AppPasswordsPage.tsx index f1f4f05135a1f..4ea3bdf10b88f 100644 --- a/console/src/access/AppPasswordsPage.tsx +++ b/console/src/access/AppPasswordsPage.tsx @@ -27,6 +27,7 @@ import { ModalOverlay, Radio, RadioGroup, + Select, Stack, Tab, Table, @@ -59,6 +60,7 @@ 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, @@ -79,6 +81,17 @@ import { 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 AppPasswordsPage = ({ user }: { user: User }) => { const { isOpen, onOpen, onClose } = useDisclosure(); const location = useLocation(); @@ -139,6 +152,7 @@ const AppPasswordsInner = (props: { user: string; name: string; roles: { name: string; id: string }[]; + expiresIn: ExpiresInOption; }>({ mode: "onChange", defaultValues: { @@ -146,6 +160,7 @@ const AppPasswordsInner = (props: { name: "", user: "", roles: [], + expiresIn: DEFAULT_EXPIRES_IN, }, }); @@ -190,6 +205,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 +271,29 @@ const AppPasswordsInner = (props: { you need to revoke it in the future. + + + Expiration + + + + The app password stops working once it expires. Expiration + cannot be changed after creation. + + {watchType == "service" && ( <> @@ -357,6 +396,36 @@ const AppPasswordsInner = (props: { ); }; +const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000; + +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), FRIENDLY_DATETIME_FORMAT_NO_SECONDS)} + + {msUntilExpiry <= 0 && ( + + )} + {msUntilExpiry > 0 && msUntilExpiry <= EXPIRING_SOON_MS && ( + + )} + + ); +}; + type ApiTokensTableProps = BoxProps & { tokens: ApiToken[]; user: User; @@ -382,6 +451,7 @@ const ApiTokensTableProps = ({ User Roles Created at + Expires @@ -446,6 +516,12 @@ const ApiTokensTableProps = ({ FRIENDLY_DATETIME_FORMAT_NO_SECONDS, )} + + + - No app passwords yet. + No app passwords yet. )} 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; From 47d5d2a7da9bc0d5bf8058550b8939e92cf45292 Mon Sep 17 00:00:00 2001 From: Justin Bradfield Date: Mon, 24 Aug 2026 23:05:48 -0500 Subject: [PATCH 2/8] console: address review of app password expirations Drop the redundant aria-label on the expiration select so the label/id wiring is what tests exercise, let StatusPill derive its own text, and cover the service password path in the test. CDX-12 --- console/src/access/AppPasswordsPage.test.tsx | 70 +++++++++++++++----- console/src/access/AppPasswordsPage.tsx | 21 ++---- 2 files changed, 58 insertions(+), 33 deletions(-) diff --git a/console/src/access/AppPasswordsPage.test.tsx b/console/src/access/AppPasswordsPage.test.tsx index c1ccc480f0e8c..1778b40cce225 100644 --- a/console/src/access/AppPasswordsPage.test.tsx +++ b/console/src/access/AppPasswordsPage.test.tsx @@ -30,10 +30,24 @@ const buildToken = (props: Partial): UserApiToken => ({ ...props, }); -/** Stubs every request the page makes, plus the create endpoint, and returns - * the body of the last create request. */ +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 createRequest: { body?: Record } = {}; + 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), @@ -42,20 +56,18 @@ const mockFrontegg = (tokens: UserApiToken[]) => { HttpResponse.json([]), ), http.get("*/frontegg/team/resources/roles/v1", () => - HttpResponse.json({ items: [] }), + HttpResponse.json({ items: [ROLE] }), ), http.post( "*/frontegg/identity/resources/users/api-tokens/v1", - async ({ request }) => { - createRequest.body = (await request.json()) as Record; - return HttpResponse.json({ - ...buildToken({ clientId: "22222222-2222-2222-2222-222222222222" }), - secret: "33333333-3333-3333-3333-333333333333", - }); - }, + capture("personal"), + ), + http.post( + "*/frontegg/identity/resources/tenants/api-tokens/v1", + capture("service"), ), ); - return createRequest; + return created; }; const renderPage = (openNewModal = false) => @@ -110,7 +122,7 @@ describe("AppPasswordsPage", () => { }); it("defaults new passwords to a 90 day expiration", async () => { - const createRequest = mockFrontegg([]); + const created = mockFrontegg([]); await renderPage(true); const user = userEvent.setup(); @@ -118,7 +130,7 @@ describe("AppPasswordsPage", () => { await user.click(screen.getByRole("button", { name: "Create Password" })); await waitFor(() => - expect(createRequest.body).toMatchObject({ + expect(created.personal).toMatchObject({ description: "New password", expiresInMinutes: 90 * 24 * 60, }), @@ -126,7 +138,7 @@ describe("AppPasswordsPage", () => { }); it("omits the expiration when no expiration is selected", async () => { - const createRequest = mockFrontegg([]); + const created = mockFrontegg([]); await renderPage(true); const user = userEvent.setup(); @@ -134,7 +146,31 @@ describe("AppPasswordsPage", () => { await user.selectOptions(screen.getByLabelText("Expiration"), "never"); await user.click(screen.getByRole("button", { name: "Create Password" })); - await waitFor(() => expect(createRequest.body).toBeDefined()); - expect(createRequest.body).not.toHaveProperty("expiresInMinutes"); + 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 4ea3bdf10b88f..89c4cd46a8590 100644 --- a/console/src/access/AppPasswordsPage.tsx +++ b/console/src/access/AppPasswordsPage.tsx @@ -92,6 +92,8 @@ 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(); @@ -275,12 +277,7 @@ const AppPasswordsInner = (props: { Expiration - {Object.entries(EXPIRES_IN_OPTIONS).map( ([value, { label }]) => (