Skip to content

Commit c2bcf51

Browse files
authored
Merge pull request #74 from marmelab/sales-policies
Add more secure RLS policies on the sales table
2 parents 515f6b0 + 8828319 commit c2bcf51

File tree

6 files changed

+113
-37
lines changed

6 files changed

+113
-37
lines changed

src/providers/supabase/dataProvider.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,14 @@ const dataProviderWithCustomMethods = {
147147
id: Identifier,
148148
data: Partial<Omit<SalesFormData, 'password'>>
149149
) {
150-
const { email, first_name, last_name, administrator, disabled } = data;
150+
const {
151+
email,
152+
first_name,
153+
last_name,
154+
administrator,
155+
avatar,
156+
disabled,
157+
} = data;
151158

152159
const { data: sale, error } = await supabase.functions.invoke<Sale>(
153160
'users',
@@ -160,6 +167,7 @@ const dataProviderWithCustomMethods = {
160167
last_name,
161168
administrator,
162169
disabled,
170+
avatar,
163171
},
164172
}
165173
);

src/sales/SalesList.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export function SalesList() {
5252
actions={<SalesListActions />}
5353
sort={{ field: 'first_name', order: 'ASC' }}
5454
>
55-
<DatagridConfigurable rowClick="edit">
55+
<DatagridConfigurable rowClick="edit" bulkActionButtons={false}>
5656
<TextField source="first_name" />
5757
<TextField source="last_name" />
5858
<TextField source="email" />

src/settings/SettingsPage.tsx

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ import {
2020
useGetIdentity,
2121
useGetOne,
2222
useNotify,
23-
useUpdate,
23+
useRecordContext,
2424
} from 'react-admin';
2525
import { useFormState } from 'react-hook-form';
2626
import ImageEditorField from '../misc/ImageEditorField';
2727
import { CrmDataProvider } from '../providers/types';
28-
import { SalesFormData } from '../types';
28+
import { Sale, SalesFormData } from '../types';
2929
import { useMutation } from '@tanstack/react-query';
3030
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
3131

@@ -84,13 +84,13 @@ const SettingsForm = ({
8484
isEditMode: boolean;
8585
setEditMode: (value: boolean) => void;
8686
}) => {
87-
const [update] = useUpdate();
8887
const notify = useNotify();
88+
const record = useRecordContext<Sale>();
8989
const { identity, refetch } = useGetIdentity();
9090
const { isDirty } = useFormState();
9191
const dataProvider = useDataProvider<CrmDataProvider>();
9292

93-
const { mutate } = useMutation({
93+
const { mutate: updatePassword } = useMutation({
9494
mutationKey: ['updatePassword'],
9595
mutationFn: async () => {
9696
if (!identity) {
@@ -110,33 +110,30 @@ const SettingsForm = ({
110110
},
111111
});
112112

113+
const { mutate: mutateSale } = useMutation({
114+
mutationKey: ['signup'],
115+
mutationFn: async (data: SalesFormData) => {
116+
if (!record) {
117+
throw new Error('Record not found');
118+
}
119+
return dataProvider.salesUpdate(record.id, data);
120+
},
121+
onSuccess: () => {
122+
refetch();
123+
notify('Your profile has been updated');
124+
},
125+
onError: () => {
126+
notify('An error occurred. Please try again.');
127+
},
128+
});
113129
if (!identity) return null;
114130

115131
const handleClickOpenPasswordChange = () => {
116-
mutate();
132+
updatePassword();
117133
};
118134

119135
const handleAvatarUpdate = async (values: any) => {
120-
await update(
121-
'sales',
122-
{
123-
id: identity.id,
124-
data: values,
125-
previousData: identity,
126-
},
127-
{
128-
onSuccess: () => {
129-
refetch();
130-
setEditMode(false);
131-
notify('Your profile has been updated');
132-
},
133-
onError: _ => {
134-
notify('An error occurred. Please try again', {
135-
type: 'error',
136-
});
137-
},
138-
}
139-
);
136+
mutateSale(values);
140137
};
141138

142139
return (

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type SignUpData = {
1616
};
1717

1818
export type SalesFormData = {
19+
avatar: string;
1920
email: string;
2021
password: string;
2122
first_name: string;

supabase/functions/users/index.ts

Lines changed: 73 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -21,16 +21,34 @@ async function updateSaleAdministrator(
2121
.select('*');
2222

2323
if (!sales?.length || salesError) {
24-
console.error('Error inviting user:', salesError);
24+
console.error('Error updating user:', salesError);
2525
throw salesError ?? new Error('Failed to update sale');
2626
}
2727
return sales.at(0);
2828
}
2929

30-
async function inviteUser(req: Request) {
30+
async function updateSaleAvatar(user_id: string, avatar: string) {
31+
const { data: sales, error: salesError } = await supabaseAdmin
32+
.from('sales')
33+
.update({ avatar })
34+
.eq('user_id', user_id)
35+
.select('*');
36+
37+
if (!sales?.length || salesError) {
38+
console.error('Error updating user:', salesError);
39+
throw salesError ?? new Error('Failed to update sale');
40+
}
41+
return sales.at(0);
42+
}
43+
44+
async function inviteUser(req: Request, currentUserSale: any) {
3145
const { email, password, first_name, last_name, disabled, administrator } =
3246
await req.json();
3347

48+
if (!currentUserSale.administrator) {
49+
return createErrorResponse(401, 'Not Authorized');
50+
}
51+
3452
const { data, error: userError } =
3553
await supabaseAdmin.auth.admin.createUser({
3654
email,
@@ -69,9 +87,16 @@ async function inviteUser(req: Request) {
6987
}
7088
}
7189

72-
async function patchUser(req: Request) {
73-
const { sales_id, email, first_name, last_name, administrator, disabled } =
74-
await req.json();
90+
async function patchUser(req: Request, currentUserSale: any) {
91+
const {
92+
sales_id,
93+
email,
94+
first_name,
95+
last_name,
96+
avatar,
97+
administrator,
98+
disabled,
99+
} = await req.json();
75100
const { data: sale } = await supabaseAdmin
76101
.from('sales')
77102
.select('*')
@@ -82,6 +107,11 @@ async function patchUser(req: Request) {
82107
return createErrorResponse(404, 'Not Found');
83108
}
84109

110+
// Users can only update their own profile unless they are an administrator
111+
if (!currentUserSale.administrator && currentUserSale.id !== sale.id) {
112+
return createErrorResponse(401, 'Not Authorized');
113+
}
114+
85115
const { data, error: userError } =
86116
await supabaseAdmin.auth.admin.updateUserById(sale.user_id, {
87117
email,
@@ -94,16 +124,42 @@ async function patchUser(req: Request) {
94124
return createErrorResponse(500, 'Internal Server Error');
95125
}
96126

127+
if (avatar) {
128+
await updateSaleAvatar(data.user.id, avatar);
129+
}
130+
131+
// Only administrators can update the administrator and disabled status
132+
if (!currentUserSale.administrator) {
133+
const { data: new_sale } = await supabaseAdmin
134+
.from('sales')
135+
.select('*')
136+
.eq('id', sales_id)
137+
.single();
138+
return new Response(
139+
JSON.stringify({
140+
data: new_sale,
141+
}),
142+
{
143+
headers: {
144+
'Content-Type': 'application/json',
145+
...corsHeaders,
146+
},
147+
}
148+
);
149+
}
150+
97151
try {
98152
await updateSaleDisabled(data.user.id, disabled);
99153
const sale = await updateSaleAdministrator(data.user.id, administrator);
100-
101154
return new Response(
102155
JSON.stringify({
103156
data: sale,
104157
}),
105158
{
106-
headers: { 'Content-Type': 'application/json', ...corsHeaders },
159+
headers: {
160+
'Content-Type': 'application/json',
161+
...corsHeaders,
162+
},
107163
}
108164
);
109165
} catch (e) {
@@ -126,18 +182,25 @@ Deno.serve(async (req: Request) => {
126182
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
127183
{ global: { headers: { Authorization: authHeader } } }
128184
);
129-
130185
const { data } = await localClient.auth.getUser();
131186
if (!data?.user) {
132187
return createErrorResponse(401, 'Unauthorized');
133188
}
189+
const currentUserSale = await supabaseAdmin
190+
.from('sales')
191+
.select('*')
192+
.eq('user_id', data.user.id)
193+
.single();
134194

195+
if (!currentUserSale?.data) {
196+
return createErrorResponse(401, 'Unauthorized');
197+
}
135198
if (req.method === 'POST') {
136-
return inviteUser(req);
199+
return inviteUser(req, currentUserSale.data);
137200
}
138201

139202
if (req.method === 'PATCH') {
140-
return patchUser(req);
203+
return patchUser(req, currentUserSale.data);
141204
}
142205

143206
return createErrorResponse(405, 'Method Not Allowed');
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
create schema if not exists "private";
2+
3+
set check_function_bodies = off;
4+
5+
drop policy "Enable insert for authenticated users only" on "public"."sales";
6+
7+
drop policy "Enable update for authenticated users only" on "public"."sales";

0 commit comments

Comments
 (0)