Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions console/src/access/AppPasswordsPage.test.tsx
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,
}),
);
});
});
110 changes: 70 additions & 40 deletions console/src/access/AppPasswordsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
ModalOverlay,
Radio,
RadioGroup,
Select,
Stack,
Tab,
Table,
Expand Down Expand Up @@ -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,
Expand All @@ -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";

Copy link
Copy Markdown
Contributor

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?

Copy link
Copy Markdown
Member Author

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


const EXPIRING_SOON_MS = 7 * 24 * 60 * 60 * 1000;

const AppPasswordsPage = ({ user }: { user: User }) => {
const { isOpen, onOpen, onClose } = useDisclosure();
const location = useLocation();
Expand Down Expand Up @@ -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,
},
});

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Prefer to use <SimpleSelect /> console's component

{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}>
Expand Down Expand Up @@ -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>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Prefer colors.foreground.secondary over colors.gray["500"]

}

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;
Expand All @@ -382,6 +435,7 @@ const ApiTokensTableProps = ({
<Th>User</Th>
<Th>Roles</Th>
<Th>Created at</Th>
<Th>Expires</Th>
<Th />
</Tr>
</Thead>
Expand Down Expand Up @@ -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>
Expand Down Expand Up @@ -545,28 +599,4 @@ const SecretBox = ({
);
};

const ConnectAppPasswordButton = ({ userStr }: { userStr: string }) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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:
https://linear.app/materializeinc/issue/CNS-146/add-service-account-password-creation-in-connect-modal

@jubrad jubrad Aug 25, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The 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;
Loading
Loading