Skip to content

CDX-12: console: support expirations on app passwords - #38444

Open
jubrad wants to merge 5 commits into
MaterializeInc:mainfrom
jubrad:justin/cdx-12-console-support-expirations-on-app-passwords-and-show-expiry
Open

CDX-12: console: support expirations on app passwords#38444
jubrad wants to merge 5 commits into
MaterializeInc:mainfrom
jubrad:justin/cdx-12-console-support-expirations-on-app-passwords-and-show-expiry

Conversation

@jubrad

@jubrad jubrad commented Aug 25, 2026

Copy link
Copy Markdown
Member

CDX-12

Problem

Console app passwords (Frontegg user/tenant API tokens) were immortal: the create flow only took a description, and the list gave no signal about which credentials were stale. That blocks customers with credential-rotation requirements and makes cleanup guesswork.

Solution

Frontegg already supports this end to end, so no backend work was needed. Confirmed against frontegg/openapi-public/identity.json:

  • POST .../{users,tenants}/api-tokens/v1 accept expiresInMinutes (omitted = never expires).
  • Both GET list responses return expires (date-time). Note the field is expires, not expiresAt.
  • There is no lastUsed/lastSeen field anywhere in the identity spec, so "show last use" is not buildable from Frontegg. Follow-up write-up is on the Linear issue.

Changes:

  • Thread an optional expiresInMinutes through createUserApiToken / createTenantApiToken and useCreateApiToken, and type expires on the token interfaces.
  • Add an Expiration select to the new app password modal: 30 / 60 / 90 days or "No expiration", defaulting to 90 days.
  • Add an Expires column to the list. Muted "Never" when unset, otherwise the date plus a red "Expired" or yellow "Expiring soon" (within 7 days) pill.
  • Drop the per-row Connect button. It opened the connect drawer scoped to a single password's user, which the sidebar's Connect entry already covers, so it was spending a lot of row width for little. Row actions are now Delete alone.
  • Compact both date columns to day granularity (DATE_FORMAT). The seventh column left MMM. dd, yyyy HH:mm z wrapping to three lines in both Created at and Expires; time-of-day is not useful for a credential's lifecycle.

Testing

New src/access/AppPasswordsPage.test.tsx covers a legacy token with no expires rendering "Never", the expired and expiring-soon pills, the default create posting expiresInMinutes: 129600, and "No expiration" posting no expiresInMinutes. yarn lint, yarn typecheck, and the console suite pass locally; console-e2e-test / console-e2e-test-prod exercise the real-region path.

Reviewer notes

  • The inline creators (connectComponents, ConnectMcpPanel, MzCliAppPasswordPage) are deliberately unchanged. The param is optional, so those keep creating non-expiring passwords. Adding an expiration control there is a follow-up.
  • Expiry is set-at-creation only; existing passwords are unaffected and render as "Never".
  • src/frontegg-auth's active_sessions cache serves repeat authentications without calling Frontegg and refreshes at ~0.8x JWT lifetime, so an already-authenticated app password can keep working for up to one refresh period past its expiry. This is the same bounded window that already exists when a password is deleted, not a new regression.
  • src/frontegg-mock models no expiry (it accepts only description). Console e2e hits real staging Frontegg so the mock is not on this path; teaching it expiresInMinutes/expires is a cheap follow-up if a Rust integration test ever needs it.

🤖 Generated with Claude Code

jubrad added 4 commits August 24, 2026 22:52
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
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
The Expires column left the verbose date format wrapping to three lines
in both date columns. Day granularity is enough for a credential's
lifecycle, so drop the time and timezone from Created at and Expires,
and let the expiry pill wrap below its date rather than squeeze it.

CDX-12
The button opened the connect drawer scoped to one password's user, which
the sidebar's Connect entry already covers. Removing it leaves the row
actions to Delete alone and gives the new Expires column room.

CDX-12
@jubrad

jubrad commented Aug 25, 2026

Copy link
Copy Markdown
Member Author
image image

@jubrad
jubrad requested a review from jasonhernandez August 25, 2026 13:49
@jubrad
jubrad marked this pull request as ready for review August 25, 2026 13:53
@jubrad
jubrad requested a review from a team as a code owner August 25, 2026 13:53
@jubrad
jubrad requested a review from jdonelson August 25, 2026 13:53
@def-

def- commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

QA LLM Review

1. MEDIUM -- Removing Connect drops the service-account context

console/src/access/AppPasswordsPage.tsx:506

The row action now only renders Delete, but the removed Connect button was the only path that passed a service app password's SQL user into the connection drawer. The remaining sidebar drawer is always scoped to the signed-in Frontegg user, so its generated SQL, external-tool, and MCP token instructions use the personal email instead of the service user and do not work with that service password.

Details

CloudConnectContent deliberately chooses forAppPassword?.user ?? user?.email and disables personal-password creation when forAppPassword is present (console/src/components/connect/ConnectDrawer.tsx:146). The navigation-bar caller only supplies user={runtimeConfig.user}, while the removed row caller supplied forAppPassword={{ user: userStr }} where userStr came from token.user. For a service password, opening the sidebar therefore shows the wrong SQL username and offers to create a separate personal password; it cannot reproduce the row flow by which users obtained correctly scoped psql, external-tool, or Basic-token instructions. Retain the row action for service tokens, or add a way for the sidebar drawer to select and carry the service token's user context.

@leedqin leedqin left a comment

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.

Looks good! some minor comments and nits


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 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.

Comment thread console/src/access/AppPasswordsPage.tsx Outdated
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"]

@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.

Done in ecdf5cf. Also switched the two pre-existing gray["500"] usages in this table (the muted user and roles cells) for consistency: in the light theme foreground.secondary is gray[500], but in dark it resolves to gray[400], so leaving them mixed would have rendered the muted text in a single row as two different shades in dark mode.

Comment thread console/src/access/AppPasswordsPage.tsx Outdated
<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

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.

Done in ecdf5cf. Passed width="100%" to match the in-modal precedent in AlterClusterModal/NewClusterForm, since SimpleSelect defaults to width="auto" and the field sits under a full-width Name input.

Use the console's SimpleSelect for the expiration control, and the
semantic foreground.secondary token for muted table text. The latter
resolves to gray[400] in the dark theme, so the raw gray[500] it
replaces was wrong there.

CDX-12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants