1
0
Fork 0

feat(shared): Initial implementation of Oauth flow

This commit is contained in:
Joost De Cock 2023-09-01 18:30:24 +02:00
parent 70b8fdd703
commit 01ef6c9896
24 changed files with 1077 additions and 172 deletions

View file

@ -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

View file

@ -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 {

View file

@ -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'],

View file

@ -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
*

View file

@ -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 }

View file

@ -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

View file

@ -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)

View file

@ -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') {

View file

@ -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])

View file

@ -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
}

View file

@ -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 (
<div className="p-8 max-w-md">
<h1 className="text-center">{t('signInFailed')}</h1>
<h3 className="text-center">{title}</h3>
<Robot pose="shrug" className="w-2/3 m-auto" embed />
<Link className="btn btn-primary btn-lg w-full" href="/signin">
<FlexButtonText>
<LeftIcon />
{t('back')}
<KeyIcon />
</FlexButtonText>
<p>{msg}</p>
<Link className={`btn btn-primary w-full ${horFlexClasses}`} href="/signin">
<KeyIcon />
{t('susi:signIn')}
</Link>
</div>
)
}
const Wrapper = ({ page, t, children }) => (
<PageWrapper {...page} title={t('signin:oneMomentPlease')} layout={BareLayout} footer={false}>
<PageWrapper {...page} title={t('susi:oneMomentPlease')} layout={BareLayout} footer={false}>
<section className="m-0 p-0 w-full">
<div className="mt-4 lg:mt-32 max-w-xl m-auto">{children}</div>
</section>
@ -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 (
<Wrapper page={page} t={t}>
<SigninLinkExpired />
<SigninFailed error={error} />
</Wrapper>
)
return (
<Wrapper page={page} t={t}>
<h1>{t('oneMomentPlease')}</h1>
<h1>{t('susi:oneMomentPlease')}</h1>
<Spinner className="w-8 h-8 m-auto animate-spin" />
</Wrapper>
)

View file

@ -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 = () => <Popout fixme>Implement SignupLinkExpired compnonent</Popout>
const Checkbox = ({ value, setter, label, children = null }) => (
<div
className={`form-control p-4 hover:cursor-pointer rounded border-l-8 my-2
${value ? 'border-success bg-success' : 'border-error bg-error'}
bg-opacity-10 shadow`}
onClick={() => setter(value ? false : true)}
>
<div className="form-control flex flex-row items-center gap-2">
<input
type="checkbox"
className="checkbox"
checked={value ? 'checked' : ''}
onChange={() => setter(value ? false : true)}
/>
<span className="label-text">{label}</span>
</div>
{children}
</div>
)
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 (
<PageWrapper {...page} title={t('joinFreeSewing')} layout={BareLayout} footer={false}>
<WelcomeWrapper>
{ready ? (
<>
<h1>{t('gdpr:privacyMatters')}</h1>
<p>{t('gdpr:compliant')}</p>
<p>{t('gdpr:consentWhyAnswer')}</p>
<h5 className="mt-8">{t('gdpr:accountQuestion')}</h5>
{details ? <GdprAccountDetails /> : null}
{consent1 ? (
<>
<Checkbox value={consent1} setter={setConsent1} label={t('gdpr:yesIDo')} />
<Checkbox
value={consent2}
setter={setConsent2}
label={t('gdpr:openDataQuestion')}
/>
</>
) : (
<button className="btn btn-primary btn-lg w-full mt-4" onClick={giveConsent}>
{t('gdpr:clickHere')}
</button>
)}
{consent1 && !consent2 ? <Popout note>{t('openDataInfo')}</Popout> : null}
<p className="text-center">
<button
className="btn btn-neutral btn-ghost btn-sm"
onClick={() => setDetails(!details)}
>
{t(details ? 'gdpr:hideDetails' : 'gdpr:showDetails')}
</button>
</p>
{!consent1 && <Popout note>{t('gdpr:noConsentNoAccountCreation')}</Popout>}
</>
) : (
<Spinner className="w-8 h-8 m-auto" />
)}
{consent1 && (
<button
onClick={createAccount}
className={`btn btn-lg w-full mt-8 ${loading ? 'btn-accent' : 'btn-primary'}`}
>
{loading ? (
<>
<Spinner />
<span>{t('gdpr:processing')}</span>
</>
) : (
<span>{t('gdpr:createAccount')}</span>
)}
</button>
)}
<p className="text-center opacity-50 mt-12">
<Link href="/docs/various/privacy" className="hover:text-secondary underline">
FreeSewing Privacy Notice
</Link>
</p>
{ready ? <ConsentForm submit={createAccount} /> : <Spinner className="w-8 h-8 m-auto" />}
</WelcomeWrapper>
<br />
</PageWrapper>

View file

@ -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 (
<PageWrapper {...page} layout={BareLayout}>
<LoadingStatus />
<div className="flex flex-col items-center h-screen justify-center text-base-content px-4">
<div className="max-w-lg w-full">
<Loading />
</div>
</div>
</PageWrapper>
)
}
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,
}
}

View file

@ -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

View file

@ -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 [
<Provider
provider="google"
login={props.login}
app={props.app}
signup={() => handleSignup('google')}
list
/>,
<Provider
provider="github"
login={props.login}
app={props.app}
signup={() => handleSignup('github')}
list
/>,
]
return (
<div style={styles.wrapper}>
<Provider
provider="google"
login={props.login}
app={props.app}
signup={() => handleSignup('google')}
/>
<Provider
provider="github"
login={props.login}
app={props.app}
signup={() => handleSignup('github')}
/>
</div>
)
}
export default Oauth

View file

@ -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 }) => (
<div
className={`form-control p-4 hover:cursor-pointer rounded border-l-8 my-2
${value ? 'border-success bg-success' : 'border-error bg-error'}
bg-opacity-10 shadow`}
onClick={() => setter(value ? false : true)}
>
<div className="form-control flex flex-row items-center gap-2">
<input
type="checkbox"
className="checkbox"
checked={value ? 'checked' : ''}
onChange={() => setter(value ? false : true)}
/>
<span className="label-text">{label}</span>
</div>
{children}
</div>
)
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 (
<>
<h1>{t('gdpr:privacyMatters')}</h1>
<p>{t('gdpr:compliant')}</p>
<p>{t('gdpr:consentWhyAnswer')}</p>
<h5 className="mt-8">{t('gdpr:accountQuestion')}</h5>
{details ? <GdprAccountDetails /> : null}
{consent1 ? (
<>
<Checkbox value={consent1} setter={setConsent1} label={t('gdpr:yesIDo')} />
<Checkbox value={consent2} setter={setConsent2} label={t('gdpr:openDataQuestion')} />
</>
) : (
<button className="btn btn-primary btn-lg w-full mt-4" onClick={giveConsent}>
{t('gdpr:clickHere')}
</button>
)}
{consent1 && !consent2 ? <Popout note>{t('openDataInfo')}</Popout> : null}
<p className="text-center">
<button className="btn btn-neutral btn-ghost btn-sm" onClick={() => setDetails(!details)}>
{t(details ? 'gdpr:hideDetails' : 'gdpr:showDetails')}
</button>
</p>
{!consent1 && <Popout note>{t('gdpr:noConsentNoAccountCreation')}</Popout>}
{consent1 && (
<button
onClick={() => submit({ consent1, consent2 })}
className="btn btn-lg w-full mt-8 btn-primary"
>
<span>{t('gdpr:createAccount')}</span>
</button>
)}
<p className="text-center opacity-50 mt-12">
<Link href="/docs/various/privacy" className="hover:text-secondary underline">
FreeSewing Privacy Notice
</Link>
</p>
</>
)
}

View file

@ -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.

View file

@ -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 = () => {
</button>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-center mt-2">
{['Google', 'Github'].map((provider) => (
<button key={provider} id={provider} className={`${horFlexClasses} btn btn-secondary`}>
<button
key={provider}
id={provider}
className={`${horFlexClasses} btn btn-secondary`}
onClick={() => initOauth(provider)}
>
{provider === 'Google' ? <GoogleIcon stroke={0} /> : <GitHubIcon />}
<span>{t('susi:signInWithProvider', { provider })}</span>
</button>

View file

@ -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 (
<div className="w-full">
<LoadingStatus />
@ -157,11 +176,12 @@ export const SignUp = () => {
{showAll ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-center mt-4">
{['Google', 'Github'].map((provider) => (
{['Google', 'GitHub'].map((provider) => (
<button
key={provider}
id={provider}
className={`${horFlexClasses} btn btn-secondary`}
onClick={() => initOauth(provider)}
>
{provider === 'Google' ? <GoogleIcon stroke={0} /> : <GitHubIcon />}
<span>{t('susi:signUpWithProvider', { provider })}</span>

View file

@ -7,8 +7,9 @@ import { useEffect, useState } from 'react'
import { Loading } from 'shared/components/spinner.mjs'
import { horFlexClasses } from 'shared/utils.mjs'
import { LockIcon, PlusIcon } from 'shared/components/icons.mjs'
import { ConsentForm, ns as gdprNs } from 'shared/components/gdpr/form.mjs'
export const ns = ['auth']
export const ns = ['auth', 'gdpr']
const Wrap = ({ children }) => (
<div className="m-auto max-w-xl text-center mt-8 p-8">{children}</div>
@ -90,21 +91,39 @@ const RoleLacking = ({ t, requiredRole, role, banner }) => (
</Wrap>
)
const ConsentLacking = ({ t, banner }) => (
<Wrap>
{banner}
<h1>{t('consentLacking')}</h1>
<p>{t('membersOnly')}</p>
<div className="flex flex-row items-center justify-center gap-4 mt-8">
<Link href="/signup" className="btn btn-primary w-32">
{t('signUp')}
</Link>
<Link href="/signin" className="btn btn-primary btn-outline w-32">
{t('signIn')}
</Link>
</div>
</Wrap>
)
const ConsentLacking = ({ banner, refresh }) => {
const { setAccount, setToken, setSeenUser } = useAccount()
const backend = useBackend()
const { t } = useTranslation(ns)
const updateConsent = async ({ consent1, consent2 }) => {
let consent = 0
if (consent1) consent = 1
if (consent1 && consent2) consent = 2
if (consent > 0) {
const result = await backend.updateConsent(consent)
console.log({ result })
if (result.success) {
setToken(result.data.token)
setAccount(result.data.account)
setSeenUser(result.data.account.username)
refresh()
} else {
console.log('something went wrong', result)
refresh()
}
}
}
return (
<Wrap>
<div className="text-left">
{banner}
<ConsentForm submit={updateConsent} />
</div>
</Wrap>
)
}
export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
const { t } = useTranslation(ns)
@ -113,6 +132,8 @@ export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
const [ready, setReady] = useState(false)
const [impersonating, setImpersonating] = useState(false)
const [error, setError] = useState(false)
const [refreshCount, setRefreshCount] = useState(0)
/*
* Avoid hydration errors
@ -126,17 +147,33 @@ export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
user: account.username,
})
}
setReady(true)
}
const verifyUser = async () => {
const result = await backend.ping()
if (!result.success) signOut()
if (!result.success) {
if (result.data?.error?.error) setError(result.data.error.error)
else signOut()
}
setReady(true)
}
if (admin && admin.token) verifyAdmin()
if (token) verifyUser()
setReady(true)
}, [admin, token])
else setReady(true)
}, [admin, token, refreshCount])
if (!ready) return <Loading />
const refresh = () => {
setRefreshCount(refreshCount + 1)
setError(false)
}
if (!ready)
return (
<>
<p>not ready</p>
<Loading />
</>
)
const banner = impersonating ? (
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
@ -152,13 +189,13 @@ export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
const childProps = { t, banner }
if (!token || !account.username) return <AuthRequired {...childProps} />
if (account.status !== 1) {
if (account.status === 0) return <AccountInactive {...childProps} />
if (account.status === -1) return <AccountDisabled {...childProps} />
if (account.status === -2) return <AccountProhibited {...childProps} />
if (error) {
if (error === 'accountInactive') return <AccountInactive {...childProps} />
if (error === 'accountDisabled') return <AccountDisabled {...childProps} />
if (error === 'accountBlocked') return <AccountProhibited {...childProps} />
if (error === 'consentLacking') return <ConsentLacking {...childProps} refresh={refresh} />
return <AccountStatusUnknown {...childProps} />
}
if (account.consent < 1) return <ConsentLacking {...childProps} />
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />

View file

@ -0,0 +1,37 @@
export const oauthConfig = {
github: {
clientId: 'e1acc4ff320d84bfe44d',
url: (state) =>
`https://github.com/login/oauth/authorize?client_id=e1acc4ff320d84bfe44d&redirect_uri=https://localhost:8000/login/callback/github&scope=read:user user:email&state=${state}`,
},
google: {
clientId: '316708420427-4spj1rj2ekgke887ng5dsi1bsu8bcg8j.apps.googleusercontent.com',
url: (state) => `fixme`,
},
}
/*
export const foo = {
github:
'https://github.com/login/oauth/authorize' +
'?client_id=' +
githubClientId,
'&redirect_uri=' +
`${process.env.GATSBY_BACKEND}oauth/callback/from/github` +
'&scope=' +
'read:user user:email' +
'&state=',
google:
'https://accounts.google.com/o/oauth2/v2/auth' +
'?response_type=code' +
'&client_id=' +
process.env.GATSBY_GOOGLE_CLIENT_ID +
'&redirect_uri=' +
`${process.env.GATSBY_BACKEND}oauth/callback/from/google` +
'&scope=' +
'https://www.googleapis.com/auth/userinfo.profile' +
' ' +
'https://www.googleapis.com/auth/userinfo.email' +
'&access_type=online' +
'&state=',
}
*/

View file

@ -67,6 +67,15 @@ const responseHandler = (response, expectedStatus = 200, expectData = true) => {
return { success: true, response }
}
// Unpack axios errors
if (response.name === 'AxiosError')
return {
success: false,
status: response.response?.status,
data: response.response?.data,
error: response.message,
}
return { success: false, response }
}
@ -81,6 +90,20 @@ Backend.prototype.signUp = async function ({ email, language }) {
return responseHandler(await api.post('/signup', { email, language }), 201)
}
/*
* backend.oauthInit: Init Oauth flow for oauth provider
*/
Backend.prototype.oauthInit = async function ({ provider, language }) {
return responseHandler(await api.post('/signin/oauth/init', { provider, language }))
}
/*
* backend.oauthSignIn: User sign in via oauth provider
*/
Backend.prototype.oauthSignIn = async function ({ state, code, provider }) {
return responseHandler(await api.post('/signin/oauth', { state, code, provider }))
}
/*
* Backend.prototype.loadConfirmation: Load a confirmation
*/
@ -117,6 +140,13 @@ Backend.prototype.updateAccount = async function (data) {
return responseHandler(await api.patch(`/account/jwt`, data, this.auth))
}
/*
* Update consent (uses the jwt-guest middleware)
*/
Backend.prototype.updateConsent = async function (consent) {
return responseHandler(await api.patch(`/consent/jwt`, { consent }, this.auth))
}
/*
* Checks whether a username is available
*/

View file

@ -6,3 +6,6 @@ processingUpdate: Processing update
generatingPdf: Generating your PDF, one moment please
pdfReady: PDF generated
pdfFailed: An unexpected error occured while generating your PDF
contactingBackend: Contacting the FreeSewing backend
contactingGitHub: Contacting GitHub
contactingGoogle: Contacting Google

View file

@ -445,3 +445,14 @@ export const horFlexClassesNoSm =
* A method that check that a var is not empty
*/
export const notEmpty = (thing) => `${thing}`.length > 0
/*
* Generates a random string (used in Oauth flow)
*/
const dec2hex = (dec) => dec.toString(16).padStart(2, '0')
export const randomString = (len = 42) => {
if (typeof window === 'undefined') return '' // Not used in SSR
const arr = new Uint8Array(len / 2)
window.crypto.getRandomValues(arr)
return Array.from(arr, dec2hex).join('')
}