Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { ArrowLeft } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { formatDateTime } from '@sim/utils/formatting'
import { useRouter } from 'next/navigation'
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
Expand All @@ -38,6 +39,7 @@ import {
type ServiceAccountProviderId,
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
import {
useCreateCredentialDraft,
Expand Down Expand Up @@ -98,6 +100,7 @@ export function ConnectedCredentialDetail({
})
const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService()
const isAdmin = credential?.role === 'admin'
const expiryLabel = useSavedTokenExpiryLabel(credential?.expiresAt)

const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
Expand Down Expand Up @@ -330,6 +333,13 @@ export function ConnectedCredentialDetail({
<ChipCopyInput value={credential.instanceUrl} copyLabel='Copy GitLab instance' />
</DetailSection>
)}
{credential.expiresAt && (
<DetailSection title='Token expiry'>
<span className='text-[var(--text-secondary)] text-small'>
{formatDateTime(new Date(credential.expiresAt), 'UTC')} · {expiryLabel}
</span>
</DetailSection>
)}
</CredentialDetailLayout>

<ChipConfirmModal
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @vitest-environment jsdom
*/

import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'

const NOW = Date.parse('2026-11-11T12:00:00.000Z')
const DAY_MS = 24 * 60 * 60 * 1000

let container: HTMLDivElement
let root: Root
let label: string | undefined

function Harness({ expiresAt }: { expiresAt?: string }) {
label = useSavedTokenExpiryLabel(expiresAt)
return null
}

function mount(expiresAt?: string) {
act(() => {
root.render(<Harness expiresAt={expiresAt} />)
})
}

describe('useSavedTokenExpiryLabel', () => {
beforeEach(() => {
vi.useFakeTimers({ now: NOW })
container = document.createElement('div')
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
vi.useRealTimers()
})

it('reads expired once the token expires while the page stays open', () => {
mount(new Date(NOW + 60_000).toISOString())
expect(label).toBe('expires in 1 day')
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(label).toBe('expired')
})

it('counts the days down while the page stays open', () => {
mount(new Date(NOW + 2 * DAY_MS).toISOString())
expect(label).toBe('expires in 2 days')
act(() => {
vi.advanceTimersByTime(DAY_MS + 1)
})
expect(label).toBe('expires in 1 day')
})

it('times a reconnected token from the moment it arrives, not from when the page opened', () => {
mount(new Date(NOW + 3 * DAY_MS).toISOString())
act(() => {
vi.advanceTimersByTime(6 * 60 * 60 * 1000)
})
mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
expect(label).toBe('expires in 1 day')
act(() => {
vi.advanceTimersByTime(60 * 60 * 1000)
})
expect(label).toBe('expired')
})

it('counts a token that gains an expiry after the page has been open for days from now', () => {
mount(undefined)
act(() => {
vi.advanceTimersByTime(3 * DAY_MS)
})
mount(new Date(Date.now() + 60 * 60 * 1000).toISOString())
expect(label).toBe('expires in 1 day')
})

it('has no caption for a token that states no expiry', () => {
mount(undefined)
expect(label).toBeUndefined()
expect(vi.getTimerCount()).toBe(0)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import {
msUntilSavedTokenExpiryLabelChanges,
savedTokenExpiryLabel,
} from '@/lib/credentials/expiry-label'

/**
* The caption for a saved token's expiry, kept current while the page stays open: it re-reads the
* clock when the expiry changes (a reconnect), when the day count drops, and when the token
* expires. `undefined` when the token states no readable expiry.
*/
export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined {
const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN
const [clock, setClock] = useState(() => ({ expiresAtMs, now: Date.now() }))
if (!Object.is(clock.expiresAtMs, expiresAtMs)) {
setClock({ expiresAtMs, now: Date.now() })
}
const { now } = clock

useEffect(() => {
const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now)
if (untilChange === null) return
const timer = setTimeout(
() => setClock((current) => ({ ...current, now: Date.now() })),
untilChange
)
return () => clearTimeout(timer)
}, [expiresAtMs, now])
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved

return Number.isFinite(expiresAtMs)
? savedTokenExpiryLabel(new Date(expiresAtMs), now)
: undefined
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
import {
ALL_CATEGORY,
Expand Down Expand Up @@ -142,15 +143,32 @@ interface ConnectedItemProps {
name: string
description: string
icon: ComponentType<{ className?: string }>
/** ISO time the saved token stops working, when its vendor stated one. */
expiresAt?: string
}

function ConnectedItem({ href, blockType, name, description, icon: Icon }: ConnectedItemProps) {
function ConnectedItem({
href,
blockType,
name,
description,
icon: Icon,
expiresAt,
}: ConnectedItemProps) {
const expiryLabel = useSavedTokenExpiryLabel(expiresAt)
return (
<SettingsResourceRow
iconVariant='custom'
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
title={name}
description={description}
badge={
expiryLabel ? (
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
{expiryLabel}
</span>
) : undefined
}
href={href}
clickLabel={`Open ${name}`}
navigable
Expand Down Expand Up @@ -439,6 +457,7 @@ function IntegrationsContent() {
name={item.name}
description={item.description}
icon={item.icon}
expiresAt={item.credential.expiresAt}
/>
))}
</IntegrationSection>
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/api/contracts/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ export const workspaceCredentialSchema = z.object({
envKey: z.string().nullable(),
envOwnerUserId: z.string().nullable(),
instanceUrl: z.string().url().optional(),
/** ISO time the saved token stops working, when its vendor stated one; absent when it never expires or is unknown. */
expiresAt: z.string().optional(),
createdBy: z.string(),
createdAt: z.string(),
updatedAt: z.string(),
Expand Down
Loading
Loading