diff --git a/markdown/dev/reference/backend/api/apikey/en.md b/markdown/dev/reference/backend/api/apikey/en.md
new file mode 100644
index 00000000000..d34d9f1325c
--- /dev/null
+++ b/markdown/dev/reference/backend/api/apikey/en.md
@@ -0,0 +1,134 @@
+---
+title: API Keys
+---
+
+Documentation for the REST API endpoints to create, read, or remove API keys.
+
+
+The FreeSewing backend REST API supports authentication both with JSON Web
+Tokens (JWT) as with API keys (KEY). This describes the endpoints that deal
+with creating, reading, and removing API keys. For authentication details,
+refer to [the section on authenticating to the
+API](/reference/backend/api#authentication).
+
+
+
+
+
+
+## Create a new API key
+
+Create a new API key. The API key will belong to the user who is authenticated
+when making the call. Supported for both JWT and KEY authentication.
+
+### Endpoints
+
+| Method | Path | Description |
+| ------ | ---- | ----------- |
+| `POST` | `/apikey/jwt` | Create a new API key. Endpoint for JWT authentication |
+| `POST` | `/apikey/key` | Create a new API key. Endpoint for API key authentication |
+
+### Parameters
+
+
+| Variable | Type | Description |
+| -------- | -------- | ----------- |
+| `name` | `string` | Create a new API key. Endpoint for JWT authentication |
+| `level` | `number` | A privilege level from 0 to 8. |
+| `expiresIn` | `number` | The number of seconds until this key expires. |
+
+
+Returns status code `200` on success, `400` on if the request is malformed, and
+`500` on server error.
+
+| Value | Type | Description |
+| ------------------- | -------- | ----------- |
+| `result` | `string` | `success` on success, and `error` on error |
+| `apikey.key` | `string` | The API key |
+| `apikey.secret` | `string` | The API secret |
+| `apikey.level` | `number` | The privilege level of the API key |
+| `apikey.expiresAt` | `string` | A string representation of the moment the API key expires |
+| `apikey.name` | `string` | The name of the API key |
+| `apikey.userId` | `number` | The ID of the user who created the API key |
+
+
+
+### Example
+
+
+```js
+const token = axios.post(
+ 'https://backend.freesewing.org/apikey/jwt',
+ {
+ name: 'My first API key',
+ level: 2, // Read only
+ expiresIn: 3600, // One hour
+ },
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+)
+```
+
+
+
+```json
+{
+ result: 'success',
+ apikey: {
+ key: '7ea12968-7758-40b6-8c73-75cc99be762b',
+ secret: '503d7adbdb3ec18ab27adfcd895d8b47a8d6bc8307d548500fbf9c05a5a8820e',
+ level: 3,
+ expiresAt: '2022-11-06T15:57:30.190Z',
+ name: 'My first API key',
+ userId: 61
+ }
+}
+```
+
+
+
+## Notes
+
+The following is good to keep in mind when working with API keys:
+
+### This is not the authentication documentation
+
+The FreeSewing backend REST API supports authentication both with JSON Web
+Tokens (JWT) as with API keys (KEY).
+
+This describes the endpoints that deal with creating, reading, and removing API
+keys. For authentication details, refer to [the section on authenticating to
+the API](/reference/backend/api#authentication).
+
+### API keys are immutable
+
+Once created, API keys cannot be updated.
+You should remove them and re-create a new one if you want to make change.
+
+### API keys have an expiry
+
+API keys have an expiry date. The maximum validity for an API key is 1 year.
+
+### API keys have a permission level
+
+API keys have a permission level. You can never create an API key with a higher
+permission level than your own permission level.
+
+### Circumstances that will trigger your API keys to be revoked
+
+As a precaution, all your API keys will be revoked when:
+
+- Your role is downgraded to a role with fewer privileges
+- Your account is (b)locked
+- You revoke your consent for FreeSewing to process your data
+
+
+This is not an exhaustive list. For example, if we find your use of our API to
+be excessive, we might also revoke your API keys to shield us from the
+financial impact of your use of our API.
+
+
+
diff --git a/markdown/dev/reference/backend/api/en.md b/markdown/dev/reference/backend/api/en.md
new file mode 100644
index 00000000000..0ac668ad7db
--- /dev/null
+++ b/markdown/dev/reference/backend/api/en.md
@@ -0,0 +1,104 @@
+---
+title: REST API
+---
+
+This is the reference documentation for the FreeSewing backend REST API.
+
+
+This documentation is under construction as we are re-working this API for v3.
+
+
+## Purpose of this API
+
+This API is how one can interact with the FreeSewing backend data.
+That means the data for our users, including all people they have added to
+their profile, as well as all of the patterns they have saved to our profile.
+
+This API also manages subscriptions to our newsletter, and a number of other
+automation tasks such as creating issues on Github.
+
+## Authentication
+
+This API is not accessible without authentication.
+
+The FreeSewing backend API allows two types of authentication:
+
+- JSON Web Tokens (jwt): This is typically used to authenticate humans in a
+ browser session.
+- API Keys (key): This is typically used to interact with the API in an
+ automated way. Like in a script, or a CI/CD context.
+
+Apart from the handlful of API endpoints that are accessible without
+authentication (typically the ones dealing with the signup flow), this API has
+a variant for each route depending on what authentication you want to use:
+
+- `/some/route/jwt` : Authenticate with JWT
+- `/some/route/key` : Authenticate with an API key and secret
+
+### JWT authentication
+
+The use of JSON Web Tokens ([jwt](https://jwt.io)) is typically used in a browser context where we want to establish a *session*.
+
+To get a token, you must first authenticate at the `/login` endpoint with username and password.
+You will receive a token in the response.
+
+In subsequent API calls, you must then include this token in the `Authorization` header prefixed by `Bearer`. Liek his:
+
+```js
+const account = await axios.get(
+ `https://backend.freesewing.org/account/jwt`,
+ {
+ headers: {
+ Authorization: `Bearer ${token}`
+ }
+ }
+)
+```
+
+### API key authentication
+
+The combination API key & secret serves as a username & password for HTTP basic authentication.
+In basic authentication, the password is sent unencrypted, but since the FreeSewing backend is only reachable over a TLS encrypted connection, this is not a problem.
+
+On the plus side, sending a username and password with a request is supported pretty much everywhere.
+In addition, there is no need to establish a session first, so this make the entire transation stateless.
+
+Below is an example using curl:
+
+```sh
+curl -u api-key-here:api-secret-here \
+ https://backend.freesewing.org/account/key
+```
+
+## Privilege levels for user roles and API keys
+
+The privilege level is an intiger from `0` to `8`. The higher the number, the higher the priviledge.
+
+User accounts have a `role` that determines their privilege level.
+The available roles (with their privilege levels) are:
+
+- **user**: `4`
+- **bughunter**: `5`
+- **support**: `6`
+- **admin**: `8`
+
+These roles are used when using JWT authentication, as that's typically used by humans.
+When using API keys, the privilege level can be set on the API key itself in a more granular way.
+
+The table below lists the priviledge of all levels as well as their corresponding `role`:
+
+| Level | Abilities | `user` | `bughunter` | `support` | `admin` |
+| --: | -- | :--: | :--: | :--: | :--: |
+| `0` | authenticate | ✅ | ✅ | ✅ | ✅ |
+| `1` | **read** measurements and patterns | ✅ | ✅ | ✅ | ✅ |
+| `2` | **read all** account data | ✅ | ✅ | ✅ | ✅ |
+| `3` | **write** measurements or patterns | ✅ | ✅ | ✅ | ✅ |
+| `4` | **write all** account data | ✅ | ✅ | ✅ | ✅ |
+| `5` | **read** measurements or patterns of **other users** | ❌ | ✅ | ✅ | ✅ |
+| `6` | **read all** account data of **other users** | ❌ | ❌ | ✅ | ✅ |
+| `7` | **write** access through **specific support methods** | ❌ | ❌ | ✅ | ✅ |
+| `8` | impersonate other user, full write access | ❌ | ❌ | ❌ | ✅ |
+
+## API Routes
+
+
diff --git a/markdown/dev/reference/backend/en.md b/markdown/dev/reference/backend/en.md
new file mode 100644
index 00000000000..6052a34e0dd
--- /dev/null
+++ b/markdown/dev/reference/backend/en.md
@@ -0,0 +1,5 @@
+---
+title: FreeSewing backend
+---
+
+FIXME: Explain what this is about
diff --git a/sites/backend/src/controllers/apikey.mjs b/sites/backend/src/controllers/apikey.mjs
index 56685baf6d6..7f593d86404 100644
--- a/sites/backend/src/controllers/apikey.mjs
+++ b/sites/backend/src/controllers/apikey.mjs
@@ -10,7 +10,7 @@ export function ApikeyController() {}
* Create API key
*
* This is the endpoint that handles creation of API keys/tokens
- * See: https://freesewing.dev/reference/backend/api
+ * See: https://freesewing.dev/reference/backend/api/apikey
*/
ApikeyController.prototype.create = async (req, res, tools) => {
const Apikey = new ApikeyModel(tools)
@@ -22,8 +22,22 @@ ApikeyController.prototype.create = async (req, res, tools) => {
/*
* Read API key
*
- * This is the endpoint that handles reading of API keys/tokens
- * See: https://freesewing.dev/reference/backend/api
+ * This is the endpoint that handles creation of API keys/tokens
+ * See: https://freesewing.dev/reference/backend/api/apikey
+ */
+ApikeyController.prototype.read = async (req, res, tools) => {
+ const Apikey = new ApikeyModel(tools)
+ await Apikey.readIfAllowed({ id: req.params.id }, req.user)
+
+ return Apikey.sendResponse(res)
+}
+
+/*
+ * Read current API key (whoami)
+ *
+ * This is the endpoint that handles reading of the API keys/token used in this
+ * request
+ * See: https://freesewing.dev/reference/backend/api/apikey
*/
ApikeyController.prototype.whoami = async (req, res, tools) => {
const User = new UserModel(tools)
diff --git a/sites/backend/src/models/apikey.mjs b/sites/backend/src/models/apikey.mjs
index 56ba475a545..2f4e6a51e9a 100644
--- a/sites/backend/src/models/apikey.mjs
+++ b/sites/backend/src/models/apikey.mjs
@@ -46,6 +46,28 @@ ApikeyModel.prototype.verify = async function (key, secret) {
return this
}
+ApikeyModel.prototype.readIfAllowed = async function (where, user) {
+ if (!this.User.authenticatedUser) await this.User.loadAuthenticatedUser(user)
+ await this.read(where)
+ if (!this.record) return this.setResponse(404, 'apikeyNotFound')
+ if (this.record.userId !== this.User.authenticatedUser.id) {
+ // Not own key - only admin can do that
+ if (this.User.authenticatedUser.role !== 'admin') {
+ return this.setResponse(400, 'permissionLackingToLoadOtherApiKey')
+ }
+ }
+
+ return this.setResponse(200, 'success', {
+ apikey: {
+ key: this.record.id,
+ level: this.record.level,
+ expiresAt: this.record.expiresAt,
+ name: this.record.name,
+ userId: this.record.userId,
+ },
+ })
+}
+
ApikeyModel.prototype.read = async function (where) {
this.record = await this.prisma.apikey.findUnique({ where })
@@ -59,9 +81,9 @@ ApikeyModel.prototype.create = async function ({ body, user }) {
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 (typeof body.expiresIn !== 'number') return this.setResponse(400, 'expiresIsNotNumeric')
if (body.expiresIn > this.config.apikeys.maxExpirySeconds)
- return this.setResponse(400, 'expiresInHigherThanMaximum')
+ return this.setResponse(400, 'expiresIsHigherThanMaximum')
// Load user making the call
await this.User.loadAuthenticatedUser(user)
diff --git a/sites/backend/src/models/user.mjs b/sites/backend/src/models/user.mjs
index 4fd6f9e71aa..a9791bab7f4 100644
--- a/sites/backend/src/models/user.mjs
+++ b/sites/backend/src/models/user.mjs
@@ -120,7 +120,7 @@ UserModel.prototype.create = async function ({ body }) {
await this.mailer.send({
template: 'signup',
language: this.language,
- to: 'joost@decock.org', // this.email,
+ to: this.email,
replacements: {
actionUrl: i18nUrl(this.language, `/confirm/signup/${this.Confirmation.record.id}`),
whyUrl: i18nUrl(this.language, `/docs/faq/email/why-signup`),
diff --git a/sites/backend/src/templates/email/blocks.mjs b/sites/backend/src/templates/email/blocks.mjs
index e267efb94e9..54a49df4341 100644
--- a/sites/backend/src/templates/email/blocks.mjs
+++ b/sites/backend/src/templates/email/blocks.mjs
@@ -35,12 +35,12 @@ export const closingRow = {
`,
text: `
-{{ closing }}
+{{{ closing }}}
-{{ greeting }}
+{{{ greeting }}}
joost
-PS: {{ text-ps }} : {{{ text-ps-link }}}`,
+PS: {{{ text-ps }}} : {{{ supportUrl }}}`,
}
export const headingRow = {
@@ -70,7 +70,7 @@ export const lead1Row = {
`,
- text: `{{ textLead }}
+ text: `{{{ text-lead }}}
{{{ actionUrl }}}
`,
}
diff --git a/sites/backend/src/templates/email/signup.mjs b/sites/backend/src/templates/email/signup.mjs
index c98baefd197..d6926680e35 100644
--- a/sites/backend/src/templates/email/signup.mjs
+++ b/sites/backend/src/templates/email/signup.mjs
@@ -21,10 +21,18 @@ export const signup = {
${closingRow.html}
`),
text: wrap.text(`
- ${headingRow.text}
- ${lead1Row.text}
- ${buttonRow.text}
- ${closingRow.text}
+{{{ heading }}}
+
+{{{ textLead }}}
+
+{{{ actionUrl }}}
+
+{{{ closing }}}
+
+{{{ greeting }}},
+joost
+
+PS: {{{ text-ps }}} : {{{ supportUrl }}}
`),
}
@@ -33,12 +41,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
+ textLead: 'To activate your account, click the link below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you cxan afford it.',
+ 'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.en,
},
// FIXME: Translate German
@@ -46,12 +56,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
+ textLead: 'To activate your account, click the link below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
- 'ps-post-link': 'if you cxan afford it.',
+ 'ps-post-link': 'if you can afford it.',
+ 'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.de,
},
// FIXME: Translate Spanish
@@ -59,12 +71,14 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
+ textLead: 'To activate your account, click the link below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
- 'ps-post-link': 'if you cxan afford it.',
+ 'ps-post-link': 'if you can afford it.',
+ 'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.es,
},
// FIXME: Translate French
@@ -72,24 +86,29 @@ export const translations = {
subject: '[FreeSewing] Confirm your E-mail address to activate your account',
heading: 'Welcome to FreeSewing',
lead: 'To activate your account, click the big black rectangle below:',
+ textLead: 'To activate your account, click the link below:',
button: 'Activate account',
closing: "That's all for now.",
greeting: 'love',
'ps-pre-link': 'FreeSewing is free (duh), but please',
'ps-link': 'become a patron',
'ps-post-link': 'if you can afford it.',
+ 'text-ps': 'FreeSewing is free (duh), but please become a patron if you can afford it',
...sharedTranslations.fr,
},
nl: {
subject: '[FreeSewing] Bevestig je E-mail adres om je account te activeren',
heading: 'Welkom bij FreeSewing',
lead: 'Om je account te activeren moet je op de grote zwarte rechthoek hieronder te klikken:',
+ textLead: 'Om je account te activeren moet je op de link hieronder te klikken:',
button: 'Account activeren',
closing: 'Daarmee is dat ook weer geregeld.',
greeting: 'liefs',
'ps-pre-link': 'FreeSewing is gratis (echt), maar gelieve',
'ps-link': 'ons werk te ondersteunen',
'ps-post-link': 'als het even kan.',
+ 'text-ps':
+ 'FreeSewing is gratis (echt), maar gelieve ons werk te ondersteunen als het even kan',
...sharedTranslations.nl,
},
}
diff --git a/sites/backend/tests/user.test.mjs b/sites/backend/tests/user.test.mjs
index 7283d28cd1a..d5aab549301 100644
--- a/sites/backend/tests/user.test.mjs
+++ b/sites/backend/tests/user.test.mjs
@@ -300,7 +300,7 @@ describe(`${user} Signup flow and authentication`, () => {
})
})
- step(`${user} Read API Key with api key (whoami)`, (done) => {
+ step(`${user} Read API Key with KEY (whoami)`, (done) => {
chai
.request(config.api)
.get(`/whoami/key`)
@@ -315,4 +315,36 @@ describe(`${user} Signup flow and authentication`, () => {
done()
})
})
+
+ step(`${user} Read API Key with KEY`, (done) => {
+ chai
+ .request(config.api)
+ .get(`/apikey/${store.apikey.key}/key`)
+ .auth(store.apikey.key, store.apikey.secret)
+ .end((err, 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`)
+ const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
+ checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
+ done()
+ })
+ })
+
+ step(`${user} Read API Key with JWT`, (done) => {
+ chai
+ .request(config.api)
+ .get(`/apikey/${store.apikey.key}/jwt`)
+ .set('Authorization', 'Bearer ' + store.token)
+ .end((err, 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`)
+ const checks = ['key', 'level', 'expiresAt', 'name', 'userId']
+ checks.forEach((i) => expect(res.body.apikey[i]).to.equal(store.apikey[i]))
+ done()
+ })
+ })
})
diff --git a/sites/shared/components/mdx/index.js b/sites/shared/components/mdx/index.js
index 49d37ea3089..9c4d3d97650 100644
--- a/sites/shared/components/mdx/index.js
+++ b/sites/shared/components/mdx/index.js
@@ -32,3 +32,5 @@ const mdxCustomComponents = (app = false) => ({
})
export default mdxCustomComponents
+
+//{children}