diff --git a/frontend/src/__tests__/apikeys.test.ts b/frontend/src/__tests__/apikeys.test.ts index ea5b09163..978bdd30c 100644 --- a/frontend/src/__tests__/apikeys.test.ts +++ b/frontend/src/__tests__/apikeys.test.ts @@ -927,5 +927,50 @@ describe('API Keys Module', () => { // The exact format depends on locale, but it should contain date parts expect(container?.innerHTML).toContain('2024'); }); + + // #492: last_used_at null renders "Never", not "0 min ago" or empty + test('renders Never for null last_used_at (issue #492)', async () => { + const mockKeys = [ + { + id: 'key-1', + name: 'Unused Key', + key_prefix: 'abc123', + is_active: true, + created_at: '2024-01-15T10:00:00Z' + // last_used_at intentionally absent + } + ]; + + (api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: mockKeys }); + await loadApiKeys(); + + const container = document.getElementById('apikeys-list'); + expect(container?.innerHTML).toContain('Never'); + }); + + // #492: populated last_used_at renders relative time with ISO title for hover + test('renders relative time with ISO title for populated last_used_at (issue #492)', async () => { + const recentTs = new Date(Date.now() - 2 * 3600 * 1000).toISOString(); // 2 hours ago + const mockKeys = [ + { + id: 'key-1', + name: 'Used Key', + key_prefix: 'abc123', + is_active: true, + created_at: '2024-01-15T10:00:00Z', + last_used_at: recentTs + } + ]; + + (api.getApiKeys as jest.Mock).mockResolvedValue({ api_keys: mockKeys }); + await loadApiKeys(); + + const container = document.getElementById('apikeys-list'); + // Relative label: ends with "h ago" for a 2-hour-old timestamp + expect(container?.innerHTML).toMatch(/\d+h ago/); + // Absolute ISO on the title attribute for hover + const span = container?.querySelector('[title]'); + expect(span?.getAttribute('title')).toBe(new Date(recentTs).toISOString()); + }); }); }); diff --git a/frontend/src/apikeys.ts b/frontend/src/apikeys.ts index 0a1e8eb66..fb4633be8 100644 --- a/frontend/src/apikeys.ts +++ b/frontend/src/apikeys.ts @@ -4,7 +4,7 @@ import * as api from './api'; import type { APIKeyInfo, CreateAPIKeyResponse } from './types'; -import { formatDateTime } from './utils'; +import { formatDateTime, formatRelativeTime } from './utils'; import { confirmDialog } from './confirmDialog'; import { showToast } from './toast'; import { openModal, closeModal } from './modal'; @@ -85,7 +85,7 @@ export function renderApiKeysList(): void { ${escapeHtml(key.key_prefix)}... ${statusText} ${formatDateTime(key.created_at)} - ${key.last_used_at ? formatDateTime(key.last_used_at) : 'Never'} + ${key.last_used_at ? `${escapeHtml(formatRelativeTime(key.last_used_at))}` : 'Never'} ${key.expires_at ? formatDateTime(key.expires_at) : 'Never'} ${key.is_active && !isExpired ? `` : ''} diff --git a/internal/auth/service_apikeys_api_test.go b/internal/auth/service_apikeys_api_test.go index 74541265b..9ec8db7a1 100644 --- a/internal/auth/service_apikeys_api_test.go +++ b/internal/auth/service_apikeys_api_test.go @@ -151,6 +151,68 @@ func TestService_ListUserAPIKeysAPI(t *testing.T) { mockStore.AssertExpectations(t) }) + // #492: last_used_at must round-trip through ListUserAPIKeysAPI so the + // frontend can render "Last used" without a separate endpoint. + t.Run("last_used_at is present in response when key was used (issue #492)", func(t *testing.T) { + mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + service := &Service{store: mockStore} + + createdAt := time.Now() + usedAt := createdAt.Add(-2 * time.Hour) + keys := []*UserAPIKey{ + { + ID: "key-used", + UserID: "user-123", + Name: "Used Key", + KeyPrefix: "prefix1", + IsActive: true, + CreatedAt: createdAt, + LastUsedAt: &usedAt, + }, + } + user := &User{ID: "user-123", Email: "test@example.com", Active: true} + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("ListAPIKeysByUser", ctx, "user-123").Return(keys, nil) + + result, err := service.ListUserAPIKeysAPI(ctx, "user-123") + + require.NoError(t, err) + resp := result.(*APIListAPIKeysResponse) + require.Len(t, resp.APIKeys, 1) + require.NotNil(t, resp.APIKeys[0].LastUsedAt, "last_used_at must be set in the API response") + assert.True(t, resp.APIKeys[0].LastUsedAt.Equal(usedAt)) + }) + + // #492: last_used_at must be nil (not zero-time) for a never-used key. + t.Run("last_used_at is nil in response for never-used key (issue #492)", func(t *testing.T) { + mockStore := new(MockStore) + t.Cleanup(func() { mockStore.AssertExpectations(t) }) + service := &Service{store: mockStore} + + keys := []*UserAPIKey{ + { + ID: "key-new", + UserID: "user-123", + Name: "New Key", + KeyPrefix: "prefix2", + IsActive: true, + CreatedAt: time.Now(), + // LastUsedAt intentionally nil + }, + } + user := &User{ID: "user-123", Email: "test@example.com", Active: true} + mockStore.On("GetUserByID", ctx, "user-123").Return(user, nil) + mockStore.On("ListAPIKeysByUser", ctx, "user-123").Return(keys, nil) + + result, err := service.ListUserAPIKeysAPI(ctx, "user-123") + + require.NoError(t, err) + resp := result.(*APIListAPIKeysResponse) + require.Len(t, resp.APIKeys, 1) + assert.Nil(t, resp.APIKeys[0].LastUsedAt, "last_used_at must be nil for a never-used key") + }) + t.Run("return empty list when no keys", func(t *testing.T) { mockStore := new(MockStore) service := &Service{store: mockStore}