From ebe15a35e631a893dd8060bf66a45afd7ba3b125 Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Wed, 15 Jul 2026 21:48:42 +0000 Subject: [PATCH 1/2] fix(deletedAt): harden soft-delete checks against null and timestamp wire shapes The Invite User widget's team search crashed with "null is not an object (evaluating 't?.deletedAt.Valid')": renderOption read `.Valid` on a `deletedAt` that the teams API returns as `null` for live rows, and `null.Valid` throws. `deletedAt` reaches the UI in three shapes depending on the endpoint and serializer: `null`/`undefined` (canonical live row), an ISO timestamp string (canonical deleted row), or the legacy Go `sql.NullTime` `{ Valid, Time }` object still emitted by some provider endpoints. Reading `.Valid` directly is unsafe for the first two. Add `isSoftDeleted` and `getDeletedAtTime` helpers (src/utils/nullTime) plus a shared `DeletedAt` union type that tolerate every shape, and route all soft-delete checks through them: TeamSearchField, UserSearchField, UserSearchFieldInput, WorkspaceCard, UsersTable, TeamTableConfiguration, and Workspaces/helper. Several of these read `.Valid` directly and shared the same latent crash. Add unit tests covering every shape, including the null regression. Signed-off-by: Lee Calcote --- src/__testing__/nullTime.test.ts | 46 +++++++++++++++ .../GettingStartedWidget/TeamSearchField.tsx | 7 +-- .../TeamTable/TeamTableConfiguration.tsx | 7 ++- .../UserSearchField/UserSearchField.tsx | 5 +- .../UserSearchField/UserSearchFieldInput.tsx | 5 +- src/custom/UsersTable/UsersTable.tsx | 3 +- src/custom/Workspaces/WorkspaceCard.tsx | 6 +- src/custom/Workspaces/helper.ts | 13 ++--- src/custom/Workspaces/types.ts | 10 ++-- src/utils/index.ts | 1 + src/utils/nullTime.ts | 56 +++++++++++++++++++ src/utils/permissions.ts | 3 +- 12 files changed, 131 insertions(+), 31 deletions(-) create mode 100644 src/__testing__/nullTime.test.ts create mode 100644 src/utils/nullTime.ts diff --git a/src/__testing__/nullTime.test.ts b/src/__testing__/nullTime.test.ts new file mode 100644 index 000000000..4df747ccf --- /dev/null +++ b/src/__testing__/nullTime.test.ts @@ -0,0 +1,46 @@ +import { getDeletedAtTime, isSoftDeleted } from '../utils/nullTime'; + +describe('isSoftDeleted', () => { + it('treats null / undefined as NOT soft-deleted (canonical live-row shape)', () => { + expect(isSoftDeleted(null)).toBe(false); + expect(isSoftDeleted(undefined)).toBe(false); + }); + + it('does not throw on null (regression: Invite User widget crash)', () => { + // The original crash was `null is not an object (evaluating 't?.deletedAt.Valid')`. + expect(() => isSoftDeleted(null)).not.toThrow(); + }); + + it('treats a non-empty timestamp string as soft-deleted (canonical deleted-row shape)', () => { + expect(isSoftDeleted('2026-07-14T20:33:05Z')).toBe(true); + }); + + it('treats an empty / whitespace string as NOT soft-deleted', () => { + expect(isSoftDeleted('')).toBe(false); + expect(isSoftDeleted(' ')).toBe(false); + }); + + it('honors the legacy Go sql.NullTime object shape', () => { + expect(isSoftDeleted({ Valid: true, Time: '2026-07-14T20:33:05Z' })).toBe(true); + expect(isSoftDeleted({ Valid: false, Time: '0001-01-01T00:00:00Z' })).toBe(false); + expect(isSoftDeleted({ Valid: false })).toBe(false); + }); +}); + +describe('getDeletedAtTime', () => { + it('returns undefined when not soft-deleted', () => { + expect(getDeletedAtTime(null)).toBeUndefined(); + expect(getDeletedAtTime(undefined)).toBeUndefined(); + expect(getDeletedAtTime({ Valid: false, Time: '0001-01-01T00:00:00Z' })).toBeUndefined(); + }); + + it('returns the string itself for the canonical deleted-row shape', () => { + expect(getDeletedAtTime('2026-07-14T20:33:05Z')).toBe('2026-07-14T20:33:05Z'); + }); + + it('returns the .Time member for the legacy object shape', () => { + expect(getDeletedAtTime({ Valid: true, Time: '2026-07-14T20:33:05Z' })).toBe( + '2026-07-14T20:33:05Z' + ); + }); +}); diff --git a/src/custom/DashboardWidgets/GettingStartedWidget/TeamSearchField.tsx b/src/custom/DashboardWidgets/GettingStartedWidget/TeamSearchField.tsx index d3825e975..e69bb6e61 100644 --- a/src/custom/DashboardWidgets/GettingStartedWidget/TeamSearchField.tsx +++ b/src/custom/DashboardWidgets/GettingStartedWidget/TeamSearchField.tsx @@ -8,14 +8,13 @@ import React, { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'r import { Chip, CircularProgress, TextField, Tooltip } from '../../../base'; import { iconSmall } from '../../../constants/iconsSizes'; import { CloseIcon } from '../../../icons'; +import { DeletedAt, isSoftDeleted } from '../../../utils/nullTime'; interface Team { id: string; ID: string; name: string; - deletedAt?: { - Valid: boolean; - } | null; + deletedAt?: DeletedAt; } interface TeamSearchFieldProps { @@ -156,7 +155,7 @@ const TeamSearchField: React.FC = ({ /> )} renderOption={(props, option) => { - if (!option.deletedAt?.Valid) { + if (!isSoftDeleted(option.deletedAt)) { return ( { - if (bulkSelect || tableMeta.rowData[4].Valid) { + if (bulkSelect || isSoftDeleted(tableMeta.rowData[4])) { return ( { - if (teams[dataIndx]['deletedAt'].Valid === true) return false; + if (isSoftDeleted(teams[dataIndx]['deletedAt'])) return false; return true; }, setRowProps: (row: any, rowIndex: number, tableState: any) => { @@ -382,7 +383,7 @@ export default function TeamTableConfiguration({ }; } - if (row[6].Valid) { + if (isSoftDeleted(row[6])) { return { style: { backgroundColor: theme.palette.icon.disabled diff --git a/src/custom/UserSearchField/UserSearchField.tsx b/src/custom/UserSearchField/UserSearchField.tsx index c9215b369..025f64266 100644 --- a/src/custom/UserSearchField/UserSearchField.tsx +++ b/src/custom/UserSearchField/UserSearchField.tsx @@ -6,6 +6,7 @@ import { useDebounce } from 'use-debounce'; import { Avatar, Box, Chip, Grid2, TextField, Typography } from '../../base'; import { PersonIcon } from '../../icons/Person'; import { useTheme } from '../../theme'; +import { DeletedAt, isSoftDeleted } from '../../utils/nullTime'; interface User { id: string; @@ -14,7 +15,7 @@ interface User { lastName: string; email: string; avatarUrl?: string; - deletedAt?: { Valid: boolean }; + deletedAt?: DeletedAt; } interface UserSearchFieldProps { @@ -178,7 +179,7 @@ const UserShareSearch: React.FC = ({ - {option.deletedAt?.Valid ? ( + {isSoftDeleted(option.deletedAt) ? ( {option.email} (deleted) diff --git a/src/custom/UserSearchField/UserSearchFieldInput.tsx b/src/custom/UserSearchField/UserSearchFieldInput.tsx index c0858afbb..d67a587d8 100644 --- a/src/custom/UserSearchField/UserSearchFieldInput.tsx +++ b/src/custom/UserSearchField/UserSearchFieldInput.tsx @@ -16,6 +16,7 @@ import { import { iconSmall } from '../../constants/iconsSizes'; import { CloseIcon, PersonIcon } from '../../icons'; +import { DeletedAt, isSoftDeleted } from '../../utils/nullTime'; interface User { userId: string; @@ -23,7 +24,7 @@ interface User { lastName: string; email: string; avatarUrl?: string; - deletedAt?: { Valid: boolean }; + deletedAt?: DeletedAt; deleted?: boolean; } @@ -110,7 +111,7 @@ const UserSearchField: React.FC = ({ if (!value) return; const isDuplicate = localUsersData.some((user) => user.userId === value.userId); - const isDeleted = value.deletedAt?.Valid === true; + const isDeleted = isSoftDeleted(value.deletedAt); if (isDuplicate || isDeleted) { setError(isDuplicate ? 'User already selected' : 'User does not exist'); diff --git a/src/custom/UsersTable/UsersTable.tsx b/src/custom/UsersTable/UsersTable.tsx index 5f247c8f3..aec6c69d5 100644 --- a/src/custom/UsersTable/UsersTable.tsx +++ b/src/custom/UsersTable/UsersTable.tsx @@ -8,6 +8,7 @@ import Github from '../../icons/Github/GithubIcon'; import Google from '../../icons/Google/GoogleIcon'; import LogoutIcon from '../../icons/Logout/LogOutIcon'; import { CHARCOAL, SistentThemeProviderWithoutBaseLine } from '../../theme'; +import { isSoftDeleted } from '../../utils/nullTime'; import { useWindowDimensions } from '../Helpers/Dimension'; import { ColView, @@ -443,7 +444,7 @@ const UsersTable: React.FC = ({ sort: false, searchable: false, customBodyRender: (_: string, tableMeta: MUIDataTableMeta) => - getValidColumnValue(tableMeta.rowData, 'deletedAt', columns).Valid !== false ? ( + isSoftDeleted(getValidColumnValue(tableMeta.rowData, 'deletedAt', columns)) ? ( { - const deleted = workspaceDetails.deletedAt.Valid; + const deleted = isSoftDeleted(workspaceDetails.deletedAt); return ( { - if (data && data.deletedAt && data.deletedAt.Valid === true) { - return getFormatDate(data.deletedAt.Time as string); - } else { - return DEFAULT_DATE; - } +export const parseDeletionTimestamp = (data: { deletedAt: DeletedAt }) => { + const time = getDeletedAtTime(data?.deletedAt); + return time != null ? getFormatDate(time as string) : DEFAULT_DATE; }; /** diff --git a/src/custom/Workspaces/types.ts b/src/custom/Workspaces/types.ts index 825372911..9e2daabcf 100644 --- a/src/custom/Workspaces/types.ts +++ b/src/custom/Workspaces/types.ts @@ -1,3 +1,5 @@ +import { DeletedAt } from '../../utils/nullTime'; + export interface AssignmentHookResult { data: T[]; workspaceData: T[]; @@ -22,9 +24,7 @@ export interface Workspace { metadata?: Record; createdAt: string; updatedAt: string; - deletedAt: { - Valid: boolean; - }; + deletedAt: DeletedAt; } export interface Environment { @@ -45,7 +45,5 @@ export interface Team { // (currently aliased to `Team.name`; flipping here without the server rename // would break the wire contract). Deferred from Phase 2.K cascade. team_name: string; - deletedAt: { - Valid: boolean; - }; + deletedAt: DeletedAt; } diff --git a/src/utils/index.ts b/src/utils/index.ts index ab6922e96..fb1f756eb 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './components'; +export * from './nullTime'; export * from './time.utils'; diff --git a/src/utils/nullTime.ts b/src/utils/nullTime.ts new file mode 100644 index 000000000..51fca09cd --- /dev/null +++ b/src/utils/nullTime.ts @@ -0,0 +1,56 @@ +/** + * Utilities for the soft-delete timestamp (`deletedAt`) that Meshery/Layer5 + * APIs attach to soft-deletable entities (teams, users, workspaces, ...). + * + * The value arrives in more than one shape depending on which endpoint and + * serializer produced it: + * + * - `null` / `undefined` — the entity is NOT soft-deleted. This is the + * canonical `meshery/schemas` wire shape: `core.NullTime` marshals a live + * row to JSON `null`. + * - an ISO-8601 timestamp `string` — the entity IS soft-deleted; the string + * is the deletion time. This is the canonical schema shape for a deleted + * row (`type: string, format: date-time`). + * - a `{ Valid: boolean; Time?: ... }` object — the legacy Go `sql.NullTime` + * JSON shape still emitted by some provider endpoints. Deleted iff `Valid`. + * + * Reading `.Valid` directly is unsafe: when `deletedAt` is `null` the access + * throws `null is not an object (evaluating '…deletedAt.Valid')` — the crash + * that took down the Invite User widget's team search. Route every soft-delete + * check through these helpers so no shape can crash the UI. + */ + +/** The legacy Go `sql.NullTime` JSON shape. */ +export interface SqlNullTime { + Valid: boolean; + Time?: string | number | Date | null; +} + +/** Every shape a `deletedAt` value may take on the wire. */ +export type DeletedAt = string | SqlNullTime | null | undefined; + +/** + * Returns `true` when a `deletedAt` value indicates the entity has been + * soft-deleted, tolerating every shape the value may take (null/undefined, + * timestamp string, or legacy `{ Valid, Time }` object). + */ +export const isSoftDeleted = (deletedAt: DeletedAt): boolean => { + if (deletedAt === null || deletedAt === undefined) return false; + if (typeof deletedAt === 'string') return deletedAt.trim().length > 0; + if (typeof deletedAt === 'object') return deletedAt.Valid === true; + return false; +}; + +/** + * Returns the deletion timestamp suitable for a date formatter when the entity + * is soft-deleted, otherwise `undefined`. Handles both the timestamp-string and + * legacy `{ Valid, Time }` object shapes. + */ +export const getDeletedAtTime = (deletedAt: DeletedAt): string | number | Date | undefined => { + if (!isSoftDeleted(deletedAt)) return undefined; + if (typeof deletedAt === 'string') return deletedAt; + if (deletedAt && typeof deletedAt === 'object') { + return deletedAt.Time ?? undefined; + } + return undefined; +}; diff --git a/src/utils/permissions.ts b/src/utils/permissions.ts index 885251f87..a74787be5 100644 --- a/src/utils/permissions.ts +++ b/src/utils/permissions.ts @@ -1,4 +1,5 @@ import { VISIBILITY } from '../constants/constants'; +import { DeletedAt } from './nullTime'; export interface User { id: string; @@ -7,7 +8,7 @@ export interface User { lastName: string; email: string; avatarUrl?: string; - deletedAt?: { Valid: boolean }; + deletedAt?: DeletedAt; roleNames?: string[]; } From 64e492627a343300d7d707c50194b340dcdbc9dc Mon Sep 17 00:00:00 2001 From: Lee Calcote Date: Wed, 15 Jul 2026 21:56:22 +0000 Subject: [PATCH 2/2] fix(TeamTable): correct deletedAt row index and drop unsafe date cast Address review feedback on the soft-delete hardening: - TeamTableConfiguration action-column render read `rowData[4]` (createdAt) instead of `rowData[6]` (deletedAt). The old `rowData[4].Valid` was harmlessly undefined on the createdAt string, but `isSoftDeleted` correctly treats a non-empty timestamp string as deleted, which turned the pre-existing wrong-index bug into a real regression that permanently disabled every team's edit/delete actions. Use index 6 to match the deletedAt column (and the already-correct setRowProps check), with a clarifying comment at both index sites. - parseDeletionTimestamp no longer casts the deletion time with `as string`. getDeletedAtTime can return string | number | Date, so widen getFormatDate to accept all three (it already calls new Date(date)) and pass the value through without a cast. Signed-off-by: Lee Calcote --- src/custom/TeamTable/TeamTableConfiguration.tsx | 4 +++- src/custom/Workspaces/helper.ts | 2 +- src/utils/time.utils.tsx | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/custom/TeamTable/TeamTableConfiguration.tsx b/src/custom/TeamTable/TeamTableConfiguration.tsx index b32202fd7..f73223db4 100644 --- a/src/custom/TeamTable/TeamTableConfiguration.tsx +++ b/src/custom/TeamTable/TeamTableConfiguration.tsx @@ -211,7 +211,8 @@ export default function TeamTableConfiguration({ sort: false, searchable: false, customBodyRender: (_: string, tableMeta: MUIDataTableMeta) => { - if (bulkSelect || isSoftDeleted(tableMeta.rowData[4])) { + // rowData index 6 is the `deletedAt` column (see colViews/columns order). + if (bulkSelect || isSoftDeleted(tableMeta.rowData[6])) { return ( { const time = getDeletedAtTime(data?.deletedAt); - return time != null ? getFormatDate(time as string) : DEFAULT_DATE; + return time != null ? getFormatDate(time) : DEFAULT_DATE; }; /** diff --git a/src/utils/time.utils.tsx b/src/utils/time.utils.tsx index f0f98aaf0..b12141efb 100644 --- a/src/utils/time.utils.tsx +++ b/src/utils/time.utils.tsx @@ -24,7 +24,7 @@ export const getFullFormattedTime = (date: string): string => { * @param {string} date - ISO format date string * @returns {string} Formatted date in "Month Day, Year" format (e.g. "Jan 1, 2025") */ -export const getFormatDate = (date: string) => { +export const getFormatDate = (date: string | number | Date) => { const options = { year: 'numeric' as const, month: 'short' as const, day: 'numeric' as const }; const formattedDate = new Date(date).toLocaleDateString('en-US', options); return formattedDate;