admin: category drag-and-drop reorder + modal-based license key management
- Add sortOrder column to ProclamationCategory with migration - Implement drag-and-drop reorder in AdminTable with persist-to-DB - Refactor AdminTable to use $derived + dragOverride (no $effect sync) - Replace window.location.reload with invalidateAll for reorder - Convert license key create to modal with subscription level dropdown - Add LicenseKeyForm.svelte following the CategoryForm pattern - Add replace action (delete + generate new key with same level) - Remove deactivateOld checkbox; each key has its own level - Use use:enhance with update() to refresh list after create/replace
This commit is contained in:
@@ -8,7 +8,20 @@ import { error } from '@sveltejs/kit';
|
||||
const config = getModelConfig('license-keys')!;
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return loadRecords(locals, config);
|
||||
const records = await loadRecords(locals, config);
|
||||
|
||||
const subscriptionLevels = await prisma.subscriptionLevel.findMany({
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' }
|
||||
});
|
||||
|
||||
return {
|
||||
...records,
|
||||
subscriptionLevels: subscriptionLevels.map((s: { id: number; name: string }) => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
@@ -17,45 +30,39 @@ export const actions = {
|
||||
|
||||
const formData = await request.formData();
|
||||
const email = formData.get('email')?.toString();
|
||||
const deactivateOld = formData.get('deactivateOld') === 'on';
|
||||
const level = formData.get('level')?.toString();
|
||||
|
||||
if (!email) {
|
||||
return { success: false, error: 'Email is required' };
|
||||
}
|
||||
if (!level) {
|
||||
return { success: false, error: 'Subscription level is required' };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
return { success: false, error: 'No user found with that email' };
|
||||
}
|
||||
|
||||
// Deactivate old keys if requested
|
||||
if (deactivateOld) {
|
||||
await prisma.licenseKey.updateMany({
|
||||
where: { userId: user.id, active: true },
|
||||
data: { active: false }
|
||||
});
|
||||
}
|
||||
|
||||
// Generate 16-char hex UUID grouped as 4-4-4-4
|
||||
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
|
||||
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
|
||||
const licenseKey = await prisma.licenseKey.create({
|
||||
await prisma.licenseKey.create({
|
||||
data: {
|
||||
key,
|
||||
active: true,
|
||||
level: 'standard',
|
||||
level,
|
||||
userId: user.id
|
||||
}
|
||||
});
|
||||
|
||||
// Send email with license key
|
||||
try {
|
||||
const profile = await prisma.profile.findUnique({ where: { userId: user.id } });
|
||||
await sendMail({
|
||||
to: email,
|
||||
subject: 'Your License Key',
|
||||
data: {
|
||||
firstName: user.name ?? 'Valued Customer',
|
||||
firstName: profile?.firstName ?? user.name ?? 'Valued Customer',
|
||||
licenseKey: key
|
||||
}
|
||||
});
|
||||
@@ -63,6 +70,32 @@ export const actions = {
|
||||
console.error('Failed to send license key email:', e);
|
||||
}
|
||||
|
||||
return { success: true, error: undefined };
|
||||
},
|
||||
replace: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const formData = await request.formData();
|
||||
const keyId = formData.get('id')?.toString();
|
||||
if (!keyId) return { success: false, error: 'Missing key id' };
|
||||
|
||||
const existing = await prisma.licenseKey.findUnique({ where: { id: keyId } });
|
||||
if (!existing) return { success: false, error: 'License key not found' };
|
||||
|
||||
await prisma.licenseKey.delete({ where: { id: keyId } });
|
||||
|
||||
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
|
||||
const key = `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`;
|
||||
|
||||
await prisma.licenseKey.create({
|
||||
data: {
|
||||
key,
|
||||
active: true,
|
||||
level: existing.level,
|
||||
userId: existing.userId
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, error: undefined };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,36 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import { enhance } from '$app/forms';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { enhance } from '$app/forms';
|
||||
import LicenseKeyForm from './LicenseKeyForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
let { data }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('license-keys')!;
|
||||
|
||||
let showCreateForm = $state(false);
|
||||
let createOpen = $state(false);
|
||||
let replaceOpen = $state(false);
|
||||
let replaceRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let createError = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form && 'success' in (form as ActionResult)) {
|
||||
const result = form as ActionResult;
|
||||
if (result.success) {
|
||||
toastMessage = 'License key created and emailed';
|
||||
function openCreate() {
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
function openReplace(record: Record<string, unknown>) {
|
||||
replaceRecord = record;
|
||||
replaceOpen = true;
|
||||
}
|
||||
|
||||
function closeCreate() {
|
||||
createOpen = false;
|
||||
}
|
||||
|
||||
function closeReplace() {
|
||||
replaceOpen = false;
|
||||
replaceRecord = null;
|
||||
}
|
||||
|
||||
function replaceEnhance() {
|
||||
return async ({ result, update }: any) => {
|
||||
if (result.type === 'success' && result.status === 200) {
|
||||
closeReplace();
|
||||
toastMessage = 'License key replaced';
|
||||
toastType = 'success';
|
||||
showCreateForm = false;
|
||||
} else {
|
||||
createError = result.error ?? 'Failed to create license key';
|
||||
toastMessage = 'Failed to replace license key';
|
||||
toastType = 'error';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function toggleForm() {
|
||||
showCreateForm = !showCreateForm;
|
||||
createError = '';
|
||||
await update();
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,70 +55,76 @@
|
||||
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleForm}
|
||||
onclick={openCreate}
|
||||
class="border border-gray-300 px-3 py-1.5 text-sm font-medium dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{showCreateForm ? 'Cancel' : 'Create Key +'}
|
||||
New License Key +
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreateForm}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/createKey"
|
||||
use:enhance
|
||||
class="mb-6 border border-gray-200 p-4 dark:border-gray-700"
|
||||
>
|
||||
<h2 class="mb-3 text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
Create New License Key
|
||||
</h2>
|
||||
{#if createError}
|
||||
<div class="mb-3 border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{createError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
User Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
required
|
||||
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<input type="checkbox" name="deactivateOld" class="h-4 w-4" />
|
||||
Deactivate existing active keys for this user
|
||||
</label>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Create & Email Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<AdminTable
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
onRowClick={openReplace}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal title="Create License Key" open={createOpen} onClose={closeCreate}>
|
||||
<LicenseKeyForm subscriptionLevels={data.subscriptionLevels} onSuccess={closeCreate} />
|
||||
</AdminModal>
|
||||
|
||||
<AdminModal title="Replace License Key" open={replaceOpen} onClose={closeReplace}>
|
||||
{#if replaceRecord}
|
||||
<div class="flex flex-col gap-4">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">
|
||||
Generate a new key to replace <strong>{String(replaceRecord.key)}</strong>?
|
||||
</p>
|
||||
<div
|
||||
class="rounded border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="mb-1 text-gray-500 dark:text-gray-400">
|
||||
User: <span class="font-medium text-gray-700 dark:text-gray-300"
|
||||
>{String(replaceRecord['user.name'] ?? '')}</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">
|
||||
Level: <span class="font-medium text-gray-700 dark:text-gray-300"
|
||||
>{String(replaceRecord.level ?? '')}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
The existing key will be deleted and a new one with the same subscription level will be
|
||||
created and emailed.
|
||||
</p>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/replace"
|
||||
use:enhance={replaceEnhance}
|
||||
class="flex justify-end gap-2 pt-2"
|
||||
>
|
||||
<input type="hidden" name="id" value={String(replaceRecord.id)} />
|
||||
<button
|
||||
type="button"
|
||||
onclick={closeReplace}
|
||||
class="border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-amber-300 bg-amber-50 px-4 py-1.5 text-sm font-medium hover:bg-amber-100 dark:border-amber-700 dark:bg-amber-900/30 dark:text-amber-300 dark:hover:bg-amber-900/50"
|
||||
>
|
||||
Generate Replacement
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{/if}
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let {
|
||||
subscriptionLevels = [] as { label: string; value: string }[],
|
||||
onSuccess
|
||||
}: {
|
||||
subscriptionLevels?: { label: string; value: string }[];
|
||||
onSuccess?: () => void;
|
||||
} = $props();
|
||||
|
||||
let email = $state('');
|
||||
let level = $state('');
|
||||
let formError = $state('');
|
||||
|
||||
function handleEnhance() {
|
||||
return async ({ result, update }: any) => {
|
||||
if (result.type === 'success' && result.status === 200) {
|
||||
if (result.data?.error) {
|
||||
formError = result.data.error;
|
||||
} else {
|
||||
email = '';
|
||||
level = '';
|
||||
formError = '';
|
||||
await update();
|
||||
onSuccess?.();
|
||||
}
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<form method="POST" action="?/createKey" use:enhance={handleEnhance}>
|
||||
{#if formError}
|
||||
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="field-email"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
User Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="field-email"
|
||||
name="email"
|
||||
required
|
||||
bind:value={email}
|
||||
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="user@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="field-level"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Subscription Level
|
||||
</label>
|
||||
<select
|
||||
name="level"
|
||||
id="field-level"
|
||||
required
|
||||
onchange={(e) => (level = (e.target as HTMLSelectElement).value)}
|
||||
class="w-full border border-gray-300 px-3 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="" disabled selected={!level}>-- Select --</option>
|
||||
{#each subscriptionLevels as opt (opt.value)}
|
||||
<option value={opt.value} selected={opt.value === level}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Create & Email Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -10,7 +10,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
const records = await delegate.findMany({
|
||||
where: { parentId: null },
|
||||
include: config.include,
|
||||
orderBy: { id: 'asc' }
|
||||
orderBy: { sortOrder: 'asc' }
|
||||
});
|
||||
|
||||
const relationOptionsData: Record<string, { label: string; value: string | number }[]> = {};
|
||||
@@ -44,8 +44,11 @@ export const actions = {
|
||||
|
||||
const name = (formData.get('name')?.toString() ?? '').trim();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const childIds = formData.getAll('childId').map(v => v.toString());
|
||||
const childNames = formData.getAll('childName').map(v => v.toString().trim()).filter(Boolean);
|
||||
const childIds = formData.getAll('childId').map((v) => v.toString());
|
||||
const childNames = formData
|
||||
.getAll('childName')
|
||||
.map((v) => v.toString().trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!name) {
|
||||
return { success: false, error: 'Name is required', fieldErrors: { name: 'Required' } };
|
||||
@@ -63,8 +66,14 @@ export const actions = {
|
||||
});
|
||||
categoryId = id;
|
||||
} else {
|
||||
const maxSort = await delegate.findFirst({
|
||||
where: { parentId: null },
|
||||
orderBy: { sortOrder: 'desc' },
|
||||
select: { sortOrder: true }
|
||||
});
|
||||
const sortOrder = (maxSort?.sortOrder ?? -1) + 1;
|
||||
const created = await delegate.create({
|
||||
data: { name }
|
||||
data: { name, sortOrder }
|
||||
});
|
||||
categoryId = created.id;
|
||||
}
|
||||
@@ -104,16 +113,10 @@ export const actions = {
|
||||
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
if (
|
||||
e &&
|
||||
typeof e === 'object' &&
|
||||
'code' in e &&
|
||||
(e as { code: string }).code === 'P2002'
|
||||
) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'A sub-category with this name already exists under this parent.'
|
||||
error: 'A sub-category with this name already exists under this parent.'
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
@@ -137,6 +140,24 @@ export const actions = {
|
||||
await delegate.deleteMany({ where: { parentId: id } });
|
||||
await delegate.delete({ where: { id } });
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
reorder: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const raw = formData.get('ids')?.toString();
|
||||
if (!raw) return { success: false, error: 'Missing ids' };
|
||||
|
||||
const ids = JSON.parse(raw) as number[];
|
||||
|
||||
const delegate = getDelegate(config);
|
||||
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
await delegate.update({
|
||||
where: { id: ids[i] },
|
||||
data: { sortOrder: i }
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import CategoryForm from './CategoryForm.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
@@ -33,6 +34,13 @@
|
||||
}
|
||||
});
|
||||
|
||||
async function handleReorder(ids: (string | number)[]) {
|
||||
const formData = new FormData();
|
||||
formData.set('ids', JSON.stringify(ids));
|
||||
await fetch('?/reorder', { method: 'POST', body: formData });
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
@@ -63,6 +71,8 @@
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
draggable={true}
|
||||
onReorder={handleReorder}
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user