Fix users unable to edit or delete their own addresses
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user