wip(backend): Work on routes, auth, and email templates
This commit is contained in:
parent
a4a453df00
commit
0313bb4572
37 changed files with 1610 additions and 1068 deletions
|
@ -4,3 +4,32 @@ This is a work in process to port the v2 backend to a new v3 backend.
|
||||||
|
|
||||||
It will be based on Express using Prisma with a SQLite database.
|
It will be based on Express using Prisma with a SQLite database.
|
||||||
Watch this space.
|
Watch this space.
|
||||||
|
|
||||||
|
## Permission levels
|
||||||
|
|
||||||
|
There are two different models to authenticate, as user, or with an API key.
|
||||||
|
|
||||||
|
The API keys have more granularity, their permission levels are:
|
||||||
|
|
||||||
|
- `0`: No permissions. Can only login but not do anything (used for testing)
|
||||||
|
- `1`: Read access to own people/patterns data
|
||||||
|
- `2`: Read access to all account data
|
||||||
|
- `3`: Write access to own people/pattern data
|
||||||
|
- `4`: Write access to all own account data (this is the `user` role)
|
||||||
|
- `5`: Read access to people/pattern data of all users (this is the `bughunter` role)
|
||||||
|
- `6`: Read access to all account data of all users
|
||||||
|
- `7`: Read access to all account data of all users + Write access for specific support functions (this is the `support` role)
|
||||||
|
- `8`: Write access to all account data of all users (this is the `admin` role)
|
||||||
|
|
||||||
|
User roles map to these permission levels as such:
|
||||||
|
|
||||||
|
- `user`: 4 (this is everybody)
|
||||||
|
- `bughunter`: 5 (a small group of people, less than 10)
|
||||||
|
- `support`: 7 (a small number of trusted collaborators, less than 5)
|
||||||
|
- `admin`: 8 (joost)
|
||||||
|
|
||||||
|
When using an API key above level 4, you need the following roles:
|
||||||
|
|
||||||
|
- 5: Requires bughunter, support, or admin
|
||||||
|
- 6,7,: Requires support or admin
|
||||||
|
- 8: Requires admin
|
||||||
|
|
|
@ -16,6 +16,7 @@
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"express": "4.18.2",
|
"express": "4.18.2",
|
||||||
|
"passport-http": "^0.3.0",
|
||||||
"pino": "^8.7.0"
|
"pino": "^8.7.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
@ -7,6 +7,18 @@ datasource db {
|
||||||
url = env("API_DB_URL")
|
url = env("API_DB_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Apikey {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
expiresAt DateTime
|
||||||
|
name String @default("")
|
||||||
|
level Int @default(0)
|
||||||
|
role String @default("user")
|
||||||
|
secret String
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
userId Int
|
||||||
|
}
|
||||||
|
|
||||||
model Confirmation {
|
model Confirmation {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
@ -36,6 +48,7 @@ model User {
|
||||||
newsletter Boolean @default(false)
|
newsletter Boolean @default(false)
|
||||||
password String
|
password String
|
||||||
patron Int @default(0)
|
patron Int @default(0)
|
||||||
|
apikeys Apikey[]
|
||||||
people Person[]
|
people Person[]
|
||||||
patterns Pattern[]
|
patterns Pattern[]
|
||||||
role String @default("user")
|
role String @default("user")
|
||||||
|
|
Binary file not shown.
|
@ -33,6 +33,19 @@ const config = {
|
||||||
audience: process.env.API_JWT_ISSUER,
|
audience: process.env.API_JWT_ISSUER,
|
||||||
expiresIn: process.env.API_JWT_EXPIRY || '7d',
|
expiresIn: process.env.API_JWT_EXPIRY || '7d',
|
||||||
},
|
},
|
||||||
|
apikeys: {
|
||||||
|
levels: [0, 1, 2, 3, 4, 5, 6, 7, 8],
|
||||||
|
expiryMaxSeconds: 365 * 24 * 3600,
|
||||||
|
},
|
||||||
|
roles: {
|
||||||
|
levels: {
|
||||||
|
user: 4,
|
||||||
|
bughunter: 5,
|
||||||
|
support: 7,
|
||||||
|
admin: 8,
|
||||||
|
},
|
||||||
|
base: 'user',
|
||||||
|
},
|
||||||
languages: ['en', 'de', 'es', 'fr', 'nl'],
|
languages: ['en', 'de', 'es', 'fr', 'nl'],
|
||||||
aws: {
|
aws: {
|
||||||
ses: {
|
ses: {
|
||||||
|
|
745
sites/backend/src/controllers/admin.mjs
Normal file
745
sites/backend/src/controllers/admin.mjs
Normal file
|
@ -0,0 +1,745 @@
|
||||||
|
//import { log, email, ehash, newHandle, uniqueHandle } from '../utils'
|
||||||
|
import jwt from 'jsonwebtoken'
|
||||||
|
import axios from 'axios'
|
||||||
|
//import path from 'path'
|
||||||
|
//import fs from 'fs'
|
||||||
|
import Zip from 'jszip'
|
||||||
|
//import rimraf from 'rimraf'
|
||||||
|
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
|
||||||
|
import { clean, asJson } from '../utils/index.mjs'
|
||||||
|
import { getUserAvatar } from '../utils/sanity.mjs'
|
||||||
|
import { log } from '../utils/log.mjs'
|
||||||
|
import { emailTemplate } from '../utils/email.mjs'
|
||||||
|
import set from 'lodash.set'
|
||||||
|
import { UserModel } from '../models/user.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Prisma is not an ORM and we can't attach methods to the model
|
||||||
|
* So here's a bunch of helper methods that expect a user object
|
||||||
|
* as input
|
||||||
|
*/
|
||||||
|
const asAccount = (user, decrypt) => ({
|
||||||
|
id: user.id,
|
||||||
|
consent: user.consent,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
data: user.data,
|
||||||
|
email: decrypt(user.email),
|
||||||
|
initial: decrypt(user.initial),
|
||||||
|
lastLogin: user.lastLogin,
|
||||||
|
newsletter: user.newsletter,
|
||||||
|
patron: user.patron,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
username: user.username,
|
||||||
|
lusername: user.lusername,
|
||||||
|
})
|
||||||
|
|
||||||
|
const getToken = (user, config) =>
|
||||||
|
jwt.sign(
|
||||||
|
{
|
||||||
|
_id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status,
|
||||||
|
aud: config.jwt.audience,
|
||||||
|
iss: config.jwt.issuer,
|
||||||
|
},
|
||||||
|
config.jwt.secretOrKey,
|
||||||
|
{ expiresIn: config.jwt.expiresIn }
|
||||||
|
)
|
||||||
|
|
||||||
|
const isUsernameAvailable = async (username, prisma) => {
|
||||||
|
const user = await prisme.user.findUnique({
|
||||||
|
where: {
|
||||||
|
lusername: username.toLowerCase(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (user === null) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// We'll send this result unless it goes ok
|
||||||
|
const result = 'error'
|
||||||
|
|
||||||
|
export function UserController() {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Signup
|
||||||
|
*
|
||||||
|
* This is the endpoint that handles account signups
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
UserController.prototype.signup = async (req, res, tools) => {
|
||||||
|
const User = new UserModel(tools)
|
||||||
|
await User.create(req.body)
|
||||||
|
|
||||||
|
return User.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Confirm account (after signup)
|
||||||
|
*
|
||||||
|
* This is the endpoint that fully unlocks the account if the user gives their consent
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
UserController.prototype.confirm = async (req, res, tools) => {
|
||||||
|
if (!req.params.id) return res.status(404).send({ error: 'missingConfirmationId', result })
|
||||||
|
if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result })
|
||||||
|
if (!req.body.consent || req.body.consent < 1)
|
||||||
|
return res.status(400).send({ error: 'consentRequired', result })
|
||||||
|
|
||||||
|
// Destructure what we need from tools
|
||||||
|
const { prisma, config, decrypt } = tools
|
||||||
|
|
||||||
|
// Retrieve confirmation record
|
||||||
|
let confirmation
|
||||||
|
try {
|
||||||
|
confirmation = await prisma.confirmation.findUnique({
|
||||||
|
where: {
|
||||||
|
id: req.params.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not lookup confirmation id ${req.params.id}`)
|
||||||
|
return res.status(404).send({ error: 'failedToRetrieveConfirmationId', result })
|
||||||
|
}
|
||||||
|
if (!confirmation) {
|
||||||
|
log.warn(err, `Could not find confirmation id ${req.params.id}`)
|
||||||
|
return res.status(404).send({ error: 'failedToFindConfirmationId', result })
|
||||||
|
}
|
||||||
|
if (confirmation.type !== 'signup') {
|
||||||
|
log.warn(err, `Confirmation mismatch; ${req.params.id} is not a signup id`)
|
||||||
|
return res.status(404).send({ error: 'confirmationIdTypeMismatch', result })
|
||||||
|
}
|
||||||
|
const data = decrypt(confirmation.data)
|
||||||
|
|
||||||
|
// Retrieve user account
|
||||||
|
let account
|
||||||
|
try {
|
||||||
|
account = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: data.id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not lookup user id ${data.id} from confirmation data`)
|
||||||
|
return res.status(404).send({ error: 'failedToRetrieveUserIdFromConfirmationData', result })
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
log.warn(err, `Could not find user id ${data.id} from confirmation data`)
|
||||||
|
return res.status(404).send({ error: 'failedToLoadUserFromConfirmationData', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update user consent and status
|
||||||
|
let updateUser
|
||||||
|
try {
|
||||||
|
updateUser = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: account.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: 1,
|
||||||
|
consent: req.body.consent,
|
||||||
|
lastLogin: new Date(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not update user id ${data.id} after confirmation`)
|
||||||
|
return res.status(404).send({ error: 'failedToUpdateUserAfterConfirmation', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account is now active, let's return a passwordless login
|
||||||
|
return res.status(200).send({
|
||||||
|
result: 'success',
|
||||||
|
token: getToken(account, config),
|
||||||
|
account: asAccount({ ...account, status: 1, consent: req.body.consent }, decrypt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Login (with username and password)
|
||||||
|
*
|
||||||
|
* This is the endpoint that provides traditional username/password login
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
UserController.prototype.login = async function (req, res, tools) {
|
||||||
|
if (Object.keys(req.body) < 1) return res.status(400).json({ error: 'postBodyMissing', result })
|
||||||
|
if (!req.body.username) return res.status(400).json({ error: 'usernameMissing', result })
|
||||||
|
if (!req.body.password) return res.status(400).json({ error: 'passwordMissing', result })
|
||||||
|
|
||||||
|
// Destructure what we need from tools
|
||||||
|
const { prisma, config, decrypt } = tools
|
||||||
|
|
||||||
|
// Retrieve user account
|
||||||
|
let account
|
||||||
|
try {
|
||||||
|
account = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{ lusername: { equals: clean(req.body.username) } },
|
||||||
|
{ ehash: { equals: hash(clean(req.body.username)) } },
|
||||||
|
{ id: { equals: parseInt(req.body.username) || -1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Error while trying to find username: ${req.body.username}`)
|
||||||
|
return res.status(401).send({ error: 'loginFailed', result })
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
log.warn(`Login attempt for non-existing user: ${req.body.username} from ${req.ip}`)
|
||||||
|
return res.status(401).send({ error: 'loginFailed', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account found, check password
|
||||||
|
const [valid, updatedPasswordField] = verifyPassword(req.body.password, account.password)
|
||||||
|
if (!valid) {
|
||||||
|
log.warn(`Wrong password for existing user: ${req.body.username} from ${req.ip}`)
|
||||||
|
return res.status(401).send({ error: 'loginFailed', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login success
|
||||||
|
log.info(`Login by user ${account.id} (${account.username})`)
|
||||||
|
if (updatedPasswordField) {
|
||||||
|
// Update the password field with a v3 hash
|
||||||
|
let updateUser
|
||||||
|
try {
|
||||||
|
updateUser = await prisma.user.update({
|
||||||
|
where: {
|
||||||
|
id: account.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
password: updatedPasswordField,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(
|
||||||
|
err,
|
||||||
|
`Could not update password field with v3 hash for user id ${account.id} (${account.username})`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).send({
|
||||||
|
result: 'success',
|
||||||
|
token: getToken(account, config),
|
||||||
|
account: asAccount({ ...account }, decrypt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.readAccount = async (req, res, tools) => {
|
||||||
|
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
|
||||||
|
|
||||||
|
// Destructure what we need from tools
|
||||||
|
const { prisma, decrypt } = tools
|
||||||
|
|
||||||
|
// Retrieve user account
|
||||||
|
let account
|
||||||
|
try {
|
||||||
|
account = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: req.user._id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not lookup user id ${req.user._id} from token data`)
|
||||||
|
return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result })
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
log.warn(err, `Could not find user id ${req.user._id} from token data`)
|
||||||
|
return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return account data
|
||||||
|
return res.status(200).send({
|
||||||
|
result: 'success',
|
||||||
|
account: asAccount({ ...account }, decrypt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.update = async (req, res, tools) => {
|
||||||
|
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
|
||||||
|
|
||||||
|
// Destructure what we need from tools
|
||||||
|
const { prisma, decrypt } = tools
|
||||||
|
|
||||||
|
// Retrieve user account
|
||||||
|
let account
|
||||||
|
try {
|
||||||
|
account = await prisma.user.findUnique({
|
||||||
|
where: {
|
||||||
|
id: req.user._id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Could not lookup user id ${req.user._id} from token data`)
|
||||||
|
return res.status(404).send({ error: 'failedToRetrieveUserIdFromTokenData', result })
|
||||||
|
}
|
||||||
|
if (!account) {
|
||||||
|
log.warn(err, `Could not find user id ${req.user._id} from token data`)
|
||||||
|
return res.status(404).send({ error: 'failedToLoadUserFromTokenData', result })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Account loaded - Handle various updates
|
||||||
|
const data = {}
|
||||||
|
// Username
|
||||||
|
if (req.body.username) {
|
||||||
|
if (!isUsernameAvailable(req.body.username, prisma)) {
|
||||||
|
return res.status(400).send({ error: 'usernameTaken', result })
|
||||||
|
}
|
||||||
|
data.username = req.body.username
|
||||||
|
data.lusername = data.username.toLowerCase()
|
||||||
|
}
|
||||||
|
// Newsletter
|
||||||
|
if (req.body.newsletter === false) data.newsletter = false
|
||||||
|
if (req.body.newsletter === true) data.newsletter = true
|
||||||
|
// Consent
|
||||||
|
if (typeof req.body.consent !== 'undefined') data.consent = req.body.consent
|
||||||
|
// Bio
|
||||||
|
if (typeof req.body.bio === 'string') userData.bio = req.body.bio
|
||||||
|
// Password
|
||||||
|
if (typeof req.body.password === 'string')
|
||||||
|
userData.password = asJson(hashPassword(req.body.password))
|
||||||
|
// Data
|
||||||
|
const userData = JSON.parse(account.data)
|
||||||
|
const uhash = hash(account.data)
|
||||||
|
if (typeof req.body.language === 'string') set(userData, 'settings.language', req.body.language)
|
||||||
|
if (typeof req.body.units === 'string') set(userData, 'settings.units', req.body.units)
|
||||||
|
if (typeof req.body.github === 'string') set(userData, 'settings.social.github', req.body.github)
|
||||||
|
if (typeof req.body.twitter === 'string')
|
||||||
|
set(userData, 'settings.social.twitter', req.body.twitter)
|
||||||
|
if (typeof req.body.instagram === 'string')
|
||||||
|
set(userData, 'settings.social.instagram', req.body.instagram)
|
||||||
|
// Did data change?
|
||||||
|
if (uhash !== hash(userData)) data.data = JSON.stringify(userData)
|
||||||
|
|
||||||
|
// Commit
|
||||||
|
prisma.user.update({
|
||||||
|
where: { id: account.id },
|
||||||
|
data,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Email change requires confirmation
|
||||||
|
if (typeof req.body.email === 'string') {
|
||||||
|
const currentEmail = decrypt(account.email)
|
||||||
|
if (req.body.email !== currentEmail) {
|
||||||
|
if (req.body.confirmation) {
|
||||||
|
// Find confirmation
|
||||||
|
let confirmation
|
||||||
|
try {
|
||||||
|
prisma.confirmation.findUnique({
|
||||||
|
where: { id: req.body.confirmation },
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Failed to find confirmation for email change`)
|
||||||
|
return res.status(500).send({ error: 'failedToFindEmailChangeConfirmation', result })
|
||||||
|
}
|
||||||
|
if (!confirmation) {
|
||||||
|
log.warn(err, `Missing confirmation for email change`)
|
||||||
|
return res.status(400).send({ error: 'missingEmailChangeConfirmation', result })
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create confirmation
|
||||||
|
let confirmation
|
||||||
|
try {
|
||||||
|
confirmation = prisma.confirmation.create({
|
||||||
|
data: {
|
||||||
|
type: 'emailchange',
|
||||||
|
data: encrypt({
|
||||||
|
language: userData.settings.language || 'en',
|
||||||
|
email: {
|
||||||
|
new: req.body.email,
|
||||||
|
current: currentEmail,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, `Failed to create confirmation for email change`)
|
||||||
|
return res.status(500).send({ error: 'failedToCreateEmailChangeConfirmation', result })
|
||||||
|
}
|
||||||
|
// Send out confirmation email
|
||||||
|
let sent
|
||||||
|
try {
|
||||||
|
sent = await email.send(
|
||||||
|
req.body.email,
|
||||||
|
currentEmail,
|
||||||
|
...emailTemplate.emailchange(
|
||||||
|
req.body.email,
|
||||||
|
currentEmail,
|
||||||
|
userData.settings.language,
|
||||||
|
confirmation.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, 'Unable to send email')
|
||||||
|
return res.status(500).send({ error: 'failedToSendEmailChangeConfirmationEmail', result })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Now handle the
|
||||||
|
/*
|
||||||
|
else if (typeof data.email === 'string' && data.email !== user.email) {
|
||||||
|
if (typeof data.confirmation === 'string') {
|
||||||
|
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (confirmation === null) return res.sendStatus(401)
|
||||||
|
if (confirmation.data.email.new === req.body.email) {
|
||||||
|
user.ehash = ehash(req.body.email)
|
||||||
|
user.email = req.body.email
|
||||||
|
return saveAndReturnAccount(res, user)
|
||||||
|
} else return res.sendStatus(400)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
let confirmation = new Confirmation({
|
||||||
|
type: 'emailchange',
|
||||||
|
data: {
|
||||||
|
handle: user.handle,
|
||||||
|
language: user.settings.language,
|
||||||
|
email: {
|
||||||
|
new: req.body.email,
|
||||||
|
current: user.email,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
confirmation.save(function (err) {
|
||||||
|
if (err) return res.sendStatus(500)
|
||||||
|
log.info('emailchangeRequest', {
|
||||||
|
newEmail: req.body.email,
|
||||||
|
confirmation: confirmation._id,
|
||||||
|
})
|
||||||
|
email.emailchange(req.body.email, user.email, user.settings.language, confirmation._id)
|
||||||
|
return saveAndReturnAccount(res, user)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
*/
|
||||||
|
return res.status(200).send({})
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
// For people who have forgotten their password, or password-less logins
|
||||||
|
UserController.prototype.confirmationLogin = function (req, res) {
|
||||||
|
if (!req.body || !req.body.id) return res.sendStatus(400)
|
||||||
|
Confirmation.findById(req.body.id, (err, confirmation) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (confirmation === null) return res.sendStatus(401)
|
||||||
|
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (user === null) {
|
||||||
|
return res.sendStatus(401)
|
||||||
|
}
|
||||||
|
if (user.status !== 'active') return res.sendStatus(403)
|
||||||
|
else {
|
||||||
|
log.info('confirmationLogin', { user, req })
|
||||||
|
let account = user.account()
|
||||||
|
let token = getToken(account)
|
||||||
|
let people = {}
|
||||||
|
Person.find({ user: user.handle }, (err, personList) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
for (let person of personList) people[person.handle] = person.info()
|
||||||
|
let patterns = {}
|
||||||
|
Pattern.find({ user: user.handle }, (err, patternList) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
for (let pattern of patternList) patterns[pattern.handle] = pattern
|
||||||
|
return user.updateLoginTime(() => res.send({ account, people, patterns, token }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD basics
|
||||||
|
|
||||||
|
// Note that the user is already crearted (in signup)
|
||||||
|
// we just need to active the account
|
||||||
|
UserController.prototype.create = (req, res) => {
|
||||||
|
if (!req.body) return res.sendStatus(400)
|
||||||
|
if (!req.body.consent || !req.body.consent.profile) return res.status(400).send('consentRequired')
|
||||||
|
Confirmation.findById(req.body.id, (err, confirmation) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (confirmation === null) return res.sendStatus(401)
|
||||||
|
User.findOne({ handle: confirmation.data.handle }, (err, user) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (user === null) return res.sendStatus(401)
|
||||||
|
user.status = 'active'
|
||||||
|
user.consent = req.body.consent
|
||||||
|
user.time.login = new Date()
|
||||||
|
log.info('accountActivated', { handle: user.handle })
|
||||||
|
let account = user.account()
|
||||||
|
let token = getToken(account)
|
||||||
|
user.save(function (err) {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
Confirmation.findByIdAndDelete(req.body.id, (err, confirmation) => {
|
||||||
|
return res.send({ account, people: {}, patterns: {}, token })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.readProfile = (req, res) => {
|
||||||
|
User.findOne({ username: req.params.username }, (err, user) => {
|
||||||
|
if (err) return res.sendStatus(404)
|
||||||
|
if (user === null) return res.sendStatus(404)
|
||||||
|
else res.send(user.profile())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveAndReturnAccount(res, user) {
|
||||||
|
user.save(function (err, updatedUser) {
|
||||||
|
if (err) {
|
||||||
|
log.error('accountUpdateFailed', err)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
} else return res.send({ account: updatedUser.account() })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function temporaryStoragePath(dir) {
|
||||||
|
return path.join(config.storage, 'tmp', dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.isUsernameAvailable = (req, res) => {
|
||||||
|
if (!req.user._id) return res.sendStatus(400)
|
||||||
|
let username = req.body.username.toLowerCase().trim()
|
||||||
|
if (username === '') return res.sendStatus(400)
|
||||||
|
User.findOne({ username: username }, (err, user) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (user === null) return res.sendStatus(200)
|
||||||
|
if (user._id + '' === req.user._id) return res.sendStatus(200)
|
||||||
|
else return res.sendStatus(400)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Re-send activation email
|
||||||
|
UserController.prototype.resend = (req, res) => {
|
||||||
|
if (!req.body) return res.sendStatus(400)
|
||||||
|
if (!req.body.email) return res.status(400).send('emailMissing')
|
||||||
|
if (!req.body.language) return res.status(400).send('languageMissing')
|
||||||
|
User.findOne(
|
||||||
|
{
|
||||||
|
ehash: ehash(req.body.email),
|
||||||
|
},
|
||||||
|
(err, user) => {
|
||||||
|
if (err) return res.sendStatus(500)
|
||||||
|
if (user === null) return res.status(404).send('noSuchUser')
|
||||||
|
else {
|
||||||
|
let confirmation = new Confirmation({
|
||||||
|
type: 'signup',
|
||||||
|
data: {
|
||||||
|
language: req.body.language,
|
||||||
|
email: user.email,
|
||||||
|
handle: user.handle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
confirmation.save(function (err) {
|
||||||
|
if (err) return res.sendStatus(500)
|
||||||
|
log.info('resendActivationRequest', {
|
||||||
|
email: req.body.email,
|
||||||
|
confirmation: confirmation._id,
|
||||||
|
})
|
||||||
|
email.signup(req.body.email, req.body.language, confirmation._id)
|
||||||
|
return res.sendStatus(200)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.resetPassword = (req, res) => {
|
||||||
|
if (!req.body) return res.sendStatus(400)
|
||||||
|
User.findOne(
|
||||||
|
{
|
||||||
|
$or: [
|
||||||
|
{ username: req.body.username.toLowerCase().trim() },
|
||||||
|
{ ehash: ehash(req.body.username) },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
(err, user) => {
|
||||||
|
if (err) {
|
||||||
|
console.log(err)
|
||||||
|
return res.sendStatus(400)
|
||||||
|
}
|
||||||
|
if (user === null) return res.sendStatus(401)
|
||||||
|
let confirmation = new Confirmation({
|
||||||
|
type: 'passwordreset',
|
||||||
|
data: {
|
||||||
|
handle: user.handle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
confirmation.save(function (err) {
|
||||||
|
if (err) return res.sendStatus(500)
|
||||||
|
log.info('passwordresetRequest', { user: user.handle, confirmation: confirmation._id })
|
||||||
|
email.passwordreset(user.email, user.settings.language, confirmation._id)
|
||||||
|
return res.sendStatus(200)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.setPassword = (req, res) => {
|
||||||
|
if (!req.body) return res.sendStatus(400)
|
||||||
|
Confirmation.findById(req.body.confirmation, (err, confirmation) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (confirmation === null) return res.sendStatus(401)
|
||||||
|
User.findOne({ handle: req.body.handle }, (err, user) => {
|
||||||
|
if (err) return res.sendStatus(400)
|
||||||
|
if (user === null) return res.sendStatus(401)
|
||||||
|
if (confirmation.type === 'passwordreset' && confirmation.data.handle === user.handle) {
|
||||||
|
user.password = req.body.password
|
||||||
|
user.save(function (err) {
|
||||||
|
log.info('passwordSet', { user, req })
|
||||||
|
let account = user.account()
|
||||||
|
let token = getToken(account)
|
||||||
|
return user.updateLoginTime(() => res.send({ account, token }))
|
||||||
|
})
|
||||||
|
} else return res.sendStatus(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.confirmChangedEmail = (req, res) => {
|
||||||
|
if (!req.body || !req.body.id || !req.user._id) return res.sendStatus(400)
|
||||||
|
Confirmation.findById(req.body.id, (err, confirmation) => {
|
||||||
|
if (err || confirmation === null) return res.sendStatus(401)
|
||||||
|
User.findById(req.user._id, async (err, user) => {
|
||||||
|
if (err || confirmation.data.handle !== user.handle) return res.sendStatus(401)
|
||||||
|
user.ehash = ehash(confirmation.data.email.new)
|
||||||
|
user.email = confirmation.data.email.new
|
||||||
|
return saveAndReturnAccount(res, user)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// // Other
|
||||||
|
UserController.prototype.patronList = (req, res) => {
|
||||||
|
User.find({ patron: { $gte: 2 } })
|
||||||
|
.sort('username')
|
||||||
|
.exec((err, users) => {
|
||||||
|
if (err || users === null) return res.sendStatus(400)
|
||||||
|
let patrons = {
|
||||||
|
2: [],
|
||||||
|
4: [],
|
||||||
|
8: [],
|
||||||
|
}
|
||||||
|
for (let key of Object.keys(users)) {
|
||||||
|
let user = users[key].profile()
|
||||||
|
patrons[user.patron].push({
|
||||||
|
handle: user.handle,
|
||||||
|
username: user.username,
|
||||||
|
bio: user.bio,
|
||||||
|
picture: user.picture,
|
||||||
|
social: user.social,
|
||||||
|
pictureUris: user.pictureUris,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return res.send(patrons)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
UserController.prototype.export = (req, res) => {
|
||||||
|
if (!req.user._id) return res.sendStatus(400)
|
||||||
|
User.findById(req.user._id, (err, user) => {
|
||||||
|
if (user === null) return res.sendStatus(400)
|
||||||
|
let dir = createTempDir()
|
||||||
|
if (!dir) return res.sendStatus(500)
|
||||||
|
let zip = new Zip()
|
||||||
|
zip.file('account.json', asJson(user.export(), null, 2))
|
||||||
|
loadAvatar(user).then((avatar) => {
|
||||||
|
if (avatar) zip.file(user.picture, data)
|
||||||
|
zip
|
||||||
|
.generateAsync({
|
||||||
|
type: 'uint8array',
|
||||||
|
comment: 'freesewing.org',
|
||||||
|
streamFiles: true,
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
let file = path.join(dir, 'export.zip')
|
||||||
|
fs.writeFile(file, data, (err) => {
|
||||||
|
log.info('dataExport', { user, req })
|
||||||
|
return res.send({ export: uri(file) })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadAvatar = async (user) => {
|
||||||
|
if (user.picture)
|
||||||
|
await fs.readFile(path.join(user.storagePath(), user.picture), (err, data) => data)
|
||||||
|
else return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// restrict processing of data, aka freeze account
|
||||||
|
UserController.prototype.restrict = (req, res) => {
|
||||||
|
if (!req.user._id) return res.sendStatus(400)
|
||||||
|
User.findById(req.user._id, (err, user) => {
|
||||||
|
if (user === null) return res.sendStatus(400)
|
||||||
|
user.status = 'frozen'
|
||||||
|
user.save(function (err) {
|
||||||
|
if (err) {
|
||||||
|
log.error('accountFreezeFailed', user)
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
return res.sendStatus(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove account
|
||||||
|
UserController.prototype.remove = (req, res) => {
|
||||||
|
if (!req.user._id) return res.sendStatus(400)
|
||||||
|
User.findById(req.user._id, (err, user) => {
|
||||||
|
if (user === null) return res.sendStatus(400)
|
||||||
|
rimraf(user.storagePath(), (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.log('rimraf', err)
|
||||||
|
log.error('accountRemovalFailed', { err, user, req })
|
||||||
|
return res.sendStatus(500)
|
||||||
|
}
|
||||||
|
user.remove((err, usr) => {
|
||||||
|
if (err !== null) {
|
||||||
|
log.error('accountRemovalFailed', { err, user, req })
|
||||||
|
return res.sendStatus(500)
|
||||||
|
} else return res.sendStatus(200)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getToken = (account) => {
|
||||||
|
return jwt.sign(
|
||||||
|
{
|
||||||
|
_id: account._id,
|
||||||
|
handle: account.handle,
|
||||||
|
role: account.role,
|
||||||
|
aud: config.jwt.audience,
|
||||||
|
iss: config.jwt.issuer,
|
||||||
|
},
|
||||||
|
config.jwt.secretOrKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createTempDir = () => {
|
||||||
|
let path = temporaryStoragePath(newHandle(10))
|
||||||
|
fs.mkdir(path, { recursive: true }, (err) => {
|
||||||
|
if (err) {
|
||||||
|
log.error('mkdirFailed', err)
|
||||||
|
path = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const uri = (path) => config.static + path.substring(config.storage.length)
|
||||||
|
|
||||||
|
*/
|
50
sites/backend/src/controllers/apikey.mjs
Normal file
50
sites/backend/src/controllers/apikey.mjs
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
|
||||||
|
import { clean, asJson } from '../utils/index.mjs'
|
||||||
|
import { log } from '../utils/log.mjs'
|
||||||
|
import { ApikeyModel } from '../models/apikey.mjs'
|
||||||
|
import { UserModel } from '../models/user.mjs'
|
||||||
|
|
||||||
|
export function ApikeyController() {}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Create API key
|
||||||
|
*
|
||||||
|
* This is the endpoint that handles creation of API keys/tokens
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
ApikeyController.prototype.create = async (req, res, tools) => {
|
||||||
|
const Apikey = new ApikeyModel(tools)
|
||||||
|
await Apikey.create(req)
|
||||||
|
|
||||||
|
return Apikey.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Read API key
|
||||||
|
*
|
||||||
|
* This is the endpoint that handles reading of API keys/tokens
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
ApikeyController.prototype.whoami = async (req, res, tools) => {
|
||||||
|
const User = new UserModel(tools)
|
||||||
|
const Apikey = new ApikeyModel(tools)
|
||||||
|
|
||||||
|
// Load user making the call
|
||||||
|
await User.loadAuthenticatedUser(req.user)
|
||||||
|
|
||||||
|
const key = User.user.apikeys.filter((key) => key.id === req.user.id)
|
||||||
|
|
||||||
|
if (key.length === 1)
|
||||||
|
Apikey.setResponse(200, 'success', {
|
||||||
|
apikey: {
|
||||||
|
key: key[0].id,
|
||||||
|
level: key[0].level,
|
||||||
|
expiresAt: key[0].expiresAt,
|
||||||
|
name: key[0].name,
|
||||||
|
userId: key[0].userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
else Apikey.setResponse(404, 'notFound')
|
||||||
|
|
||||||
|
return Apikey.sendResponse(res)
|
||||||
|
}
|
|
@ -229,7 +229,7 @@ UserController.prototype.login = async function (req, res, tools) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
UserController.prototype.readAccount = async (req, res, tools) => {
|
UserController.prototype.whoami = async (req, res, tools) => {
|
||||||
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
|
if (!req.user?._id) return res.status(400).send({ error: 'bearerMissing', result })
|
||||||
|
|
||||||
// Destructure what we need from tools
|
// Destructure what we need from tools
|
||||||
|
|
|
@ -23,11 +23,7 @@ const app = express()
|
||||||
app.use(express.json())
|
app.use(express.json())
|
||||||
app.use(express.static('public'))
|
app.use(express.static('public'))
|
||||||
|
|
||||||
// Load middleware
|
const tools = {
|
||||||
loadExpressMiddleware(app)
|
|
||||||
loadPassportMiddleware(passport, config)
|
|
||||||
|
|
||||||
const params = {
|
|
||||||
app,
|
app,
|
||||||
passport,
|
passport,
|
||||||
prisma,
|
prisma,
|
||||||
|
@ -35,8 +31,13 @@ const params = {
|
||||||
...mailer(config),
|
...mailer(config),
|
||||||
config,
|
config,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load middleware
|
||||||
|
loadExpressMiddleware(app)
|
||||||
|
loadPassportMiddleware(passport, tools)
|
||||||
|
|
||||||
// Load routes
|
// Load routes
|
||||||
for (const type in routes) routes[type](params)
|
for (const type in routes) routes[type](tools)
|
||||||
|
|
||||||
// Catch-all route (Load index.html once instead of at every request)
|
// Catch-all route (Load index.html once instead of at every request)
|
||||||
const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html'))
|
const index = fs.readFileSync(path.resolve('.', 'src', 'landing', 'index.html'))
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
//import bodyParser from 'body-parser'
|
//import bodyParser from 'body-parser'
|
||||||
import cors from 'cors'
|
import cors from 'cors'
|
||||||
|
import http from 'passport-http'
|
||||||
import jwt from 'passport-jwt'
|
import jwt from 'passport-jwt'
|
||||||
|
import { ApikeyModel } from './models/apikey.mjs'
|
||||||
|
|
||||||
function loadExpressMiddleware(app) {
|
function loadExpressMiddleware(app) {
|
||||||
// FIXME: Is this still needed in FreeSewing v3?
|
// FIXME: Is this still needed in FreeSewing v3?
|
||||||
|
@ -8,12 +10,19 @@ function loadExpressMiddleware(app) {
|
||||||
app.use(cors())
|
app.use(cors())
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadPassportMiddleware(passport, config) {
|
function loadPassportMiddleware(passport, tools) {
|
||||||
|
passport.use(
|
||||||
|
new http.BasicStrategy(async (key, secret, done) => {
|
||||||
|
const Apikey = new ApikeyModel(tools)
|
||||||
|
await Apikey.verify(key, secret)
|
||||||
|
return Apikey.verified ? done(null, { ...Apikey.record, apikey: true }) : done(false)
|
||||||
|
})
|
||||||
|
)
|
||||||
passport.use(
|
passport.use(
|
||||||
new jwt.Strategy(
|
new jwt.Strategy(
|
||||||
{
|
{
|
||||||
jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: jwt.ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
...config.jwt,
|
...tools.config.jwt,
|
||||||
},
|
},
|
||||||
(jwt_payload, done) => {
|
(jwt_payload, done) => {
|
||||||
return done(null, jwt_payload)
|
return done(null, jwt_payload)
|
||||||
|
|
119
sites/backend/src/models/apikey.mjs
Normal file
119
sites/backend/src/models/apikey.mjs
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
import { log } from '../utils/log.mjs'
|
||||||
|
import { hash, hashPassword, randomString, verifyPassword } from '../utils/crypto.mjs'
|
||||||
|
import { clean, asJson } from '../utils/index.mjs'
|
||||||
|
import { UserModel } from './user.mjs'
|
||||||
|
|
||||||
|
export function ApikeyModel(tools) {
|
||||||
|
this.config = tools.config
|
||||||
|
this.prisma = tools.prisma
|
||||||
|
this.User = new UserModel(tools)
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.setExists = function () {
|
||||||
|
this.exists = this.record ? true : false
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.setResponse = function (status = 200, error = false, data = {}) {
|
||||||
|
this.response = {
|
||||||
|
status,
|
||||||
|
body: {
|
||||||
|
result: 'success',
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if (status > 201) {
|
||||||
|
this.response.body.error = error
|
||||||
|
this.response.body.result = 'error'
|
||||||
|
this.error = true
|
||||||
|
} else this.error = false
|
||||||
|
|
||||||
|
return this.setExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.sendResponse = async function (res) {
|
||||||
|
return res.status(this.response.status).send(this.response.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.verify = async function (key, secret) {
|
||||||
|
await this.read({ id: key })
|
||||||
|
const [valid] = await verifyPassword(secret, this.record.secret)
|
||||||
|
this.verified = valid
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.read = async function (where) {
|
||||||
|
this.record = await this.prisma.apikey.findUnique({ where })
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.create = async function ({ body, user }) {
|
||||||
|
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
if (!body.name) return this.setResponse(400, 'nameMissing')
|
||||||
|
if (!body.level) return this.setResponse(400, 'levelMissing')
|
||||||
|
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
|
||||||
|
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
|
||||||
|
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
|
||||||
|
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresInNotNumeric')
|
||||||
|
if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
|
||||||
|
return this.setResponse(400, 'expiresInHigherThanMaximum')
|
||||||
|
|
||||||
|
// Load user making the call
|
||||||
|
await this.User.loadAuthenticatedUser(user)
|
||||||
|
if (body.level > this.config.roles.levels[this.User.user.role])
|
||||||
|
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
|
||||||
|
|
||||||
|
// Generate api secret
|
||||||
|
const secret = randomString(32)
|
||||||
|
const expiresAt = new Date(Date.now() + body.expiresIn * 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.record = await this.prisma.apikey.create({
|
||||||
|
data: {
|
||||||
|
expiresAt,
|
||||||
|
name: body.name,
|
||||||
|
level: body.level,
|
||||||
|
secret: asJson(hashPassword(secret)),
|
||||||
|
userId: user._id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, 'Could not create apikey')
|
||||||
|
return this.setResponse(500, 'createApikeyFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.setResponse(200, 'success', {
|
||||||
|
apikey: {
|
||||||
|
key: this.record.id,
|
||||||
|
secret,
|
||||||
|
level: this.record.level,
|
||||||
|
expiresAt: this.record.expiresAt,
|
||||||
|
name: this.record.name,
|
||||||
|
userId: this.record.userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ApikeyModel.prototype.___read = async function ({ user, params }) {
|
||||||
|
// Load user making the call
|
||||||
|
await this.User.loadAuthenticatedUser(user)
|
||||||
|
|
||||||
|
const key = this.User.user.apikeys.filter((key) => key.id === params.id)
|
||||||
|
|
||||||
|
return key.length === 1
|
||||||
|
? this.setResponse(200, 'success', {
|
||||||
|
apikey: {
|
||||||
|
key: key[0].id,
|
||||||
|
level: key[0].level,
|
||||||
|
expiresAt: key[0].expiresAt,
|
||||||
|
name: key[0].name,
|
||||||
|
userId: key[0].userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: this.setResponse(404, 'notFound')
|
||||||
|
}
|
|
@ -29,6 +29,19 @@ UserModel.prototype.load = async function (where) {
|
||||||
return this.setExists()
|
return this.setExists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserModel.prototype.loadAuthenticatedUser = async function (user) {
|
||||||
|
if (!user) return this
|
||||||
|
const where = user?.apikey ? { id: user.userId } : { id: user._id }
|
||||||
|
this.user = await this.prisma.user.findUnique({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
apikeys: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
UserModel.prototype.setExists = function () {
|
UserModel.prototype.setExists = function () {
|
||||||
this.exists = this.record ? true : false
|
this.exists = this.record ? true : false
|
||||||
|
|
||||||
|
@ -108,7 +121,7 @@ UserModel.prototype.create = async function (body) {
|
||||||
})
|
})
|
||||||
|
|
||||||
// Send signup email
|
// Send signup email
|
||||||
await this.sendSignupEmail()
|
//await this.sendSignupEmail()
|
||||||
|
|
||||||
return body.unittest && this.email.split('@').pop() === this.config.tests.domain
|
return body.unittest && this.email.split('@').pop() === this.config.tests.domain
|
||||||
? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id })
|
? this.setResponse(201, false, { email: this.email, confirmation: this.confirmation.record.id })
|
||||||
|
@ -147,3 +160,50 @@ UserModel.prototype.update = async function (data) {
|
||||||
UserModel.prototype.sendResponse = async function (res) {
|
UserModel.prototype.sendResponse = async function (res) {
|
||||||
return res.status(this.response.status).send(this.response.body)
|
return res.status(this.response.status).send(this.response.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UserModel.prototype.createApikey = async function ({ body, user }) {
|
||||||
|
if (Object.keys(body) < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
if (!body.name) return this.setResponse(400, 'nameMissing')
|
||||||
|
if (!body.level) return this.setResponse(400, 'levelMissing')
|
||||||
|
if (typeof body.level !== 'number') return this.setResponse(400, 'levelNotNumeric')
|
||||||
|
if (!this.config.apikeys.levels.includes(body.level)) return this.setResponse(400, 'invalidLevel')
|
||||||
|
if (!body.expiresIn) return this.setResponse(400, 'expiresInMissing')
|
||||||
|
if (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresInNotNumeric')
|
||||||
|
if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
|
||||||
|
return this.setResponse(400, 'expiresInHigherThanMaximum')
|
||||||
|
|
||||||
|
// Load user making the call
|
||||||
|
await this.loadAuthenticatedUser(user)
|
||||||
|
if (body.level > this.config.roles.levels[this.user.role])
|
||||||
|
return this.setResponse(400, 'keyLevelExceedsRoleLevel')
|
||||||
|
|
||||||
|
// Generate api secret
|
||||||
|
const secret = randomString(32)
|
||||||
|
const expiresAt = new Date(Date.now() + body.expiresIn * 1000)
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.record = await this.prisma.apikey.create({
|
||||||
|
data: {
|
||||||
|
expiresAt,
|
||||||
|
name: body.name,
|
||||||
|
level: body.level,
|
||||||
|
secret: asJson(hashPassword(secret)),
|
||||||
|
userId: user._id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
log.warn(err, 'Could not create apikey')
|
||||||
|
return this.setResponse(500, 'createApikeyFailed')
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.setResponse(200, 'success', {
|
||||||
|
apikey: {
|
||||||
|
key: this.record.id,
|
||||||
|
secret,
|
||||||
|
level: this.record.level,
|
||||||
|
expiresAt: this.record.expiresAt,
|
||||||
|
name: this.record.name,
|
||||||
|
userId: this.record.userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
30
sites/backend/src/routes/apikey.mjs
Normal file
30
sites/backend/src/routes/apikey.mjs
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { ApikeyController } from '../controllers/apikey.mjs'
|
||||||
|
|
||||||
|
const Apikey = new ApikeyController()
|
||||||
|
const jwt = ['jwt', { session: false }]
|
||||||
|
const bsc = ['basic', { session: false }]
|
||||||
|
|
||||||
|
export function apikeyRoutes(tools) {
|
||||||
|
const { app, passport } = tools
|
||||||
|
|
||||||
|
// Create Apikey
|
||||||
|
app.post('/apikey/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Apikey.create(req, res, tools)
|
||||||
|
)
|
||||||
|
app.post('/apikey/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Apikey.create(req, res, tools)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read Apikey
|
||||||
|
app.get('/apikey/:id/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Apikey.read(req, res, tools)
|
||||||
|
)
|
||||||
|
app.get('/apikey/:id/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Apikey.read(req, res, tools)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Read current Apikey
|
||||||
|
app.get('/whoami/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Apikey.whoami(req, res, tools)
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,5 +1,7 @@
|
||||||
|
import { apikeyRoutes } from './apikey.mjs'
|
||||||
import { userRoutes } from './user.mjs'
|
import { userRoutes } from './user.mjs'
|
||||||
|
|
||||||
export const routes = {
|
export const routes = {
|
||||||
|
apikeyRoutes,
|
||||||
userRoutes,
|
userRoutes,
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,11 @@ import { UserController } from '../controllers/user.mjs'
|
||||||
|
|
||||||
const User = new UserController()
|
const User = new UserController()
|
||||||
const jwt = ['jwt', { session: false }]
|
const jwt = ['jwt', { session: false }]
|
||||||
|
const bsc = ['basic', { session: false }]
|
||||||
|
|
||||||
export function userRoutes(tools) {
|
export function userRoutes(tools) {
|
||||||
const { app, passport } = tools
|
const { app, passport } = tools
|
||||||
|
|
||||||
// Sign up
|
// Sign up
|
||||||
app.post('/signup', (req, res) => User.signup(req, res, tools))
|
app.post('/signup', (req, res) => User.signup(req, res, tools))
|
||||||
|
|
||||||
|
@ -14,13 +16,15 @@ export function userRoutes(tools) {
|
||||||
// Login
|
// Login
|
||||||
app.post('/login', (req, res) => User.login(req, res, tools))
|
app.post('/login', (req, res) => User.login(req, res, tools))
|
||||||
|
|
||||||
// Read account (own data)
|
// Read current jwt
|
||||||
app.get('/account', passport.authenticate(...jwt), (req, res) =>
|
|
||||||
User.readAccount(req, res, tools)
|
app.get('/whoami/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools))
|
||||||
)
|
app.get('/account/jwt', passport.authenticate(...jwt), (req, res) => User.whoami(req, res, tools))
|
||||||
|
app.get('/account/key', passport.authenticate(...bsc), (req, res) => User.whoami(req, res, tools))
|
||||||
|
|
||||||
// Update account
|
// Update account
|
||||||
app.put('/account', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools))
|
app.put('/account/jwt', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools))
|
||||||
|
app.put('/account/key', passport.authenticate(...jwt), (req, res) => User.update(req, res, tools))
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
|
|
|
@ -1,38 +0,0 @@
|
||||||
// Line breaks
|
|
||||||
const nl = "\n"
|
|
||||||
const nl2 = "\n\n"
|
|
||||||
|
|
||||||
export const templates = {
|
|
||||||
emailChange: t => [
|
|
||||||
t.emailchangeTitle,
|
|
||||||
t.emailchangeCopy1,
|
|
||||||
t.emailchangeActionLink,
|
|
||||||
t.questionsJustReply,
|
|
||||||
].join(nl2),
|
|
||||||
goodbye: t => [
|
|
||||||
goodbyeTitle,
|
|
||||||
goodbyeCopy1,
|
|
||||||
].join(nl2),
|
|
||||||
newsletterSubscribe: t => `Confirm your newsletter subscription.${nl}
|
|
||||||
Somebody asked to subscribe this email address to the FreeSewing newsletter.
|
|
||||||
If it was you, please click below to confirm your subscription:${nl}
|
|
||||||
${t.newsletterConfirmationLink}${nl}`,
|
|
||||||
newsletterWelcome: t => `You are now subscribed to the FreeSewing newsletter${nl}
|
|
||||||
If you'd like to catch up, we keep an online archive of previous editions at: https://freesewing.org/newsletter/${nl}
|
|
||||||
You can unsubscribe at any time by visiting this link: ${t.newsletterUnsubscribeLink}${nl}`,
|
|
||||||
passwordReset: t => [
|
|
||||||
t.passwordresetTitle,
|
|
||||||
t.passwordresetCopy1,
|
|
||||||
t.passwordresetActionLink,
|
|
||||||
t.questionsJustReply,
|
|
||||||
].join(nl2),
|
|
||||||
signup: (t, to, url) => [
|
|
||||||
t.signupCopy1,
|
|
||||||
t.signupCopy2,
|
|
||||||
url,
|
|
||||||
t.questionsJustReply,
|
|
||||||
'joost',
|
|
||||||
'--',
|
|
||||||
`${t.signupWhy} [${to}]`,
|
|
||||||
].join(nl2),
|
|
||||||
}
|
|
269
sites/backend/src/templates/email/blocks.mjs
Normal file
269
sites/backend/src/templates/email/blocks.mjs
Normal file
|
@ -0,0 +1,269 @@
|
||||||
|
/*
|
||||||
|
* buttonRow uses the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - button
|
||||||
|
*/
|
||||||
|
export const buttonRow = {
|
||||||
|
html: `
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 25px">
|
||||||
|
<table class="sm-w-full sm-mx-auto" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="hover-bg-blue-600" style="border-radius: 2px; background-color: #262626">
|
||||||
|
<a href="{{ actionUrl}}" target="_blank" class="sm-block sm-p-15px sm-border-0" style="text-decoration: none; border: 1px solid #000; display: inline-block; border-radius: 2px; padding: 15px 25px; font-size: 16px; font-weight: 700; color: #fff">{{ button }} →</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
text: `{{ actionUrl }}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* closingRow uses the following replacements:
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const closingRow = {
|
||||||
|
html: `
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 30px">
|
||||||
|
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
||||||
|
{{ closing }}.
|
||||||
|
<br><br>
|
||||||
|
{{ greeting }},
|
||||||
|
<br>
|
||||||
|
joost
|
||||||
|
<br><br>
|
||||||
|
<small>
|
||||||
|
PS: {{ ps-pre-link}}
|
||||||
|
<a href="{{ supportUrl }}" target="_blank" style="text-decoration: none; color: #262626">
|
||||||
|
<b>{{ ps-link}}</b>
|
||||||
|
</a> {{ ps-post-link }}.
|
||||||
|
</small>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
text: `
|
||||||
|
{{ closing }}
|
||||||
|
|
||||||
|
{{ greeting }}
|
||||||
|
joost
|
||||||
|
|
||||||
|
PS: {{ text-ps }} : {{ text-ps-link }}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* headingRow uses the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
*/
|
||||||
|
export const headingRow = {
|
||||||
|
html: `
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 30px">
|
||||||
|
<h2 style="margin: 0; font-size: 30px; color: #525252">
|
||||||
|
<a href="{{ actionUrl }}" target="_blank" style="text-decoration: none; color: #525252">
|
||||||
|
{{ heading }}
|
||||||
|
</a>
|
||||||
|
</h2>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
text: `
|
||||||
|
{{ heading }}
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* lead1Row uses the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - lead
|
||||||
|
*/
|
||||||
|
export const lead1Row = {
|
||||||
|
html: `
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 15px">
|
||||||
|
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
||||||
|
<a href="{{ actionUrl }}" target="_blank" style="text-decoration: none; color: #262626">
|
||||||
|
<b>{{ lead }}</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>`,
|
||||||
|
text: `{{ textLead }}
|
||||||
|
{{ actionUrl }}
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Helper methods to wrap the body with all it takes
|
||||||
|
* Uses the following replacements:
|
||||||
|
* - title
|
||||||
|
* - intro
|
||||||
|
* - body
|
||||||
|
* - urlWebsite
|
||||||
|
* - urlWhy
|
||||||
|
* - whyDidIGetThis
|
||||||
|
*/
|
||||||
|
export const wrap = {
|
||||||
|
html: (body) => `<!DOCTYPE html>
|
||||||
|
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="x-apple-disable-message-reformatting">
|
||||||
|
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||||
|
<!--[if mso]>
|
||||||
|
<noscript>
|
||||||
|
<xml>
|
||||||
|
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||||
|
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||||
|
</o:OfficeDocumentSettings>
|
||||||
|
</xml>
|
||||||
|
</noscript>
|
||||||
|
<style>
|
||||||
|
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
||||||
|
</style>
|
||||||
|
<![endif]-->
|
||||||
|
<title>{{ title }}</title>
|
||||||
|
<style>
|
||||||
|
.hover-bg-blue-600:hover {
|
||||||
|
background-color: #2563eb !important
|
||||||
|
}
|
||||||
|
@media screen {
|
||||||
|
.all-font-sans {
|
||||||
|
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 525px) {
|
||||||
|
.sm-mx-auto {
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important
|
||||||
|
}
|
||||||
|
.sm-block {
|
||||||
|
display: block !important
|
||||||
|
}
|
||||||
|
.sm-w-full {
|
||||||
|
width: 100% !important
|
||||||
|
}
|
||||||
|
.sm-max-w-full {
|
||||||
|
max-width: 100% !important
|
||||||
|
}
|
||||||
|
.sm-border-0 {
|
||||||
|
border-width: 0px !important
|
||||||
|
}
|
||||||
|
.sm-p-15px {
|
||||||
|
padding: 15px !important
|
||||||
|
}
|
||||||
|
.sm-py-10px {
|
||||||
|
padding-top: 10px !important;
|
||||||
|
padding-bottom: 10px !important
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<style>
|
||||||
|
a[x-apple-data-detectors] {
|
||||||
|
color: inherit !important;
|
||||||
|
text-decoration: inherit !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body style="word-break: break-word; -webkit-font-smoothing: antialiased; margin: 0; width: 100%; padding: 0">
|
||||||
|
<div style="display: none">
|
||||||
|
{{ intro }}
|
||||||
|
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
||||||
|
</div>
|
||||||
|
<div role="article" aria-roledescription="email" aria-label="Please confirm your new email address" lang="en">
|
||||||
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding: 10px 15px">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="500">
|
||||||
|
<![endif]-->
|
||||||
|
<table class="sm-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
${body}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="background-color: #fff; padding-top: 16px; padding-bottom: 16px">
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top" width="500">
|
||||||
|
<![endif]-->
|
||||||
|
<table align="center" class="sm-max-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
|
<tr>
|
||||||
|
<td align="left" style="border-top: 1px solid #ddd; padding: 8px" ;>
|
||||||
|
<p style="margin: 0; font-size: 14px; line-height: 24px; color: #a3a3a3">
|
||||||
|
<a href="{{ urlWebsite }}" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>{{ website }}</b></a>
|
||||||
|
<span style="font-size: 13px; color: #737373"> | </span>
|
||||||
|
<a href="https://github.com/fresewing/freesewing" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Github</b></a>
|
||||||
|
<span style="font-size: 13px; color: #737373"> | </span>
|
||||||
|
<a href="https://discord.freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Discord</b></a>
|
||||||
|
<span style="font-size: 13px; color: #737373"> | </span>
|
||||||
|
<a href="https://twitter.com/freesewing_org" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Twitter</b></a>
|
||||||
|
<span style="font-size: 13px; color: #737373"> | </span>
|
||||||
|
<a href="{{ urlWhy }}" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>{{ whyDidIGetThis }}</b></a>
|
||||||
|
<br>
|
||||||
|
FreeSewing
|
||||||
|
<span style="font-size: 13px; color: #737373"> - </span>
|
||||||
|
Plantin en Moretuslei 69
|
||||||
|
<span style="font-size: 13px; color: #737373"> - </span>
|
||||||
|
Antwerp
|
||||||
|
<span style="font-size: 13px; color: #737373"> - </span>
|
||||||
|
Belgium
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<![endif]-->
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
text: (body) => `
|
||||||
|
${body}
|
||||||
|
|
||||||
|
--
|
||||||
|
FreeSewing
|
||||||
|
Plantin en Moretuslei 69
|
||||||
|
Antwerp
|
||||||
|
Belgium
|
||||||
|
|
||||||
|
{{ website }} : {{ urlWebsite }}
|
||||||
|
Github : https://github.com/fresewing/freesewing
|
||||||
|
Discord : https://discord.freesewing.org/
|
||||||
|
Twitter : https://twitter.com/freesewing_org
|
||||||
|
{{ whyDidIGetThis }} : {{ urlWhy }}
|
||||||
|
`,
|
||||||
|
}
|
23
sites/backend/src/templates/email/emailchange.mjs
Normal file
23
sites/backend/src/templates/email/emailchange.mjs
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
* - lead
|
||||||
|
* - button
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const emailChange = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow.html}
|
||||||
|
${lead1Row.html}
|
||||||
|
${buttonRow.html}
|
||||||
|
${closingRow.html}
|
||||||
|
`),
|
||||||
|
text: wrap.text(`${headingRow.text}${lead1Row.text}${buttonRow.text}${closingRow.text}`),
|
||||||
|
}
|
38
sites/backend/src/templates/email/goodbye.mjs
Normal file
38
sites/backend/src/templates/email/goodbye.mjs
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { headingRow, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - heading
|
||||||
|
* - lead1
|
||||||
|
* - lead2
|
||||||
|
* - greeting
|
||||||
|
* - ps
|
||||||
|
*/
|
||||||
|
export const goodbye = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow.html}
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 15px">
|
||||||
|
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
||||||
|
{{ lead1 }}
|
||||||
|
<br>
|
||||||
|
{{ lead2 }}
|
||||||
|
<br><br>
|
||||||
|
{{ greeting }},
|
||||||
|
<br>
|
||||||
|
joost
|
||||||
|
<br><br>
|
||||||
|
<small>PS: {{ ps }}.</small>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`),
|
||||||
|
text: wrap.text(`${headingRow.text}
|
||||||
|
{{lead1}}
|
||||||
|
{{lead2}}
|
||||||
|
|
||||||
|
{{greeting}}
|
||||||
|
joost
|
||||||
|
|
||||||
|
PS: {{ps}}`),
|
||||||
|
}
|
15
sites/backend/src/templates/email/index.mjs
Normal file
15
sites/backend/src/templates/email/index.mjs
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import { emailChange } from './emailchange.mjs'
|
||||||
|
import { goodbye } from './goodbye.mjs'
|
||||||
|
import { loginLink } from './loginlink.mjs'
|
||||||
|
import { newsletterSub } from './newslettersub.mjs'
|
||||||
|
import { passwordReset } from './passwordreset.mjs'
|
||||||
|
import { signup } from './signup.mjs'
|
||||||
|
|
||||||
|
export const templates = {
|
||||||
|
emailChange,
|
||||||
|
goodbye,
|
||||||
|
loginLink,
|
||||||
|
newsletterSub,
|
||||||
|
passwordReset,
|
||||||
|
signup,
|
||||||
|
}
|
39
sites/backend/src/templates/email/loginlink.mjs
Normal file
39
sites/backend/src/templates/email/loginlink.mjs
Normal file
|
@ -0,0 +1,39 @@
|
||||||
|
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
* - lead
|
||||||
|
* - button
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const loginLink = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow}
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 15px">
|
||||||
|
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
||||||
|
{{ prelead }}
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<a href="__URL__" target="_blank" style="text-decoration: none; color: #262626">
|
||||||
|
<b>{{ lead }}:</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${buttonRow.text}
|
||||||
|
${closingRow.text}
|
||||||
|
`),
|
||||||
|
text: wrap.text(`${headingRow.text}
|
||||||
|
{{ prelead }}
|
||||||
|
{{lead }}
|
||||||
|
${buttonRow.text}
|
||||||
|
${closingRow.text}
|
||||||
|
`),
|
||||||
|
}
|
28
sites/backend/src/templates/email/newslettersub.mjs
Normal file
28
sites/backend/src/templates/email/newslettersub.mjs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
* - lead
|
||||||
|
* - button
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const newsletterSub = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow.html}
|
||||||
|
${lead1Row.html}
|
||||||
|
${buttonRow.html}
|
||||||
|
${closingRow.html}
|
||||||
|
`),
|
||||||
|
text: wrap.text(`
|
||||||
|
${headingRow.text}
|
||||||
|
${lead1Row.text}
|
||||||
|
${buttonRow.text}
|
||||||
|
${closingRow.text}
|
||||||
|
`),
|
||||||
|
}
|
41
sites/backend/src/templates/email/passwordreset.mjs
Normal file
41
sites/backend/src/templates/email/passwordreset.mjs
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
* - lead
|
||||||
|
* - button
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const passwordReset = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow.html}
|
||||||
|
<tr>
|
||||||
|
<td align="left" class="sm-p-15px" style="padding-top: 15px">
|
||||||
|
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
||||||
|
You forgot your FreeSewing password and that's fine.
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
<a href="__URL__" target="_blank" style="text-decoration: none; color: #262626">
|
||||||
|
<b>To re-gain access to your account, click the big black rectangle below:</b>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
${buttonRow.html}
|
||||||
|
${closingRow.html}
|
||||||
|
`),
|
||||||
|
test: wrap.text(`${headingRow.text}
|
||||||
|
You forgot your FreeSewing password and that's fine.
|
||||||
|
|
||||||
|
To re-gain access to your account, click the link below:
|
||||||
|
|
||||||
|
${buttonRow.text}
|
||||||
|
${closingRow.text}
|
||||||
|
`),
|
||||||
|
}
|
28
sites/backend/src/templates/email/signup.mjs
Normal file
28
sites/backend/src/templates/email/signup.mjs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { buttonRow, closingRow, headingRow, lead1Row, wrap } from './blocks.mjs'
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Used the following replacements:
|
||||||
|
* - actionUrl
|
||||||
|
* - heading
|
||||||
|
* - lead
|
||||||
|
* - button
|
||||||
|
* - closing
|
||||||
|
* - greeting
|
||||||
|
* - ps-pre-link
|
||||||
|
* - ps-link
|
||||||
|
* - ps-post-link
|
||||||
|
*/
|
||||||
|
export const signup = {
|
||||||
|
html: wrap.html(`
|
||||||
|
${headingRow.html}
|
||||||
|
${lead1Row.html}
|
||||||
|
${buttonRow.html}
|
||||||
|
${closingRow.html}
|
||||||
|
`),
|
||||||
|
text: wrap.text(`
|
||||||
|
${headingRow.text}
|
||||||
|
${lead1Row.text}
|
||||||
|
${buttonRow.text}
|
||||||
|
${closingRow.text}
|
||||||
|
`),
|
||||||
|
}
|
|
@ -12,7 +12,7 @@ export const hash = (string) => createHash('sha256').update(string).digest('hex'
|
||||||
* Generates a random string
|
* Generates a random string
|
||||||
*
|
*
|
||||||
* This is not used in anything cryptographic. It is only used as a temporary
|
* This is not used in anything cryptographic. It is only used as a temporary
|
||||||
* username to avoid username collisions
|
* username to avoid username collisions or to generate (long) API key secrets
|
||||||
*/
|
*/
|
||||||
export const randomString = (bytes = 8) => randomBytes(bytes).toString('hex')
|
export const randomString = (bytes = 8) => randomBytes(bytes).toString('hex')
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { templates } from '../templates/email.mjs'
|
import { templates } from '../templates/email/index.mjs'
|
||||||
// FIXME: Update this after we re-structure the i18n package
|
// FIXME: Update this after we re-structure the i18n package
|
||||||
import en from '../../../../packages/i18n/dist/en/email.mjs'
|
import en from '../../../../packages/i18n/dist/en/email.mjs'
|
||||||
import nl from '../../../../packages/i18n/dist/en/email.mjs'
|
import nl from '../../../../packages/i18n/dist/en/email.mjs'
|
||||||
|
|
|
@ -243,7 +243,7 @@ describe(`${user} Signup flow and authentication`, () => {
|
||||||
step(`${user} Should load account with JWT`, (done) => {
|
step(`${user} Should load account with JWT`, (done) => {
|
||||||
chai
|
chai
|
||||||
.request(config.api)
|
.request(config.api)
|
||||||
.get('/account')
|
.get('/account/jwt')
|
||||||
.set('Authorization', 'Bearer ' + store.token)
|
.set('Authorization', 'Bearer ' + store.token)
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(res.status).to.equal(200)
|
expect(res.status).to.equal(200)
|
||||||
|
@ -257,297 +257,62 @@ describe(`${user} Signup flow and authentication`, () => {
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
|
||||||
describe(`${user} Account management`, () => {
|
step(`${user} Should load account with JWT (whoami)`, (done) => {
|
||||||
/* TODO: Need to do this once we have a UI to kick the tires
|
|
||||||
step(`${user} Should update the account avatar`, (done) => {
|
|
||||||
chai
|
chai
|
||||||
.request(config.api)
|
.request(config.api)
|
||||||
.put('/account')
|
.get('/whoami/jwt')
|
||||||
.set('Authorization', 'Bearer ' + store.token)
|
.set('Authorization', 'Bearer ' + store.token)
|
||||||
.send({
|
|
||||||
avatar: data.avatar,
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
console.log(res)
|
|
||||||
expect(res.status).to.equal(200)
|
|
||||||
expect(res.type).to.equal('application/json')
|
|
||||||
expect(res.charset).to.equal('utf-8')
|
|
||||||
expect(res.body.result).to.equal(`success`)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it(`${user} Should update the account username`, (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.api)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + store.token)
|
|
||||||
.send({
|
|
||||||
username: data..username + '_updated',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
expect(res.status).to.equal(200)
|
expect(res.status).to.equal(200)
|
||||||
expect(res.type).to.equal('application/json')
|
expect(res.type).to.equal('application/json')
|
||||||
expect(res.charset).to.equal('utf-8')
|
expect(res.charset).to.equal('utf-8')
|
||||||
expect(res.body.result).to.equal(`success`)
|
expect(res.body.result).to.equal(`success`)
|
||||||
expect(res.body.account.email).to.equal(data.email)
|
expect(res.body.account.email).to.equal(data.email)
|
||||||
expect(res.body.account.username).to.equal(store.username+'_updated')
|
expect(res.body.account.username).to.equal(store.username)
|
||||||
expect(res.body.account.lusername).to.equal(store.username.toLowerCase()+'_updated')
|
expect(res.body.account.lusername).to.equal(store.username.toLowerCase())
|
||||||
expect(typeof res.body.account.id).to.equal(`number`)
|
expect(typeof res.body.account.id).to.equal(`number`)
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
*/
|
|
||||||
})
|
|
||||||
|
|
||||||
/*
|
step(`${user} Create API Key`, (done) => {
|
||||||
|
|
||||||
|
|
||||||
describe('Account management', () => {
|
|
||||||
it('should update the account avatar', (done) => {
|
|
||||||
chai
|
chai
|
||||||
.request(config.backend)
|
.request(config.api)
|
||||||
.put('/account')
|
.post('/apikey/jwt')
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
.set('Authorization', 'Bearer ' + store.token)
|
||||||
.send({
|
.send({
|
||||||
avatar: config.avatar,
|
name: 'Test API key',
|
||||||
|
level: 4,
|
||||||
|
expiresIn: 60,
|
||||||
})
|
})
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
res.should.have.status(200)
|
expect(res.status).to.equal(200)
|
||||||
let data = JSON.parse(res.text)
|
expect(res.type).to.equal('application/json')
|
||||||
data.account.pictureUris.l.slice(-4).should.equal('.png')
|
expect(res.charset).to.equal('utf-8')
|
||||||
done()
|
expect(res.body.result).to.equal(`success`)
|
||||||
})
|
expect(typeof res.body.apikey.key).to.equal('string')
|
||||||
})
|
expect(typeof res.body.apikey.secret).to.equal('string')
|
||||||
it('should update the account username', (done) => {
|
expect(typeof res.body.apikey.expiresAt).to.equal('string')
|
||||||
chai
|
expect(res.body.apikey.level).to.equal(4)
|
||||||
.request(config.backend)
|
store.apikey = res.body.apikey
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
username: config.user.username + '_updated',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.account.username.should.equal(config.user.username + '_updated')
|
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should restore the account username', (done) => {
|
step(`${user} Read API Key with api key (whoami)`, (done) => {
|
||||||
chai
|
chai
|
||||||
.request(config.backend)
|
.request(config.api)
|
||||||
.put('/account')
|
.get(`/whoami/key`)
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
.auth(store.apikey.key, store.apikey.secret)
|
||||||
.send({
|
|
||||||
username: config.user.username,
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
.end((err, res) => {
|
||||||
res.should.have.status(200)
|
expect(res.status).to.equal(200)
|
||||||
let data = JSON.parse(res.text)
|
expect(res.type).to.equal('application/json')
|
||||||
data.account.username.should.equal(config.user.username)
|
expect(res.charset).to.equal('utf-8')
|
||||||
done()
|
expect(res.body.result).to.equal(`success`)
|
||||||
})
|
const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
|
||||||
})
|
checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
|
||||||
|
|
||||||
it('should not update the account username if that username is taken', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
username: 'admin',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(400)
|
|
||||||
res.text.should.equal('usernameTaken')
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update the account bio', (done) => {
|
|
||||||
let bio = 'This is the test bio '
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
bio: bio,
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.account.bio.should.equal(bio)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update the account language', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
settings: {
|
|
||||||
language: 'nl',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.account.settings.language.should.equal('nl')
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update the account units', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
settings: {
|
|
||||||
units: 'imperial',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.account.settings.units.should.equal('imperial')
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
for (let network of ['github', 'twitter', 'instagram']) {
|
|
||||||
it(`should update the account's ${network} username`, (done) => {
|
|
||||||
let data = { social: {} }
|
|
||||||
data.social[network] = network
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send(data)
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
JSON.parse(res.text).account.social[network].should.equal(network)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should update the account password', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
password: 'changeme',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should login with the new password', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.post('/login')
|
|
||||||
.send({
|
|
||||||
username: config.user.username,
|
|
||||||
password: 'changeme',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should restore the account password', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.put('/account')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
password: config.user.password,
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
done()
|
done()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('Other user endpoints', () => {
|
|
||||||
it("should load a user's profile", (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.get('/users/admin')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.username.should.equal('admin')
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should confirm that a username is available', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.post('/available/username')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
username: Date.now() + ' ' + Date.now(),
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should confirm that a username is not available', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.post('/available/username')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.send({
|
|
||||||
username: 'admin',
|
|
||||||
})
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(400)
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should load the patron list', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.get('/patrons')
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data['2'].should.be.an('array')
|
|
||||||
data['4'].should.be.an('array')
|
|
||||||
data['8'].should.be.an('array')
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should export the user data', (done) => {
|
|
||||||
chai
|
|
||||||
.request(config.backend)
|
|
||||||
.get('/account/export')
|
|
||||||
.set('Authorization', 'Bearer ' + config.user.token)
|
|
||||||
.end((err, res) => {
|
|
||||||
res.should.have.status(200)
|
|
||||||
let data = JSON.parse(res.text)
|
|
||||||
data.export.should.be.a('string')
|
|
||||||
store.exportLink = data.export
|
|
||||||
done()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
*/
|
|
||||||
|
|
|
@ -1,231 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="x-apple-disable-message-reformatting">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
|
||||||
<!--[if mso]>
|
|
||||||
<noscript>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
</noscript>
|
|
||||||
<style>
|
|
||||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<title>A Responsive Email Template</title>
|
|
||||||
|
|
||||||
<style>/* Tailwind CSS components */
|
|
||||||
/**
|
|
||||||
* @import here any custom CSS components - that is, CSS that
|
|
||||||
* you'd want loaded before the Tailwind utilities, so the
|
|
||||||
* utilities can still override them.
|
|
||||||
*/
|
|
||||||
/* Tailwind CSS utility classes */
|
|
||||||
.block {
|
|
||||||
display: block !important
|
|
||||||
}
|
|
||||||
.table {
|
|
||||||
display: table !important
|
|
||||||
}
|
|
||||||
.border {
|
|
||||||
border-width: 1px !important
|
|
||||||
}
|
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-10px {
|
|
||||||
padding-top: 10px !important;
|
|
||||||
padding-bottom: 10px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
|
||||||
padding-top: 28px !important
|
|
||||||
}
|
|
||||||
.pt-30px {
|
|
||||||
padding-top: 30px !important
|
|
||||||
}
|
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
|
||||||
/*
|
|
||||||
* Here is where you can define your custom utility classes.
|
|
||||||
*
|
|
||||||
* We wrap them in the `utilities` @layer directive, so
|
|
||||||
* that Tailwind moves them to the correct location.
|
|
||||||
*
|
|
||||||
* More info:
|
|
||||||
* https://tailwindcss.com/docs/functions-and-directives#layer
|
|
||||||
*/
|
|
||||||
.hover-bg-blue-600:hover {
|
|
||||||
background-color: #2563eb !important
|
|
||||||
}
|
|
||||||
@media screen {
|
|
||||||
.all-font-sans {
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif !important
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 525px) {
|
|
||||||
.sm-mx-auto {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important
|
|
||||||
}
|
|
||||||
.sm-block {
|
|
||||||
display: block !important
|
|
||||||
}
|
|
||||||
.sm-w-full {
|
|
||||||
width: 100% !important
|
|
||||||
}
|
|
||||||
.sm-max-w-full {
|
|
||||||
max-width: 100% !important
|
|
||||||
}
|
|
||||||
.sm-border-0 {
|
|
||||||
border-width: 0px !important
|
|
||||||
}
|
|
||||||
.sm-p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
|
||||||
padding-top: 10px !important;
|
|
||||||
padding-bottom: 10px !important
|
|
||||||
}
|
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
|
||||||
<style>a[x-apple-data-detectors] {
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: inherit !important;
|
|
||||||
}</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body style="word-break: break-word; -webkit-font-smoothing: antialiased; margin: 0; width: 100%; padding: 0">
|
|
||||||
|
|
||||||
<div style="display: none">
|
|
||||||
Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through...
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
</div>
|
|
||||||
<div role="article" aria-roledescription="email" aria-label="A Responsive Email Template" lang="en">
|
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="sm-py-30px" style="background-color: #fff; padding-top: 40px; padding-bottom: 40px; padding-left: 15px; padding-right: 15px">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<table class="sm-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- COPY -->
|
|
||||||
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="sm-p-15px" style="padding-top: 30px">
|
|
||||||
<h2 class="font-heavy" style="margin: 0; font-size: 30px; color: #525252">undefined</h2>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="sm-p-15px" style="padding-top: 15px">
|
|
||||||
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est. Aenean at mollis ipsum.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<!-- BULLETPROOF BUTTON -->
|
|
||||||
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="sm-p-15px" style="padding-top: 25px">
|
|
||||||
<table class="sm-w-full sm-mx-auto" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="hover-bg-blue-600" style="border-radius: 2px; background-color: #262626">
|
|
||||||
<a href="https://litmus.com" target="_blank" class="sm-block sm-p-15px sm-border-0" style="text-decoration: none; border: 1px solid #000; display: inline-block; border-radius: 2px; padding-top: 15px; padding-bottom: 15px; padding-left: 25px; padding-right: 25px; font-size: 16px; font-weight: 700; color: #fff">Learn More →</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="background-color: #fff; padding-top: 16px; padding-bottom: 16px">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<!-- UNSUBSCRIBE COPY -->
|
|
||||||
<table align="center" class="sm-max-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="border-top: 1px solid #ddd; padding: 8px" ;>
|
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 24px; color: #a3a3a3">
|
|
||||||
<a href="https://freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Website</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://github.com/fresewing/freesewing" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Github</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://discord.freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Discord</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://twitter.com/freesewing_org" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Twitter</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Why did I get this?</b></a>
|
|
||||||
<br>
|
|
||||||
FreeSewing
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Plantin en Moretuslei 69
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Antwerp
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Belgium
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
|
@ -37,19 +37,9 @@
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px !important
|
border-width: 1px !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
.pt-7 {
|
||||||
padding-top: 28px !important
|
padding-top: 28px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
/* Your custom utility classes */
|
||||||
/*
|
/*
|
||||||
* Here is where you can define your custom utility classes.
|
* Here is where you can define your custom utility classes.
|
||||||
|
@ -88,22 +78,10 @@
|
||||||
.sm-p-15px {
|
.sm-p-15px {
|
||||||
padding: 15px !important
|
padding: 15px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
.sm-py-10px {
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
@ -121,7 +99,7 @@
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-left: 15px; padding-right: 15px; padding-top: 10px; padding-bottom: 10px">
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px">
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -46,13 +46,6 @@
|
||||||
.bg-neutral-800 {
|
.bg-neutral-800 {
|
||||||
background-color: #262626 !important
|
background-color: #262626 !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.py-15px {
|
.py-15px {
|
||||||
padding-top: 15px !important;
|
padding-top: 15px !important;
|
||||||
padding-bottom: 15px !important
|
padding-bottom: 15px !important
|
||||||
|
@ -70,9 +63,6 @@
|
||||||
.pt-30px {
|
.pt-30px {
|
||||||
padding-top: 30px !important
|
padding-top: 30px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
.font-bold {
|
.font-bold {
|
||||||
font-weight: 700 !important
|
font-weight: 700 !important
|
||||||
}
|
}
|
||||||
|
@ -117,22 +107,10 @@
|
||||||
.sm-p-15px {
|
.sm-p-15px {
|
||||||
padding: 15px !important
|
padding: 15px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
.sm-py-10px {
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
@ -150,7 +128,7 @@
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-left: 15px; padding-right: 15px; padding-top: 10px; padding-bottom: 10px">
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px">
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -37,19 +37,9 @@
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px !important
|
border-width: 1px !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
.pt-7 {
|
||||||
padding-top: 28px !important
|
padding-top: 28px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
/* Your custom utility classes */
|
||||||
/*
|
/*
|
||||||
* Here is where you can define your custom utility classes.
|
* Here is where you can define your custom utility classes.
|
||||||
|
@ -92,18 +82,6 @@
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
|
|
@ -37,19 +37,9 @@
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px !important
|
border-width: 1px !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
.pt-7 {
|
||||||
padding-top: 28px !important
|
padding-top: 28px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
/* Your custom utility classes */
|
||||||
/*
|
/*
|
||||||
* Here is where you can define your custom utility classes.
|
* Here is where you can define your custom utility classes.
|
||||||
|
@ -88,22 +78,10 @@
|
||||||
.sm-p-15px {
|
.sm-p-15px {
|
||||||
padding: 15px !important
|
padding: 15px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
.sm-py-10px {
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
@ -121,7 +99,7 @@
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-left: 15px; padding-right: 15px; padding-top: 10px; padding-bottom: 10px">
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px">
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -37,19 +37,9 @@
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px !important
|
border-width: 1px !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
.pt-7 {
|
||||||
padding-top: 28px !important
|
padding-top: 28px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
/* Your custom utility classes */
|
||||||
/*
|
/*
|
||||||
* Here is where you can define your custom utility classes.
|
* Here is where you can define your custom utility classes.
|
||||||
|
@ -88,22 +78,10 @@
|
||||||
.sm-p-15px {
|
.sm-p-15px {
|
||||||
padding: 15px !important
|
padding: 15px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
.sm-py-10px {
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
@ -121,7 +99,7 @@
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-left: 15px; padding-right: 15px; padding-top: 10px; padding-bottom: 10px">
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px">
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -37,19 +37,9 @@
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px !important
|
border-width: 1px !important
|
||||||
}
|
}
|
||||||
.p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
.pt-7 {
|
||||||
padding-top: 28px !important
|
padding-top: 28px !important
|
||||||
}
|
}
|
||||||
.pt-5px {
|
|
||||||
padding-top: 5px !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
/* Your custom utility classes */
|
||||||
/*
|
/*
|
||||||
* Here is where you can define your custom utility classes.
|
* Here is where you can define your custom utility classes.
|
||||||
|
@ -88,22 +78,10 @@
|
||||||
.sm-p-15px {
|
.sm-p-15px {
|
||||||
padding: 15px !important
|
padding: 15px !important
|
||||||
}
|
}
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
.sm-py-10px {
|
||||||
padding-top: 10px !important;
|
padding-top: 10px !important;
|
||||||
padding-bottom: 10px !important
|
padding-bottom: 10px !important
|
||||||
}
|
}
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
}</style>
|
||||||
<style>a[x-apple-data-detectors] {
|
<style>a[x-apple-data-detectors] {
|
||||||
color: inherit !important;
|
color: inherit !important;
|
||||||
|
@ -121,7 +99,7 @@
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-left: 15px; padding-right: 15px; padding-top: 10px; padding-bottom: 10px">
|
<td align="center" class="sm-py-10px" style="background-color: #fff; padding-top: 10px; padding-bottom: 10px; padding-left: 15px; padding-right: 15px">
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
<!--[if (gte mso 9)|(IE)]>
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
||||||
<tr>
|
<tr>
|
||||||
|
|
|
@ -1,254 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en" xmlns:v="urn:schemas-microsoft-com:vml">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="x-apple-disable-message-reformatting">
|
|
||||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
|
||||||
<!--[if mso]>
|
|
||||||
<noscript>
|
|
||||||
<xml>
|
|
||||||
<o:OfficeDocumentSettings xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
||||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
||||||
</o:OfficeDocumentSettings>
|
|
||||||
</xml>
|
|
||||||
</noscript>
|
|
||||||
<style>
|
|
||||||
td,th,div,p,a,h1,h2,h3,h4,h5,h6 {font-family: "Segoe UI", sans-serif; mso-line-height-rule: exactly;}
|
|
||||||
</style>
|
|
||||||
<![endif]-->
|
|
||||||
|
|
||||||
<title>A Responsive Email Template</title>
|
|
||||||
|
|
||||||
<style>/* Tailwind CSS components */
|
|
||||||
/**
|
|
||||||
* @import here any custom CSS components - that is, CSS that
|
|
||||||
* you'd want loaded before the Tailwind utilities, so the
|
|
||||||
* utilities can still override them.
|
|
||||||
*/
|
|
||||||
/* Tailwind CSS utility classes */
|
|
||||||
.block {
|
|
||||||
display: block !important
|
|
||||||
}
|
|
||||||
.inline-block {
|
|
||||||
display: inline-block !important
|
|
||||||
}
|
|
||||||
.table {
|
|
||||||
display: table !important
|
|
||||||
}
|
|
||||||
.rounded-sm {
|
|
||||||
border-radius: 2px !important
|
|
||||||
}
|
|
||||||
.border {
|
|
||||||
border-width: 1px !important
|
|
||||||
}
|
|
||||||
.bg-neutral-800 {
|
|
||||||
background-color: #262626 !important
|
|
||||||
}
|
|
||||||
.py-40px {
|
|
||||||
padding-top: 40px !important;
|
|
||||||
padding-bottom: 40px !important
|
|
||||||
}
|
|
||||||
.px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
.py-15px {
|
|
||||||
padding-top: 15px !important;
|
|
||||||
padding-bottom: 15px !important
|
|
||||||
}
|
|
||||||
.px-25px {
|
|
||||||
padding-left: 25px !important;
|
|
||||||
padding-right: 25px !important
|
|
||||||
}
|
|
||||||
.py-10px {
|
|
||||||
padding-top: 10px !important;
|
|
||||||
padding-bottom: 10px !important
|
|
||||||
}
|
|
||||||
.pt-7_5 {
|
|
||||||
padding-top: 30px !important
|
|
||||||
}
|
|
||||||
.pt-7 {
|
|
||||||
padding-top: 28px !important
|
|
||||||
}
|
|
||||||
.pt-15px {
|
|
||||||
padding-top: 15px !important
|
|
||||||
}
|
|
||||||
.pt-25px {
|
|
||||||
padding-top: 25px !important
|
|
||||||
}
|
|
||||||
.pt-30px {
|
|
||||||
padding-top: 30px !important
|
|
||||||
}
|
|
||||||
.text-3xl {
|
|
||||||
font-size: 30px !important
|
|
||||||
}
|
|
||||||
.font-bold {
|
|
||||||
font-weight: 700 !important
|
|
||||||
}
|
|
||||||
.text-neutral-600 {
|
|
||||||
color: #525252 !important
|
|
||||||
}
|
|
||||||
.text-white {
|
|
||||||
color: #fff !important
|
|
||||||
}
|
|
||||||
/* Your custom utility classes */
|
|
||||||
/*
|
|
||||||
* Here is where you can define your custom utility classes.
|
|
||||||
*
|
|
||||||
* We wrap them in the `utilities` @layer directive, so
|
|
||||||
* that Tailwind moves them to the correct location.
|
|
||||||
*
|
|
||||||
* More info:
|
|
||||||
* https://tailwindcss.com/docs/functions-and-directives#layer
|
|
||||||
*/
|
|
||||||
.hover-bg-blue-600:hover {
|
|
||||||
background-color: #2563eb !important
|
|
||||||
}
|
|
||||||
@media screen {
|
|
||||||
.all-font-sans {
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif !important
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@media (max-width: 525px) {
|
|
||||||
.sm-mx-auto {
|
|
||||||
margin-left: auto !important;
|
|
||||||
margin-right: auto !important
|
|
||||||
}
|
|
||||||
.sm-block {
|
|
||||||
display: block !important
|
|
||||||
}
|
|
||||||
.sm-w-full {
|
|
||||||
width: 100% !important
|
|
||||||
}
|
|
||||||
.sm-max-w-full {
|
|
||||||
max-width: 100% !important
|
|
||||||
}
|
|
||||||
.sm-border-0 {
|
|
||||||
border-width: 0px !important
|
|
||||||
}
|
|
||||||
.sm-p-15px {
|
|
||||||
padding: 15px !important
|
|
||||||
}
|
|
||||||
.sm-py-30px {
|
|
||||||
padding-top: 30px !important;
|
|
||||||
padding-bottom: 30px !important
|
|
||||||
}
|
|
||||||
.sm-py-10px {
|
|
||||||
padding-top: 10px !important;
|
|
||||||
padding-bottom: 10px !important
|
|
||||||
}
|
|
||||||
.sm-py-50px {
|
|
||||||
padding-top: 50px !important;
|
|
||||||
padding-bottom: 50px !important
|
|
||||||
}
|
|
||||||
.sm-px-15px {
|
|
||||||
padding-left: 15px !important;
|
|
||||||
padding-right: 15px !important
|
|
||||||
}
|
|
||||||
}</style>
|
|
||||||
<style>a[x-apple-data-detectors] {
|
|
||||||
color: inherit !important;
|
|
||||||
text-decoration: inherit !important;
|
|
||||||
}</style>
|
|
||||||
|
|
||||||
</head>
|
|
||||||
<body style="word-break: break-word; -webkit-font-smoothing: antialiased; margin: 0; width: 100%; padding: 0">
|
|
||||||
|
|
||||||
<div style="display: none">
|
|
||||||
Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through...
|
|
||||||
͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏
|
|
||||||
</div>
|
|
||||||
<div role="article" aria-roledescription="email" aria-label="A Responsive Email Template" lang="en">
|
|
||||||
|
|
||||||
<table class="all-font-sans" style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="sm-py-50px sm-px-15px" style="background-color: #fff; padding: 15px">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<table class="sm-max-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- COPY -->
|
|
||||||
<table style="width: 100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="sm-p-15px" style="padding-top: 5px">
|
|
||||||
<p style="margin: 0; font-size: 16px; line-height: 25px; color: #262626">
|
|
||||||
Yo,
|
|
||||||
<br><br>
|
|
||||||
Message goes here dog.
|
|
||||||
<br><br>
|
|
||||||
Cheers,
|
|
||||||
<br>
|
|
||||||
The Team
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center" style="background-color: #fff; padding-top: 16px; padding-bottom: 16px">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<!-- UNSUBSCRIBE COPY -->
|
|
||||||
<table align="center" class="sm-max-w-full" style="width: 100%; max-width: 500px" border="0" cellpadding="0" cellspacing="0" role="presentation">
|
|
||||||
<tr>
|
|
||||||
<td align="left" style="border-top: 1px solid #ddd; padding: 8px" ;>
|
|
||||||
<p style="margin: 0; font-size: 14px; line-height: 24px; color: #a3a3a3">
|
|
||||||
<a href="https://freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Website</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://github.com/fresewing/freesewing" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Github</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://discord.freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Discord</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://twitter.com/freesewing_org" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Twitter</b></a>
|
|
||||||
<span style="font-size: 13px; color: #737373"> | </span>
|
|
||||||
<a href="https://freesewing.org/" target="_blank" style="text-decoration: none; color: #a3a3a3"><b>Why did I get this?</b></a>
|
|
||||||
<br>
|
|
||||||
FreeSewing
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Plantin en Moretuslei 69
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Antwerp
|
|
||||||
<span style="font-size: 13px; color: #737373"> - </span>
|
|
||||||
Belgium
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
</table>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
|
@ -1,71 +0,0 @@
|
||||||
---
|
|
||||||
title: "A Responsive Email Template"
|
|
||||||
preheader: "Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through..."
|
|
||||||
---
|
|
||||||
|
|
||||||
<extends src="src/layouts/main.html">
|
|
||||||
<block name="template">
|
|
||||||
<table class="w-full all:font-sans">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="py-[40px] px-[15px] sm:py-[30px] bg-white">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<table class="w-full max-w-[500px] sm:w-full">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table class="w-full">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- COPY -->
|
|
||||||
<table class="w-full">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="pt-7.5 sm:p-[15px]">
|
|
||||||
<h2 class="m-0 text-3xl text-neutral-600 font-heavy">{{{ main_title }}}</h2>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="pt-[15px] sm:p-[15px]">
|
|
||||||
<p class="m-0 text-base leading-[25px] text-neutral-800">
|
|
||||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed varius, leo a ullamcorper feugiat, ante purus sodales justo, a faucibus libero lacus a est. Aenean at mollis ipsum.
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
|
||||||
<!-- BULLETPROOF BUTTON -->
|
|
||||||
<table class="w-full">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="pt-[25px] sm:p-[15px]">
|
|
||||||
<table class="sm:w-full sm:mx-auto">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="rounded-sm bg-neutral-800 hover:bg-blue-600">
|
|
||||||
<a href="https://litmus.com" target="_blank" class="inline-block sm:block py-[15px] px-[25px] sm:p-[15px] sm:border-0 rounded-sm [text-decoration:none] text-base text-white font-bold" style="border: 1px solid #000;">Learn More →</a>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<component src="src/components/footer.html"></component>
|
|
||||||
</table>
|
|
||||||
</block>
|
|
||||||
</extends>
|
|
|
@ -1,54 +0,0 @@
|
||||||
---
|
|
||||||
title: "A Responsive Email Template"
|
|
||||||
preheader: "Entice the open with some amazing preheader text. Use a little mystery and get those subscribers to read through..."
|
|
||||||
---
|
|
||||||
|
|
||||||
<extends src="src/layouts/main.html">
|
|
||||||
<block name="template">
|
|
||||||
<table class="w-full all:font-sans">
|
|
||||||
<tr>
|
|
||||||
<td align="center" class="p-[15px] sm:py-[50px] sm:px-[15px] bg-white">
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
<table align="center" border="0" cellspacing="0" cellpadding="0" width="500">
|
|
||||||
<tr>
|
|
||||||
<td align="center" valign="top" width="500">
|
|
||||||
<![endif]-->
|
|
||||||
<table class="w-full max-w-[500px] sm:max-w-full">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<table class="w-full">
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<!-- COPY -->
|
|
||||||
<table class="w-full">
|
|
||||||
<tr>
|
|
||||||
<td align="left" class="pt-[5px] sm:p-[15px]">
|
|
||||||
<p class="m-0 text-base leading-[25px] text-neutral-800">
|
|
||||||
Yo,
|
|
||||||
<br><br>
|
|
||||||
Message goes here dog.
|
|
||||||
<br><br>
|
|
||||||
Cheers,
|
|
||||||
<br>
|
|
||||||
The Team
|
|
||||||
</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<!--[if (gte mso 9)|(IE)]>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
<![endif]-->
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<component src="src/components/footer.html"></component>
|
|
||||||
</table>
|
|
||||||
</block>
|
|
||||||
</extends>
|
|
Loading…
Add table
Add a link
Reference in a new issue