Replace account and license-keys pages with profile page and dashboard

This commit is contained in:
NeilClack
2026-09-10 13:11:41 -05:00
parent f50eb34769
commit 1aecc51bbd
20 changed files with 157 additions and 254 deletions
@@ -0,0 +1,25 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let {
title,
description = '',
children
}: {
title: string;
description?: string;
children: Snippet;
} = $props();
</script>
<section class="mt-8">
<div>
<h2 class="text-lg font-semibold text-slate-900">{title}</h2>
{#if description}
<p class="mt-1 text-sm text-slate-500">{description}</p>
{/if}
</div>
<div class="mt-4">
{@render children()}
</div>
</section>
@@ -7,23 +7,10 @@ export const load: PageServerLoad = async ({ locals }) => {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findFirst({
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
select: { firstName: true }
orderBy: { createdAt: 'desc' }
});
const keyStats = await prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
});
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys
};
return { licenseKeys };
};
@@ -1,33 +1,90 @@
<script lang="ts">
import { resolve } from '$app/paths';
import DashboardSection from '$lib/components/DashboardSection.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
function formatDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
return date.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric'
});
}
</script>
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">
Welcome, {data.firstName}!
</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Here's an overview of your account.</p>
<div class="mt-6 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<a
href={resolve('/license-keys')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<span class="text-sm font-medium text-slate-600">Active License Keys</span>
<span class="text-3xl font-bold text-indigo-600">{data.activeKeys}</span>
<span class="text-sm text-slate-500">{data.totalKeys} total</span>
</a>
<a
href={resolve('/account')}
class="group flex flex-col gap-1 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm transition hover:border-indigo-200 hover:shadow"
>
<span class="text-sm font-medium text-slate-600">Account</span>
<span class="text-2xl font-semibold text-slate-900">Manage Profile</span>
<span class="text-sm text-slate-500">Edit your details</span>
</a>
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Dashboard</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Manage your account and license keys.</p>
</div>
<!--
Dashboard sections. This page is the account management surface; personal
information (name, email, phone, addresses) lives on the Profile page.
Planned sections to add below as <DashboardSection> blocks:
- Schedules: create and manage listening schedules.
- Devices / playback.
- Billing / orders.
-->
<DashboardSection title="License Keys" description="Your active and inactive license keys.">
{#if data.licenseKeys.length > 0}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Desktop / tablet: table -->
<table class="hidden w-full text-left text-sm sm:table">
<thead
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase"
>
<tr>
<th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th>
<th class="px-6 py-3 font-semibold">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 font-mono text-slate-800">{key.key}</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</td>
<td class="px-6 py-4 text-slate-600">{formatDate(key.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile: card list -->
<ul class="divide-y divide-slate-100 sm:hidden">
{#each data.licenseKeys as key (key.key)}
<li class="flex flex-col gap-2 px-4 py-4">
<div class="flex items-center justify-between gap-4">
<span class="min-w-0 font-mono text-sm break-all text-slate-800">{key.key}</span>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</div>
<span class="text-xs text-slate-500">Created {formatDate(key.createdAt)}</span>
</li>
{/each}
</ul>
</div>
{:else}
<div class="rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
<p class="text-sm text-slate-600">You don't have any license keys yet.</p>
</div>
{/if}
</DashboardSection>
</div>
@@ -1,24 +1,21 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findFirst: vi.fn() },
licenseKey: { groupBy: vi.fn() }
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const licenseKeyFindMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindFirst.mockReset();
licenseKeyGroupBy.mockReset();
licenseKeyFindMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
@@ -28,44 +25,35 @@ describe('(protected)/dashboard page server load', () => {
expect(isHttpError(err, 403)).toBe(true);
});
it('returns firstName from profile', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
licenseKeyFindMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('Jane');
expect(result.licenseKeys).toEqual(keys);
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindFirst.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
it('returns empty array when user has no keys', async () => {
licenseKeyFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('User');
expect(result.licenseKeys).toEqual([]);
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
it('loads license keys filtered by user, newest first', async () => {
licenseKeyFindMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(3);
expect(result.totalKeys).toBe(5);
});
await load({ locals } as any);
it('returns 0 for activeKeys when none are active', async () => {
profileFindFirst.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(0);
expect(result.totalKeys).toBe(4);
expect(licenseKeyFindMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { createdAt: 'desc' }
});
});
});
@@ -1,16 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { key: 'asc' }
});
return { licenseKeys };
};
@@ -1,66 +0,0 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div>
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">License Keys</h1>
<p class="mt-1 text-sm text-slate-500">Your active and inactive license keys.</p>
</div>
</div>
{#if data.licenseKeys.length > 0}
<div class="mt-6 overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<!-- Desktop / tablet: table -->
<table class="hidden w-full text-left text-sm sm:table">
<thead
class="border-b border-slate-200 bg-slate-50 text-xs tracking-wide text-slate-500 uppercase"
>
<tr>
<th class="px-6 py-3 font-semibold">Key</th>
<th class="px-6 py-3 font-semibold">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
{#each data.licenseKeys as key (key.key)}
<tr class="hover:bg-slate-50">
<td class="px-6 py-4 font-mono text-slate-800">{key.key}</td>
<td class="px-6 py-4">
<span
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile: card list -->
<ul class="divide-y divide-slate-100 sm:hidden">
{#each data.licenseKeys as key (key.key)}
<li class="flex items-center justify-between gap-4 px-4 py-4">
<span class="min-w-0 font-mono text-sm break-all text-slate-800">{key.key}</span>
<span
class="shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium {key.active
? 'bg-green-100 text-green-700'
: 'bg-slate-100 text-slate-600'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</li>
{/each}
</ul>
</div>
{:else}
<div class="mt-6 rounded-2xl border border-dashed border-slate-300 bg-white p-8 text-center">
<p class="text-sm text-slate-600">You don't have any license keys yet.</p>
</div>
{/if}
</div>
@@ -1,59 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/license-keys page server load', () => {
beforeEach(() => {
findMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
findMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual(keys);
});
it('passes userId in the where clause', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(findMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { key: 'asc' }
});
});
it('returns empty array when user has no keys', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual([]);
});
});
@@ -24,8 +24,8 @@
</script>
<div class="mx-auto w-full max-w-3xl">
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Account</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Manage your profile and addresses.</p>
<h1 class="text-2xl font-bold tracking-tight text-slate-900 sm:text-3xl">Profile</h1>
<p class="mt-1 text-sm text-slate-500 sm:text-base">Manage your account details and addresses.</p>
<!-- Email (read-only) -->
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-5 shadow-sm sm:p-6">
@@ -26,7 +26,7 @@ const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
(actions as any)[name]({ request, locals } as any);
describe('(protected)/account page server', () => {
describe('(protected)/profile page server', () => {
beforeEach(() => {
userFindUnique.mockReset();
profileFindFirst.mockReset();
@@ -27,7 +27,7 @@ import { load, actions } from './+page.server';
const invokeDefault = (request: Request) => actions.default({ request } as any);
describe('account setup page server load', () => {
describe('profile setup page server load', () => {
beforeEach(() => {
create.mockReset();
});
@@ -48,7 +48,7 @@ describe('account setup page server load', () => {
});
});
describe('account setup page server action', () => {
describe('profile setup page server action', () => {
beforeEach(() => {
create.mockReset();
});
+3 -3
View File
@@ -14,9 +14,9 @@ export const load: LayoutServerLoad = async ({ locals, url }) => {
if (!user) {
throw error(403, 'not authorized');
}
if (user.profiles.length === 0 && !url.pathname.startsWith('/account/setup')) {
// Skip redirect when already on /account/setup to avoid redirect loop
throw redirect(303, '/account/setup');
if (user.profiles.length === 0 && !url.pathname.startsWith('/profile/setup')) {
// Skip redirect when already on /profile/setup to avoid redirect loop
throw redirect(303, '/profile/setup');
}
return { userRoleId: user.roleId };
+4 -4
View File
@@ -39,7 +39,7 @@ describe('(protected) layout server load', () => {
expect(isHttpError(err, 403)).toBe(true);
});
it('redirects to /account/setup when user has no profile', async () => {
it('redirects to /profile/setup when user has no profile', async () => {
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] });
const locals = createLocals(mockUser() as any);
@@ -48,15 +48,15 @@ describe('(protected) layout server load', () => {
expect(isRedirect(err)).toBe(true);
if (isRedirect(err)) {
expect(err.status).toBe(303);
expect(err.location).toBe('/account/setup');
expect(err.location).toBe('/profile/setup');
}
});
it('skips redirect when already on /account/setup', async () => {
it('skips redirect when already on /profile/setup', async () => {
findUnique.mockResolvedValue({ id: 'user-1', roleId: null, profiles: [] });
const locals = createLocals(mockUser() as any);
const url = new URL('http://localhost/account/setup');
const url = new URL('http://localhost/profile/setup');
const result = await load({ locals, url } as any);
expect(result).toEqual({ userRoleId: null });
});
+8 -21
View File
@@ -114,16 +114,10 @@
Dashboard
</a>
<a
href={resolve('/account')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/account')}`}
href={resolve('/profile')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/profile')}`}
>
Account
</a>
<a
href={resolve('/license-keys')}
class={`rounded-md px-3 py-2 text-sm font-medium transition ${activeClass('/license-keys')}`}
>
License Keys
Profile
</a>
{#if isAdmin}
@@ -277,18 +271,11 @@
Dashboard
</a>
<a
href={resolve('/account')}
href={resolve('/profile')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/account')}`}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/profile')}`}
>
Account
</a>
<a
href={resolve('/license-keys')}
onclick={closeMenu}
class={`rounded-lg px-4 py-3 text-base font-medium transition ${activeClass('/license-keys')}`}
>
License Keys
Profile
</a>
{#if isAdmin}
@@ -382,8 +369,8 @@
>
<p>&copy; {new Date().getFullYear()} Radiant Wave Technologies. All rights reserved.</p>
{#if $session.data?.user}
<a href={resolve('/account')} class="transition hover:text-slate-900">
Manage your account
<a href={resolve('/profile')} class="transition hover:text-slate-900">
Manage your profile
</a>
{:else}
<a href={resolve('/login')} class="transition hover:text-slate-900">Sign in</a>
+2 -2
View File
@@ -22,7 +22,7 @@
return;
}
window.location.href = '/account';
window.location.href = '/dashboard';
}
</script>
@@ -104,7 +104,7 @@
<button
type="button"
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account' });
await authClient.signIn.social({ provider: 'google', callbackURL: '/dashboard' });
}}
class="mx-auto mt-4 flex h-11 items-center justify-center rounded-lg bg-white hover:bg-slate-50"
>
+2 -2
View File
@@ -48,7 +48,7 @@ describe('login page', () => {
await expect.element(page.getByText('Invalid credentials')).toBeInTheDocument();
});
it('calls signIn.social with Google provider and /account callbackURL', async () => {
it('calls signIn.social with Google provider and /dashboard callbackURL', async () => {
signIn.social.mockResolvedValue({});
render(Login);
@@ -58,7 +58,7 @@ describe('login page', () => {
expect(signIn.social).toHaveBeenCalledWith({
provider: 'google',
callbackURL: '/account'
callbackURL: '/dashboard'
});
});
});
+1 -1
View File
@@ -110,7 +110,7 @@
<button
type="button"
onclick={async () => {
await authClient.signIn.social({ provider: 'google', callbackURL: '/account/setup' });
await authClient.signIn.social({ provider: 'google', callbackURL: '/profile/setup' });
}}
class="mt-4 flex h-11 w-full items-center justify-center rounded-lg border border-slate-300 bg-white hover:bg-slate-50"
>
+2 -2
View File
@@ -44,7 +44,7 @@ describe('register page', () => {
await expect.element(page.getByAltText('Sign Up with Google')).toBeInTheDocument();
});
it('calls signIn.social with Google provider and /account/setup callbackURL', async () => {
it('calls signIn.social with Google provider and /profile/setup callbackURL', async () => {
signIn.social.mockResolvedValue({});
render(Register);
@@ -53,7 +53,7 @@ describe('register page', () => {
expect(signIn.social).toHaveBeenCalledWith({
provider: 'google',
callbackURL: '/account/setup'
callbackURL: '/profile/setup'
});
});
});