feat(shared): Initial implementation of Oauth flow
This commit is contained in:
parent
70b8fdd703
commit
01ef6c9896
24 changed files with 1077 additions and 172 deletions
|
@ -197,6 +197,8 @@ BACKEND_ENABLE_OAUTH_GITHUB=no
|
||||||
#BACKEND_GITHUB_CLIENT_ID=githubOauthClientIdHere
|
#BACKEND_GITHUB_CLIENT_ID=githubOauthClientIdHere
|
||||||
# Github client secret
|
# Github client secret
|
||||||
#BACKEND_GITHUB_CLIENT_SECRET=githubOauthClientSecretHere
|
#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 #
|
# Google #
|
||||||
# Enable Google as Oauth provider
|
# Enable Google as Oauth provider
|
||||||
|
|
|
@ -36,8 +36,8 @@ model Confirmation {
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
data String
|
data String
|
||||||
type String
|
type String
|
||||||
user User @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
userId Int
|
userId Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
model Subscriber {
|
model Subscriber {
|
||||||
|
|
|
@ -213,23 +213,57 @@ if (baseConfig.use.ses)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Oauth config for Github as a provider
|
// Oauth config for Github as a provider
|
||||||
if (baseConfig.use.oauth?.github)
|
if (baseConfig.use.oauth?.github) {
|
||||||
baseConfig.oauth.github = {
|
baseConfig.oauth.github = {
|
||||||
clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID,
|
clientId: process.env.BACKEND_OAUTH_GITHUB_CLIENT_ID,
|
||||||
clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET,
|
clientSecret: process.env.BACKEND_OAUTH_GITHUB_CLIENT_SECRET,
|
||||||
tokenUri: 'https://github.com/login/oauth/access_token',
|
tokenUri: 'https://github.com/login/oauth/access_token',
|
||||||
dataUri: 'https://api.github.com/user',
|
dataUri: 'https://api.github.com/user',
|
||||||
emailUri: 'https://api.github.com/user/emails',
|
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
|
// Oauth config for Google as a provider
|
||||||
if (baseConfig.use.oauth?.google)
|
if (baseConfig.use.oauth?.google) {
|
||||||
baseConfig.oauth.google = {
|
baseConfig.oauth.google = {
|
||||||
clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID,
|
clientId: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_ID,
|
||||||
clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET,
|
clientSecret: process.env.BACKEND_OAUTH_GOOGLE_CLIENT_SECRET,
|
||||||
tokenUri: 'https://oauth2.googleapis.com/token',
|
tokenUri: 'https://oauth2.googleapis.com/token',
|
||||||
dataUri: 'https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos',
|
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
|
// Load local config
|
||||||
const config = postConfig(baseConfig)
|
const config = postConfig(baseConfig)
|
||||||
|
@ -241,6 +275,7 @@ export const website = config.website
|
||||||
export const github = config.github
|
export const github = config.github
|
||||||
export const instance = config.instance
|
export const instance = config.instance
|
||||||
export const exports = config.exports
|
export const exports = config.exports
|
||||||
|
export const oauth = config.oauth
|
||||||
|
|
||||||
const vars = {
|
const vars = {
|
||||||
BACKEND_DB_URL: ['required', 'db.url'],
|
BACKEND_DB_URL: ['required', 'db.url'],
|
||||||
|
|
|
@ -15,6 +15,35 @@ UsersController.prototype.signup = async (req, res, tools) => {
|
||||||
return User.sendResponse(res)
|
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)
|
* Confirm account (after signup)
|
||||||
*
|
*
|
||||||
|
@ -74,7 +103,7 @@ UsersController.prototype.signinvialink = async function (req, res, tools) {
|
||||||
*/
|
*/
|
||||||
UsersController.prototype.whoami = async (req, res, tools) => {
|
UsersController.prototype.whoami = async (req, res, tools) => {
|
||||||
const User = new UserModel(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)
|
return User.sendResponse(res)
|
||||||
}
|
}
|
||||||
|
@ -92,6 +121,19 @@ UsersController.prototype.update = async (req, res, tools) => {
|
||||||
return User.sendResponse(res)
|
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
|
* Updates the MFA setting of the authenticated user
|
||||||
*
|
*
|
||||||
|
|
|
@ -19,9 +19,9 @@ async function checkAccess(payload, tools, type) {
|
||||||
if (payload.aud !== `${api}/${instance}`) return false
|
if (payload.aud !== `${api}/${instance}`) return false
|
||||||
const User = new UserModel(tools)
|
const User = new UserModel(tools)
|
||||||
const uid = payload.userId || payload._id
|
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) {
|
function loadExpressMiddleware(app) {
|
||||||
|
@ -36,7 +36,7 @@ function loadPassportMiddleware(passport, tools) {
|
||||||
/*
|
/*
|
||||||
* We check more than merely the API key
|
* 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
|
return ok
|
||||||
? done(null, {
|
? done(null, {
|
||||||
|
@ -48,6 +48,7 @@ function loadPassportMiddleware(passport, tools) {
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
passport.use(
|
passport.use(
|
||||||
|
'jwt',
|
||||||
new jwt.Strategy(
|
new jwt.Strategy(
|
||||||
{
|
{
|
||||||
jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
|
@ -57,7 +58,7 @@ function loadPassportMiddleware(passport, tools) {
|
||||||
/*
|
/*
|
||||||
* We check more than merely the token
|
* 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
|
return ok
|
||||||
? done(null, {
|
? 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 }
|
export { loadExpressMiddleware, loadPassportMiddleware }
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import jwt from 'jsonwebtoken'
|
import jwt from 'jsonwebtoken'
|
||||||
import { log } from '../utils/log.mjs'
|
import { log } from '../utils/log.mjs'
|
||||||
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.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 { clean, asJson, i18nUrl, writeExportedData } from '../utils/index.mjs'
|
||||||
import { decorateModel } from '../utils/model-decorator.mjs'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
import { userCard } from '../templates/svg/user-card.mjs'
|
import { userCard } from '../templates/svg/user-card.mjs'
|
||||||
|
import { oauth } from '../utils/oauth.mjs'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This model handles all user updates
|
* 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
|
* Loads a user from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the account data
|
* 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
|
* Loads a user from the database based on the where clause you pass it
|
||||||
* In addition prepares it for returning the account data
|
* 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
|
* These are all placeholders, but fields that get encrypted need _some_ value
|
||||||
* because encrypting null will cause an error.
|
* because encrypting null will cause an error.
|
||||||
*/
|
*/
|
||||||
data: this.encrypt('{}'),
|
data: this.encrypt({}),
|
||||||
bio: this.encrypt(''),
|
bio: this.encrypt(''),
|
||||||
img: this.encrypt(this.config.avatars.user),
|
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
|
* 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
|
* 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
|
* 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()
|
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
|
* Updates the user data - Used when we pass through user-provided data
|
||||||
* so we can't be certain it's safe
|
* 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
|
* 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'
|
* 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 &&
|
||||||
this.record.role !== 'blocked'
|
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
|
* 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
|
* is allowed in. It will update the `lastSeen` field of the user as
|
||||||
* well as increase the call counter for either JWT or KEY.
|
* 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.
|
* 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
|
* Construct data object for update operation
|
||||||
*/
|
*/
|
||||||
const data = { lastSeen: new Date() }
|
const data = { lastSeen: new Date() }
|
||||||
data[`${type}Calls`] = { increment: 1 }
|
data[`${type === 'key' ? 'key' : 'jwt'}Calls`] = { increment: 1 }
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Now update the dabatase record
|
* 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
|
* An error means it's not good. Return false
|
||||||
*/
|
*/
|
||||||
|
console.log(err)
|
||||||
log.warn({ id }, 'Could not update lastSeen field from middleware')
|
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
|
* An error means it's not good. Return false
|
||||||
*/
|
*/
|
||||||
log.warn({ id }, 'Could not update apikey lastSeen field from middleware')
|
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,
|
* 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.
|
* 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)
|
await this.createRecord(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.warn(err, 'Could not create user record')
|
log.warn(err, 'Could not create user record')
|
||||||
console.log(user)
|
|
||||||
return this.setResponse(500, 'createUserFailed')
|
return this.setResponse(500, 'createUserFailed')
|
||||||
}
|
}
|
||||||
// That's the user, now load their people as sets
|
// That's the user, now load their people as sets
|
||||||
|
|
|
@ -2,6 +2,7 @@ import { UsersController } from '../controllers/users.mjs'
|
||||||
|
|
||||||
const Users = new UsersController()
|
const Users = new UsersController()
|
||||||
const jwt = ['jwt', { session: false }]
|
const jwt = ['jwt', { session: false }]
|
||||||
|
const guest = ['jwt-guest', { session: false }]
|
||||||
const bsc = ['basic', { session: false }]
|
const bsc = ['basic', { session: false }]
|
||||||
|
|
||||||
export function usersRoutes(tools) {
|
export function usersRoutes(tools) {
|
||||||
|
@ -10,6 +11,12 @@ export function usersRoutes(tools) {
|
||||||
// Sign Up
|
// Sign Up
|
||||||
app.post('/signup', (req, res) => Users.signup(req, res, tools))
|
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
|
// Confirm account
|
||||||
app.post('/confirm/signup/:id', (req, res) => Users.confirm(req, res, tools))
|
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)
|
// Login via sign-in link (aka magic link)
|
||||||
app.post('/signinlink/:id/:check', (req, res) => Users.signinvialink(req, res, tools))
|
app.post('/signinlink/:id/:check', (req, res) => Users.signinvialink(req, res, tools))
|
||||||
|
|
||||||
// Read current jwt
|
// Read current jwt This gets special treatment as it is a route that we allow
|
||||||
app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => Users.whoami(req, res, tools))
|
// 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) =>
|
app.get('/account/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
Users.whoami(req, res, tools)
|
Users.whoami(req, res, tools)
|
||||||
)
|
)
|
||||||
|
@ -39,6 +53,11 @@ export function usersRoutes(tools) {
|
||||||
Users.update(req, res, 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)
|
// Enable MFA (totp)
|
||||||
app.post('/account/mfa/jwt', passport.authenticate(...jwt), (req, res) =>
|
app.post('/account/mfa/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
Users.updateMfa(req, res, tools)
|
Users.updateMfa(req, res, tools)
|
||||||
|
|
|
@ -53,7 +53,7 @@ export function encryption(stringKey, salt = 'FreeSewing') {
|
||||||
try {
|
try {
|
||||||
data = asJson(data)
|
data = asJson(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.log({ type: 'encrypt', err, data })
|
||||||
throw 'Could not parse input to encrypt() call'
|
throw 'Could not parse input to encrypt() call'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -86,7 +86,7 @@ export function encryption(stringKey, salt = 'FreeSewing') {
|
||||||
try {
|
try {
|
||||||
data = JSON.parse(data)
|
data = JSON.parse(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err)
|
console.log({ type: 'decrypt', err, data })
|
||||||
throw 'Could not parse encrypted data in decrypt() call'
|
throw 'Could not parse encrypted data in decrypt() call'
|
||||||
}
|
}
|
||||||
if (!data.iv || typeof data.ct === 'undefined') {
|
if (!data.iv || typeof data.ct === 'undefined') {
|
||||||
|
|
|
@ -106,11 +106,16 @@ export function decorateModel(Model, tools, modelConfig) {
|
||||||
*/
|
*/
|
||||||
if (this.record) {
|
if (this.record) {
|
||||||
for (const field of this.jsonFields) {
|
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 {
|
try {
|
||||||
this.clear[field] = JSON.parse(this.clear[field])
|
this.clear[field] = JSON.parse(this.clear[field])
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log(err, typeof this.clear[field])
|
console.log({ err, val: this.clear[field] })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.record[field] = JSON.parse(this.record[field])
|
this.record[field] = JSON.parse(this.record[field])
|
||||||
|
|
130
sites/backend/src/utils/oauth.mjs
Normal file
130
sites/backend/src/utils/oauth.mjs
Normal 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
|
||||||
|
}
|
|
@ -1,43 +1,60 @@
|
||||||
|
// Dependencies
|
||||||
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
|
import { nsMerge, horFlexClasses } from 'shared/utils.mjs'
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
// Dependencies
|
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
|
||||||
import Link from 'next/link'
|
|
||||||
// Components
|
// 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 { BareLayout } from 'site/components/layouts/bare.mjs'
|
||||||
import { Spinner } from 'shared/components/spinner.mjs'
|
import { Spinner } from 'shared/components/spinner.mjs'
|
||||||
import { Robot } from 'shared/components/robot/index.mjs'
|
import { Robot } from 'shared/components/robot/index.mjs'
|
||||||
import { FlexButtonText } from 'shared/components/buttons/flex-button-text.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
|
// 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 SigninFailed = ({ error }) => {
|
||||||
const { t } = useTranslation('signin')
|
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 (
|
return (
|
||||||
<div className="p-8 max-w-md">
|
<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 />
|
<Robot pose="shrug" className="w-2/3 m-auto" embed />
|
||||||
<Link className="btn btn-primary btn-lg w-full" href="/signin">
|
<p>{msg}</p>
|
||||||
<FlexButtonText>
|
<Link className={`btn btn-primary w-full ${horFlexClasses}`} href="/signin">
|
||||||
<LeftIcon />
|
<KeyIcon />
|
||||||
{t('back')}
|
{t('susi:signIn')}
|
||||||
<KeyIcon />
|
|
||||||
</FlexButtonText>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const Wrapper = ({ page, t, children }) => (
|
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">
|
<section className="m-0 p-0 w-full">
|
||||||
<div className="mt-4 lg:mt-32 max-w-xl m-auto">{children}</div>
|
<div className="mt-4 lg:mt-32 max-w-xl m-auto">{children}</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -76,9 +93,8 @@ const ConfirmSignInPage = ({ page }) => {
|
||||||
id: confirmationId,
|
id: confirmationId,
|
||||||
check: confirmationCheck,
|
check: confirmationCheck,
|
||||||
})
|
})
|
||||||
if (result?.data?.token) return storeAccount(result.data)
|
if (result.data?.token) return storeAccount(result.data)
|
||||||
if (result?.status === 404) return setError(404)
|
if (result.data.error) return setError(result.data.error)
|
||||||
|
|
||||||
return setError(true)
|
return setError(true)
|
||||||
}
|
}
|
||||||
// Call async method
|
// Call async method
|
||||||
|
@ -91,13 +107,13 @@ const ConfirmSignInPage = ({ page }) => {
|
||||||
if (error)
|
if (error)
|
||||||
return (
|
return (
|
||||||
<Wrapper page={page} t={t}>
|
<Wrapper page={page} t={t}>
|
||||||
<SigninLinkExpired />
|
<SigninFailed error={error} />
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Wrapper page={page} t={t}>
|
<Wrapper page={page} t={t}>
|
||||||
<h1>{t('oneMomentPlease')}</h1>
|
<h1>{t('susi:oneMomentPlease')}</h1>
|
||||||
<Spinner className="w-8 h-8 m-auto animate-spin" />
|
<Spinner className="w-8 h-8 m-auto animate-spin" />
|
||||||
</Wrapper>
|
</Wrapper>
|
||||||
)
|
)
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
|
import { nsMerge } from 'shared/utils.mjs'
|
||||||
// Context
|
// Context
|
||||||
import { LoadingContext } from 'shared/context/loading-context.mjs'
|
import { LoadingContext } from 'shared/context/loading-context.mjs'
|
||||||
// Dependencies
|
// 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 { WelcomeWrapper } from 'shared/components/wrappers/welcome.mjs'
|
||||||
import { Spinner } from 'shared/components/spinner.mjs'
|
import { Spinner } from 'shared/components/spinner.mjs'
|
||||||
import { Popout } from 'shared/components/popout/index.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
|
// 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 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 = () => {
|
const ConfirmSignUpPage = () => {
|
||||||
// Context
|
// Context
|
||||||
const { loading } = useContext(LoadingContext)
|
const { loading } = useContext(LoadingContext)
|
||||||
|
@ -57,13 +38,10 @@ const ConfirmSignUpPage = () => {
|
||||||
const { t } = useTranslation(ns)
|
const { t } = useTranslation(ns)
|
||||||
|
|
||||||
const [id, setId] = useState(false)
|
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 [ready, setReady] = useState(false)
|
||||||
const [error, setError] = useState(false)
|
const [error, setError] = useState(false)
|
||||||
|
|
||||||
const createAccount = async () => {
|
const createAccount = async ({ consent1, consent2 }) => {
|
||||||
let consent = 0
|
let consent = 0
|
||||||
if (consent1) consent = 1
|
if (consent1) consent = 1
|
||||||
if (consent1 && consent2) consent = 2
|
if (consent1 && consent2) consent = 2
|
||||||
|
@ -80,11 +58,6 @@ const ConfirmSignUpPage = () => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const giveConsent = () => {
|
|
||||||
setConsent1(true)
|
|
||||||
setConsent2(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Async inside useEffect requires this approach
|
// Async inside useEffect requires this approach
|
||||||
const getConfirmation = async () => {
|
const getConfirmation = async () => {
|
||||||
|
@ -114,61 +87,7 @@ const ConfirmSignUpPage = () => {
|
||||||
return (
|
return (
|
||||||
<PageWrapper {...page} title={t('joinFreeSewing')} layout={BareLayout} footer={false}>
|
<PageWrapper {...page} title={t('joinFreeSewing')} layout={BareLayout} footer={false}>
|
||||||
<WelcomeWrapper>
|
<WelcomeWrapper>
|
||||||
{ready ? (
|
{ready ? <ConsentForm submit={createAccount} /> : <Spinner className="w-8 h-8 m-auto" />}
|
||||||
<>
|
|
||||||
<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>
|
|
||||||
</WelcomeWrapper>
|
</WelcomeWrapper>
|
||||||
<br />
|
<br />
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
|
|
93
sites/org/pages/signin/callback/[provider].mjs
Normal file
93
sites/org/pages/signin/callback/[provider].mjs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,14 @@
|
||||||
// Dependencies
|
// Dependencies
|
||||||
import dynamic from 'next/dynamic'
|
import dynamic from 'next/dynamic'
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
|
import { nsMerge } from 'shared/utils.mjs'
|
||||||
// Components
|
// 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 { BareLayout } from 'site/components/layouts/bare.mjs'
|
||||||
import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
|
|
||||||
// Translation namespaces used on this page
|
// 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
|
* Some things should never generated as SSR
|
||||||
|
|
70
sites/shared/components/account/oauth.mjs
Normal file
70
sites/shared/components/account/oauth.mjs
Normal 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
|
90
sites/shared/components/gdpr/form.mjs
Normal file
90
sites/shared/components/gdpr/form.mjs
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
|
@ -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?
|
alreadyHaveAnAccount: Already have an account?
|
||||||
back: Back
|
back: Back
|
||||||
backToSignIn: Back to sign in
|
backToSignIn: Back to sign in
|
||||||
|
@ -5,7 +7,11 @@ backToSignUp: Back to sign up
|
||||||
checkYourInbox: Go check your inbox for an email from
|
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.
|
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.
|
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
|
contact: Contact support
|
||||||
|
contactingGithub: Contacting GitHub
|
||||||
|
contactingGoogle: Contacting Google
|
||||||
createAFreeSewingAccount: Create a FreeSewing account
|
createAFreeSewingAccount: Create a FreeSewing account
|
||||||
dontHaveAV2Account: Don't have a v2 account?
|
dontHaveAV2Account: Don't have a v2 account?
|
||||||
dontHaveAnAccount: Don't have an account yet?
|
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 #"
|
emailUsernameId: "Your Email address, Username, or User #"
|
||||||
err2: Unfortunately, we cannot recover from this error, we need a human being to look into this.
|
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.
|
err3: Feel free to try again, or reach out to support so we can assist you.
|
||||||
|
fewerOptions: Fewer options
|
||||||
haveAV2Account: Have a v2 account?
|
haveAV2Account: Have a v2 account?
|
||||||
joinFreeSewing: Join FreeSewing
|
joinFreeSewing: Join FreeSewing
|
||||||
migrateItHere: Migrate it here
|
migrateItHere: Migrate it here
|
||||||
|
@ -25,6 +32,11 @@ migrateV2Account: Migrate your v2 account
|
||||||
migrateV2Desc: Enter your v2 username & password to migrate your 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.
|
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?
|
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
|
notFound: No such user was found
|
||||||
oneMomentPlease: One moment please
|
oneMomentPlease: One moment please
|
||||||
password: Your Password
|
password: Your Password
|
||||||
|
@ -35,6 +47,7 @@ regainAccess: Re-gain access
|
||||||
signIn: Sign in
|
signIn: Sign in
|
||||||
signInAsOtherUser: Sign in as a different user
|
signInAsOtherUser: Sign in as a different user
|
||||||
signInFailed: Sign in failed
|
signInFailed: Sign in failed
|
||||||
|
signInFailedMsg: Not entirely certain why, but it did not work as expected.
|
||||||
signInHere: Sign in here
|
signInHere: Sign in here
|
||||||
signInToThing: "Sign in to { thing }"
|
signInToThing: "Sign in to { thing }"
|
||||||
signInWithProvider: Sign in with { provider }
|
signInWithProvider: Sign in with { provider }
|
||||||
|
@ -43,6 +56,9 @@ signUpWithProvider: Sign up with {provider}
|
||||||
signupAgain: Sign up again
|
signupAgain: Sign up again
|
||||||
signupLinkExpired: Signup link expired
|
signupLinkExpired: Signup link expired
|
||||||
somethingWentWrong: Something went wrong
|
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
|
toReceiveSignupLink: To receive a sign-up link, enter your email address
|
||||||
tryAgain: Try again
|
tryAgain: Try again
|
||||||
usePassword: Use your password
|
usePassword: Use your password
|
||||||
|
@ -50,3 +66,4 @@ usernameMissing: Please provide your username
|
||||||
welcome: Welcome
|
welcome: Welcome
|
||||||
welcomeBackName: "Welcome back { name }"
|
welcomeBackName: "Welcome back { name }"
|
||||||
welcomeMigrateName: Welcome to FreeSewing v3 {name}. Please note that this is still alpha code.
|
welcomeMigrateName: Welcome to FreeSewing v3 {name}. Please note that this is still alpha code.
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import { useTranslation } from 'next-i18next'
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { useRouter } from 'next/router'
|
import { useRouter } from 'next/router'
|
||||||
import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs'
|
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
|
// Components
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import {
|
import {
|
||||||
|
@ -25,7 +25,7 @@ export const ns = ['susi', 'errors', 'status']
|
||||||
|
|
||||||
export const SignIn = () => {
|
export const SignIn = () => {
|
||||||
const { setAccount, setToken, seenUser, setSeenUser } = useAccount()
|
const { setAccount, setToken, seenUser, setSeenUser } = useAccount()
|
||||||
const { t } = useTranslation(ns)
|
const { t, i18n } = useTranslation(ns)
|
||||||
const backend = useBackend()
|
const backend = useBackend()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { setLoadingStatus, LoadingStatus } = useLoadingStatus()
|
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 ${
|
const btnClasses = `btn capitalize w-full mt-4 ${
|
||||||
signInFailed ? 'btn-warning' : 'btn-primary'
|
signInFailed ? 'btn-warning' : 'btn-primary'
|
||||||
} transition-colors ease-in-out duration-300 ${horFlexClasses}`
|
} transition-colors ease-in-out duration-300 ${horFlexClasses}`
|
||||||
|
@ -197,7 +206,12 @@ export const SignIn = () => {
|
||||||
</button>
|
</button>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-center mt-2">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-center mt-2">
|
||||||
{['Google', 'Github'].map((provider) => (
|
{['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 />}
|
{provider === 'Google' ? <GoogleIcon stroke={0} /> : <GitHubIcon />}
|
||||||
<span>{t('susi:signInWithProvider', { provider })}</span>
|
<span>{t('susi:signInWithProvider', { provider })}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -6,7 +6,15 @@ import { useLoadingStatus } from 'shared/hooks/use-loading-status.mjs'
|
||||||
// Context
|
// Context
|
||||||
import { ModalContext } from 'shared/context/modal-context.mjs'
|
import { ModalContext } from 'shared/context/modal-context.mjs'
|
||||||
// Dependencies
|
// 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
|
// Components
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Robot } from 'shared/components/robot/index.mjs'
|
import { Robot } from 'shared/components/robot/index.mjs'
|
||||||
|
@ -39,6 +47,8 @@ export const SignUp = () => {
|
||||||
const [result, setResult] = useState(false)
|
const [result, setResult] = useState(false)
|
||||||
const [showAll, setShowAll] = useState(false)
|
const [showAll, setShowAll] = useState(false)
|
||||||
|
|
||||||
|
const state = ''
|
||||||
|
|
||||||
const updateEmail = (value) => {
|
const updateEmail = (value) => {
|
||||||
setEmail(value)
|
setEmail(value)
|
||||||
const valid = (validateEmail(value) && validateTld(value)) || false
|
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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<LoadingStatus />
|
<LoadingStatus />
|
||||||
|
@ -157,11 +176,12 @@ export const SignUp = () => {
|
||||||
{showAll ? (
|
{showAll ? (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 items-center mt-4">
|
<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
|
<button
|
||||||
key={provider}
|
key={provider}
|
||||||
id={provider}
|
id={provider}
|
||||||
className={`${horFlexClasses} btn btn-secondary`}
|
className={`${horFlexClasses} btn btn-secondary`}
|
||||||
|
onClick={() => initOauth(provider)}
|
||||||
>
|
>
|
||||||
{provider === 'Google' ? <GoogleIcon stroke={0} /> : <GitHubIcon />}
|
{provider === 'Google' ? <GoogleIcon stroke={0} /> : <GitHubIcon />}
|
||||||
<span>{t('susi:signUpWithProvider', { provider })}</span>
|
<span>{t('susi:signUpWithProvider', { provider })}</span>
|
||||||
|
|
|
@ -7,8 +7,9 @@ import { useEffect, useState } from 'react'
|
||||||
import { Loading } from 'shared/components/spinner.mjs'
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
import { horFlexClasses } from 'shared/utils.mjs'
|
import { horFlexClasses } from 'shared/utils.mjs'
|
||||||
import { LockIcon, PlusIcon } from 'shared/components/icons.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 }) => (
|
const Wrap = ({ children }) => (
|
||||||
<div className="m-auto max-w-xl text-center mt-8 p-8">{children}</div>
|
<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>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ConsentLacking = ({ t, banner }) => (
|
const ConsentLacking = ({ banner, refresh }) => {
|
||||||
<Wrap>
|
const { setAccount, setToken, setSeenUser } = useAccount()
|
||||||
{banner}
|
const backend = useBackend()
|
||||||
<h1>{t('consentLacking')}</h1>
|
const { t } = useTranslation(ns)
|
||||||
<p>{t('membersOnly')}</p>
|
|
||||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
const updateConsent = async ({ consent1, consent2 }) => {
|
||||||
<Link href="/signup" className="btn btn-primary w-32">
|
let consent = 0
|
||||||
{t('signUp')}
|
if (consent1) consent = 1
|
||||||
</Link>
|
if (consent1 && consent2) consent = 2
|
||||||
<Link href="/signin" className="btn btn-primary btn-outline w-32">
|
if (consent > 0) {
|
||||||
{t('signIn')}
|
const result = await backend.updateConsent(consent)
|
||||||
</Link>
|
console.log({ result })
|
||||||
</div>
|
if (result.success) {
|
||||||
</Wrap>
|
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' }) => {
|
export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
|
||||||
const { t } = useTranslation(ns)
|
const { t } = useTranslation(ns)
|
||||||
|
@ -113,6 +132,8 @@ export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
|
||||||
|
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
const [impersonating, setImpersonating] = useState(false)
|
const [impersonating, setImpersonating] = useState(false)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Avoid hydration errors
|
* Avoid hydration errors
|
||||||
|
@ -126,17 +147,33 @@ export const AuthWrapper = ({ children, requiredRole = 'user' }) => {
|
||||||
user: account.username,
|
user: account.username,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
setReady(true)
|
||||||
}
|
}
|
||||||
const verifyUser = async () => {
|
const verifyUser = async () => {
|
||||||
const result = await backend.ping()
|
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 (admin && admin.token) verifyAdmin()
|
||||||
if (token) verifyUser()
|
if (token) verifyUser()
|
||||||
setReady(true)
|
else setReady(true)
|
||||||
}, [admin, token])
|
}, [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 ? (
|
const banner = impersonating ? (
|
||||||
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
|
<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 }
|
const childProps = { t, banner }
|
||||||
|
|
||||||
if (!token || !account.username) return <AuthRequired {...childProps} />
|
if (!token || !account.username) return <AuthRequired {...childProps} />
|
||||||
if (account.status !== 1) {
|
if (error) {
|
||||||
if (account.status === 0) return <AccountInactive {...childProps} />
|
if (error === 'accountInactive') return <AccountInactive {...childProps} />
|
||||||
if (account.status === -1) return <AccountDisabled {...childProps} />
|
if (error === 'accountDisabled') return <AccountDisabled {...childProps} />
|
||||||
if (account.status === -2) return <AccountProhibited {...childProps} />
|
if (error === 'accountBlocked') return <AccountProhibited {...childProps} />
|
||||||
|
if (error === 'consentLacking') return <ConsentLacking {...childProps} refresh={refresh} />
|
||||||
return <AccountStatusUnknown {...childProps} />
|
return <AccountStatusUnknown {...childProps} />
|
||||||
}
|
}
|
||||||
if (account.consent < 1) return <ConsentLacking {...childProps} />
|
|
||||||
|
|
||||||
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
|
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
|
||||||
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />
|
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />
|
||||||
|
|
37
sites/shared/config/oauth.config.mjs
Normal file
37
sites/shared/config/oauth.config.mjs
Normal 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=',
|
||||||
|
}
|
||||||
|
*/
|
|
@ -67,6 +67,15 @@ const responseHandler = (response, expectedStatus = 200, expectData = true) => {
|
||||||
return { success: true, response }
|
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 }
|
return { success: false, response }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -81,6 +90,20 @@ Backend.prototype.signUp = async function ({ email, language }) {
|
||||||
return responseHandler(await api.post('/signup', { email, language }), 201)
|
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
|
* 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))
|
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
|
* Checks whether a username is available
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -6,3 +6,6 @@ processingUpdate: Processing update
|
||||||
generatingPdf: Generating your PDF, one moment please
|
generatingPdf: Generating your PDF, one moment please
|
||||||
pdfReady: PDF generated
|
pdfReady: PDF generated
|
||||||
pdfFailed: An unexpected error occured while generating your PDF
|
pdfFailed: An unexpected error occured while generating your PDF
|
||||||
|
contactingBackend: Contacting the FreeSewing backend
|
||||||
|
contactingGitHub: Contacting GitHub
|
||||||
|
contactingGoogle: Contacting Google
|
||||||
|
|
|
@ -445,3 +445,14 @@ export const horFlexClassesNoSm =
|
||||||
* A method that check that a var is not empty
|
* A method that check that a var is not empty
|
||||||
*/
|
*/
|
||||||
export const notEmpty = (thing) => `${thing}`.length > 0
|
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('')
|
||||||
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue