What I’ve come up with is to use the extensions functionality of Strapi to override the behavior of the connect function.
This retrieves the patron’s Patreon profile at time of Strapi user registration, something that happens by default thanks to users-permissions. Normally only the username and e-mail is saved to the Strapi user model, but my patch accesses more of the profile data and assigns a Strapi ‘Patron’ role if their list of benefits contains an ID that I assigned in the Strapi admin UI under a custom patreon-benefit-id
single content-type.
Here is my new connect function which lives in src/extensions/users-permissions/server/services/providers.js
const connect = async (provider, query) => {
console.log(`provider connect q_q = ${provider}`)
const accessToken = query.access_token || query.code || query.oauth_token;
if (!accessToken) {
throw new Error('No access_token.');
}
// Get the profile.
const profile = await getProfile(provider, query);
console.log(' >> profile')
console.log(profile)
if (!profile) {
throw new Error('No profile')
}
const email = _.toLower(profile.email);
// We need at least the mail.
if (!email) {
throw new Error('Email was not available.');
}
const users = await strapi.query('plugin::users-permissions.user').findMany({
where: { email },
});
const advancedSettings = await strapi
.store({ type: 'plugin', name: 'users-permissions', key: 'advanced' })
.get();
const user = _.find(users, { provider });
if (_.isEmpty(user) && !advancedSettings.allow_register) {
throw new Error('Register action is actually not available.');
}
// Retrieve default role.
const defaultRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { type: advancedSettings.default_role } });
const patronRole = await strapi
.query('plugin::users-permissions.role')
.findOne({ where: { name: 'Patron' }})
// get the user's patron status
const patreonBenefitId = await strapi
.query('api::patreon-benefit-id.patreon-benefit-id')
.findOne({ where: { id: 1 }})
console.log(` >> patreon Benefit Id `)
console.log(patreonBenefitId)
console.log(` >> patreon-benefit-id:${patreonBenefitId.id}`)
const isPatron = profile.benefits.includes(patreonBenefitId.benefit_id) // "Full library access" benefit
console.log(` >> user:`)
console.log(user)
// Update the user's role to match their patron status
const selectedRole = (isPatron) ? patronRole.id : defaultRole.id
if (!_.isEmpty(user)) {
const updatedUser = await strapi
.query('plugin::users-permissions.user')
.update({
where: { email },
data: {
...user,
role: selectedRole
}
})
return updatedUser;
}
if (users.length && advancedSettings.unique_email) {
throw new Error('Email is already taken.');
}
// Create the new user.
const newUser = {
...profile,
email, // overwrite with lowercased email
provider,
role: selectedRole,
confirmed: true,
};
const createdUser = await strapi
.query('plugin::users-permissions.user')
.create({ data: newUser });
return createdUser;
};
This new connect function depends on a ‘Patron’ role existing in Strapi Roles, as well as the aforementioned patreon-benefit-id
single content-type with a {String} ‘benefit_id’ field.
And here’s the patreon section which lives in src/extensions/users-permissions/server/services/providers-registry.js
async patreon({ accessToken }) {
console.log(' >> overrriden patreon')
const patreon = purest({
provider: 'patreon',
config: {
patreon: {
default: {
origin: 'https://www.patreon.com',
path: 'api/oauth2/{path}',
headers: {
authorization: 'Bearer {auth}',
},
},
},
},
});
return patreon
.get('v2/identity')
.auth(accessToken)
.qs(new URLSearchParams({
'include': 'memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits,memberships.campaign',
'fields[user]': 'full_name,email',
'fields[member]': 'full_name,is_follower,patron_status,currently_entitled_amount_cents,campaign_lifetime_support_cents',
'fields[tier]': 'title',
'fields[benefit]': 'title',
}).toString())
.request()
.then(({ body }) => {
const patreonData = body.data.attributes;
let memberships = []
let benefits = []
if (body?.included !== undefined) {
memberships = body.included
.filter((i) => i.type === 'member')
.filter((i) => i.attributes.patron_status === 'active_patron')
.map((i) => i.id)
benefits = body.included
.filter((i) => i.type === 'benefit')
.map((i) => i.id)
}
console.log(`memberships:${memberships}, benefits:${benefits}`)
return {
username: patreonData.full_name,
email: patreonData.email,
memberships: memberships,
benefits: benefits,
};
});
I don’t like this solution because there is so much code unrelated to the solution in the extensions directory. I copied the whole of the users-permissions plugin into that directory and I think it’s only two files, providers.js
and 'providers-registry.js` which contains changes. I would love to have less repeated code and override only two functions in the plugin, but I don’t think that’s possible.
Ideally, I wish users-permissions had this functionality built-in. Something that would allow choosing which data to save from the provider profile into the Strapi user object. If that was possible, I think the logic to programmatically add the Strapi Role could be added to a lifecyle hook like the following.
async bootstrap({ strapi }) {
strapi.db.lifecycles.subscribe({
models: ['plugin::users-permissions.user'],
async beforeCreate(event) {
// @todo assign appropriate role
// this is NOT currently possible because the event params only contains provider username and e-mail
},
})
}
Maybe a custom Patreon plugin that handles the auth flow with a nice UI would be something I could make in the future. I’d have to think more about how it could live in harmony with users-permissions though. Or is improving users-permissions the best way? Anyway, my solution above is far from ideal but it is a solution that works so I think I’ll move onto my next challenge.