1
0
Fork 0

wip(org): Working on admin & content creation pages

This commit is contained in:
joostdecock 2023-08-15 17:32:47 +02:00
parent 61da3e1dab
commit 50b6747584
26 changed files with 1221 additions and 3383 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) </PageWrapper>
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>
)
}
export default UserAdminPage export default UserAdminPage

View file

@ -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')}
href="/new/blogpost"
/>
{control > 3 ? (
<>
<Box
title={t('newApikey')}
Icon={KeyIcon}
description={t('keyNewInfo')}
href="/new/apikey"
/>
<Box
title={t('designNew')}
Icon={DesignIcon}
description={t('designNewInfo')}
href="https://freesewing.dev/tutorials/pattern-design"
/>
<Box
title={t('pluginNew')}
Icon={PluginIcon}
description={t('pluginNewInfo')}
href="https://freesewing.dev/guides/plugins"
/>
</>
) : null}
</div> </div>
{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`}>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('newApikey')}</span>
<KeyIcon 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('keyNewInfo')}
</div>
</Link>
<a
href="https://freesewing.dev/tutorials/pattern-design"
className={`${boxClasses} from-neutral to-success`}
>
<h4 className="flex flex-row items-start justify-between w-full text-primary-content m-0 p-0">
<span>{t('designNew')}</span>
<DesignIcon 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('designNewInfo')}
</div>
</a>
<a
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}
</PageWrapper> </PageWrapper>
) )
} }

View 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'],
},
},
}
}

View file

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

View 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 />
)
}

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

View 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>
)
}

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

View 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>
)
}

View file

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

View file

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

View file

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

View file

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

View file

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

3177
yarn.lock

File diff suppressed because it is too large Load diff