api: switch license endpoint to POST/JSON, fix active check, add key copy button

- Change /api/v1/license from GET with query params to POST with JSON body
- Request body uses license_key and email_address
- Response returns key, email_address, subscription_level
- Add active check (reject deactivated license keys)
- Exclude /api/v1/automations/ from API key auth (GHL webhook)
- Add copyableFields prop to AdminTable with clipboard icon
- Enable copy button on license key field in license-keys admin page
This commit is contained in:
NeilClack
2026-07-08 14:11:01 -05:00
parent 46b2cdf53d
commit 4d21288511
4 changed files with 58 additions and 19 deletions
+4 -1
View File
@@ -4,7 +4,10 @@ import { building } from '$app/environment';
import { validateApiKey } from '$lib/server/api-key'; import { validateApiKey } from '$lib/server/api-key';
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
if (event.url.pathname.startsWith('/api/v1/')) { if (
event.url.pathname.startsWith('/api/v1/') &&
!event.url.pathname.startsWith('/api/v1/automations/')
) {
const authHeader = event.request.headers.get('authorization'); const authHeader = event.request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'API key required' }), { return new Response(JSON.stringify({ error: 'API key required' }), {
@@ -9,6 +9,7 @@
fieldTypes = {} as Record<string, PrismaScalarType>, fieldTypes = {} as Record<string, PrismaScalarType>,
singular = 'record', singular = 'record',
draggable = false, draggable = false,
copyableFields = [] as string[],
onRowClick, onRowClick,
onNew, onNew,
onReorder onReorder
@@ -19,6 +20,7 @@
fieldTypes?: Record<string, PrismaScalarType>; fieldTypes?: Record<string, PrismaScalarType>;
singular?: string; singular?: string;
draggable?: boolean; draggable?: boolean;
copyableFields?: string[];
onRowClick?: (record: Record<string, unknown>) => void; onRowClick?: (record: Record<string, unknown>) => void;
onNew?: () => void; onNew?: () => void;
onReorder?: (ids: (string | number)[]) => void; onReorder?: (ids: (string | number)[]) => void;
@@ -109,6 +111,10 @@
hoverIndex = null; hoverIndex = null;
} }
function copyValue(v: unknown) {
navigator.clipboard.writeText(String(v ?? ''));
}
function formatCellValue(v: unknown, field: string): string { function formatCellValue(v: unknown, field: string): string {
if (v == null) return ''; if (v == null) return '';
const ft = fieldTypes[field]; const ft = fieldTypes[field];
@@ -196,7 +202,33 @@
{/if} {/if}
{#each listFields as field (field)} {#each listFields as field (field)}
<td class="px-4 py-2 text-sm"> <td class="px-4 py-2 text-sm">
<span class="inline-flex items-center gap-1.5">
{formatCellValue(resolveFieldValue(record, field), field)} {formatCellValue(resolveFieldValue(record, field), field)}
{#if copyableFields.includes(field)}
<button
type="button"
onclick={(e) => {
e.stopPropagation();
copyValue(resolveFieldValue(record, field));
}}
title="Copy to clipboard"
class="shrink-0 leading-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg
class="size-3.5"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3.5" y="2.5" width="11" height="11" rx="1" />
<path d="M5.5 2.5V1.5a1 1 0 011-1h3a1 1 0 011 1v1" />
</svg>
</button>
{/if}
</span>
</td> </td>
{/each} {/each}
</tr> </tr>
@@ -68,6 +68,7 @@
searchFields={config.searchFields} searchFields={config.searchFields}
fieldTypes={config.fieldTypes} fieldTypes={config.fieldTypes}
singular={config.displayNameSingular} singular={config.displayNameSingular}
copyableFields={['key']}
onRowClick={openReplace} onRowClick={openReplace}
/> />
</div> </div>
+20 -17
View File
@@ -2,29 +2,31 @@ import { error } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { prisma } from '$lib/db'; import { prisma } from '$lib/db';
// Get the key and email form the URL export const POST: RequestHandler = async ({ request }) => {
// First, check if the key even exists, throw error if not const body = await request.json();
// Second, check that the email is attached to a real user, throw error const licenseKey = body.license_key ?? '';
// Third, check that the key retrieved from the database belongs to the user, throw error const email = body.email_address ?? '';
// Return response
export const GET: RequestHandler = async ({ url }) => { if (!licenseKey) {
const key = url.searchParams.get('key') ?? ''; throw error(400, 'license_key is required');
const email = url.searchParams.get('email') ?? ''; }
if (key === '') { if (!email) {
throw error(400, 'license key required'); throw error(400, 'email_address is required');
} }
const storedKey = await prisma.licenseKey.findFirst({ const storedKey = await prisma.licenseKey.findFirst({
where: { where: { key: licenseKey }
key: key
}
}); });
if (!storedKey) { if (!storedKey) {
throw error(400, 'license key not found'); throw error(400, 'license key not found');
} }
const userAccount = await prisma.user.findUniqueOrThrow({ where: { email: email } }); if (!storedKey.active) {
throw error(400, 'license key is not active');
}
const userAccount = await prisma.user.findUnique({ where: { email } });
if (!userAccount) { if (!userAccount) {
throw error(400, 'user does not exist'); throw error(400, 'user does not exist');
@@ -37,8 +39,9 @@ export const GET: RequestHandler = async ({ url }) => {
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
key: storedKey.key, key: storedKey.key,
active: storedKey.active, email_address: email,
level: storedKey.level subscription_level: storedKey.level
}) }),
{ headers: { 'content-type': 'application/json' } }
); );
}; };