From 01ef6c98960f67930bf9ebe10c0689a7777c8f7a Mon Sep 17 00:00:00 2001 From: Joost De Cock Date: Fri, 1 Sep 2023 18:30:24 +0200 Subject: [PATCH] feat(shared): Initial implementation of Oauth flow --- sites/backend/example.env | 2 + sites/backend/prisma/schema.prisma | 4 +- sites/backend/src/config.mjs | 39 ++- sites/backend/src/controllers/users.mjs | 44 ++- sites/backend/src/middleware.mjs | 36 +- sites/backend/src/models/user.mjs | 322 +++++++++++++++++- sites/backend/src/routes/users.mjs | 23 +- sites/backend/src/utils/crypto.mjs | 4 +- sites/backend/src/utils/model-decorator.mjs | 9 +- sites/backend/src/utils/oauth.mjs | 130 +++++++ .../confirm/signin/[...confirmation].mjs | 58 ++-- .../confirm/signup/[...confirmation].mjs | 91 +---- .../org/pages/signin/callback/[provider].mjs | 93 +++++ sites/org/pages/welcome/index.mjs | 5 +- sites/shared/components/account/oauth.mjs | 70 ++++ sites/shared/components/gdpr/form.mjs | 90 +++++ sites/shared/components/susi/en.yaml | 17 + sites/shared/components/susi/sign-in.mjs | 20 +- sites/shared/components/susi/sign-up.mjs | 24 +- .../shared/components/wrappers/auth/index.mjs | 87 +++-- sites/shared/config/oauth.config.mjs | 37 ++ sites/shared/hooks/use-backend.mjs | 30 ++ sites/shared/i18n/status/en.yaml | 3 + sites/shared/utils.mjs | 11 + 24 files changed, 1077 insertions(+), 172 deletions(-) create mode 100644 sites/backend/src/utils/oauth.mjs create mode 100644 sites/org/pages/signin/callback/[provider].mjs create mode 100644 sites/shared/components/account/oauth.mjs create mode 100644 sites/shared/components/gdpr/form.mjs create mode 100644 sites/shared/config/oauth.config.mjs diff --git a/sites/backend/example.env b/sites/backend/example.env index 064224d2f3a..130c30d142e 100644 --- a/sites/backend/example.env +++ b/sites/backend/example.env @@ -197,6 +197,8 @@ BACKEND_ENABLE_OAUTH_GITHUB=no #BACKEND_GITHUB_CLIENT_ID=githubOauthClientIdHere # Github client secret #BACKEND_GITHUB_CLIENT_SECRET=githubOauthClientSecretHere +# Github callback site. If not set will use production FreeSewing sites based on language +#BACKEND_GITHUB_CALLBACK_SITE=http://localhost:8000 # Google # # Enable Google as Oauth provider diff --git a/sites/backend/prisma/schema.prisma b/sites/backend/prisma/schema.prisma index 9dfc3af8ca7..5c311442b6c 100644 --- a/sites/backend/prisma/schema.prisma +++ b/sites/backend/prisma/schema.prisma @@ -36,8 +36,8 @@ model Confirmation { createdAt DateTime @default(now()) data String type String - user User @relation(fields: [userId], references: [id]) - userId Int + user User? @relation(fields: [userId], references: [id]) + userId Int? } model Subscriber { diff --git a/sites/backend/src/config.mjs b/sites/backend/src/config.mjs index 5e8da65b873..fcb42df29c1 100644 --- a/sites/backend/src/config.mjs +++ b/sites/backend/src/config.mjs @@ -213,23 +213,57 @@ if (baseConfig.use.ses) } // Oauth config for Github as a provider -if (baseConfig.use.oauth?.github) +if (baseConfig.use.oauth?.github) { baseConfig.oauth.github = { clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID, clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET, tokenUri: 'https://github.com/login/oauth/access_token', dataUri: 'https://api.github.com/user', emailUri: 'https://api.github.com/user/emails', + redirectUri: `${ + process.env.BACKEND_OAUTH_GITHUB_CALLBACK_SITE + ? process.env.BACKEND_OAUTH_GITHUB_CALLBACK_SITE + : 'https://next.freesewing.org' + }/signin/callback/github`, } + baseConfig.oauth.github.url = (state) => + '' + + 'https://github.com/login/oauth/authorize?client_id=' + + baseConfig.oauth.github.clientId + + '&redirect_uri=' + + baseConfig.oauth.github.redirectUri + + `&scope=read:user user:email&state=${state}` +} // Oauth config for Google as a provider -if (baseConfig.use.oauth?.google) +if (baseConfig.use.oauth?.google) { baseConfig.oauth.google = { clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID, clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET, tokenUri: 'https://oauth2.googleapis.com/token', dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos', + redirectUri: `${ + process.env.BACKEND_OAUTH_GOOGLE_CALLBACK_SITE + ? process.env.BACKEND_OAUTH_GOOGLE_CALLBACK_SITE + : 'https://next.freesewing.org' + }/signin/callback/google`, } + baseConfig.oauth.google.url = (state) => + '' + + 'https://accounts.google.com/o/oauth2/v2/auth' + + '?response_type=code' + + '&client_id=' + + baseConfig.oauth.google.clientId + + '&redirect_uri=' + + baseConfig.oauth.google.redirectUri + + '&scope=' + + 'https://www.googleapis.com/auth/userinfo.profile' + + ' ' + + 'https://www.googleapis.com/auth/userinfo.email' + + '&access_type=online' + + '&state=' + + state +} // Load local config const config = postConfig(baseConfig) @@ -241,6 +275,7 @@ export const website = config.website export const github = config.github export const instance = config.instance export const exports = config.exports +export const oauth = config.oauth const vars = { BACKEND_DB_URL: ['required', 'db.url'], diff --git a/sites/backend/src/controllers/users.mjs b/sites/backend/src/controllers/users.mjs index 5dedd241a07..829d661183e 100644 --- a/sites/backend/src/controllers/users.mjs +++ b/sites/backend/src/controllers/users.mjs @@ -15,6 +15,35 @@ UsersController.prototype.signup = async (req, res, tools) => { return User.sendResponse(res) } +/* + * Init Oauth flow with GitHub or Google + * + * This is the endpoint that starts the Oauth flow + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.oauthInit = async (req, res, tools) => { + const User = new UserModel(tools) + await User.oauthInit(req) + + return User.sendResponse(res) +} + +/* + * Sing In with Oauth via GitHub or Google + * + * This is the endpoint that finalizes the Oauth flow + * Note that SignIn and SignUp are the same flow/endpoints + * We will simply deal with the fact that the user does not exist, + * and treat it as a sign up + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.oauthSignIn = async (req, res, tools) => { + const User = new UserModel(tools) + await User.oauthSignIn(req) + + return User.sendResponse(res) +} + /* * Confirm account (after signup) * @@ -74,7 +103,7 @@ UsersController.prototype.signinvialink = async function (req, res, tools) { */ UsersController.prototype.whoami = async (req, res, tools) => { const User = new UserModel(tools) - await User.guardedRead({ id: req.user.uid }, req) + await User.whoami({ id: req.user.uid }, req) return User.sendResponse(res) } @@ -92,6 +121,19 @@ UsersController.prototype.update = async (req, res, tools) => { return User.sendResponse(res) } +/* + * Updates the consent of the authenticated user (jwt-guest route) + * + * See: https://freesewing.dev/reference/backend/api + */ +UsersController.prototype.updateConsent = async (req, res, tools) => { + const User = new UserModel(tools) + await User.guardedRead({ id: req.user.uid }, req) + await User.updateConsent(req) + + return User.sendResponse(res) +} + /* * Updates the MFA setting of the authenticated user * diff --git a/sites/backend/src/middleware.mjs b/sites/backend/src/middleware.mjs index 0727f8d61b5..5b621e50a77 100644 --- a/sites/backend/src/middleware.mjs +++ b/sites/backend/src/middleware.mjs @@ -19,9 +19,9 @@ async function checkAccess(payload, tools, type) { if (payload.aud !== `${api}/${instance}`) return false const User = new UserModel(tools) const uid = payload.userId || payload._id - const ok = await User.papersPlease(uid, type, payload) + const [ok, err] = await User.papersPlease(uid, type, payload) - return ok + return [ok, err] } function loadExpressMiddleware(app) { @@ -36,7 +36,7 @@ function loadPassportMiddleware(passport, tools) { /* * We check more than merely the API key */ - const ok = Apikey.verified ? await checkAccess(Apikey.record, tools, 'key') : false + const [ok] = Apikey.verified ? await checkAccess(Apikey.record, tools, 'key') : false return ok ? done(null, { @@ -48,6 +48,7 @@ function loadPassportMiddleware(passport, tools) { }) ) passport.use( + 'jwt', new jwt.Strategy( { jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), @@ -57,7 +58,7 @@ function loadPassportMiddleware(passport, tools) { /* * We check more than merely the token */ - const ok = await checkAccess(jwt_payload, tools, 'jwt') + const [ok] = await checkAccess(jwt_payload, tools, 'jwt') return ok ? done(null, { @@ -69,6 +70,33 @@ function loadPassportMiddleware(passport, tools) { } ) ) + /* + * This special strategy is only used for the whoami/jwt-guest route + */ + passport.use( + 'jwt-guest', + new jwt.Strategy( + { + jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(), + ...tools.config.jwt, + }, + async (jwt_payload, done) => { + /* + * We check more than merely the token + */ + const [ok, err] = await checkAccess(jwt_payload, tools, 'jwt-guest') + + return ok + ? done(null, { + ...jwt_payload, + uid: jwt_payload._id, + level: tools.config.roles.levels[jwt_payload.role] || 0, + guestError: err ? err : false, + }) + : done(false) + } + ) + ) } export { loadExpressMiddleware, loadPassportMiddleware } diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs index e07590bd0ee..96ecd2a8622 100644 --- a/sites/backend/src/models/user.mjs +++ b/sites/backend/src/models/user.mjs @@ -1,10 +1,11 @@ import jwt from 'jsonwebtoken' import { log } from '../utils/log.mjs' import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs' -import { replaceImage, importImage, removeImage } from '../utils/cloudflare-images.mjs' +import { replaceImage, ensureImage, importImage, removeImage } from '../utils/cloudflare-images.mjs' import { clean, asJson, i18nUrl, writeExportedData } from '../utils/index.mjs' import { decorateModel } from '../utils/model-decorator.mjs' import { userCard } from '../templates/svg/user-card.mjs' +import { oauth } from '../utils/oauth.mjs' /* * This model handles all user updates @@ -18,6 +19,190 @@ export function UserModel(tools) { }) } +/* + * Start Oauth flow, supported providers are google and github + * + * To initialize the Oauth flow, all we need is to generate a secret + * and let the client know what URL to connect to to trigger the authentication. + * For the secret, we'll just use the UUID of the confirmation. + * + * @param {body} object - The request body + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.oauthInit = async function ({ body }) { + /* + * Is the provider set and a known Oauth provider? + */ + if ( + typeof body.provider === 'undefined' || + !Object.keys(this.config.oauth).includes(body.provider.toLowerCase()) + ) + return this.setResponse(403, 'invalidProvider') + + /* + * Is the language set and a known langauge? + */ + if ( + typeof body.language === 'undefined' || + !this.config.languages.includes(body.language.toLowerCase()) + ) + return this.setResponse(403, 'invalidLanguage') + + const provider = body.provider.toLowerCase() + const language = body.language.toLowerCase() + + /* + * Create confirmation + */ + await this.Confirmation.createRecord({ + type: 'oauth-init', + data: { provider, language }, + }) + + /* + * Return the confirmation ID as Oauth state, along with the + * authentication URL the client should use. + */ + return this.setResponse200({ + authUrl: this.config.oauth[provider].url(this.Confirmation.record.id, language), + }) +} + +/* + * Sign In via Oauth, supported providers are google and github + * + * This could be an existing user (Sign In) or a new user (Sign Up) + * so we need to deal with both cases. + * + * @param {body} object - The request body + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.oauthSignIn = async function ({ body }) { + /* + * Is the provider set and a known Oauth provider? + */ + if ( + typeof body.provider === 'undefined' || + !Object.keys(this.config.oauth).includes(body.provider.toLowerCase()) + ) + return this.setResponse(403, 'invalidProvider') + + /* + * Is state set? + */ + if (typeof body.state !== 'string') return this.setResponse(403, 'stateInvalid') + + /* + * Is code set? + */ + if (typeof body.code !== 'string') return this.setResponse(403, 'codeInvalid') + + /* + * Attempt to retrieve the confirmation record, its ID is the state value + */ + await this.Confirmation.read({ id: body.state }) + + /* + * Get token in exchange for Oauth code + */ + const provider = body.provider.toLowerCase() + const token = await oauth[provider].getToken(body.code) + + /* + * Load user data from API + */ + const oauthData = await oauth[provider].loadUser(token) + + /* + * Does the user exist? + */ + await this.read({ ehash: hash(clean(oauthData.email)) }) + if (this.exists) { + /* + * Final check for account status and other things before returning + */ + const [ok, err, status] = this.isOk() + if (ok === true) return this.signInOk() + else return this.setResponse(status, err) + } + + /* + * This is a new user, so essentially a sign-up. + * We need to handle this the same way, expect without the need to confirm email + * + * Let's start by making sure the username we use is available + */ + let lusername = clean(oauthData.username) + let available = await this.isLusernameAvailable(lusername) + while (!available) { + lusername += '+' + available = await this.isLusernameAvailable(lusername) + } + + /* + * Create all data to create the record + */ + const email = clean(oauthData.email) + const ihash = hash(email) + const extraData = {} + if (provider === 'github') { + extraData.githubEmail = oauthData.email + extraData.githubUsername = oauthData.username + } + if (oauthData.website) extraData.website = oauthData.website + if (oauthData.twitter) extraData.twitter = oauthData.twitter + const data = { + ehash: ihash, + ihash, + email: this.encrypt(email), + initial: this.encrypt(email), + username: lusername, + lusername: lusername, + language: this.Confirmation.clear.data.language, + mfaEnabled: false, + mfaSecret: '', + password: asJson(hashPassword(randomString())), + data: this.encrypt(extraData), + bio: this.encrypt(oauthData.bio || '--'), + } + + /* + * Next, if there is an image (url) let's handle that first + */ + if (oauthData.img) { + try { + const img = await replaceImage({ + id: `user-${ihash}`, + metadata: { ihash }, + url: oauthData.img, + }) + if (img) data.img = this.encrypt(img) + else data.img = this.encrypt(this.config.avatars.user) + } catch (err) { + log.info(err, `Unable to update image post-oauth signup for user ${email}`) + return this.setResponse(500, 'createAccountFailed') + } + } else data.img = this.encrypt(this.config.avatars.user) + + /* + * Now attempt to create the record in the database + */ + try { + this.record = await this.prisma.user.create({ data }) + } catch (err) { + /* + * Could not create record. Log warning and return 500 + */ + log.warn(err, 'Could not create user record') + return this.setResponse(500, 'createAccountFailed') + } + + /* + * Consent won't be ok yet, but we must handle that in the frontend + */ + return this.signInOk() +} + /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data @@ -210,6 +395,46 @@ UserModel.prototype.removeAccount = async function ({ user }) { }) } +/* + * This is a less strict version of guardedRead that will not err with 401 + * when the authentication is valid, but consent has not been granted. + * This is required for the Oauth flow where people signup and are in this + * state where they are authenticated by have not provided consent yet. + * + * So in that case, this will return a limited set of data to allow the frontend + * to present/update the consent choices. + * + * @param {where} object - The where clasuse for the Prisma query + * @param {user} object - The user as provided by middleware + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.whoami = async function (where, { user }) { + /* + * Check middleware for guest-specific errors + */ + if (user.guestError) { + let status = 401 + if (user.guestError === 'consentLacking') status = 451 + if (user.guestError === 'statusLacking') status = 403 + return this.setResponse(status, { error: user.guestError }) + } + + /* + * Enforce RBAC + */ + if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Read record from database + */ + await this.read(where) + + return this.setResponse200({ + result: 'success', + account: this.asAccount(), + }) +} + /* * Loads a user from the database based on the where clause you pass it * In addition prepares it for returning the account data @@ -536,7 +761,7 @@ UserModel.prototype.guardedCreate = async function ({ body }) { * These are all placeholders, but fields that get encrypted need _some_ value * because encrypting null will cause an error. */ - data: this.encrypt('{}'), + data: this.encrypt({}), bio: this.encrypt(''), img: this.encrypt(this.config.avatars.user), } @@ -699,7 +924,9 @@ UserModel.prototype.passwordSignIn = async function (req) { /* * Final check for account status and other things before returning */ - return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed') + const [ok, err, status] = this.isOk() + if (ok === true) return this.signInOk() + else return this.setResponse(status, err) } /* @@ -771,12 +998,14 @@ UserModel.prototype.linkSignIn = async function (req) { /* * Before we return, remove the confirmation so it works only once */ - await this.Confirmation.delete() + //await this.Confirmation.delete() /* * Sign in was a success, run a final check before returning */ - return this.isOk() ? this.signInOk() : this.setResponse(401, 'signInFailed') + const [ok, err, status] = this.isOk(401, 'signInFailed') + if (ok === true) return this.signInOk() + else return this.setResponse(status, err) } /* @@ -952,6 +1181,51 @@ UserModel.prototype.confirm = async function ({ body, params }) { return this.signInOk() } +/* + * Updates the consent of the authenticated user + * Goes through jwt-guest middelware so one of the few routes you can access without consent + * + * @param {body} object - The request body + * @param {user} object - The user as loaded by auth middleware + * @returns {UserModel} object - The UserModel + */ +UserModel.prototype.updateConsent = async function ({ body, user }) { + /* + * Enforce RBAC + */ + if (!this.rbac.writeSome(user)) return this.setResponse(403, 'insufficientAccessLevel') + + /* + * Is consent valid? + */ + if (![0, 1, 2].includes(body.consent)) return this.setResponse(400, 'consentInvalid') + + /* + * Create data to update the record + */ + const data = { consent: body.consent } + if (this.record.status === 0 && body.consent > 0) data.status = 1 + + /* + * Now update the database record + */ + await this.update(data) + + /* + * Construct data to return + */ + const returnData = { + result: 'success', + account: this.asAccount(), + token: this.getToken(), + } + + /* + * Return data + */ + return this.setResponse200(returnData) +} + /* * Updates the user data - Used when we pass through user-provided data * so we can't be certain it's safe @@ -1487,7 +1761,7 @@ UserModel.prototype.getToken = function () { /* * Helper method to check an account is ok */ -UserModel.prototype.isOk = function () { +UserModel.prototype.isOk = function (failStatus = 401, failMsg = 'authenticationFailed') { /* * These are all the checks we run to see if an account is 'ok' */ @@ -1499,9 +1773,14 @@ UserModel.prototype.isOk = function () { this.record.role && this.record.role !== 'blocked' ) - return true + return [true, false] - return false + if (!this.exists) return [false, 'noSuchUser', 404] + if (this.record.consent < 1) return [false, 'consentLacking', 451] + if (this.record.status < 1) return [false, 'statusLacking', 403] + if (this.record.role === 'blocked') return [false, 'accountBlocked', 403] + + return [false, failMsg, failStatus] } /* @@ -1559,7 +1838,10 @@ UserModel.prototype.isLusernameAvailable = async function (lusername) { * Helper method that is called by middleware to verify whether the user * is allowed in. It will update the `lastSeen` field of the user as * well as increase the call counter for either JWT or KEY. - * It will also check whether the user status is ok and consent granted. + * It will also check whether the user status is ok. + * It will NOT check whether consent is granted because the users who + * sign up with Oauth need to be able to get their data during onboarding + * so they can consent. * * If this returns false, the request will never make it past the middleware. * @@ -1573,7 +1855,7 @@ UserModel.prototype.papersPlease = async function (id, type, payload) { * Construct data object for update operation */ const data = { lastSeen: new Date() } - data[`${type}Calls`] = { increment: 1 } + data[`${type === 'key' ? 'key' : 'jwt'}Calls`] = { increment: 1 } /* * Now update the dabatase record @@ -1585,8 +1867,9 @@ UserModel.prototype.papersPlease = async function (id, type, payload) { /* * An error means it's not good. Return false */ + console.log(err) log.warn({ id }, 'Could not update lastSeen field from middleware') - return false + return [false, 'failedToUpdateLastSeen'] } /* @@ -1604,25 +1887,29 @@ UserModel.prototype.papersPlease = async function (id, type, payload) { * An error means it's not good. Return false */ log.warn({ id }, 'Could not update apikey lastSeen field from middleware') - return false + return [false, 'failedToUpdateKeyCallCount'] } } /* - * Verify the consent and status + * Is consent given? (Lacking consent is only allowed under the jwt-guest type) */ - if (user.consent < 1) return false + if (user.consent < 1) return [type === 'jwt-guest' ? true : false, 'consentLacking'] /* - * Is the account active? + * Is the account active? (Lacking status is only allowed under the jwt-guest type) */ - if (user.status < 1) return false + if (user.status < 1) { + if (user.status === -1) return [type === 'jwt-guest' ? true : false, 'accountDisabled'] + if (user.status === -2) return [type === 'jwt-guest' ? true : false, 'accountBlocked'] + return [type === 'jwt-guest' ? true : false, 'accountInactive'] + } /* * If we get here, the lastSeen field was updated, user exists, * and their consent and status are ok, so so return true and let them through. */ - return true + return [true, false] } /* @@ -1760,7 +2047,6 @@ UserModel.prototype.import = async function (user) { await this.createRecord(data) } catch (err) { log.warn(err, 'Could not create user record') - console.log(user) return this.setResponse(500, 'createUserFailed') } // That's the user, now load their people as sets diff --git a/sites/backend/src/routes/users.mjs b/sites/backend/src/routes/users.mjs index 683326bb4ff..99103328012 100644 --- a/sites/backend/src/routes/users.mjs +++ b/sites/backend/src/routes/users.mjs @@ -2,6 +2,7 @@ import { UsersController } from '../controllers/users.mjs' const Users = new UsersController() const jwt = ['jwt', { session: false }] +const guest = ['jwt-guest', { session: false }] const bsc = ['basic', { session: false }] export function usersRoutes(tools) { @@ -10,6 +11,12 @@ export function usersRoutes(tools) { // Sign Up app.post('/signup', (req, res) => Users.signup(req, res, tools)) + // Init Oauth with GitHub or Google + app.post('/signin/oauth/init', (req, res) => Users.oauthInit(req, res, tools)) + + // Sign in (or sign up) with Oauth via GitHub or Google + app.post('/signin/oauth', (req, res) => Users.oauthSignIn(req, res, tools)) + // Confirm account app.post('/confirm/signup/:id', (req, res) => Users.confirm(req, res, tools)) @@ -22,8 +29,15 @@ export function usersRoutes(tools) { // Login via sign-in link (aka magic link) app.post('/signinlink/:id/:check', (req, res) => Users.signinvialink(req, res, tools)) - // Read current jwt - app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools)) + // Read current jwt This gets special treatment as it is a route that we allow + // even when the account status or consent would normally prohibit access. + // This way, we can return info to the frontend that allows us to better inform + // the user then merely returning 401 + app.get('/whoami/jwt', passport.authenticate(...guest), (req, res) => + Users.whoami(req, res, tools) + ) + + // Read the accound data app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools) ) @@ -39,6 +53,11 @@ export function usersRoutes(tools) { Users.update(req, res, tools) ) + // Update consent specifically (jwt-guest) + app.patch('/consent/jwt', passport.authenticate(...guest), (req, res) => + Users.updateConsent(req, res, tools) + ) + // Enable MFA (totp) app.post('/account/mfa/jwt', passport.authenticate(...jwt), (req, res) => Users.updateMfa(req, res, tools) diff --git a/sites/backend/src/utils/crypto.mjs b/sites/backend/src/utils/crypto.mjs index b017e6ac620..a793dc06ba6 100644 --- a/sites/backend/src/utils/crypto.mjs +++ b/sites/backend/src/utils/crypto.mjs @@ -53,7 +53,7 @@ export function encryption(stringKey, salt = 'FreeSewing') { try { data = asJson(data) } catch (err) { - console.log(err) + console.log({ type: 'encrypt', err, data }) throw 'Could not parse input to encrypt() call' } @@ -86,7 +86,7 @@ export function encryption(stringKey, salt = 'FreeSewing') { try { data = JSON.parse(data) } catch (err) { - console.log(err) + console.log({ type: 'decrypt', err, data }) throw 'Could not parse encrypted data in decrypt() call' } if (!data.iv || typeof data.ct === 'undefined') { diff --git a/sites/backend/src/utils/model-decorator.mjs b/sites/backend/src/utils/model-decorator.mjs index f81802788df..0f56869c0a2 100644 --- a/sites/backend/src/utils/model-decorator.mjs +++ b/sites/backend/src/utils/model-decorator.mjs @@ -106,11 +106,16 @@ export function decorateModel(Model, tools, modelConfig) { */ if (this.record) { for (const field of this.jsonFields) { - if (this.encryptedFields && this.encryptedFields.includes(field)) { + //this.record[field] = JSON.parse(this.record[field]) + if ( + this.encryptedFields && + this.encryptedFields.includes(field) && + typeof this.clear[field] === 'string' + ) { try { this.clear[field] = JSON.parse(this.clear[field]) } catch (err) { - console.log(err, typeof this.clear[field]) + console.log({ err, val: this.clear[field] }) } } else { this.record[field] = JSON.parse(this.record[field]) diff --git a/sites/backend/src/utils/oauth.mjs b/sites/backend/src/utils/oauth.mjs new file mode 100644 index 00000000000..73b8a2cedcc --- /dev/null +++ b/sites/backend/src/utils/oauth.mjs @@ -0,0 +1,130 @@ +import axios from 'axios' +import { oauth as config } from '../config.mjs' + +export const oauth = { + github: {}, + google: {}, +} + +oauth.github.getToken = async (code) => { + let result + try { + result = await axios.post( + config.github.tokenUri, + { + client_id: config.github.clientId, + client_secret: config.github.clientSecret, + code, + redirect_uri: config.github.redirectUri, + }, + { + headers: { + Accept: 'application/json', + }, + } + ) + } catch (err) { + console.log(err) + } + + return result.data.access_token +} + +oauth.github.loadUser = async (token) => { + const headers = { + Authorization: `Bearer ${token}`, + Accept: 'application/vnd.github+json', + 'X-Github-Api-Version': '2022-11-28', + } + let result + try { + result = await axios.get(config.github.dataUri, { headers }) + } catch (err) { + console.log(err) + } + + /* + * If the user has their email address set as public in GitHub we are done + * If not, we need an extra API call to get the email address. + */ + const allData = result.data + if (!allData.email) { + try { + result = await axios.get(config.github.emailUri, { headers }) + } catch (err) { + console.log(err) + } + for (const entry of result.data) { + if (entry.primary) { + allData.email = entry.email + } + } + } + + /* + * Return the data we need + */ + return { + email: allData.email, + username: allData.login, + website: allData.blog, + twitter: allData.twitter_username, + bio: allData.bio, + img: allData.avatar_url, + } +} + +oauth.google.getToken = async (code) => { + let result + try { + result = await axios.post( + config.google.tokenUri, + { + client_id: config.google.clientId, + client_secret: config.google.clientSecret, + code, + redirect_uri: config.google.redirectUri, + grant_type: 'authorization_code', + }, + { + headers: { + Accept: 'application/json', + }, + } + ) + } catch (err) { + console.log(err) + } + + return result.data.access_token +} + +oauth.google.loadUser = async (token) => { + const headers = { + Authorization: `Bearer ${token}`, + } + let result + try { + result = await axios.get(config.google.dataUri, { headers }) + } catch (err) { + console.log(err) + } + + /* + * Extract, then return, the data we need + */ + const data = {} + for (const name of result.data.names) { + if (name.metadata.primary) data.username = name.displayName + } + for (const img of result.data.photos) { + if (img.metadata.primary) data.img = img.url + } + for (const email of result.data.emailAddresses) { + if (email.metadata.primary) data.email = email.value + } + // Ensure a username is set + if (!data.username) data.username = data.email + + return data +} diff --git a/sites/org/pages/confirm/signin/[...confirmation].mjs b/sites/org/pages/confirm/signin/[...confirmation].mjs index 6a5a805381b..ed1b6e13925 100644 --- a/sites/org/pages/confirm/signin/[...confirmation].mjs +++ b/sites/org/pages/confirm/signin/[...confirmation].mjs @@ -1,43 +1,60 @@ +// Dependencies +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge, horFlexClasses } from 'shared/utils.mjs' // Hooks import { useEffect, useState } from 'react' import { useAccount } from 'shared/hooks/use-account.mjs' import { useBackend } from 'shared/hooks/use-backend.mjs' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' -// Dependencies -import { serverSideTranslations } from 'next-i18next/serverSideTranslations' -import Link from 'next/link' // Components -import { PageWrapper } from 'shared/components/wrappers/page.mjs' +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' import { BareLayout } from 'site/components/layouts/bare.mjs' import { Spinner } from 'shared/components/spinner.mjs' import { Robot } from 'shared/components/robot/index.mjs' import { FlexButtonText } from 'shared/components/buttons/flex-button-text.mjs' -import { LeftIcon, KeyIcon } from 'shared/components/icons.mjs' +import { KeyIcon } from 'shared/components/icons.mjs' +import Link from 'next/link' // Translation namespaces used on this page -const ns = Array.from(new Set(['signin', 'locales', 'themes'])) +const ns = nsMerge('susi', 'locales', 'themes', pageNs) -export const SigninLinkExpired = () => { - const { t } = useTranslation('signin') +const SigninFailed = ({ error }) => { + const { t } = useTranslation(ns) + + let title = t('susi:signinFailed') + let msg = t('susi:who knows') + if (error) { + if (error === 'noSuchUser') { + title = t('susi:noSuchUser') + msg = t('susi:noSuchUserMsg') + } else if (error === 'consentLacking') { + title = t('susi:consentLacking') + msg = t('susi:consentLackingMsg') + } else if (error === 'statusLacking') { + title = t('susi:statusLacking') + msg = t('susi:statusLackingMsg') + } else if (error === 'accountBlocked') { + title = t('susi:accountBlocked') + msg = t('susi:accountBlockedMsg') + } + } return (
-

{t('signInFailed')}

+

{title}

- - - - {t('back')} - - +

{msg}

+ + + {t('susi:signIn')}
) } const Wrapper = ({ page, t, children }) => ( - +
{children}
@@ -76,9 +93,8 @@ const ConfirmSignInPage = ({ page }) => { id: confirmationId, check: confirmationCheck, }) - if (result?.data?.token) return storeAccount(result.data) - if (result?.status === 404) return setError(404) - + if (result.data?.token) return storeAccount(result.data) + if (result.data.error) return setError(result.data.error) return setError(true) } // Call async method @@ -91,13 +107,13 @@ const ConfirmSignInPage = ({ page }) => { if (error) return ( - + ) return ( -

{t('oneMomentPlease')}

+

{t('susi:oneMomentPlease')}

) diff --git a/sites/org/pages/confirm/signup/[...confirmation].mjs b/sites/org/pages/confirm/signup/[...confirmation].mjs index 74cc9d3db86..c31a12c16e9 100644 --- a/sites/org/pages/confirm/signup/[...confirmation].mjs +++ b/sites/org/pages/confirm/signup/[...confirmation].mjs @@ -4,6 +4,7 @@ import { useAccount } from 'shared/hooks/use-account.mjs' import { useBackend } from 'shared/hooks/use-backend.mjs' import { useRouter } from 'next/router' import { useTranslation } from 'next-i18next' +import { nsMerge } from 'shared/utils.mjs' // Context import { LoadingContext } from 'shared/context/loading-context.mjs' // Dependencies @@ -15,33 +16,13 @@ import { BareLayout, ns as layoutNs } from 'site/components/layouts/bare.mjs' import { WelcomeWrapper } from 'shared/components/wrappers/welcome.mjs' import { Spinner } from 'shared/components/spinner.mjs' import { Popout } from 'shared/components/popout/index.mjs' -import { GdprAccountDetails, ns as gdprNs } from 'shared/components/gdpr/details.mjs' +import { ConsentForm, ns as gdprNs } from 'shared/components/gdpr/form.mjs' // Translation namespaces used on this page -const ns = Array.from(new Set([...pageNs, ...layoutNs, ...gdprNs, 'confirm', 'locales', 'themes'])) +const ns = nsMerge(pageNs, layoutNs, gdprNs, 'confirm') const SignupLinkExpired = () => Implement SignupLinkExpired compnonent -const Checkbox = ({ value, setter, label, children = null }) => ( -
setter(value ? false : true)} - > -
- setter(value ? false : true)} - /> - {label} -
- {children} -
-) - const ConfirmSignUpPage = () => { // Context const { loading } = useContext(LoadingContext) @@ -57,13 +38,10 @@ const ConfirmSignUpPage = () => { const { t } = useTranslation(ns) const [id, setId] = useState(false) - const [details, setDetails] = useState(false) - const [consent1, setConsent1] = useState(false) - const [consent2, setConsent2] = useState(false) const [ready, setReady] = useState(false) const [error, setError] = useState(false) - const createAccount = async () => { + const createAccount = async ({ consent1, consent2 }) => { let consent = 0 if (consent1) consent = 1 if (consent1 && consent2) consent = 2 @@ -80,11 +58,6 @@ const ConfirmSignUpPage = () => { } } - const giveConsent = () => { - setConsent1(true) - setConsent2(true) - } - useEffect(() => { // Async inside useEffect requires this approach const getConfirmation = async () => { @@ -114,61 +87,7 @@ const ConfirmSignUpPage = () => { return ( - {ready ? ( - <> -

{t('gdpr:privacyMatters')}

-

{t('gdpr:compliant')}

-

{t('gdpr:consentWhyAnswer')}

-
{t('gdpr:accountQuestion')}
- {details ? : null} - {consent1 ? ( - <> - - - - ) : ( - - )} - {consent1 && !consent2 ? {t('openDataInfo')} : null} -

- -

- {!consent1 && {t('gdpr:noConsentNoAccountCreation')}} - - ) : ( - - )} - {consent1 && ( - - )} -

- - FreeSewing Privacy Notice - -

+ {ready ? : }

diff --git a/sites/org/pages/signin/callback/[provider].mjs b/sites/org/pages/signin/callback/[provider].mjs new file mode 100644 index 00000000000..7cfd4a49229 --- /dev/null +++ b/sites/org/pages/signin/callback/[provider].mjs @@ -0,0 +1,93 @@ +// Dependencies +import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' +// Hooks +import { useEffect, useState } from 'react' +import { useRouter } from 'next/router' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' +import { useTranslation } from 'next-i18next' +// Components +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' +import { BareLayout } from 'site/components/layouts/bare.mjs' +//import { SignIn, ns as susiNs } from 'shared/components/susi/sign-in.mjs' +import { Loading } from 'shared/components/spinner.mjs' + +const ns = nsMerge(pageNs) + +/* + * Each page MUST be wrapped in the PageWrapper component. + * You also MUST spread props.page into this wrapper component + * when path and locale come from static props (as here) + * or set them manually. + */ +const OauthCallbackPage = ({ page, provider }) => { + const router = useRouter() + const { t } = useTranslation(ns) + const backend = useBackend() + const { setAccount, setToken, setSeenUser } = useAccount() + const { setLoadingStatus, LoadingStatus } = useLoadingStatus() + + useEffect(() => { + const oauthFlow = async () => { + const urlParams = new URLSearchParams(window.location.search) + const state = urlParams.get('state') + const code = urlParams.get('code') + const result = await backend.oauthSignIn({ state, code, provider }) + if (result.data?.account && result.data?.token) { + setAccount(result.data.account) + setToken(result.data.token) + setSeenUser(result.data.account.username) + setLoadingStatus([ + true, + t('susi:welcomeBackName', { name: result.data.account.username }), + true, + true, + ]) + router.push('/welcome') + } else { + } + } + oauthFlow() + }, [provider]) + + return ( + + +
+
+ +
+
+
+ ) +} + +export default OauthCallbackPage + +export async function getStaticProps({ locale, params }) { + return { + props: { + ...(await serverSideTranslations(locale, ns)), + provider: params.provider, + page: { + locale, + path: ['signin', 'callback', params.provider], + }, + }, + } +} + +/* + * getStaticPaths() is used to specify for which routes (think URLs) + * this page should be used to generate the result. + * + * To learn more, see: https://nextjs.org/docs/basic-features/data-fetching + */ +export async function getStaticPaths() { + return { + paths: [`/signin/callback/github`, `/signin/callback/google`], + fallback: false, + } +} diff --git a/sites/org/pages/welcome/index.mjs b/sites/org/pages/welcome/index.mjs index 1f393a6667f..445dbe70b30 100644 --- a/sites/org/pages/welcome/index.mjs +++ b/sites/org/pages/welcome/index.mjs @@ -1,13 +1,14 @@ // Dependencies import dynamic from 'next/dynamic' import { serverSideTranslations } from 'next-i18next/serverSideTranslations' +import { nsMerge } from 'shared/utils.mjs' // Components -import { PageWrapper } from 'shared/components/wrappers/page.mjs' +import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs' import { BareLayout } from 'site/components/layouts/bare.mjs' import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs' // Translation namespaces used on this page -const ns = [...new Set(['account', ...authNs])] +const ns = nsMerge('account', authNs, pageNs) /* * Some things should never generated as SSR diff --git a/sites/shared/components/account/oauth.mjs b/sites/shared/components/account/oauth.mjs new file mode 100644 index 00000000000..c6e9241aa8a --- /dev/null +++ b/sites/shared/components/account/oauth.mjs @@ -0,0 +1,70 @@ +import React from 'react' +import Provider from './provider' +import oauthConfig from '../../../config/oauth' + +const Oauth = (props) => { + const handleSignup = (provider) => { + props.app.setLoading(true) + props.app.backend + .initOauth({ + provider: provider, + language: process.env.GATSBY_LANGUAGE, + }) + .then((result) => { + if (result.status === 200) window.location = oauthConfig[provider] + result.data.state + else { + props.app.setLoading(false) + props.app.setNotification({ + type: 'error', + msg: props.app.translate('errors.something'), + }) + } + }) + } + + const styles = { + wrapper: { + display: 'flex', + flexDirection: 'row', + justifyContent: 'space-between', + flexWrap: 'wrap', + }, + } + + if (props.list) + return [ + handleSignup('google')} + list + />, + handleSignup('github')} + list + />, + ] + + return ( +
+ handleSignup('google')} + /> + handleSignup('github')} + /> +
+ ) +} + +export default Oauth diff --git a/sites/shared/components/gdpr/form.mjs b/sites/shared/components/gdpr/form.mjs new file mode 100644 index 00000000000..f8d3595fe9d --- /dev/null +++ b/sites/shared/components/gdpr/form.mjs @@ -0,0 +1,90 @@ +// Hooks +import { useState } from 'react' +import { useAccount } from 'shared/hooks/use-account.mjs' +import { useBackend } from 'shared/hooks/use-backend.mjs' +import { useRouter } from 'next/router' +import { useTranslation } from 'next-i18next' +// Components +import { Popout } from 'shared/components/popout/index.mjs' +import { Link } from 'shared/components/link.mjs' +import { GdprAccountDetails, ns as gdprNs } from 'shared/components/gdpr/details.mjs' + +export const ns = ['gdpr'] + +const Checkbox = ({ value, setter, label, children = null }) => ( +
setter(value ? false : true)} + > +
+ setter(value ? false : true)} + /> + {label} +
+ {children} +
+) + +export const ConsentForm = ({ submit }) => { + // State + const [details, setDetails] = useState(false) + const [consent1, setConsent1] = useState(false) + const [consent2, setConsent2] = useState(false) + + // Hooks + const router = useRouter() + const { setAccount, setToken } = useAccount() + const backend = useBackend() + const { t } = useTranslation(ns) + + const giveConsent = () => { + setConsent1(true) + setConsent2(true) + } + + return ( + <> +

{t('gdpr:privacyMatters')}

+

{t('gdpr:compliant')}

+

{t('gdpr:consentWhyAnswer')}

+
{t('gdpr:accountQuestion')}
+ {details ? : null} + {consent1 ? ( + <> + + + + ) : ( + + )} + {consent1 && !consent2 ? {t('openDataInfo')} : null} +

+ +

+ {!consent1 && {t('gdpr:noConsentNoAccountCreation')}} + {consent1 && ( + + )} +

+ + FreeSewing Privacy Notice + +

+ + ) +} diff --git a/sites/shared/components/susi/en.yaml b/sites/shared/components/susi/en.yaml index 5fb335e396e..0117043fa1c 100644 --- a/sites/shared/components/susi/en.yaml +++ b/sites/shared/components/susi/en.yaml @@ -1,3 +1,5 @@ +accountBlocked: Your account is blocked +accountBlockedMsg: This is highly unusual but it seems your account is administratively blocked. Your only recourse is to contact support. alreadyHaveAnAccount: Already have an account? back: Back backToSignIn: Back to sign in @@ -5,7 +7,11 @@ backToSignUp: Back to sign up checkYourInbox: Go check your inbox for an email from clickSigninLink: Click the sign-in link in that email to sign in to your FreeSewing account. clickSignupLink: Click your personal signup link in that email to create your FreeSewing account. +consentLacking: We lack consent to process your data +consentLackingMsg: Getting your consent is part of sign up process. Look for the email you received when you signed up for instracutions. You can sign up again with the same email address to receive the email again. contact: Contact support +contactingGithub: Contacting GitHub +contactingGoogle: Contacting Google createAFreeSewingAccount: Create a FreeSewing account dontHaveAV2Account: Don't have a v2 account? dontHaveAnAccount: Don't have an account yet? @@ -17,6 +23,7 @@ emailSigninLink: Email me a sign-in link emailUsernameId: "Your Email address, Username, or User #" err2: Unfortunately, we cannot recover from this error, we need a human being to look into this. err3: Feel free to try again, or reach out to support so we can assist you. +fewerOptions: Fewer options haveAV2Account: Have a v2 account? joinFreeSewing: Join FreeSewing migrateItHere: Migrate it here @@ -25,6 +32,11 @@ migrateV2Account: Migrate your v2 account migrateV2Desc: Enter your v2 username & password to migrate your account. migrateV2Info: Your v2 account will not be changed, this will only create a v3 account with the v2 account data. migrateV3UserAlreadyExists: Cannot migrate over an existing v3 account. Perhaps just sign in instead? +moreOptions: More options +noMagicFound: No such magic (link) found +noMagicFoundMsg: The magic link you used is either expired, or invalid. Note that each magic link can only be used once. +noSuchUser: User not found +noSuchUserMsg: We tried to find the user account you requested, but were unable to find it. notFound: No such user was found oneMomentPlease: One moment please password: Your Password @@ -35,6 +47,7 @@ regainAccess: Re-gain access signIn: Sign in signInAsOtherUser: Sign in as a different user signInFailed: Sign in failed +signInFailedMsg: Not entirely certain why, but it did not work as expected. signInHere: Sign in here signInToThing: "Sign in to { thing }" signInWithProvider: Sign in with { provider } @@ -43,6 +56,9 @@ signUpWithProvider: Sign up with {provider} signupAgain: Sign up again signupLinkExpired: Signup link expired somethingWentWrong: Something went wrong +sorry: Sorry +statusLacking: Your account is in a non-active status +statusLackingMsg: The current status of your account prohibits us from proceeding. The most common reason for this is that you did not complete the onboarding process and thus your account was never activated. You can sign up again with the same email address to remediate this. toReceiveSignupLink: To receive a sign-up link, enter your email address tryAgain: Try again usePassword: Use your password @@ -50,3 +66,4 @@ usernameMissing: Please provide your username welcome: Welcome welcomeBackName: "Welcome back { name }" welcomeMigrateName: Welcome to FreeSewing v3 {name}. Please note that this is still alpha code. + diff --git a/sites/shared/components/susi/sign-in.mjs b/sites/shared/components/susi/sign-in.mjs index 639300906ef..057996cfae1 100644 --- a/sites/shared/components/susi/sign-in.mjs +++ b/sites/shared/components/susi/sign-in.mjs @@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next' import { useBackend } from 'shared/hooks/use-backend.mjs' import { useRouter } from 'next/router' import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' -import { horFlexClasses, horFlexClassesNoSm } from 'shared/utils.mjs' +import { horFlexClasses, horFlexClassesNoSm, capitalize } from 'shared/utils.mjs' // Components import Link from 'next/link' import { @@ -25,7 +25,7 @@ export const ns = ['susi', 'errors', 'status'] export const SignIn = () => { const { setAccount, setToken, seenUser, setSeenUser } = useAccount() - const { t } = useTranslation(ns) + const { t, i18n } = useTranslation(ns) const backend = useBackend() const router = useRouter() const { setLoadingStatus, LoadingStatus } = useLoadingStatus() @@ -94,6 +94,15 @@ export const SignIn = () => { } } + const initOauth = async (provider) => { + setLoadingStatus([true, t(`susi:contactingBackend`)]) + const result = await backend.oauthInit({ provider, language: i18n.language }) + if (result.success) { + setLoadingStatus([true, t(`susi:contacting${capitalize(provider)}`)]) + window.location.href = result.data.authUrl + } + } + const btnClasses = `btn capitalize w-full mt-4 ${ signInFailed ? 'btn-warning' : 'btn-primary' } transition-colors ease-in-out duration-300 ${horFlexClasses}` @@ -197,7 +206,12 @@ export const SignIn = () => {
{['Google', 'Github'].map((provider) => ( - diff --git a/sites/shared/components/susi/sign-up.mjs b/sites/shared/components/susi/sign-up.mjs index ac9bb7d2e8d..cd208b8425d 100644 --- a/sites/shared/components/susi/sign-up.mjs +++ b/sites/shared/components/susi/sign-up.mjs @@ -6,7 +6,15 @@ import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs' // Context import { ModalContext } from 'shared/context/modal-context.mjs' // Dependencies -import { validateEmail, validateTld, horFlexClasses, horFlexClassesNoSm } from 'shared/utils.mjs' +import { + randomString, + validateEmail, + validateTld, + horFlexClasses, + horFlexClassesNoSm, + capitalize, +} from 'shared/utils.mjs' +import { oauthConfig } from 'shared/config/oauth.config.mjs' // Components import Link from 'next/link' import { Robot } from 'shared/components/robot/index.mjs' @@ -39,6 +47,8 @@ export const SignUp = () => { const [result, setResult] = useState(false) const [showAll, setShowAll] = useState(false) + const state = '' + const updateEmail = (value) => { setEmail(value) const valid = (validateEmail(value) && validateTld(value)) || false @@ -87,6 +97,15 @@ export const SignUp = () => { } } + const initOauth = async (provider) => { + setLoadingStatus([true, t(`status:contactingBackend`)]) + const result = await backend.oauthInit({ provider, language: i18n.language }) + if (result.success) { + setLoadingStatus([true, t(`status:contacting${provider}`)]) + window.location.href = result.data.authUrl + } + } + return (
@@ -157,11 +176,12 @@ export const SignUp = () => { {showAll ? ( <>
- {['Google', 'Github'].map((provider) => ( + {['Google', 'GitHub'].map((provider) => (