Fix users unable to edit or delete their own addresses

This commit is contained in:
NeilClack
2026-09-11 12:42:35 -05:00
parent 848e646880
commit cf8fc7b719
3 changed files with 330 additions and 6 deletions
@@ -90,5 +90,77 @@ export const actions: Actions = {
console.error('Address create error:', err); console.error('Address create error:', err);
return fail(500, { error: 'Failed to add address.', action: 'addAddress' }); return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
} }
},
updateAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const id = data.get('id') as string;
const street = data.get('street') as string;
const city = data.get('city') as string;
const state = data.get('state') as string;
const postalCode = data.get('postalCode') as string;
const country = data.get('country') as string;
if (!id) {
return fail(400, { error: 'Missing address id.', action: 'updateAddress' });
}
if (!street || !city || !state || !postalCode || !country) {
return fail(400, { error: 'All address fields are required.', action: 'updateAddress' });
}
const address = await prisma.address.findFirst({
where: { id, profile: { userId: locals.user.id } },
select: { id: true }
});
if (!address) {
throw error(404, 'Address not found');
}
try {
await prisma.address.update({
where: { id },
data: { street, city, state, postalCode, country }
});
return { success: true, action: 'updateAddress' };
} catch (err) {
console.error('Address update error:', err);
return fail(500, { error: 'Failed to update address.', action: 'updateAddress' });
}
},
deleteAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const id = data.get('id') as string;
if (!id) {
return fail(400, { error: 'Missing address id.', action: 'deleteAddress' });
}
const address = await prisma.address.findFirst({
where: { id, profile: { userId: locals.user.id } },
select: { id: true }
});
if (!address) {
throw error(404, 'Address not found');
}
try {
await prisma.address.delete({ where: { id } });
return { success: true, action: 'deleteAddress' };
} catch (err) {
console.error('Address delete error:', err);
return fail(500, { error: 'Failed to delete address.', action: 'deleteAddress' });
}
} }
}; };
@@ -16,6 +16,44 @@
}); });
let showAddressForm = $state(false); let showAddressForm = $state(false);
let editingAddressId = $state<string | null>(null);
let addrStreet = $state('');
let addrCity = $state('');
let addrState = $state('');
let addrPostal = $state('');
let addrCountry = $state('US');
function openAddAddress() {
editingAddressId = null;
addrStreet = '';
addrCity = '';
addrState = '';
addrPostal = '';
addrCountry = 'US';
showAddressForm = true;
}
function openEditAddress(address: {
id: string;
street: string;
city: string;
state: string;
postalCode: string;
country: string;
}) {
editingAddressId = address.id;
addrStreet = address.street;
addrCity = address.city;
addrState = address.state;
addrPostal = address.postalCode;
addrCountry = address.country;
showAddressForm = true;
}
function closeAddressForm() {
showAddressForm = false;
editingAddressId = null;
}
function handlePhoneInput(event: Event) { function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement; const input = event.target as HTMLInputElement;
@@ -102,7 +140,7 @@
<h2 class="text-lg font-semibold text-slate-900">Addresses</h2> <h2 class="text-lg font-semibold text-slate-900">Addresses</h2>
<button <button
type="button" type="button"
onclick={() => (showAddressForm = !showAddressForm)} onclick={() => (showAddressForm ? closeAddressForm() : openAddAddress())}
class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50" class="rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
> >
{showAddressForm ? 'Cancel' : 'Add Address'} {showAddressForm ? 'Cancel' : 'Add Address'}
@@ -114,20 +152,41 @@
Address added successfully. Address added successfully.
</div> </div>
{/if} {/if}
{#if form?.action === 'addAddress' && form?.error} {#if form?.action === 'updateAddress' && form?.success}
<div class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
Address updated successfully.
</div>
{/if}
{#if form?.action === 'deleteAddress' && form?.success}
<div class="mt-4 rounded-lg bg-green-50 px-4 py-3 text-sm text-green-800" role="status">
Address deleted successfully.
</div>
{/if}
{#if (form?.action === 'addAddress' ||
form?.action === 'updateAddress' ||
form?.action === 'deleteAddress') &&
form?.error}
<div class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert"> <div class="mt-4 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
{form.error} {form.error}
</div> </div>
{/if} {/if}
{#if showAddressForm} {#if showAddressForm}
<form method="POST" action="?/addAddress" class="mt-5 flex flex-col gap-4"> <form
method="POST"
action={editingAddressId ? '?/updateAddress' : '?/addAddress'}
class="mt-5 flex flex-col gap-4"
>
{#if editingAddressId}
<input type="hidden" name="id" value={editingAddressId} />
{/if}
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<label for="street" class="text-sm font-medium text-slate-700">Street</label> <label for="street" class="text-sm font-medium text-slate-700">Street</label>
<input <input
type="text" type="text"
id="street" id="street"
name="street" name="street"
bind:value={addrStreet}
required required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500" class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/> />
@@ -139,6 +198,7 @@
type="text" type="text"
id="city" id="city"
name="city" name="city"
bind:value={addrCity}
required required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500" class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/> />
@@ -149,6 +209,7 @@
type="text" type="text"
id="state" id="state"
name="state" name="state"
bind:value={addrState}
required required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500" class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/> />
@@ -161,6 +222,7 @@
type="text" type="text"
id="postalCode" id="postalCode"
name="postalCode" name="postalCode"
bind:value={addrPostal}
required required
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500" class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/> />
@@ -171,8 +233,8 @@
type="text" type="text"
id="country" id="country"
name="country" name="country"
bind:value={addrCountry}
required required
value="US"
class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500" class="h-11 w-full rounded-lg border-slate-300 px-3 text-sm text-slate-900 focus:border-indigo-500 focus:ring-indigo-500"
/> />
</div> </div>
@@ -182,7 +244,7 @@
type="submit" type="submit"
class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8" class="flex h-11 w-full items-center justify-center rounded-lg bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 sm:w-auto sm:px-8"
> >
Save Address {editingAddressId ? 'Update Address' : 'Save Address'}
</button> </button>
</div> </div>
</form> </form>
@@ -198,6 +260,30 @@
{address.postalCode} {address.postalCode}
</p> </p>
<p class="text-sm text-slate-600">{address.country}</p> <p class="text-sm text-slate-600">{address.country}</p>
<div class="mt-3 flex gap-2">
<button
type="button"
onclick={() => openEditAddress(address)}
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50"
>
Edit
</button>
<form
method="POST"
action="?/deleteAddress"
onsubmit={(event) => {
if (!confirm('Delete this address?')) event.preventDefault();
}}
>
<input type="hidden" name="id" value={address.id} />
<button
type="submit"
class="rounded-md border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50"
>
Delete
</button>
</form>
</div>
</div> </div>
{/each} {/each}
</div> </div>
@@ -11,7 +11,7 @@ vi.mock('$lib/db', () => ({
prisma: { prisma: {
user: { findUnique: vi.fn() }, user: { findUnique: vi.fn() },
profile: { findFirst: vi.fn(), updateMany: vi.fn() }, profile: { findFirst: vi.fn(), updateMany: vi.fn() },
address: { create: vi.fn() } address: { create: vi.fn(), findFirst: vi.fn(), update: vi.fn(), delete: vi.fn() }
} }
})); }));
@@ -22,6 +22,9 @@ const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>; const profileFindFirst = prisma.profile.findFirst as ReturnType<typeof vi.fn>;
const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>; const profileUpdateMany = prisma.profile.updateMany as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>; const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
const addressFindFirst = prisma.address.findFirst as ReturnType<typeof vi.fn>;
const addressUpdate = prisma.address.update as ReturnType<typeof vi.fn>;
const addressDelete = prisma.address.delete as ReturnType<typeof vi.fn>;
const invokeAction = (name: string, request: Request, locals: App.Locals) => const invokeAction = (name: string, request: Request, locals: App.Locals) =>
(actions as any)[name]({ request, locals } as any); (actions as any)[name]({ request, locals } as any);
@@ -32,6 +35,9 @@ describe('(protected)/profile page server', () => {
profileFindFirst.mockReset(); profileFindFirst.mockReset();
profileUpdateMany.mockReset(); profileUpdateMany.mockReset();
addressCreate.mockReset(); addressCreate.mockReset();
addressFindFirst.mockReset();
addressUpdate.mockReset();
addressDelete.mockReset();
}); });
describe('load', () => { describe('load', () => {
@@ -254,4 +260,164 @@ describe('(protected)/profile page server', () => {
expect((result as any).status).toBe(500); expect((result as any).status).toBe(500);
}); });
}); });
describe('updateAddress action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const err = await Promise.resolve(
invokeAction('updateAddress', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when id is missing', async () => {
const request = createFormRequest({
id: '',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.action).toBe('updateAddress');
});
it('throws 404 when the address is not owned by the user', async () => {
addressFindFirst.mockResolvedValue(null);
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(invokeAction('updateAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
expect(addressFindFirst).toHaveBeenCalledWith({
where: { id: 'addr-1', profile: { userId: 'user-1' } },
select: { id: true }
});
});
it('updates the owned address on success', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressUpdate.mockResolvedValue({});
const request = createFormRequest({
id: 'addr-1',
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(result).toEqual({ success: true, action: 'updateAddress' });
expect(addressUpdate).toHaveBeenCalledWith({
where: { id: 'addr-1' },
data: {
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
}
});
});
it('returns fail(500) when prisma update throws', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressUpdate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
id: 'addr-1',
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
describe('deleteAddress action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({ id: 'addr-1' });
const err = await Promise.resolve(
invokeAction('deleteAddress', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when id is missing', async () => {
const request = createFormRequest({ id: '' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.action).toBe('deleteAddress');
});
it('throws 404 when the address is not owned by the user', async () => {
addressFindFirst.mockResolvedValue(null);
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(invokeAction('deleteAddress', request, locals)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 404)).toBe(true);
});
it('deletes the owned address on success', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressDelete.mockResolvedValue({});
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(result).toEqual({ success: true, action: 'deleteAddress' });
expect(addressDelete).toHaveBeenCalledWith({ where: { id: 'addr-1' } });
});
it('returns fail(500) when prisma delete throws', async () => {
addressFindFirst.mockResolvedValue({ id: 'addr-1' });
addressDelete.mockRejectedValue(new Error('db error'));
const request = createFormRequest({ id: 'addr-1' });
const locals = createLocals(mockUser() as any);
const result = await invokeAction('deleteAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
}); });