diff --git a/apps/console/src/__generated__/iam/APIKeysPageQuery.graphql.ts b/apps/console/src/__generated__/iam/APIKeysPageQuery.graphql.ts index 00877c258..521d17e7a 100644 --- a/apps/console/src/__generated__/iam/APIKeysPageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/APIKeysPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<76057cc6f78c32b15c6e580c7a0c30da>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -124,6 +124,13 @@ return { "name": "expiresAt", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + }, { "alias": null, "args": null, @@ -187,12 +194,12 @@ return { ] }, "params": { - "cacheID": "cf6f6f621bdcb1bd24a2dd94f856729f", + "cacheID": "4db28039c0fc6e587959d14faf5f3045", "id": null, "metadata": {}, "name": "APIKeysPageQuery", "operationKind": "query", - "text": "query APIKeysPageQuery {\n viewer {\n ...PersonalAPIKeyListFragment\n id\n }\n}\n\nfragment PersonalAPIKeyListFragment on Identity {\n id\n personalAPIKeys(first: 1000) {\n edges {\n node {\n id\n ...PersonalAPIKeyRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment PersonalAPIKeyRowFragment on PersonalAPIKey {\n id\n name\n createdAt\n expiresAt\n}\n" + "text": "query APIKeysPageQuery {\n viewer {\n ...PersonalAPIKeyListFragment\n id\n }\n}\n\nfragment PersonalAPIKeyListFragment on Identity {\n id\n personalAPIKeys(first: 1000) {\n edges {\n node {\n id\n ...PersonalAPIKeyRowFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n }\n }\n}\n\nfragment PersonalAPIKeyRowFragment on PersonalAPIKey {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n}\n" } }; })(); diff --git a/apps/console/src/__generated__/iam/PersonalAPIKeyRowFragment.graphql.ts b/apps/console/src/__generated__/iam/PersonalAPIKeyRowFragment.graphql.ts index 0feffd696..ee85f474b 100644 --- a/apps/console/src/__generated__/iam/PersonalAPIKeyRowFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/PersonalAPIKeyRowFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<<5899ebfb2c67ad34f132f53990b54136>> * @lightSyntaxTransform * @nogrep */ @@ -14,6 +14,7 @@ export type PersonalAPIKeyRowFragment$data = { readonly createdAt: string; readonly expiresAt: string; readonly id: string; + readonly lastUsedAt: string | null | undefined; readonly name: string; readonly token?: string | null | undefined; readonly " $fragmentType": "PersonalAPIKeyRowFragment"; @@ -77,6 +78,13 @@ const node: ReaderFragment = { "name": "expiresAt", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + }, { "condition": "includeToken", "kind": "Condition", @@ -96,6 +104,6 @@ const node: ReaderFragment = { "abstractKey": null }; -(node as any).hash = "d17db443fa203ee5f9c7d0f4576295f0"; +(node as any).hash = "919daef49b4889be6801516d93f803cf"; export default node; diff --git a/apps/console/src/__generated__/iam/PersonalAPIKeyRowRefetchQuery.graphql.ts b/apps/console/src/__generated__/iam/PersonalAPIKeyRowRefetchQuery.graphql.ts index 3ffad672e..c627c20c4 100644 --- a/apps/console/src/__generated__/iam/PersonalAPIKeyRowRefetchQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/PersonalAPIKeyRowRefetchQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<2d413d4e4cf2528a45ff243c0fa733bb>> + * @generated SignedSource<<3f5cf991e78b2be0165979f65ecf845d>> * @lightSyntaxTransform * @nogrep */ @@ -133,6 +133,13 @@ return { "name": "expiresAt", "storageKey": null }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "lastUsedAt", + "storageKey": null + }, { "condition": "includeToken", "kind": "Condition", @@ -157,16 +164,16 @@ return { ] }, "params": { - "cacheID": "b4732244e28d5847baaa5520a95a4cfb", + "cacheID": "f9dbf41761006cde825eb68290509c75", "id": null, "metadata": {}, "name": "PersonalAPIKeyRowRefetchQuery", "operationKind": "query", - "text": "query PersonalAPIKeyRowRefetchQuery(\n $includeToken: Boolean = false\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PersonalAPIKeyRowFragment_2T7Twf\n id\n }\n}\n\nfragment PersonalAPIKeyRowFragment_2T7Twf on PersonalAPIKey {\n id\n name\n createdAt\n expiresAt\n token @include(if: $includeToken)\n}\n" + "text": "query PersonalAPIKeyRowRefetchQuery(\n $includeToken: Boolean = false\n $id: ID!\n) {\n node(id: $id) {\n __typename\n ...PersonalAPIKeyRowFragment_2T7Twf\n id\n }\n}\n\nfragment PersonalAPIKeyRowFragment_2T7Twf on PersonalAPIKey {\n id\n name\n createdAt\n expiresAt\n lastUsedAt\n token @include(if: $includeToken)\n}\n" } }; })(); -(node as any).hash = "d17db443fa203ee5f9c7d0f4576295f0"; +(node as any).hash = "919daef49b4889be6801516d93f803cf"; export default node; diff --git a/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyRow.tsx b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyRow.tsx index 12235c537..cbea04d41 100644 --- a/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyRow.tsx +++ b/apps/console/src/pages/iam/apiKeys/_components/PersonalAPIKeyRow.tsx @@ -28,6 +28,7 @@ export const personalAPIKeyRowFragment = graphql` name createdAt expiresAt + lastUsedAt token @include(if: $includeToken) } `; @@ -110,6 +111,11 @@ export function PersonalAPIKeyRow(props: { {expired ? __("Expired") : __("Active")} + + + {key.lastUsedAt ? formatDate(key.lastUsedAt) : __("Never")} + + {formatDate(key.createdAt)} diff --git a/pkg/coredata/migrations/20260105T150000Z.sql b/pkg/coredata/migrations/20260105T150000Z.sql new file mode 100644 index 000000000..bef13a6db --- /dev/null +++ b/pkg/coredata/migrations/20260105T150000Z.sql @@ -0,0 +1,2 @@ +ALTER TABLE iam_personal_api_keys ADD COLUMN last_used_at TIMESTAMPTZ; + diff --git a/pkg/coredata/personal_api_key.go b/pkg/coredata/personal_api_key.go index a97c0fe42..1db12a8eb 100644 --- a/pkg/coredata/personal_api_key.go +++ b/pkg/coredata/personal_api_key.go @@ -33,6 +33,7 @@ type ( Name string `db:"name"` ExpiresAt time.Time `db:"expires_at"` ExpireReason *ExpireReason `db:"expire_reason"` + LastUsedAt *time.Time `db:"last_used_at"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` } @@ -61,6 +62,7 @@ SELECT name, expires_at, expire_reason, + last_used_at, created_at, updated_at FROM @@ -117,6 +119,7 @@ SELECT name, expires_at, expire_reason, + last_used_at, created_at, updated_at FROM @@ -170,13 +173,14 @@ func (a *PersonalAPIKey) Insert( ) error { q := ` INSERT INTO - iam_personal_api_keys (id, identity_id, name, expires_at, expire_reason, created_at, updated_at) + iam_personal_api_keys (id, identity_id, name, expires_at, expire_reason, last_used_at, created_at, updated_at) VALUES ( @api_key_id, @identity_id, @name, @expires_at, @expire_reason, + @last_used_at, @created_at, @updated_at ) @@ -188,6 +192,7 @@ VALUES ( "name": a.Name, "expires_at": a.ExpiresAt, "expire_reason": a.ExpireReason, + "last_used_at": a.LastUsedAt, "created_at": a.CreatedAt, "updated_at": a.UpdatedAt, } @@ -211,6 +216,7 @@ SET name = @name, expires_at = @expires_at, expire_reason = @expire_reason, + last_used_at = @last_used_at, updated_at = @updated_at WHERE id = @api_key_id @@ -221,6 +227,7 @@ WHERE "name": a.Name, "expires_at": a.ExpiresAt, "expire_reason": a.ExpireReason, + "last_used_at": a.LastUsedAt, "updated_at": a.UpdatedAt, } diff --git a/pkg/iam/api_key.go b/pkg/iam/api_key.go index 736ffbc1e..f5f8e244a 100644 --- a/pkg/iam/api_key.go +++ b/pkg/iam/api_key.go @@ -66,6 +66,13 @@ func (s *APIKeyService) GetAPIKey(ctx context.Context, keyID gid.GID) (*coredata return NewPersonalAPIKeyExpiredError(keyID) } + apiKey.LastUsedAt = &now + apiKey.UpdatedAt = now + + if err := apiKey.Update(ctx, tx); err != nil { + return fmt.Errorf("cannot update personal api key last used at: %w", err) + } + return nil }, ) diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 3fa19f790..6bbb65bfe 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -312,6 +312,7 @@ type PersonalAPIKey implements Node { id: ID! name: String! expiresAt: Datetime! + lastUsedAt: Datetime createdAt: Datetime! token: String @goField(forceResolver: true) diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index f9d577fcf..61bed1ef2 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -276,6 +276,7 @@ type ComplexityRoot struct { CreatedAt func(childComplexity int) int ExpiresAt func(childComplexity int) int ID func(childComplexity int) int + LastUsedAt func(childComplexity int) int Name func(childComplexity int) int Permission func(childComplexity int, action string) int Token func(childComplexity int) int @@ -1530,6 +1531,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.PersonalAPIKey.ID(childComplexity), true + case "PersonalAPIKey.lastUsedAt": + if e.complexity.PersonalAPIKey.LastUsedAt == nil { + break + } + + return e.complexity.PersonalAPIKey.LastUsedAt(childComplexity), true case "PersonalAPIKey.name": if e.complexity.PersonalAPIKey.Name == nil { break @@ -2598,6 +2605,7 @@ type PersonalAPIKey implements Node { id: ID! name: String! expiresAt: Datetime! + lastUsedAt: Datetime createdAt: Datetime! token: String @goField(forceResolver: true) @@ -9070,6 +9078,35 @@ func (ec *executionContext) fieldContext_PersonalAPIKey_expiresAt(_ context.Cont return fc, nil } +func (ec *executionContext) _PersonalAPIKey_lastUsedAt(ctx context.Context, field graphql.CollectedField, obj *types.PersonalAPIKey) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_PersonalAPIKey_lastUsedAt, + func(ctx context.Context) (any, error) { + return obj.LastUsedAt, nil + }, + nil, + ec.marshalODatetime2ᚖtimeᚐTime, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_PersonalAPIKey_lastUsedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "PersonalAPIKey", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Datetime does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _PersonalAPIKey_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.PersonalAPIKey) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -9320,6 +9357,8 @@ func (ec *executionContext) fieldContext_PersonalAPIKeyEdge_node(_ context.Conte return ec.fieldContext_PersonalAPIKey_name(ctx, field) case "expiresAt": return ec.fieldContext_PersonalAPIKey_expiresAt(ctx, field) + case "lastUsedAt": + return ec.fieldContext_PersonalAPIKey_lastUsedAt(ctx, field) case "createdAt": return ec.fieldContext_PersonalAPIKey_createdAt(ctx, field) case "token": @@ -17483,6 +17522,8 @@ func (ec *executionContext) _PersonalAPIKey(ctx context.Context, sel ast.Selecti if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "lastUsedAt": + out.Values[i] = ec._PersonalAPIKey_lastUsedAt(ctx, field, obj) case "createdAt": out.Values[i] = ec._PersonalAPIKey_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { diff --git a/pkg/server/api/connect/v1/types/personal_api_key.go b/pkg/server/api/connect/v1/types/personal_api_key.go index 50434047e..6eb03423d 100644 --- a/pkg/server/api/connect/v1/types/personal_api_key.go +++ b/pkg/server/api/connect/v1/types/personal_api_key.go @@ -61,9 +61,10 @@ func NewPersonalAPIKeyEdge(personalAPIKey *coredata.PersonalAPIKey, orderField c func NewPersonalAPIKey(personalAPIKey *coredata.PersonalAPIKey) *PersonalAPIKey { return &PersonalAPIKey{ - ID: personalAPIKey.ID, - Name: personalAPIKey.Name, - ExpiresAt: personalAPIKey.ExpiresAt, - CreatedAt: personalAPIKey.CreatedAt, + ID: personalAPIKey.ID, + Name: personalAPIKey.Name, + ExpiresAt: personalAPIKey.ExpiresAt, + LastUsedAt: personalAPIKey.LastUsedAt, + CreatedAt: personalAPIKey.CreatedAt, } } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 2712923c3..9d46c7e4b 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -281,12 +281,13 @@ type PasswordRequired struct { func (PasswordRequired) IsAssumeOrganizationSessionResult() {} type PersonalAPIKey struct { - ID gid.GID `json:"id"` - Name string `json:"name"` - ExpiresAt time.Time `json:"expiresAt"` - CreatedAt time.Time `json:"createdAt"` - Token *string `json:"token,omitempty"` - Permission bool `json:"permission"` + ID gid.GID `json:"id"` + Name string `json:"name"` + ExpiresAt time.Time `json:"expiresAt"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Token *string `json:"token,omitempty"` + Permission bool `json:"permission"` } func (PersonalAPIKey) IsNode() {}