wip(org): Working on admin & content creation pages
This commit is contained in:
parent
61da3e1dab
commit
50b6747584
26 changed files with 1221 additions and 3383 deletions
|
@ -379,6 +379,7 @@ shared:
|
||||||
'remark-mdx-frontmatter': '3.0.0'
|
'remark-mdx-frontmatter': '3.0.0'
|
||||||
'remark-smartypants': '2.0.0'
|
'remark-smartypants': '2.0.0'
|
||||||
'sharp': '0.32.4'
|
'sharp': '0.32.4'
|
||||||
|
"slugify": "^1.6.6"
|
||||||
# see: https://github.com/npm/cli/issues/2610#issuecomment-1295371753
|
# see: https://github.com/npm/cli/issues/2610#issuecomment-1295371753
|
||||||
'svg-to-pdfkit': 'https://git@github.com/eriese/SVG-to-PDFKit'
|
'svg-to-pdfkit': 'https://git@github.com/eriese/SVG-to-PDFKit'
|
||||||
'tlds': '1.240.0'
|
'tlds': '1.240.0'
|
||||||
|
|
|
@ -31,3 +31,13 @@ AdminController.prototype.updateUser = async (req, res, tools) => {
|
||||||
|
|
||||||
return Admin.sendResponse(res)
|
return Admin.sendResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Update user
|
||||||
|
*/
|
||||||
|
AdminController.prototype.impersonateUser = async (req, res, tools) => {
|
||||||
|
const Admin = new AdminModel(tools)
|
||||||
|
await Admin.impersonateUser(req)
|
||||||
|
|
||||||
|
return Admin.User.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
|
@ -23,3 +23,25 @@ FlowsController.prototype.sendLanguageSuggestion = async (req, res, tools) => {
|
||||||
|
|
||||||
return Flow.sendResponse(res)
|
return Flow.sendResponse(res)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upload an image to Cloudflare
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
FlowsController.prototype.uploadImage = async (req, res, tools) => {
|
||||||
|
const Flow = new FlowModel(tools)
|
||||||
|
await Flow.uploadImage(req)
|
||||||
|
|
||||||
|
return Flow.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove an image from Cloudflare
|
||||||
|
* See: https://freesewing.dev/reference/backend/api
|
||||||
|
*/
|
||||||
|
FlowsController.prototype.removeImage = async (req, res, tools) => {
|
||||||
|
const Flow = new FlowModel(tools)
|
||||||
|
await Flow.removeImage(req)
|
||||||
|
|
||||||
|
return Flow.sendResponse(res)
|
||||||
|
}
|
||||||
|
|
|
@ -138,3 +138,39 @@ AdminModel.prototype.updateUser = async function ({ params, body, user }) {
|
||||||
*/
|
*/
|
||||||
return this.setResponse200({ user: this.User.asAccount() })
|
return this.setResponse200({ user: this.User.asAccount() })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Impersonates a user
|
||||||
|
* This logs an admin in as another user
|
||||||
|
*
|
||||||
|
* @param {params} object - The (URL) request parameters.
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @returns {AdminModel} object - The AdminModel
|
||||||
|
*/
|
||||||
|
AdminModel.prototype.impersonateUser = async function ({ params, body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
|
if (!this.rbac.admin(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is id set?
|
||||||
|
*/
|
||||||
|
if (!params.id) return this.setResponse(403, 'idMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Attempt to load the user from the database
|
||||||
|
*/
|
||||||
|
await this.User.read({ id: Number(params.id) })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* If the user cannot be found, return 404
|
||||||
|
*/
|
||||||
|
if (!this.User.record) return this.setResponse(404)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 200, token, and data
|
||||||
|
*/
|
||||||
|
return this.User.signInOk()
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { i18nUrl } from '../utils/index.mjs'
|
import { i18nUrl } from '../utils/index.mjs'
|
||||||
import { decorateModel } from '../utils/model-decorator.mjs'
|
import { decorateModel } from '../utils/model-decorator.mjs'
|
||||||
|
import { ensureImage, replaceImage, removeImage } from '../utils/cloudflare-images.mjs'
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This model handles all flows (typically that involves sending out emails)
|
* This model handles all flows (typically that involves sending out emails)
|
||||||
|
@ -112,3 +113,96 @@ FlowModel.prototype.sendLanguageSuggestion = async function ({ body, user }) {
|
||||||
*/
|
*/
|
||||||
return this.setResponse200({})
|
return this.setResponse200({})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upload an image to cloudflare
|
||||||
|
*
|
||||||
|
* @param {body} object - The request body
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @returns {FlowModel} object - The FlowModel
|
||||||
|
*/
|
||||||
|
FlowModel.prototype.uploadImage = async function ({ body, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
|
if (!this.rbac.readSome(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Do we have a POST body?
|
||||||
|
*/
|
||||||
|
if (Object.keys(body).length < 1) return this.setResponse(400, 'postBodyMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is img set?
|
||||||
|
*/
|
||||||
|
if (!body.img) return this.setResponse(400, 'imgMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is type set and valid?
|
||||||
|
*/
|
||||||
|
if (!body.type) return this.setResponse(400, 'typeMissing')
|
||||||
|
if (!['blog', 'showcase'].includes(body.type)) return this.setResponse(400, 'typeInvalid')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is subId set and valid?
|
||||||
|
*/
|
||||||
|
if (!body.subId) return this.setResponse(400, 'subIdMissing')
|
||||||
|
if (body.subId !== 'main' && ![1, 2, 3, 4, 5, 6, 7, 8, 9].includes(Number(body.subId)))
|
||||||
|
return this.setResponse(400, 'subIdInvalid')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is slug set?
|
||||||
|
*/
|
||||||
|
if (!body.slug) return this.setResponse(400, 'slugMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upload the image
|
||||||
|
*/
|
||||||
|
const id = `${body.type}-${body.slug}${
|
||||||
|
body.subId && body.subId !== 'main' ? '-' + body.subId : ''
|
||||||
|
}`
|
||||||
|
const b64 = body.img
|
||||||
|
const metadata = { uploadedBy: user.uid }
|
||||||
|
/*
|
||||||
|
* You need to be a curator to overwrite (replace) an image.
|
||||||
|
* Regular users can only update new images, not overwrite images.
|
||||||
|
* If not, any user could overwrite any showcase image.
|
||||||
|
*/
|
||||||
|
const imgId = this.rbac.curator(user)
|
||||||
|
? await replaceImage({ b64, id, metadata })
|
||||||
|
: await ensureImage({ b64, id, metadata })
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 200 and the image ID
|
||||||
|
*/
|
||||||
|
return this.setResponse200({ imgId })
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove an image from cloudflare
|
||||||
|
*
|
||||||
|
* @param {params} object - The request (URL) params
|
||||||
|
* @param {user} object - The user as loaded by auth middleware
|
||||||
|
* @returns {FlowModel} object - The FlowModel
|
||||||
|
*/
|
||||||
|
FlowModel.prototype.removeImage = async function ({ params, user }) {
|
||||||
|
/*
|
||||||
|
* Enforce RBAC
|
||||||
|
*/
|
||||||
|
if (!this.rbac.curator(user)) return this.setResponse(403, 'insufficientAccessLevel')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Is id set?
|
||||||
|
*/
|
||||||
|
if (!params.id) return this.setResponse(400, 'idMissing')
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove the image
|
||||||
|
*/
|
||||||
|
const gone = await removeImage(params.id)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Return 204
|
||||||
|
*/
|
||||||
|
return gone ? this.setResponse(204) : this.setResponse(500, 'unableToRemoveImage')
|
||||||
|
}
|
||||||
|
|
|
@ -486,16 +486,16 @@ const v2lut = {
|
||||||
'größe 42, mit brüsten': 8,
|
'größe 42, mit brüsten': 8,
|
||||||
'größe 44, mit brüsten': 9,
|
'größe 44, mit brüsten': 9,
|
||||||
'größe 46, mit brüsten': 10,
|
'größe 46, mit brüsten': 10,
|
||||||
'grösse 28, mit brüsten': 1,
|
'maat 28, met borsten': 1,
|
||||||
'grösse 30, mit brüsten': 2,
|
'maat 30, met borsten': 2,
|
||||||
'grösse 32, mit brüsten': 3,
|
'maat 32, met borsten': 3,
|
||||||
'grösse 34, mit brüsten': 4,
|
'maat 34, met borsten': 4,
|
||||||
'grösse 36, mit brüsten': 5,
|
'maat 36, met borsten': 5,
|
||||||
'grösse 38, mit brüsten': 6,
|
'maat 38, met borsten': 6,
|
||||||
'grösse 40, mit brüsten': 7,
|
'maat 40, met borsten': 7,
|
||||||
'grösse 42, mit brüsten': 8,
|
'maat 42, met borsten': 8,
|
||||||
'grösse 44, mit brüsten': 9,
|
'maat 44, met borsten': 9,
|
||||||
'grösse 46, mit brüsten': 10,
|
'maat 46, met borsten': 10,
|
||||||
'taille 28, avec des seins': 1,
|
'taille 28, avec des seins': 1,
|
||||||
'taille 30, avec des seins': 2,
|
'taille 30, avec des seins': 2,
|
||||||
'taille 32, avec des seins': 3,
|
'taille 32, avec des seins': 3,
|
||||||
|
|
|
@ -30,4 +30,9 @@ export function adminRoutes(tools) {
|
||||||
app.patch('/admin/user/:id/key', passport.authenticate(...bsc), (req, res) =>
|
app.patch('/admin/user/:id/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
Admin.updateUser(req, res, tools)
|
Admin.updateUser(req, res, tools)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Impersonate user
|
||||||
|
app.get('/admin/impersonate/:id/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Admin.impersonateUser(req, res, tools)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -22,4 +22,20 @@ export function flowsRoutes(tools) {
|
||||||
app.post('/flows/language-suggestion/key', passport.authenticate(...bsc), (req, res) =>
|
app.post('/flows/language-suggestion/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
Flow.sendLanguageSuggestion(req, res, tools)
|
Flow.sendLanguageSuggestion(req, res, tools)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Upload an image
|
||||||
|
app.post('/images/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Flow.uploadImage(req, res, tools)
|
||||||
|
)
|
||||||
|
app.post('/images/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Flow.uploadImage(req, res, tools)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remove an image
|
||||||
|
app.delete('/images/:id/jwt', passport.authenticate(...jwt), (req, res) =>
|
||||||
|
Flow.removeImage(req, res, tools)
|
||||||
|
)
|
||||||
|
app.delete('/images/:id/key', passport.authenticate(...bsc), (req, res) =>
|
||||||
|
Flow.removeImage(req, res, tools)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -58,6 +58,7 @@ export async function replaceImage(props, isTest = false) {
|
||||||
result = await axios.post(config.api, form, { headers })
|
result = await axios.post(config.api, form, { headers })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Failed to replace image on cloudflare', err)
|
console.log('Failed to replace image on cloudflare', err)
|
||||||
|
console.log(err.response.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.data?.result?.id ? result.data.result.id : false
|
return result.data?.result?.id ? result.data.result.id : false
|
||||||
|
@ -74,12 +75,24 @@ export async function ensureImage(props, isTest = false) {
|
||||||
await axios.post(config.api, form, { headers })
|
await axios.post(config.api, form, { headers })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// It's fine
|
// It's fine
|
||||||
console.log(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return props.id
|
return props.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Method that removes an image fron cloudflare
|
||||||
|
*/
|
||||||
|
export async function removeImage(id) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${config.api}/${id}`, { headers })
|
||||||
|
} catch (err) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Method that imports and image from URL and does not bother waiting for the answer
|
* Method that imports and image from URL and does not bother waiting for the answer
|
||||||
*/
|
*/
|
||||||
|
@ -109,7 +122,14 @@ export async function importImage(props, isTest = false) {
|
||||||
/*
|
/*
|
||||||
* Helper method to construct the form data for cloudflare
|
* Helper method to construct the form data for cloudflare
|
||||||
*/
|
*/
|
||||||
function getFormData({ id, metadata, url = false, b64 = false, blob = false, notPublic = false }) {
|
function getFormData({
|
||||||
|
id,
|
||||||
|
metadata = {},
|
||||||
|
url = false,
|
||||||
|
b64 = false,
|
||||||
|
blob = false,
|
||||||
|
notPublic = false,
|
||||||
|
}) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('id', id)
|
form.append('id', id)
|
||||||
form.append('metadata', JSON.stringify(metadata))
|
form.append('metadata', JSON.stringify(metadata))
|
||||||
|
|
|
@ -36,7 +36,6 @@
|
||||||
"@mdx-js/runtime": "2.0.0-next.9",
|
"@mdx-js/runtime": "2.0.0-next.9",
|
||||||
"@tailwindcss/typography": "0.5.9",
|
"@tailwindcss/typography": "0.5.9",
|
||||||
"algoliasearch": "4.19.1",
|
"algoliasearch": "4.19.1",
|
||||||
"react-copy-to-clipboard": "5.1.0",
|
|
||||||
"daisyui": "3.5.1",
|
"daisyui": "3.5.1",
|
||||||
"echarts": "5.4.2",
|
"echarts": "5.4.2",
|
||||||
"echarts-for-react": "3.0.2",
|
"echarts-for-react": "3.0.2",
|
||||||
|
@ -47,10 +46,11 @@
|
||||||
"lodash.set": "4.3.2",
|
"lodash.set": "4.3.2",
|
||||||
"luxon": "3.3.0",
|
"luxon": "3.3.0",
|
||||||
"next": "13.4.13",
|
"next": "13.4.13",
|
||||||
|
"react-copy-to-clipboard": "5.1.0",
|
||||||
"react-dropzone": "14.2.3",
|
"react-dropzone": "14.2.3",
|
||||||
|
"react-hot-toast": "2.4.1",
|
||||||
"react-hotkeys-hook": "4.4.1",
|
"react-hotkeys-hook": "4.4.1",
|
||||||
"react-instantsearch-dom": "6.40.4",
|
"react-instantsearch-dom": "6.40.4",
|
||||||
"react-hot-toast": "2.4.1",
|
|
||||||
"react-markdown": "8.0.7",
|
"react-markdown": "8.0.7",
|
||||||
"react-swipeable": "7.0.1",
|
"react-swipeable": "7.0.1",
|
||||||
"react-timeago": "7.1.0",
|
"react-timeago": "7.1.0",
|
||||||
|
@ -62,6 +62,7 @@
|
||||||
"remark-copy-linked-files": "git+https://git@github.com/joostdecock/remark-copy-linked-files",
|
"remark-copy-linked-files": "git+https://git@github.com/joostdecock/remark-copy-linked-files",
|
||||||
"remark-gfm": "3.0.1",
|
"remark-gfm": "3.0.1",
|
||||||
"remark-mdx-frontmatter": "3.0.0",
|
"remark-mdx-frontmatter": "3.0.0",
|
||||||
|
"slugify": "^1.6.6",
|
||||||
"use-persisted-state": "0.3.3",
|
"use-persisted-state": "0.3.3",
|
||||||
"yaml-loader": "0.8.0"
|
"yaml-loader": "0.8.0"
|
||||||
},
|
},
|
||||||
|
@ -70,8 +71,8 @@
|
||||||
"autoprefixer": "10.4.14",
|
"autoprefixer": "10.4.14",
|
||||||
"eslint-config-next": "13.4.13",
|
"eslint-config-next": "13.4.13",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
"postcss": "8.4.27",
|
|
||||||
"playwright": "^1.32.3",
|
"playwright": "^1.32.3",
|
||||||
|
"postcss": "8.4.27",
|
||||||
"remark-extract-frontmatter": "3.2.0",
|
"remark-extract-frontmatter": "3.2.0",
|
||||||
"remark-mdx-frontmatter": "3.0.0",
|
"remark-mdx-frontmatter": "3.0.0",
|
||||||
"tailwindcss": "3.3.3",
|
"tailwindcss": "3.3.3",
|
||||||
|
|
|
@ -1,117 +1,15 @@
|
||||||
// Dependencies
|
// Dependencies
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
import { nsMerge } from 'shared/utils.mjs'
|
import { nsMerge } from 'shared/utils.mjs'
|
||||||
import { cloudflareConfig } from 'shared/config/cloudflare.mjs'
|
|
||||||
import { formatNumber } from 'shared/utils.mjs'
|
|
||||||
// Hooks
|
// Hooks
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
|
||||||
import { useToast } from 'shared/hooks/use-toast.mjs'
|
|
||||||
// Components
|
// Components
|
||||||
import Link from 'next/link'
|
|
||||||
import Markdown from 'react-markdown'
|
|
||||||
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
||||||
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
import { Popout } from 'shared/components/popout/index.mjs'
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
import { Collapse } from 'shared/components/collapse.mjs'
|
import { Hits } from 'shared/components/admin.mjs'
|
||||||
import { Spinner } from 'shared/components/spinner.mjs'
|
|
||||||
import { Json } from 'shared/components/json.mjs'
|
|
||||||
import { AccountRole } from 'shared/components/account/role.mjs'
|
|
||||||
import { AccountStatus } from 'shared/components/account/status.mjs'
|
|
||||||
|
|
||||||
const Hits = ({ results, t }) => (
|
|
||||||
<>
|
|
||||||
{results && results.username && results.username.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h2>Results based on username</h2>
|
|
||||||
{results.username.map((user) => (
|
|
||||||
<User user={user} key={user.id} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{results && results.email && results.email.length > 0 && (
|
|
||||||
<>
|
|
||||||
<h2>Results based on E-mail address</h2>
|
|
||||||
{results.email.map((user) => (
|
|
||||||
<User user={user} key={user.id} />
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
|
|
||||||
const Row = ({ title, val }) => (
|
|
||||||
<tr className="py-1">
|
|
||||||
<td className="text-sm px-2 text-right font-bold">{title}</td>
|
|
||||||
<td className="text-sm">{val}</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
|
|
||||||
export const User = ({ user, detail = false }) => (
|
|
||||||
<div className="my-8">
|
|
||||||
<div className="flex flex-row w-full gap-4">
|
|
||||||
<div
|
|
||||||
className="w-52 h-52 bg-base-100 rounded-lg shadow shrink-0"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `url(${cloudflareConfig.url}${user.img}/sq500)`,
|
|
||||||
backgroundSize: 'cover',
|
|
||||||
}}
|
|
||||||
></div>
|
|
||||||
<div className="w-full">
|
|
||||||
<h6 className="flex flex-row items-center gap-2 flex-wrap">
|
|
||||||
{user.username}
|
|
||||||
<span className="font-light">|</span>
|
|
||||||
<AccountRole role={user.role} />
|
|
||||||
<span className="font-light">|</span>
|
|
||||||
<AccountStatus status={user.status} />
|
|
||||||
<span className="font-light">|</span>
|
|
||||||
{user.id}
|
|
||||||
</h6>
|
|
||||||
<div className="flex flex-row flex-wrap gap-4 w-full">
|
|
||||||
<div className="max-w-xs">
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<Row title="Email" val={user.email} />
|
|
||||||
<Row title="Initial" val={user.initial} />
|
|
||||||
<Row title="GitHub" val={user.github} />
|
|
||||||
<Row title="MFA" val={user.mfaEnabled ? 'Yes' : 'No'} />
|
|
||||||
<Row title="Passhash" val={user.passwordType} />
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-xs">
|
|
||||||
<table>
|
|
||||||
<tbody>
|
|
||||||
<Row title="Patron" val={user.patron} />
|
|
||||||
<Row title="Consent" val={user.consent} />
|
|
||||||
<Row title="Control" val={user.control} />
|
|
||||||
<Row title="Calls (jwt)" val={user.jwtCalls} />
|
|
||||||
<Row title="Calls (key)" val={user.keyCalls} />
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-xs flex flex-col gap-2">
|
|
||||||
<button className="btn btn-primary">Impersonate</button>
|
|
||||||
{detail ? (
|
|
||||||
<Link href={`/admin/user/${user.id}`} className="btn btn-primary btn-outline">
|
|
||||||
More options
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link href="/admin" className="btn btn-primary btn-outline">
|
|
||||||
Search other users
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="max-w-full truncate">
|
|
||||||
<Markdown>{user.bio}</Markdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Translation namespaces used on this page
|
// Translation namespaces used on this page
|
||||||
const namespaces = nsMerge(pageNs, authNs)
|
const namespaces = nsMerge(pageNs, authNs)
|
||||||
|
@ -150,7 +48,7 @@ const AdminPage = ({ page }) => {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Username, ID, or E-mail address"
|
placeholder="Username, ID, or E-mail address"
|
||||||
/>
|
/>
|
||||||
{loading ? <Spinner /> : <Hits {...{ backend, t, results }} />}
|
{loading ? <Loading /> : <Hits {...{ backend, t, results }} />}
|
||||||
</AuthWrapper>
|
</AuthWrapper>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,101 +1,19 @@
|
||||||
// Dependencies
|
// Dependencies
|
||||||
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
import { nsMerge } from 'shared/utils.mjs'
|
import { nsMerge } from 'shared/utils.mjs'
|
||||||
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
|
|
||||||
// Hooks
|
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useTranslation } from 'next-i18next'
|
|
||||||
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
|
||||||
import { useToast } from 'shared/hooks/use-toast.mjs'
|
|
||||||
// Components
|
// Components
|
||||||
import Link from 'next/link'
|
|
||||||
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
||||||
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
import { Json } from 'shared/components/json.mjs'
|
import { ManageUser } from 'shared/components/admin.mjs'
|
||||||
import { User } from 'site/pages/admin/index.mjs'
|
|
||||||
|
|
||||||
// Translation namespaces used on this page
|
// Translation namespaces used on this page
|
||||||
const namespaces = nsMerge(pageNs, authNs)
|
const namespaces = nsMerge(pageNs, authNs)
|
||||||
|
|
||||||
const roles = ['user', 'curator', 'bughunter', 'support', 'admin']
|
const UserAdminPage = ({ page, userId }) => (
|
||||||
|
<PageWrapper {...page} title={`User ${userId}`}>
|
||||||
const UserAdminPage = ({ page, userId }) => {
|
<AuthWrapper requiredRole="admin">{userId ? <ManageUser userId={userId} /> : null}</AuthWrapper>
|
||||||
const { t } = useTranslation(namespaces)
|
|
||||||
const toast = useToast()
|
|
||||||
const backend = useBackend()
|
|
||||||
|
|
||||||
const [user, setUser] = useState({})
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadUser = async () => {
|
|
||||||
const result = await backend.adminLoadUser(userId)
|
|
||||||
if (result.success) setUser(result.data.user)
|
|
||||||
}
|
|
||||||
loadUser()
|
|
||||||
}, [userId])
|
|
||||||
|
|
||||||
const updateUser = async (data) => {
|
|
||||||
const result = await backend.adminUpdateUser({ id: userId, data })
|
|
||||||
if (result.success) {
|
|
||||||
toast.for.settingsSaved()
|
|
||||||
setUser(result.data.user)
|
|
||||||
} else toast.for.backendError()
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = async (val) => {
|
|
||||||
setQ(val)
|
|
||||||
if (val.length < 2) return
|
|
||||||
/*
|
|
||||||
* Search backend
|
|
||||||
*/
|
|
||||||
setLoading(true)
|
|
||||||
const result = await backend.adminSearchUsers(val)
|
|
||||||
if (result.success) {
|
|
||||||
setResults(result.data.users)
|
|
||||||
}
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageWrapper {...page} title={user.id ? user.username : 'One moment please...'}>
|
|
||||||
<AuthWrapper requiredRole="admin">
|
|
||||||
{user.id ? <User user={user} /> : null}
|
|
||||||
<div className="flex flex-row flex-wrap gap-2 my-2">
|
|
||||||
{roles.map((role) => (
|
|
||||||
<button
|
|
||||||
key={role}
|
|
||||||
className="btn btn-primary btn-outline btn-sm"
|
|
||||||
onClick={() => updateUser({ role })}
|
|
||||||
disabled={role === user.role}
|
|
||||||
>
|
|
||||||
Assign {role} role
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-row flex-wrap gap-2 my-2">
|
|
||||||
{user.mfaEnabled && (
|
|
||||||
<button
|
|
||||||
className="btn btn-warning btn-outline btn-sm"
|
|
||||||
onClick={() => updateUser({ mfaEnabled: false })}
|
|
||||||
>
|
|
||||||
Disable MFA
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{Object.keys(freeSewingConfig.statuses).map((status) => (
|
|
||||||
<button
|
|
||||||
className="btn btn-warning btn-outline btn-sm"
|
|
||||||
onClick={() => updateUser({ status })}
|
|
||||||
disabled={Number(status) === user.status}
|
|
||||||
>
|
|
||||||
Set {freeSewingConfig.statuses[status].name.toUpperCase()} status
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{user.id ? <Json js={user} /> : null}
|
|
||||||
</AuthWrapper>
|
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
|
||||||
|
|
||||||
export default UserAdminPage
|
export default UserAdminPage
|
||||||
|
|
||||||
|
|
|
@ -6,12 +6,46 @@ import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
// Components
|
// Components
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
||||||
import { KeyIcon, MeasieIcon, DesignIcon, PageIcon, PluginIcon } from 'shared/components/icons.mjs'
|
import {
|
||||||
|
KeyIcon,
|
||||||
|
MeasieIcon,
|
||||||
|
DesignIcon,
|
||||||
|
PageIcon,
|
||||||
|
PluginIcon,
|
||||||
|
ShowcaseIcon,
|
||||||
|
RssIcon,
|
||||||
|
} from 'shared/components/icons.mjs'
|
||||||
|
|
||||||
// Translation namespaces used on this page
|
// Translation namespaces used on this page
|
||||||
// Note that we include the account namespace here for the 'new' keyword
|
// Note that we include the account namespace here for the 'new' keyword
|
||||||
const namespaces = [...pageNs, 'account']
|
const namespaces = [...pageNs, 'account']
|
||||||
|
|
||||||
|
const Box = ({ title, Icon, description, href }) => {
|
||||||
|
const linkProps = {
|
||||||
|
href,
|
||||||
|
className:
|
||||||
|
'p-8 -ml-4 -mr-4 md:m-0 rounded-none md:rounded-xl md:shadow hover:bg-secondary bg-base-200 hover:text-secondary-content',
|
||||||
|
}
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<>
|
||||||
|
<h4 className="flex flex-row items-start justify-between w-full m-0 p-0 text-inherit">
|
||||||
|
<span>{title}</span>
|
||||||
|
<Icon className="w-12 h-12 -mt-2" />
|
||||||
|
</h4>
|
||||||
|
<div className={`normal-case text-base font-medium text-left pt-2 text-inherit`}>
|
||||||
|
{description}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return href.slice(0, 4) === 'http' ? (
|
||||||
|
<a {...linkProps}>{inner}</a>
|
||||||
|
) : (
|
||||||
|
<Link {...linkProps}>{inner}</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Each page MUST be wrapped in the PageWrapper component.
|
* Each page MUST be wrapped in the PageWrapper component.
|
||||||
* You also MUST spread props.page into this wrapper component
|
* You also MUST spread props.page into this wrapper component
|
||||||
|
@ -25,77 +59,55 @@ const NewIndexPage = ({ page }) => {
|
||||||
const control = account.control ? account.control : 99
|
const control = account.control ? account.control : 99
|
||||||
|
|
||||||
const boxClasses =
|
const boxClasses =
|
||||||
'p-8 -ml-4 -mr-4 md:m-0 bg-gradient-to-tr rounded-none md:rounded-xl md:shadow hover:from-secondary hover:to-secondary'
|
'p-8 -ml-4 -mr-4 md:m-0 rounded-none md:rounded-xl md:shadow hover:bg-secondary bg-base-200 hover:text-secondary-content'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageWrapper {...page} title={t('new')}>
|
<PageWrapper {...page} title={t('new')}>
|
||||||
<div className="w-full flex flex-col md:grid md:grid-cols-2 gap-4">
|
<div className="w-full max-w-xl flex flex-col gap-4">
|
||||||
<Link href="/new/pattern" className={`${boxClasses} from-accent to-primary`}>
|
<h2>{t('newBasic')}</h2>
|
||||||
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
|
<Box
|
||||||
<span>{t('patternNew')}</span>
|
title={t('patternNew')}
|
||||||
<PageIcon className="w-12 h-12 text-primary-content -mt-2" />
|
Icon={PageIcon}
|
||||||
</h4>
|
description={t('patternNewInfo')}
|
||||||
<div className={`normal-case text-base font-medium text-primary-content text-left pt-2`}>
|
href="/new/pattern"
|
||||||
{t('patternNewInfo')}
|
/>
|
||||||
</div>
|
<Box title={t('newSet')} Icon={MeasieIcon} description={t('setNewInfo')} href="/new/set" />
|
||||||
</Link>
|
<h2>{t('newAdvanced')}</h2>
|
||||||
|
<Box
|
||||||
<Link href="/new/set" className={`${boxClasses} from-secondary to-success`}>
|
title={t('showcaseNew')}
|
||||||
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
|
Icon={ShowcaseIcon}
|
||||||
<span>{t('newSet')}</span>
|
description={t('showcaseNewInfo')}
|
||||||
<MeasieIcon className="w-12 h-12 text-primary-content -mt-2" />
|
href="/new/showcase"
|
||||||
</h4>
|
/>
|
||||||
<div className={`normal-case text-base font-medium text-primary-content text-left pt-2`}>
|
<Box
|
||||||
{t('setNewInfo')}
|
title={t('blogNew')}
|
||||||
</div>
|
Icon={RssIcon}
|
||||||
</Link>
|
description={t('blogNewInfo')}
|
||||||
</div>
|
href="/new/blogpost"
|
||||||
|
/>
|
||||||
{control > 3 ? (
|
{control > 3 ? (
|
||||||
<div className="w-full flex flex-col md:grid md:grid-cols-3 gap-4 mt-4">
|
<>
|
||||||
<Link href="/new/apikey" className={`${boxClasses} from-neutral to-info`}>
|
<Box
|
||||||
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
|
title={t('newApikey')}
|
||||||
<span>{t('newApikey')}</span>
|
Icon={KeyIcon}
|
||||||
<KeyIcon className="w-12 h-12 text-primary-content -mt-2" />
|
description={t('keyNewInfo')}
|
||||||
</h4>
|
href="/new/apikey"
|
||||||
<div
|
/>
|
||||||
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
|
<Box
|
||||||
>
|
title={t('designNew')}
|
||||||
{t('keyNewInfo')}
|
Icon={DesignIcon}
|
||||||
</div>
|
description={t('designNewInfo')}
|
||||||
</Link>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://freesewing.dev/tutorials/pattern-design"
|
href="https://freesewing.dev/tutorials/pattern-design"
|
||||||
className={`${boxClasses} from-neutral to-success`}
|
/>
|
||||||
>
|
<Box
|
||||||
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
|
title={t('pluginNew')}
|
||||||
<span>{t('designNew')}</span>
|
Icon={PluginIcon}
|
||||||
<DesignIcon className="w-12 h-12 text-primary-content -mt-2" />
|
description={t('pluginNewInfo')}
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
|
|
||||||
>
|
|
||||||
{t('designNewInfo')}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="https://freesewing.dev/guides/plugins"
|
href="https://freesewing.dev/guides/plugins"
|
||||||
className={`${boxClasses} from-neutral to-accent`}
|
/>
|
||||||
>
|
</>
|
||||||
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
|
|
||||||
<span>{t('pluginNew')}</span>
|
|
||||||
<PluginIcon className="w-12 h-12 text-primary-content -mt-2" />
|
|
||||||
</h4>
|
|
||||||
<div
|
|
||||||
className={`normal-case text-base font-medium text-primary-content text-left pt-2`}
|
|
||||||
>
|
|
||||||
{t('pluginNewInfo')}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</PageWrapper>
|
</PageWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
42
sites/org/pages/new/showcase.mjs
Normal file
42
sites/org/pages/new/showcase.mjs
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// Dependencies
|
||||||
|
import { serverSideTranslations } from 'next-i18next/serverSideTranslations'
|
||||||
|
// Hooks
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
// Components
|
||||||
|
import { PageWrapper, ns as pageNs } from 'shared/components/wrappers/page.mjs'
|
||||||
|
import { ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
|
import { ns as apikeysNs } from 'shared/components/account/apikeys.mjs'
|
||||||
|
import { CreateShowcasePost } from 'shared/components/github/create-showcase.mjs'
|
||||||
|
|
||||||
|
// Translation namespaces used on this page
|
||||||
|
const namespaces = [...new Set([...apikeysNs, ...authNs, ...pageNs])]
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Each page MUST be wrapped in the PageWrapper component.
|
||||||
|
* You also MUST spread props.page into this wrapper component
|
||||||
|
* when path and locale come from static props (as here)
|
||||||
|
* or set them manually.
|
||||||
|
*/
|
||||||
|
const NewShowcasePage = ({ page }) => {
|
||||||
|
const { t } = useTranslation(namespaces)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageWrapper {...page} title={t('showcaseNew')}>
|
||||||
|
<CreateShowcasePost noTitle />
|
||||||
|
</PageWrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NewShowcasePage
|
||||||
|
|
||||||
|
export async function getStaticProps({ locale }) {
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...(await serverSideTranslations(locale, namespaces)),
|
||||||
|
page: {
|
||||||
|
locale,
|
||||||
|
path: ['new', 'showcase'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
|
@ -190,6 +190,12 @@ designNewInfo: FreeSewing designs are small bundles of JavaScript code that gene
|
||||||
pluginNew: Create a new plugin
|
pluginNew: Create a new plugin
|
||||||
pluginNewInfo: FreeSewing's functionality can be further extended with plugins. Creating a plugin is easy, and we have a guide to take you from start to finish.
|
pluginNewInfo: FreeSewing's functionality can be further extended with plugins. Creating a plugin is easy, and we have a guide to take you from start to finish.
|
||||||
|
|
||||||
|
showcaseNew: Create a new showcase post
|
||||||
|
showcaseNewInfo: If you would like to share something you (or someone else) made based on our designs, you can start a draft showcase post here.
|
||||||
|
|
||||||
|
blogNew: Create a new blog post
|
||||||
|
blogNewInfo: If you would like to write on the FreeSewing blog, you can start a draft blog post here.
|
||||||
|
|
||||||
newBasic: The basics
|
newBasic: The basics
|
||||||
newAdvanced: Go further
|
newAdvanced: Go further
|
||||||
|
|
||||||
|
|
200
sites/shared/components/admin.mjs
Normal file
200
sites/shared/components/admin.mjs
Normal file
|
@ -0,0 +1,200 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { cloudflareConfig } from 'shared/config/cloudflare.mjs'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
|
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
|
import { useToast } from 'shared/hooks/use-toast.mjs'
|
||||||
|
import { formatNumber } from 'shared/utils.mjs'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import Markdown from 'react-markdown'
|
||||||
|
|
||||||
|
import { Popout } from 'shared/components/popout/index.mjs'
|
||||||
|
import { Collapse } from 'shared/components/collapse.mjs'
|
||||||
|
import { Spinner } from 'shared/components/spinner.mjs'
|
||||||
|
import { Json } from 'shared/components/json.mjs'
|
||||||
|
import { AccountRole } from 'shared/components/account/role.mjs'
|
||||||
|
import { AccountStatus } from 'shared/components/account/status.mjs'
|
||||||
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
|
|
||||||
|
import { freeSewingConfig } from 'shared/config/freesewing.config.mjs'
|
||||||
|
|
||||||
|
const roles = ['user', 'curator', 'bughunter', 'support', 'admin']
|
||||||
|
|
||||||
|
export const ImpersonateButton = ({ userId }) => {
|
||||||
|
const toast = useToast()
|
||||||
|
const backend = useBackend()
|
||||||
|
const { impersonate } = useAccount()
|
||||||
|
|
||||||
|
if (!userId) return null
|
||||||
|
|
||||||
|
const impersonateUser = async () => {
|
||||||
|
const result = await backend.adminImpersonateUser(userId)
|
||||||
|
if (result.success) {
|
||||||
|
impersonate(result.data)
|
||||||
|
toast.for.settingsSaved()
|
||||||
|
} else toast.for.backendError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className="btn btn-primary" onClick={impersonateUser}>
|
||||||
|
Impersonate
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Row = ({ title, val }) => (
|
||||||
|
<tr className="py-1">
|
||||||
|
<td className="text-sm px-2 text-right font-bold">{title}</td>
|
||||||
|
<td className="text-sm">{val}</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const Hits = ({ results, t }) => (
|
||||||
|
<>
|
||||||
|
{results && results.username && results.username.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>Results based on username</h2>
|
||||||
|
{results.username.map((user) => (
|
||||||
|
<User user={user} key={user.id} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{results && results.email && results.email.length > 0 && (
|
||||||
|
<>
|
||||||
|
<h2>Results based on E-mail address</h2>
|
||||||
|
{results.email.map((user) => (
|
||||||
|
<User user={user} key={user.id} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ShowUser = ({ user, button = null }) => (
|
||||||
|
<div className="flex flex-row w-full gap-4">
|
||||||
|
<div
|
||||||
|
className="w-52 h-52 bg-base-100 rounded-lg shadow shrink-0"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${cloudflareConfig.url}${user.img}/sq500)`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<div className="w-full">
|
||||||
|
<h6 className="flex flex-row items-center gap-2 flex-wrap">
|
||||||
|
{user.username}
|
||||||
|
<span className="font-light">|</span>
|
||||||
|
<AccountRole role={user.role} />
|
||||||
|
<span className="font-light">|</span>
|
||||||
|
<AccountStatus status={user.status} />
|
||||||
|
<span className="font-light">|</span>
|
||||||
|
{user.id}
|
||||||
|
</h6>
|
||||||
|
<div className="flex flex-row flex-wrap gap-4 w-full">
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<Row title="Email" val={user.email} />
|
||||||
|
<Row title="Initial" val={user.initial} />
|
||||||
|
<Row title="GitHub" val={user.github} />
|
||||||
|
<Row title="MFA" val={user.mfaEnabled ? 'Yes' : 'No'} />
|
||||||
|
<Row title="Passhash" val={user.passwordType} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-xs">
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<Row title="Patron" val={user.patron} />
|
||||||
|
<Row title="Consent" val={user.consent} />
|
||||||
|
<Row title="Control" val={user.control} />
|
||||||
|
<Row title="Calls (jwt)" val={user.jwtCalls} />
|
||||||
|
<Row title="Calls (key)" val={user.keyCalls} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-xs flex flex-col gap-2">{button}</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-full truncate">
|
||||||
|
<Markdown>{user.bio}</Markdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const User = ({ user }) => (
|
||||||
|
<div className="my-8">
|
||||||
|
<ShowUser
|
||||||
|
user={user}
|
||||||
|
button={
|
||||||
|
<Link href={`/admin/user/${user.id}`} className="btn btn-primary">
|
||||||
|
Manage user
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const ManageUser = ({ userId }) => {
|
||||||
|
// Hooks
|
||||||
|
const backend = useBackend()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [user, setUser] = useState({})
|
||||||
|
|
||||||
|
// Effect
|
||||||
|
useEffect(() => {
|
||||||
|
const loadUser = async () => {
|
||||||
|
const result = await backend.adminLoadUser(userId)
|
||||||
|
if (result.success) setUser(result.data.user)
|
||||||
|
}
|
||||||
|
loadUser()
|
||||||
|
}, [userId])
|
||||||
|
|
||||||
|
const updateUser = async (data) => {
|
||||||
|
const result = await backend.adminUpdateUser({ id: userId, data })
|
||||||
|
if (result.success) {
|
||||||
|
toast.for.settingsSaved()
|
||||||
|
setUser(result.data.user)
|
||||||
|
} else toast.for.backendError()
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.id ? (
|
||||||
|
<div className="my-8">
|
||||||
|
<ShowUser user={user} button={<ImpersonateButton userId={user.id} />} />
|
||||||
|
<div className="flex flex-row flex-wrap gap-2 my-2">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<button
|
||||||
|
key={role}
|
||||||
|
className="btn btn-primary btn-outline btn-sm"
|
||||||
|
onClick={() => updateUser({ role })}
|
||||||
|
disabled={role === user.role}
|
||||||
|
>
|
||||||
|
Assign {role} role
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row flex-wrap gap-2 my-2">
|
||||||
|
{user.mfaEnabled && (
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-outline btn-sm"
|
||||||
|
onClick={() => updateUser({ mfaEnabled: false })}
|
||||||
|
>
|
||||||
|
Disable MFA
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{Object.keys(freeSewingConfig.statuses).map((status) => (
|
||||||
|
<button
|
||||||
|
className="btn btn-warning btn-outline btn-sm"
|
||||||
|
onClick={() => updateUser({ status })}
|
||||||
|
disabled={Number(status) === user.status}
|
||||||
|
>
|
||||||
|
Set {freeSewingConfig.statuses[status].name.toUpperCase()} status
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{user.id ? <Json js={user} /> : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Loading />
|
||||||
|
)
|
||||||
|
}
|
7
sites/shared/components/code-box.mjs
Normal file
7
sites/shared/components/code-box.mjs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
export const CodeBox = ({ children }) => (
|
||||||
|
<div className="hljs my-4">
|
||||||
|
<pre className="language-md hljs text-base lg:text-lg whitespace-break-spaces overflow-scroll pr-4">
|
||||||
|
{children}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)
|
231
sites/shared/components/github/create-showcase.mjs
Normal file
231
sites/shared/components/github/create-showcase.mjs
Normal file
|
@ -0,0 +1,231 @@
|
||||||
|
// Dependencies
|
||||||
|
import { nsMerge, capitalize, cloudflareImageUrl } from 'shared/utils.mjs'
|
||||||
|
// Hooks
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
|
import { useToast } from 'shared/hooks/use-toast.mjs'
|
||||||
|
import { useTranslation } from 'next-i18next'
|
||||||
|
// Components
|
||||||
|
import { Popout } from 'shared/components/popout/index.mjs'
|
||||||
|
import { AuthWrapper, ns as authNs } from 'shared/components/wrappers/auth/index.mjs'
|
||||||
|
import { DesignPicker } from './design-picker.mjs'
|
||||||
|
import {
|
||||||
|
TitleInput,
|
||||||
|
SlugInput,
|
||||||
|
ImageInput,
|
||||||
|
CaptionInput,
|
||||||
|
IntroInput,
|
||||||
|
BodyInput,
|
||||||
|
} from './inputs.mjs'
|
||||||
|
import { Collapse } from 'shared/components/collapse.mjs'
|
||||||
|
import { Tab } from 'shared/components/account/bio.mjs'
|
||||||
|
import { CodeBox } from 'shared/components/code-box.mjs'
|
||||||
|
|
||||||
|
export const ns = nsMerge('account', authNs)
|
||||||
|
|
||||||
|
const Title = ({ children }) => (
|
||||||
|
<h3 className="flex flex-row flex-wrap items-end justify-between">{children}</h3>
|
||||||
|
)
|
||||||
|
|
||||||
|
const Tip = ({ children }) => <p className="italic opacity-70">{children}</p>
|
||||||
|
|
||||||
|
const Item = ({ title, children }) => (
|
||||||
|
<div className="collapse collapse-arrow bg-base-100 hover:bg-secondary hover:bg-opacity-20">
|
||||||
|
<input type="radio" name="my-accordion-2" />
|
||||||
|
<div className="collapse-title text-xl font-medium">{title}</div>
|
||||||
|
<div className="collapse-content bg-base-100 hover:bg-base-100">{children}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const CreateShowcasePost = ({ noTitle = false }) => {
|
||||||
|
const { account } = useAccount()
|
||||||
|
const backend = useBackend()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation(ns)
|
||||||
|
|
||||||
|
const [designs, setDesigns] = useState([])
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [slug, setSlug] = useState(false)
|
||||||
|
const [img, setImg] = useState(false)
|
||||||
|
const [caption, setCaption] = useState('')
|
||||||
|
const [intro, setIntro] = useState('')
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
const [extraImages, setExtraImages] = useState({})
|
||||||
|
const [activeTab, setActiveTab] = useState('create')
|
||||||
|
|
||||||
|
// Shared props for tabs
|
||||||
|
const tabProps = { activeTab, setActiveTab, t }
|
||||||
|
|
||||||
|
const addImage = () => {
|
||||||
|
const id = Object.keys(extraImages).length + 1
|
||||||
|
const newImages = { ...extraImages }
|
||||||
|
newImages[id] = null
|
||||||
|
setExtraImages(newImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setExtraImg = (key, img) => {
|
||||||
|
console.log('setting extra', { key, img })
|
||||||
|
const newImages = { ...extraImages }
|
||||||
|
newImages[key] = img
|
||||||
|
setExtraImages(newImages)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthWrapper>
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
{!noTitle && <h1>{t('showcaseNew')}</h1>}
|
||||||
|
<Tip>{t('showcaseNewInfo')}</Tip>
|
||||||
|
|
||||||
|
<div className="tabs w-full">
|
||||||
|
<Tab id="create" {...tabProps} />
|
||||||
|
<Tab id="preview" {...tabProps} />
|
||||||
|
</div>
|
||||||
|
{activeTab === 'create' ? (
|
||||||
|
<>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Designs</b>:{' '}
|
||||||
|
<span className="text-sm">{designs.map((d) => capitalize(d)).join(', ')}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>Pick one or more designs that are featured in this post.</Tip>
|
||||||
|
<DesignPicker {...{ designs, setDesigns }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Title</b>: <span className="text-sm">{title}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>Give your post a title. A good title is more than just a few words.</Tip>
|
||||||
|
<TitleInput {...{ title, setTitle }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Slug</b>: <span className="text-sm">{slug}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
The slug is the part of the URL that uniquely identifies the post. We can generate
|
||||||
|
one based on the title, but you can also customize it.
|
||||||
|
</Tip>
|
||||||
|
<SlugInput {...{ title, slug, setSlug }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Main Image</b>: <span className="text-sm">{img}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
The main image will be shown at the top of the post, and as the only image on the
|
||||||
|
showcase index page.
|
||||||
|
</Tip>
|
||||||
|
<ImageInput {...{ img, setImg, slug }} type="showcase" subId="main" />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Main Image Caption</b>: <span className="text-sm">{caption}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
The caption is the text that goes under the main image. Can include
|
||||||
|
copyrights/credits. Markdown is allowed.
|
||||||
|
</Tip>
|
||||||
|
<CaptionInput {...{ caption, setCaption }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Intro</b>: <span className="text-sm">{intro}</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>
|
||||||
|
A brief paragraph that will be shown on post previews on social media and so on.
|
||||||
|
</Tip>
|
||||||
|
<IntroInput {...{ intro, setIntro }} />
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Additional Images</b>: {Object.keys(extraImages).length}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{img ? (
|
||||||
|
<>
|
||||||
|
<Tip>Here you can add any images you want to include in the post body.</Tip>
|
||||||
|
{Object.keys(extraImages).map((key) => (
|
||||||
|
<>
|
||||||
|
<ImageInput
|
||||||
|
key={key}
|
||||||
|
setImg={(img) => setExtraImg(key, img)}
|
||||||
|
type="showcase"
|
||||||
|
subId={key}
|
||||||
|
img={extraImages[key]}
|
||||||
|
slug={slug}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
<button className="btn btn-secondary mt-2" onClick={addImage}>
|
||||||
|
Add Image
|
||||||
|
</button>
|
||||||
|
{Object.keys(extraImages).length > 0 && (
|
||||||
|
<>
|
||||||
|
<h5>Using extra images in your post</h5>
|
||||||
|
<p>To include these images, use this markup:</p>
|
||||||
|
<CodeBox>
|
||||||
|
{Object.keys(extraImages)
|
||||||
|
.map((key) => `[Image caption here][img${key}]`)
|
||||||
|
.join('\n\n')}
|
||||||
|
</CodeBox>
|
||||||
|
<p>Then, at at the bottom of your post, make sure to include this:</p>
|
||||||
|
<CodeBox>
|
||||||
|
{Object.keys(extraImages)
|
||||||
|
.map(
|
||||||
|
(key) =>
|
||||||
|
`[img${key}]: ${cloudflareImageUrl({
|
||||||
|
id: extraImages[key],
|
||||||
|
variant: 'main',
|
||||||
|
})}`
|
||||||
|
)
|
||||||
|
.join('\n')}
|
||||||
|
</CodeBox>
|
||||||
|
<pre></pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Popout note compact>
|
||||||
|
Please add a main image first
|
||||||
|
</Popout>
|
||||||
|
)}
|
||||||
|
</Item>
|
||||||
|
<Item
|
||||||
|
title={
|
||||||
|
<span>
|
||||||
|
<b>Post body</b>: {body.slice(0, 30) + '...'}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Tip>The actual post body. Supports Markdown.</Tip>
|
||||||
|
<BodyInput {...{ body, setBody }} />
|
||||||
|
</Item>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p>Post preview here</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AuthWrapper>
|
||||||
|
)
|
||||||
|
}
|
46
sites/shared/components/github/design-picker.mjs
Normal file
46
sites/shared/components/github/design-picker.mjs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
import { collection } from 'shared/hooks/use-design.mjs'
|
||||||
|
|
||||||
|
export const DesignPicker = ({ designs = [], setDesigns }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-row items-center gap-1 flex-wrap mb-4">
|
||||||
|
{designs.length > 0 && (
|
||||||
|
<>
|
||||||
|
<b>Featured:</b>
|
||||||
|
{designs.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
className="btn btn-sm btn-success hover:btn-error capitalize"
|
||||||
|
onClick={() => setDesigns(designs.filter((des) => d !== des))}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-warning hover:btn-error capitalize"
|
||||||
|
onClick={() => setDesigns([])}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-row items-center gap-1 flex-wrap">
|
||||||
|
<b>Not featured:</b>
|
||||||
|
{collection
|
||||||
|
.filter((d) => designs.includes(d) === false)
|
||||||
|
.map((d, i) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
className="btn btn-sm btn-neutral btn-outline hover:btn-success capitalize"
|
||||||
|
onClick={() => setDesigns([...designs, d])}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
return <p>Design picker here</p>
|
||||||
|
}
|
147
sites/shared/components/github/inputs.mjs
Normal file
147
sites/shared/components/github/inputs.mjs
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// Dependencies
|
||||||
|
import { slugify, slugifyNoTrim, cloudflareImageUrl } from 'shared/utils.mjs'
|
||||||
|
// Hooks
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
|
import { useToast } from 'shared/hooks/use-toast.mjs'
|
||||||
|
import { useDropzone } from 'react-dropzone'
|
||||||
|
import { Popout } from 'shared/components/popout/index.mjs'
|
||||||
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
|
|
||||||
|
export const ns = ['account']
|
||||||
|
|
||||||
|
export const CaptionInput = ({ caption, setCaption }) => (
|
||||||
|
<input
|
||||||
|
className="input input-text input-bordered input-lg w-full"
|
||||||
|
value={caption}
|
||||||
|
placeholder="Type your caption here"
|
||||||
|
onChange={(evt) => setCaption(evt.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const IntroInput = ({ intro, setIntro }) => (
|
||||||
|
<input
|
||||||
|
className="input input-text input-bordered input-lg w-full"
|
||||||
|
value={intro}
|
||||||
|
placeholder="Type your intro here"
|
||||||
|
onChange={(evt) => setIntro(evt.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const BodyInput = ({ body, setBody }) => (
|
||||||
|
<textarea
|
||||||
|
className="input input-text input-bordered input-lg w-full h-96"
|
||||||
|
placeholder="Type your post body here"
|
||||||
|
onChange={(evt) => setBody(evt.target.value)}
|
||||||
|
rows={16}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</textarea>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const TitleInput = ({ title, setTitle }) => (
|
||||||
|
<input
|
||||||
|
className="input input-text input-bordered input-lg w-full"
|
||||||
|
value={title}
|
||||||
|
placeholder="Type your title here"
|
||||||
|
onChange={(evt) => setTitle(evt.target.value)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
export const SlugInput = ({ slug, setSlug, title }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (title !== slug) setSlug(slugify(title))
|
||||||
|
}, [title])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className="input input-text input-bordered input-lg w-full mb-2"
|
||||||
|
value={slug}
|
||||||
|
placeholder="Type your title here"
|
||||||
|
onChange={(evt) => setSlug(slugifyNoTrim(evt.target.value))}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ImageInput = ({ slug = false, setImg, img, type = 'showcase', subId = false }) => {
|
||||||
|
const backend = useBackend()
|
||||||
|
const toast = useToast()
|
||||||
|
const { t } = useTranslation(ns)
|
||||||
|
const [uploading, setUploading] = useState(false)
|
||||||
|
|
||||||
|
const onDrop = useCallback(
|
||||||
|
(acceptedFiles) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = async () => {
|
||||||
|
setUploading(true)
|
||||||
|
const result = await backend.uploadImage({ type, subId, slug, img: reader.result })
|
||||||
|
setUploading(false)
|
||||||
|
if (result.success) setImg(result.data.imgId)
|
||||||
|
}
|
||||||
|
acceptedFiles.forEach((file) => reader.readAsDataURL(file))
|
||||||
|
},
|
||||||
|
[slug]
|
||||||
|
)
|
||||||
|
|
||||||
|
const { getRootProps, getInputProps } = useDropzone({ onDrop })
|
||||||
|
|
||||||
|
const removeImage = async () => {
|
||||||
|
setUploading(true)
|
||||||
|
const result = await backend.removeImage(img)
|
||||||
|
setUploading(false)
|
||||||
|
if (result.response.status === 204) setImg('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!slug)
|
||||||
|
return (
|
||||||
|
<Popout note compact>
|
||||||
|
A <b>slug</b> is mandatory
|
||||||
|
</Popout>
|
||||||
|
)
|
||||||
|
if (!subId)
|
||||||
|
return (
|
||||||
|
<Popout note compact>
|
||||||
|
A <b>subId</b> prop is mandatory
|
||||||
|
</Popout>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (uploading) return <Loading />
|
||||||
|
|
||||||
|
if (img)
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className="bg-base-100 w-full h-36 mb-2"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${cloudflareImageUrl({
|
||||||
|
id: img,
|
||||||
|
variant: 'sq500',
|
||||||
|
})})`,
|
||||||
|
backgroundSize: 'contain',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
<button className="btn btn-error btn-sm" onClick={removeImage}>
|
||||||
|
Remove Image
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
{...getRootProps()}
|
||||||
|
className={`
|
||||||
|
flex rounded-lg w-full flex-col items-center justify-center
|
||||||
|
lg:h-64 lg:border-4 lg:border-secondary lg:border-dashed
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<input {...getInputProps()} />
|
||||||
|
<p className="hidden lg:block p-0 m-0">{t('imgDragAndDropImageHere')}</p>
|
||||||
|
<p className="hidden lg:block p-0 my-2">{t('or')}</p>
|
||||||
|
<button className={`btn btn-secondary btn-outline mt-4 px-8`}>{t('imgSelectImage')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -1,6 +1,7 @@
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useTranslation } from 'next-i18next'
|
import { useTranslation } from 'next-i18next'
|
||||||
import { useAccount } from 'shared/hooks/use-account.mjs'
|
import { useAccount } from 'shared/hooks/use-account.mjs'
|
||||||
|
import { useBackend } from 'shared/hooks/use-backend.mjs'
|
||||||
import { roles } from 'config/roles.mjs'
|
import { roles } from 'config/roles.mjs'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Loading } from 'shared/components/spinner.mjs'
|
import { Loading } from 'shared/components/spinner.mjs'
|
||||||
|
@ -8,19 +9,20 @@ import { Loading } from 'shared/components/spinner.mjs'
|
||||||
export const ns = ['auth']
|
export const ns = ['auth']
|
||||||
|
|
||||||
const Wrap = ({ children }) => (
|
const Wrap = ({ children }) => (
|
||||||
<div className="m-auto max-w-lg text-center mt-24 p-8">{children}</div>
|
<div className="m-auto max-w-xl text-center mt-24 p-8">{children}</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ContactSupport = ({ t }) => (
|
const ContactSupport = ({ t }) => (
|
||||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
||||||
<Link href="/support" className="btn btn-warning w-full">
|
<Link href="/support" className="btn btn-success w-full">
|
||||||
{t('contactSupport')}
|
{t('contactSupport')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AuthRequired = ({ t }) => (
|
const AuthRequired = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('authRequired')}</h1>
|
<h1>{t('authRequired')}</h1>
|
||||||
<p>{t('membersOnly')}</p>
|
<p>{t('membersOnly')}</p>
|
||||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
||||||
|
@ -34,8 +36,9 @@ const AuthRequired = ({ t }) => (
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AccountInactive = ({ t }) => (
|
const AccountInactive = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('accountInactive')}</h1>
|
<h1>{t('accountInactive')}</h1>
|
||||||
<p>{t('accountInactiveMsg')}</p>
|
<p>{t('accountInactiveMsg')}</p>
|
||||||
<p>{t('signupAgain')}</p>
|
<p>{t('signupAgain')}</p>
|
||||||
|
@ -47,40 +50,45 @@ const AccountInactive = ({ t }) => (
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AccountDisabled = ({ t }) => (
|
const AccountDisabled = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('accountDisabled')}</h1>
|
<h1>{t('accountDisabled')}</h1>
|
||||||
<p>{t('accountDisabledMsg')}</p>
|
<p>{t('accountDisabledMsg')}</p>
|
||||||
<ContactSupport t={t} />
|
<ContactSupport t={t} />
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AccountProhibited = ({ t }) => (
|
const AccountProhibited = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('accountProhibited')}</h1>
|
<h1>{t('accountProhibited')}</h1>
|
||||||
<p>{t('accountProhibitedMsg')}</p>
|
<p>{t('accountProhibitedMsg')}</p>
|
||||||
<ContactSupport t={t} />
|
<ContactSupport t={t} />
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const AccountStatusUnknown = ({ t }) => (
|
const AccountStatusUnknown = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('statusUnknown')}</h1>
|
<h1>{t('statusUnknown')}</h1>
|
||||||
<p>{t('statusUnknownMsg')}</p>
|
<p>{t('statusUnknownMsg')}</p>
|
||||||
<ContactSupport t={t} />
|
<ContactSupport t={t} />
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const RoleLacking = ({ t, requiredRole, role }) => (
|
const RoleLacking = ({ t, requiredRole, role, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('roleLacking')}</h1>
|
<h1>{t('roleLacking')}</h1>
|
||||||
<p dangerouslySetInnerHTML={{ __html: t('roleLackingMsg', { requiredRole, role }) }} />
|
<p dangerouslySetInnerHTML={{ __html: t('roleLackingMsg', { requiredRole, role }) }} />
|
||||||
<ContactSupport t={t} />
|
<ContactSupport t={t} />
|
||||||
</Wrap>
|
</Wrap>
|
||||||
)
|
)
|
||||||
|
|
||||||
const ConsentLacking = ({ t }) => (
|
const ConsentLacking = ({ t, banner }) => (
|
||||||
<Wrap>
|
<Wrap>
|
||||||
|
{banner}
|
||||||
<h1>{t('consentLacking')}</h1>
|
<h1>{t('consentLacking')}</h1>
|
||||||
<p>{t('membersOnly')}</p>
|
<p>{t('membersOnly')}</p>
|
||||||
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
<div className="flex flex-row items-center justify-center gap-4 mt-8">
|
||||||
|
@ -96,28 +104,55 @@ const ConsentLacking = ({ t }) => (
|
||||||
|
|
||||||
export const AuthWrapper = ({ children, app, requiredRole = 'user' }) => {
|
export const AuthWrapper = ({ children, app, requiredRole = 'user' }) => {
|
||||||
const { t } = useTranslation(ns)
|
const { t } = useTranslation(ns)
|
||||||
const { account, token } = useAccount()
|
const { account, token, admin, stopImpersonating } = useAccount()
|
||||||
|
const backend = useBackend()
|
||||||
|
|
||||||
const [ready, setReady] = useState(false)
|
const [ready, setReady] = useState(false)
|
||||||
|
const [impersonating, setImpersonating] = useState(false)
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Avoid hydration errors
|
* Avoid hydration errors
|
||||||
*/
|
*/
|
||||||
useEffect(() => setReady(true))
|
useEffect(() => {
|
||||||
|
const verifyAdmin = async () => {
|
||||||
|
const result = await backend.adminPing(admin.token)
|
||||||
|
if (result.success && result.data.account.role === 'admin') {
|
||||||
|
setImpersonating({
|
||||||
|
admin: result.data.account.username,
|
||||||
|
user: account.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (admin && admin.token) verifyAdmin()
|
||||||
|
setReady(true)
|
||||||
|
}, [admin])
|
||||||
|
|
||||||
if (!ready) return <Loading />
|
if (!ready) return <Loading />
|
||||||
|
|
||||||
if (!token || !account.username) return <AuthRequired t={t} />
|
const banner = impersonating ? (
|
||||||
|
<div className="bg-warning rounded-lg shadow py-4 px-6 flex flex-row items-center gap-4 justify-between">
|
||||||
|
<span className="text-base-100 text-left">
|
||||||
|
Hi <b>{impersonating.admin}</b>, you are currently impersonating <b>{impersonating.user}</b>
|
||||||
|
</span>
|
||||||
|
<button className="btn btn-neutral" onClick={stopImpersonating}>
|
||||||
|
Stop Impersonating
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null
|
||||||
|
|
||||||
|
const childProps = { t, banner }
|
||||||
|
|
||||||
|
if (!token || !account.username) return <AuthRequired {...childProps} />
|
||||||
if (account.status !== 1) {
|
if (account.status !== 1) {
|
||||||
if (account.status === 0) return <AccountInactive t={t} />
|
if (account.status === 0) return <AccountInactive {...childProps} />
|
||||||
if (account.status === -1) return <AccountDisabled t={t} />
|
if (account.status === -1) return <AccountDisabled {...childProps} />
|
||||||
if (account.status === -2) return <AccountProhibited t={t} />
|
if (account.status === -2) return <AccountProhibited {...childProps} />
|
||||||
return <AccountStatusUnknown t={t} />
|
return <AccountStatusUnknown {...childProps} />
|
||||||
}
|
}
|
||||||
if (account.consent < 1) return <ConsentLacking />
|
if (account.consent < 1) return <ConsentLacking {...childProps} />
|
||||||
|
|
||||||
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
|
if (!roles.levels[account.role] || roles.levels[account.role] < roles.levels[requiredRole]) {
|
||||||
return <RoleLacking t={t} role={account.role} requiredRole={requiredRole} />
|
return <RoleLacking {...childProps} role={account.role} requiredRole={requiredRole} />
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|
|
@ -4,6 +4,7 @@ import createPersistedState from 'use-persisted-state'
|
||||||
* Set up local storage state for account & token
|
* Set up local storage state for account & token
|
||||||
*/
|
*/
|
||||||
const usePersistedAccount = createPersistedState('fs-account')
|
const usePersistedAccount = createPersistedState('fs-account')
|
||||||
|
const usePersistedAdmin = createPersistedState('fs-admin')
|
||||||
const usePersistedToken = createPersistedState('fs-token')
|
const usePersistedToken = createPersistedState('fs-token')
|
||||||
const usePersistedSeenUser = createPersistedState('fs-seen-user')
|
const usePersistedSeenUser = createPersistedState('fs-seen-user')
|
||||||
|
|
||||||
|
@ -18,6 +19,7 @@ const noAccount = { username: false, control: 2 }
|
||||||
export function useAccount() {
|
export function useAccount() {
|
||||||
// (persisted) State (saved to local storage)
|
// (persisted) State (saved to local storage)
|
||||||
const [account, setAccount] = usePersistedAccount(noAccount)
|
const [account, setAccount] = usePersistedAccount(noAccount)
|
||||||
|
const [admin, setAdmin] = usePersistedAdmin(noAccount)
|
||||||
const [token, setToken] = usePersistedToken(null)
|
const [token, setToken] = usePersistedToken(null)
|
||||||
const [seenUser, setSeenUser] = usePersistedSeenUser(false)
|
const [seenUser, setSeenUser] = usePersistedSeenUser(false)
|
||||||
|
|
||||||
|
@ -27,6 +29,26 @@ export function useAccount() {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Impersonate a user.
|
||||||
|
// Only admins can do this but that is enforced at the backend.
|
||||||
|
const impersonate = (data) => {
|
||||||
|
setAdmin({ token, account })
|
||||||
|
const newAccount = {
|
||||||
|
...data.account,
|
||||||
|
impersonatingAdmin: { id: account.id, username: account.username },
|
||||||
|
}
|
||||||
|
setAdmin({ token, account: { ...account } })
|
||||||
|
setAccount(newAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopImpersonating = () => {
|
||||||
|
setAccount(admin.account)
|
||||||
|
setToken(admin.token)
|
||||||
|
clearAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearAdmin = () => setAdmin(noAccount)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
account,
|
account,
|
||||||
setAccount,
|
setAccount,
|
||||||
|
@ -35,5 +57,9 @@ export function useAccount() {
|
||||||
seenUser,
|
seenUser,
|
||||||
setSeenUser,
|
setSeenUser,
|
||||||
signOut,
|
signOut,
|
||||||
|
admin,
|
||||||
|
clearAdmin,
|
||||||
|
impersonate,
|
||||||
|
stopImpersonating,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,9 +8,11 @@ import { useMemo } from 'react'
|
||||||
*/
|
*/
|
||||||
const apiHandler = axios.create({
|
const apiHandler = axios.create({
|
||||||
baseURL: freeSewingConfig.backend,
|
baseURL: freeSewingConfig.backend,
|
||||||
timeout: 3000,
|
timeout: 6660,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const auth = (token) => (token ? { headers: { Authorization: 'Bearer ' + token } } : {})
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This api object handles async code for different HTTP methods
|
* This api object handles async code for different HTTP methods
|
||||||
*/
|
*/
|
||||||
|
@ -316,6 +318,20 @@ Backend.prototype.confirmNewsletterUnsubscribe = async function ({ id, ehash })
|
||||||
return responseHandler(await api.delete('/subscriber', { id, ehash }))
|
return responseHandler(await api.delete('/subscriber', { id, ehash }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Upload an image
|
||||||
|
*/
|
||||||
|
Backend.prototype.uploadImage = async function (body) {
|
||||||
|
return responseHandler(await api.post('/images/jwt', body, this.auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Remove an (uploaded) image
|
||||||
|
*/
|
||||||
|
Backend.prototype.removeImage = async function (id) {
|
||||||
|
return responseHandler(await api.delete(`/images/${id}/jwt`, this.auth))
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Search user (admin method)
|
* Search user (admin method)
|
||||||
*/
|
*/
|
||||||
|
@ -337,20 +353,27 @@ Backend.prototype.adminUpdateUser = async function ({ id, data }) {
|
||||||
return responseHandler(await api.patch(`/admin/user/${id}/jwt`, data, this.auth))
|
return responseHandler(await api.patch(`/admin/user/${id}/jwt`, data, this.auth))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Impersonate user (admin method)
|
||||||
|
*/
|
||||||
|
Backend.prototype.adminImpersonateUser = async function (id) {
|
||||||
|
return responseHandler(await api.get(`/admin/impersonate/${id}/jwt`, this.auth))
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verify an admin account while impersonating another user
|
||||||
|
*/
|
||||||
|
Backend.prototype.adminPing = async function (token) {
|
||||||
|
return responseHandler(await api.get(`/whoami/jwt`, auth(token)))
|
||||||
|
}
|
||||||
|
|
||||||
export function useBackend() {
|
export function useBackend() {
|
||||||
const { token } = useAccount()
|
const { token } = useAccount()
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* This backend object is what we'll end up returning
|
* This backend object is what we'll end up returning
|
||||||
*/
|
*/
|
||||||
const backend = useMemo(() => {
|
const backend = useMemo(() => new Backend(auth(token)), [token])
|
||||||
/*
|
|
||||||
* Set up authentication headers
|
|
||||||
*/
|
|
||||||
const auth = token ? { headers: { Authorization: 'Bearer ' + token } } : {}
|
|
||||||
|
|
||||||
return new Backend(auth)
|
|
||||||
}, [token])
|
|
||||||
|
|
||||||
return backend
|
return backend
|
||||||
}
|
}
|
||||||
|
|
|
@ -54,6 +54,16 @@ export const extendSiteNav = async (siteNav, lang) => {
|
||||||
s: 'new/set',
|
s: 'new/set',
|
||||||
0: 20,
|
0: 20,
|
||||||
},
|
},
|
||||||
|
showcase: {
|
||||||
|
t: t('newShowcase'),
|
||||||
|
s: 'new/showcase',
|
||||||
|
0: 30,
|
||||||
|
},
|
||||||
|
blog: {
|
||||||
|
t: t('newBlog'),
|
||||||
|
s: 'new/blog',
|
||||||
|
0: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add designs
|
// Add designs
|
||||||
|
|
|
@ -1,10 +1,25 @@
|
||||||
import tlds from 'tlds/index.json' assert { type: 'json' }
|
import tlds from 'tlds/index.json' assert { type: 'json' }
|
||||||
|
import _slugify from 'slugify'
|
||||||
import get from 'lodash.get'
|
import get from 'lodash.get'
|
||||||
import set from 'lodash.set'
|
import set from 'lodash.set'
|
||||||
import orderBy from 'lodash.orderby'
|
import orderBy from 'lodash.orderby'
|
||||||
import unset from 'lodash.unset'
|
import unset from 'lodash.unset'
|
||||||
import { cloudflareConfig } from './config/cloudflare.mjs'
|
import { cloudflareConfig } from './config/cloudflare.mjs'
|
||||||
|
|
||||||
|
const slugifyConfig = {
|
||||||
|
replacement: '-', // replace spaces with replacement character, defaults to `-`
|
||||||
|
remove: undefined, // remove characters that match regex, defaults to `undefined`
|
||||||
|
lower: true, // convert to lower case, defaults to `false`
|
||||||
|
strict: true, // strip special characters except replacement, defaults to `false`
|
||||||
|
locale: 'en', // language code of the locale to use
|
||||||
|
trim: true, // trim leading and trailing replacement chars, defaults to `true`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slugify a string
|
||||||
|
export const slugify = (input) => _slugify(input, slugifyConfig)
|
||||||
|
// Slugify a string, but don't trim it. Handy when slugifying user input
|
||||||
|
export const slugifyNoTrim = (input) => _slugify(input, { ...slugifyConfig, trim: false })
|
||||||
|
|
||||||
// Method that returns a unique ID when all you need is an ID
|
// Method that returns a unique ID when all you need is an ID
|
||||||
// but you can't be certain you have one
|
// but you can't be certain you have one
|
||||||
export const getId = (id) => (id ? id : Date.now())
|
export const getId = (id) => (id ? id : Date.now())
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue