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);
|
||||
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 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) {
|
||||
const input = event.target as HTMLInputElement;
|
||||
@@ -102,7 +140,7 @@
|
||||
<h2 class="text-lg font-semibold text-slate-900">Addresses</h2>
|
||||
<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"
|
||||
>
|
||||
{showAddressForm ? 'Cancel' : 'Add Address'}
|
||||
@@ -114,20 +152,41 @@
|
||||
Address added successfully.
|
||||
</div>
|
||||
{/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">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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">
|
||||
<label for="street" class="text-sm font-medium text-slate-700">Street</label>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
name="street"
|
||||
bind:value={addrStreet}
|
||||
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"
|
||||
/>
|
||||
@@ -139,6 +198,7 @@
|
||||
type="text"
|
||||
id="city"
|
||||
name="city"
|
||||
bind:value={addrCity}
|
||||
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"
|
||||
/>
|
||||
@@ -149,6 +209,7 @@
|
||||
type="text"
|
||||
id="state"
|
||||
name="state"
|
||||
bind:value={addrState}
|
||||
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"
|
||||
/>
|
||||
@@ -161,6 +222,7 @@
|
||||
type="text"
|
||||
id="postalCode"
|
||||
name="postalCode"
|
||||
bind:value={addrPostal}
|
||||
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"
|
||||
/>
|
||||
@@ -171,8 +233,8 @@
|
||||
type="text"
|
||||
id="country"
|
||||
name="country"
|
||||
bind:value={addrCountry}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -182,7 +244,7 @@
|
||||
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"
|
||||
>
|
||||
Save Address
|
||||
{editingAddressId ? 'Update Address' : 'Save Address'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -198,6 +260,30 @@
|
||||
{address.postalCode}
|
||||
</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>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
user: { findUnique: 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 profileUpdateMany = prisma.profile.updateMany 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) =>
|
||||
(actions as any)[name]({ request, locals } as any);
|
||||
@@ -32,6 +35,9 @@ describe('(protected)/profile page server', () => {
|
||||
profileFindFirst.mockReset();
|
||||
profileUpdateMany.mockReset();
|
||||
addressCreate.mockReset();
|
||||
addressFindFirst.mockReset();
|
||||
addressUpdate.mockReset();
|
||||
addressDelete.mockReset();
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
@@ -254,4 +260,164 @@ describe('(protected)/profile page server', () => {
|
||||
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