Skip to content

Commit 900fdcb

Browse files
committed
fix(credentials): keep saved-token expiry captions current and drop a past GitLab personal-token expiry
The integrations list and detail captions re-read the clock when the day count drops or the token expires, instead of only on render. A GitLab personal token's stated expiry that is already past is dropped, the same check the service-account validators apply, now shared as credibleTokenExpiry.
1 parent 6fafaf9 commit 900fdcb

9 files changed

Lines changed: 122 additions & 16 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { formatDateTime } from '@sim/utils/formatting'
1919
import { useRouter } from 'next/navigation'
2020
import { SaveDiscardChips } from '@/components/settings/save-discard-actions'
2121
import { writeOAuthReturnContext } from '@/lib/credentials/client-state'
22-
import { savedTokenExpiryLabel } from '@/lib/credentials/expiry-label'
2322
import { resolveCredentialDisplay } from '@/lib/integrations/credential-display'
2423
import { ConnectOAuthModal } from '@/app/workspace/[workspaceId]/components/connect-oauth-modal'
2524
import {
@@ -40,6 +39,7 @@ import {
4039
type ServiceAccountProviderId,
4140
} from '@/app/workspace/[workspaceId]/integrations/components/connect-service-account-modal'
4241
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
42+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
4343
import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state'
4444
import {
4545
useCreateCredentialDraft,
@@ -100,6 +100,7 @@ export function ConnectedCredentialDetail({
100100
})
101101
const connectMicrosoftDataverseOAuthService = useConnectMicrosoftDataverseOAuthService()
102102
const isAdmin = credential?.role === 'admin'
103+
const expiryLabel = useSavedTokenExpiryLabel(credential?.expiresAt)
103104

104105
const [showDeleteConfirmDialog, setShowDeleteConfirmDialog] = useState(false)
105106
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
@@ -335,8 +336,7 @@ export function ConnectedCredentialDetail({
335336
{credential.expiresAt && (
336337
<DetailSection title='Token expiry'>
337338
<span className='text-[var(--text-secondary)] text-small'>
338-
{formatDateTime(new Date(credential.expiresAt), 'UTC')} ·{' '}
339-
{savedTokenExpiryLabel(new Date(credential.expiresAt), Date.now())}
339+
{formatDateTime(new Date(credential.expiresAt), 'UTC')} · {expiryLabel}
340340
</span>
341341
</DetailSection>
342342
)}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
5+
import { act } from 'react'
6+
import { createRoot, type Root } from 'react-dom/client'
7+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
9+
10+
const NOW = Date.parse('2026-11-11T12:00:00.000Z')
11+
const DAY_MS = 24 * 60 * 60 * 1000
12+
13+
let container: HTMLDivElement
14+
let root: Root
15+
let label: string | undefined
16+
17+
function Harness({ expiresAt }: { expiresAt?: string }) {
18+
label = useSavedTokenExpiryLabel(expiresAt)
19+
return null
20+
}
21+
22+
function mount(expiresAt?: string) {
23+
act(() => {
24+
root.render(<Harness expiresAt={expiresAt} />)
25+
})
26+
}
27+
28+
describe('useSavedTokenExpiryLabel', () => {
29+
beforeEach(() => {
30+
vi.useFakeTimers({ now: NOW })
31+
container = document.createElement('div')
32+
root = createRoot(container)
33+
})
34+
35+
afterEach(() => {
36+
act(() => root.unmount())
37+
vi.useRealTimers()
38+
})
39+
40+
it('reads expired once the token expires while the page stays open', () => {
41+
mount(new Date(NOW + 60_000).toISOString())
42+
expect(label).toBe('expires in 1 day')
43+
act(() => {
44+
vi.advanceTimersByTime(60_000)
45+
})
46+
expect(label).toBe('expired')
47+
})
48+
49+
it('counts the days down while the page stays open', () => {
50+
mount(new Date(NOW + 2 * DAY_MS).toISOString())
51+
expect(label).toBe('expires in 2 days')
52+
act(() => {
53+
vi.advanceTimersByTime(DAY_MS + 1)
54+
})
55+
expect(label).toBe('expires in 1 day')
56+
})
57+
58+
it('has no caption for a token that states no expiry', () => {
59+
mount(undefined)
60+
expect(label).toBeUndefined()
61+
expect(vi.getTimerCount()).toBe(0)
62+
})
63+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { useEffect, useState } from 'react'
2+
import {
3+
msUntilSavedTokenExpiryLabelChanges,
4+
savedTokenExpiryLabel,
5+
} from '@/lib/credentials/expiry-label'
6+
7+
/**
8+
* The caption for a saved token's expiry, kept current while the page stays open: it re-reads the
9+
* clock when the day count drops and when the token expires. `undefined` when the token states no
10+
* readable expiry.
11+
*/
12+
export function useSavedTokenExpiryLabel(expiresAt: string | undefined): string | undefined {
13+
const [now, setNow] = useState(() => Date.now())
14+
const expiresAtMs = expiresAt ? new Date(expiresAt).getTime() : Number.NaN
15+
16+
useEffect(() => {
17+
const untilChange = msUntilSavedTokenExpiryLabelChanges(new Date(expiresAtMs), now)
18+
if (untilChange === null) return
19+
const timer = setTimeout(() => setNow(Date.now()), untilChange)
20+
return () => clearTimeout(timer)
21+
}, [expiresAtMs, now])
22+
23+
return Number.isFinite(expiresAtMs)
24+
? savedTokenExpiryLabel(new Date(expiresAtMs), now)
25+
: undefined
26+
}

‎apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
import { Lock } from '@sim/emcn/icons'
1515
import { useParams } from 'next/navigation'
1616
import { useQueryStates } from 'nuqs'
17-
import { savedTokenExpiryLabel } from '@/lib/credentials/expiry-label'
1817
import {
1918
blockTypeToIconMap,
2019
formatIntegrationType,
@@ -27,6 +26,7 @@ import { IntegrationTabsHeader } from '@/app/workspace/[workspaceId]/components/
2726
import { IntegrationSection } from '@/app/workspace/[workspaceId]/integrations/components/integration-section'
2827
import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase'
2928
import { ShowcaseWithExplore } from '@/app/workspace/[workspaceId]/integrations/components/showcase-with-explore'
29+
import { useSavedTokenExpiryLabel } from '@/app/workspace/[workspaceId]/integrations/hooks/use-saved-token-expiry-label'
3030
import { useScrollRestoration } from '@/app/workspace/[workspaceId]/integrations/hooks/use-scroll-restoration'
3131
import {
3232
ALL_CATEGORY,
@@ -155,16 +155,17 @@ function ConnectedItem({
155155
icon: Icon,
156156
expiresAt,
157157
}: ConnectedItemProps) {
158+
const expiryLabel = useSavedTokenExpiryLabel(expiresAt)
158159
return (
159160
<SettingsResourceRow
160161
iconVariant='custom'
161162
icon={<IntegrationTile blockType={blockType} icon={Icon} />}
162163
title={name}
163164
description={description}
164165
badge={
165-
expiresAt ? (
166+
expiryLabel ? (
166167
<span className='whitespace-nowrap text-[var(--text-muted)] text-caption'>
167-
{savedTokenExpiryLabel(new Date(expiresAt), Date.now())}
168+
{expiryLabel}
168169
</span>
169170
) : undefined
170171
}

‎apps/sim/lib/credentials/expiry-label.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,13 @@ export function savedTokenExpiryLabel(expiresAt: Date, now: number): string {
1111
const days = Math.ceil(remainingMs / DAY_MS)
1212
return `expires in ${days} ${days === 1 ? 'day' : 'days'}`
1313
}
14+
15+
/**
16+
* How long until {@link savedTokenExpiryLabel} reads differently: until the day count drops, or
17+
* until the token expires on its last day. `null` once it reads `expired`, which never changes.
18+
*/
19+
export function msUntilSavedTokenExpiryLabelChanges(expiresAt: Date, now: number): number | null {
20+
const remainingMs = expiresAt.getTime() - now
21+
if (!(remainingMs > 0)) return null
22+
return remainingMs - (Math.ceil(remainingMs / DAY_MS) - 1) * DAY_MS
23+
}

‎apps/sim/lib/credentials/gitlab-personal-token.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ describe('GitLab personal token verification', () => {
9595
it.each([
9696
['midnight UTC on its expiry date', '2027-03-01', new Date('2027-03-01T00:00:00.000Z')],
9797
['no expiry for a date that does not exist', '2027-02-30', null],
98+
['no expiry for a date already past', '2020-01-01', null],
9899
])('records %s', async (_, expiresAt, expected) => {
99100
mocks.fetch
100101
.mockReset()

‎apps/sim/lib/credentials/gitlab-personal-token.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { resourceScopeFromOwner, sameResourceScope } from '@/lib/core/resource-s
55
import { decryptSecret, encryptSecret } from '@/lib/core/security/encryption'
66
import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server'
77
import { isHostNotFound } from '@/lib/credentials/token-service-accounts/errors'
8-
import { gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry'
8+
import { credibleTokenExpiry, gitlabTokenExpiration } from '@/lib/credentials/vendor-token-expiry'
99
import { normalizeGitLabHost } from '@/tools/gitlab/utils'
1010

1111
const gitLabUserSchema = z.object({
@@ -100,7 +100,7 @@ export async function verifyGitLabPersonalToken(accessToken: string, rawHost?: s
100100
instanceUrl,
101101
displayName: `${user.data.name || user.data.username} (${new URL(instanceUrl).host})`,
102102
grantedScopes: token.data.scopes,
103-
expiresAt: gitlabTokenExpiration(token.data.expires_at) ?? null,
103+
expiresAt: credibleTokenExpiry(gitlabTokenExpiration(token.data.expires_at)) ?? null,
104104
}
105105
}
106106

‎apps/sim/lib/credentials/token-service-accounts/validators/http.ts‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
TokenServiceAccountValidationResult,
2121
TokenServiceAccountValidator,
2222
} from '@/lib/credentials/token-service-accounts/server'
23+
import { credibleTokenExpiry } from '@/lib/credentials/vendor-token-expiry'
2324

2425
interface TokenCheckRequest {
2526
url: string
@@ -98,18 +99,12 @@ interface TokenCheck {
9899
}) => Date | undefined
99100
}
100101

101-
/**
102-
* The expiry a check states for an accepted token, when it is still ahead. The vendor just accepted
103-
* the token, so a stated expiry that has already passed contradicts the request (a misread value or
104-
* a skewed clock) and is dropped rather than recorded as fact; an imminent one is kept.
105-
*/
102+
/** The expiry a check states for an accepted token, when it is credible. */
106103
function credibleExpiry(
107104
check: TokenCheck,
108105
accepted: { headers: ProviderResponse['headers']; body: unknown }
109106
): Date | undefined {
110-
const expiresAt = check.expiresAt?.(accepted)
111-
if (!expiresAt || Number.isNaN(expiresAt.getTime())) return undefined
112-
return expiresAt.getTime() > Date.now() ? expiresAt : undefined
107+
return credibleTokenExpiry(check.expiresAt?.(accepted))
113108
}
114109

115110
/** `accepted` with the expiry the check states, when there is a credible one. */

‎apps/sim/lib/credentials/vendor-token-expiry.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,13 @@ export function gitlabTokenExpiration(expiresAt: unknown): Date | undefined {
5959
const match = typeof expiresAt === 'string' ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(expiresAt) : null
6060
return match ? calendarMomentUtc(match.slice(1, 4).map(Number)) : undefined
6161
}
62+
63+
/**
64+
* A stated expiry for a token the vendor just accepted, when it is still ahead. The acceptance
65+
* proves the token works now, so a moment already past contradicts the request (a misread value or
66+
* a skewed clock) and is dropped rather than recorded as fact; an imminent one is kept.
67+
*/
68+
export function credibleTokenExpiry(expiresAt: Date | undefined): Date | undefined {
69+
if (!expiresAt || Number.isNaN(expiresAt.getTime())) return undefined
70+
return expiresAt.getTime() > Date.now() ? expiresAt : undefined
71+
}

0 commit comments

Comments
 (0)