Add last used at to personal api key

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 16:46:13 +01:00
parent cf72beaba4
commit 71263367f6
11 changed files with 108 additions and 20 deletions

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<76057cc6f78c32b15c6e580c7a0c30da>> * @generated SignedSource<<ee849e769490a70fd3d28e8f809245c2>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -124,6 +124,13 @@ return {
"name": "expiresAt", "name": "expiresAt",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
},
{ {
"alias": null, "alias": null,
"args": null, "args": null,
@@ -187,12 +194,12 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "cf6f6f621bdcb1bd24a2dd94f856729f", "cacheID": "4db28039c0fc6e587959d14faf5f3045",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "APIKeysPageQuery", "name": "APIKeysPageQuery",
"operationKind": "query", "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"
} }
}; };
})(); })();

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<d4d4692688621656de57fda5884703dd>> * @generated SignedSource<<5899ebfb2c67ad34f132f53990b54136>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -14,6 +14,7 @@ export type PersonalAPIKeyRowFragment$data = {
readonly createdAt: string; readonly createdAt: string;
readonly expiresAt: string; readonly expiresAt: string;
readonly id: string; readonly id: string;
readonly lastUsedAt: string | null | undefined;
readonly name: string; readonly name: string;
readonly token?: string | null | undefined; readonly token?: string | null | undefined;
readonly " $fragmentType": "PersonalAPIKeyRowFragment"; readonly " $fragmentType": "PersonalAPIKeyRowFragment";
@@ -77,6 +78,13 @@ const node: ReaderFragment = {
"name": "expiresAt", "name": "expiresAt",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
},
{ {
"condition": "includeToken", "condition": "includeToken",
"kind": "Condition", "kind": "Condition",
@@ -96,6 +104,6 @@ const node: ReaderFragment = {
"abstractKey": null "abstractKey": null
}; };
(node as any).hash = "d17db443fa203ee5f9c7d0f4576295f0"; (node as any).hash = "919daef49b4889be6801516d93f803cf";
export default node; export default node;

View File

@@ -1,5 +1,5 @@
/** /**
* @generated SignedSource<<2d413d4e4cf2528a45ff243c0fa733bb>> * @generated SignedSource<<3f5cf991e78b2be0165979f65ecf845d>>
* @lightSyntaxTransform * @lightSyntaxTransform
* @nogrep * @nogrep
*/ */
@@ -133,6 +133,13 @@ return {
"name": "expiresAt", "name": "expiresAt",
"storageKey": null "storageKey": null
}, },
{
"alias": null,
"args": null,
"kind": "ScalarField",
"name": "lastUsedAt",
"storageKey": null
},
{ {
"condition": "includeToken", "condition": "includeToken",
"kind": "Condition", "kind": "Condition",
@@ -157,16 +164,16 @@ return {
] ]
}, },
"params": { "params": {
"cacheID": "b4732244e28d5847baaa5520a95a4cfb", "cacheID": "f9dbf41761006cde825eb68290509c75",
"id": null, "id": null,
"metadata": {}, "metadata": {},
"name": "PersonalAPIKeyRowRefetchQuery", "name": "PersonalAPIKeyRowRefetchQuery",
"operationKind": "query", "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; export default node;

View File

@@ -28,6 +28,7 @@ export const personalAPIKeyRowFragment = graphql`
name name
createdAt createdAt
expiresAt expiresAt
lastUsedAt
token @include(if: $includeToken) token @include(if: $includeToken)
} }
`; `;
@@ -110,6 +111,11 @@ export function PersonalAPIKeyRow(props: {
{expired ? __("Expired") : __("Active")} {expired ? __("Expired") : __("Active")}
</div> </div>
</Td> </Td>
<Td>
<span className="text-sm text-txt-secondary">
{key.lastUsedAt ? formatDate(key.lastUsedAt) : __("Never")}
</span>
</Td>
<Td> <Td>
<span className="text-sm text-txt-secondary"> <span className="text-sm text-txt-secondary">
{formatDate(key.createdAt)} {formatDate(key.createdAt)}

View File

@@ -0,0 +1,2 @@
ALTER TABLE iam_personal_api_keys ADD COLUMN last_used_at TIMESTAMPTZ;

View File

@@ -33,6 +33,7 @@ type (
Name string `db:"name"` Name string `db:"name"`
ExpiresAt time.Time `db:"expires_at"` ExpiresAt time.Time `db:"expires_at"`
ExpireReason *ExpireReason `db:"expire_reason"` ExpireReason *ExpireReason `db:"expire_reason"`
LastUsedAt *time.Time `db:"last_used_at"`
CreatedAt time.Time `db:"created_at"` CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"` UpdatedAt time.Time `db:"updated_at"`
} }
@@ -61,6 +62,7 @@ SELECT
name, name,
expires_at, expires_at,
expire_reason, expire_reason,
last_used_at,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -117,6 +119,7 @@ SELECT
name, name,
expires_at, expires_at,
expire_reason, expire_reason,
last_used_at,
created_at, created_at,
updated_at updated_at
FROM FROM
@@ -170,13 +173,14 @@ func (a *PersonalAPIKey) Insert(
) error { ) error {
q := ` q := `
INSERT INTO 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 ( VALUES (
@api_key_id, @api_key_id,
@identity_id, @identity_id,
@name, @name,
@expires_at, @expires_at,
@expire_reason, @expire_reason,
@last_used_at,
@created_at, @created_at,
@updated_at @updated_at
) )
@@ -188,6 +192,7 @@ VALUES (
"name": a.Name, "name": a.Name,
"expires_at": a.ExpiresAt, "expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason, "expire_reason": a.ExpireReason,
"last_used_at": a.LastUsedAt,
"created_at": a.CreatedAt, "created_at": a.CreatedAt,
"updated_at": a.UpdatedAt, "updated_at": a.UpdatedAt,
} }
@@ -211,6 +216,7 @@ SET
name = @name, name = @name,
expires_at = @expires_at, expires_at = @expires_at,
expire_reason = @expire_reason, expire_reason = @expire_reason,
last_used_at = @last_used_at,
updated_at = @updated_at updated_at = @updated_at
WHERE WHERE
id = @api_key_id id = @api_key_id
@@ -221,6 +227,7 @@ WHERE
"name": a.Name, "name": a.Name,
"expires_at": a.ExpiresAt, "expires_at": a.ExpiresAt,
"expire_reason": a.ExpireReason, "expire_reason": a.ExpireReason,
"last_used_at": a.LastUsedAt,
"updated_at": a.UpdatedAt, "updated_at": a.UpdatedAt,
} }

View File

@@ -66,6 +66,13 @@ func (s *APIKeyService) GetAPIKey(ctx context.Context, keyID gid.GID) (*coredata
return NewPersonalAPIKeyExpiredError(keyID) 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 return nil
}, },
) )

View File

@@ -312,6 +312,7 @@ type PersonalAPIKey implements Node {
id: ID! id: ID!
name: String! name: String!
expiresAt: Datetime! expiresAt: Datetime!
lastUsedAt: Datetime
createdAt: Datetime! createdAt: Datetime!
token: String @goField(forceResolver: true) token: String @goField(forceResolver: true)

View File

@@ -276,6 +276,7 @@ type ComplexityRoot struct {
CreatedAt func(childComplexity int) int CreatedAt func(childComplexity int) int
ExpiresAt func(childComplexity int) int ExpiresAt func(childComplexity int) int
ID func(childComplexity int) int ID func(childComplexity int) int
LastUsedAt func(childComplexity int) int
Name func(childComplexity int) int Name func(childComplexity int) int
Permission func(childComplexity int, action string) int Permission func(childComplexity int, action string) int
Token func(childComplexity int) 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 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": case "PersonalAPIKey.name":
if e.complexity.PersonalAPIKey.Name == nil { if e.complexity.PersonalAPIKey.Name == nil {
break break
@@ -2598,6 +2605,7 @@ type PersonalAPIKey implements Node {
id: ID! id: ID!
name: String! name: String!
expiresAt: Datetime! expiresAt: Datetime!
lastUsedAt: Datetime
createdAt: Datetime! createdAt: Datetime!
token: String @goField(forceResolver: true) token: String @goField(forceResolver: true)
@@ -9070,6 +9078,35 @@ func (ec *executionContext) fieldContext_PersonalAPIKey_expiresAt(_ context.Cont
return fc, nil 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) { func (ec *executionContext) _PersonalAPIKey_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.PersonalAPIKey) (ret graphql.Marshaler) {
return graphql.ResolveField( return graphql.ResolveField(
ctx, ctx,
@@ -9320,6 +9357,8 @@ func (ec *executionContext) fieldContext_PersonalAPIKeyEdge_node(_ context.Conte
return ec.fieldContext_PersonalAPIKey_name(ctx, field) return ec.fieldContext_PersonalAPIKey_name(ctx, field)
case "expiresAt": case "expiresAt":
return ec.fieldContext_PersonalAPIKey_expiresAt(ctx, field) return ec.fieldContext_PersonalAPIKey_expiresAt(ctx, field)
case "lastUsedAt":
return ec.fieldContext_PersonalAPIKey_lastUsedAt(ctx, field)
case "createdAt": case "createdAt":
return ec.fieldContext_PersonalAPIKey_createdAt(ctx, field) return ec.fieldContext_PersonalAPIKey_createdAt(ctx, field)
case "token": case "token":
@@ -17483,6 +17522,8 @@ func (ec *executionContext) _PersonalAPIKey(ctx context.Context, sel ast.Selecti
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1) atomic.AddUint32(&out.Invalids, 1)
} }
case "lastUsedAt":
out.Values[i] = ec._PersonalAPIKey_lastUsedAt(ctx, field, obj)
case "createdAt": case "createdAt":
out.Values[i] = ec._PersonalAPIKey_createdAt(ctx, field, obj) out.Values[i] = ec._PersonalAPIKey_createdAt(ctx, field, obj)
if out.Values[i] == graphql.Null { if out.Values[i] == graphql.Null {

View File

@@ -64,6 +64,7 @@ func NewPersonalAPIKey(personalAPIKey *coredata.PersonalAPIKey) *PersonalAPIKey
ID: personalAPIKey.ID, ID: personalAPIKey.ID,
Name: personalAPIKey.Name, Name: personalAPIKey.Name,
ExpiresAt: personalAPIKey.ExpiresAt, ExpiresAt: personalAPIKey.ExpiresAt,
LastUsedAt: personalAPIKey.LastUsedAt,
CreatedAt: personalAPIKey.CreatedAt, CreatedAt: personalAPIKey.CreatedAt,
} }
} }

View File

@@ -284,6 +284,7 @@ type PersonalAPIKey struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
Name string `json:"name"` Name string `json:"name"`
ExpiresAt time.Time `json:"expiresAt"` ExpiresAt time.Time `json:"expiresAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
Token *string `json:"token,omitempty"` Token *string `json:"token,omitempty"`
Permission bool `json:"permission"` Permission bool `json:"permission"`