diff --git a/.env.example b/.env.example index 64c335c87..c01132523 100644 --- a/.env.example +++ b/.env.example @@ -67,15 +67,19 @@ UNSAFE_ENABLE_MOCK_INTEGRATION=true # Whether demo mode is read-only. Defaults to true (the public demo blocks all mutations). # Set to false for writable preview deployments so the demo user can modify the dashboard. # DEMO_READ_ONLY=false -# Central Homarr Workshop API. Override only when developing the Workshop locally. +# Central Workshop API is https://homarr.dev. Override only when developing +# the standalone Docusaurus or Homarr clients against a local backend. # NEXT_PUBLIC_WORKSHOP_API_URL=http://localhost:8090 # WORKSHOP_API_URL=http://localhost:8090 -# Workshop development backend and GitHub OAuth. +# Workshop development service and GitHub OAuth. # The GitHub OAuth callback URL is http://localhost:8090/api/oauth2-redirect. GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= PB_SUPERUSER_EMAIL= PB_SUPERUSER_PASSWORD= PB_EXPOSE_PORT=8090 -PB_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003,http://localhost:7575,http://127.0.0.1:7575 +PB_PUBLIC_URL=http://localhost:8090 +# Workshop is intentionally consumable by arbitrary self-hosted Homarr origins. +# PocketBase is stateless and authorizes writes with bearer tokens, not cookies. +PB_ALLOWED_ORIGINS=* diff --git a/.github/workflows/deployment-docs.yml b/.github/workflows/deployment-docs.yml deleted file mode 100644 index eeb53d101..000000000 --- a/.github/workflows/deployment-docs.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: "[Deployment] Documentation" - -on: - push: - branches: ["dev"] - paths: - - "apps/docs/**" - - "packages/definitions/src/docs/**" - - ".github/workflows/deployment-docs.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: "pages" - cancel-in-progress: false - -env: - SKIP_ENV_VALIDATION: true - TURBO_API: ${{ secrets.TURBO_API }} - TURBO_TEAM: ${{ secrets.TURBO_TEAM }} - TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} - TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }} - -jobs: - build: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v7 - - name: Setup - uses: ./tooling/github/setup - - name: Build docs - run: pnpm turbo build --filter=@homarr/docs - env: - CI: "true" - - name: Setup Pages - uses: actions/configure-pages@v6 - - name: Upload pages artifact - uses: actions/upload-pages-artifact@v5 - with: - path: "apps/docs/build" - - deploy: - runs-on: ubuntu-latest - needs: build - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v5 diff --git a/.github/workflows/deployment-workshop.yml b/.github/workflows/deployment-workshop.yml new file mode 100644 index 000000000..9e6328de4 --- /dev/null +++ b/.github/workflows/deployment-workshop.yml @@ -0,0 +1,53 @@ +name: "[Deployment] Workshop" + +on: + push: + branches: ["dev"] + paths: + - "apps/docs/**" + - "apps/workshop/**" + - "packages/definitions/src/docs/**" + - "packages/validation/**" + - "packages/workshop/**" + - "patches/**" + - "package.json" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".github/workflows/deployment-workshop.yml" + workflow_dispatch: + +permissions: + contents: read + packages: write + +concurrency: + group: workshop-${{ github.ref }} + cancel-in-progress: true + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + - name: Build and publish Workshop + uses: docker/build-push-action@v7 + with: + context: . + file: apps/workshop/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: | + ghcr.io/homarr-labs/workshop:dev + ghcr.io/homarr-labs/workshop:sha-${{ github.sha }} + cache-from: type=gha,scope=workshop + cache-to: type=gha,mode=max,scope=workshop diff --git a/.impeccable/critique/2026-07-13T21-26-11Z__apps-docs-src-components-workshop.md b/.impeccable/critique/2026-07-13T21-26-11Z__apps-docs-src-components-workshop.md new file mode 100644 index 000000000..e8155aded --- /dev/null +++ b/.impeccable/critique/2026-07-13T21-26-11Z__apps-docs-src-components-workshop.md @@ -0,0 +1,87 @@ +--- +score: 9 +p0: 0 +p1: 1 +timestamp: 2026-07-13T21-26-11Z +slug: apps-docs-src-components-workshop +--- +# Workshop V2 audit + +## Purpose + +The Workshop is Homarr's central community catalog for custom widgets and CSS themes. It must let people discover, inspect, export, publish, update, report, and install creations from homarr.dev and from Homarr itself without requiring raw copy-and-paste as the default workflow. + +### Feature North Star + +A Homarr user can move from a useful local creation to a safe community submission, or from a community listing to an installed local creation, in one understandable flow with source visibility and an explicit confirmation boundary. + +### Developer-experience North Star + +Workshop behavior is defined once: one typed contract, one client, versioned PocketBase migrations, deterministic local startup, shared validation fixtures, typed failures, and UI components that contain presentation rather than PocketBase queries or authorization rules. + +## Baseline audit + +The abandoned prototype is preserved at `origin/feat/widget-store-prototype-backup` (`89260b875`). This rewrite starts at `dev` commit `ba7718bde`. + +| Area | Severity | Baseline finding | V2 resolution | +| ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Branch health | P0 | PR #5984 was based on an obsolete `dev` commit and conflicted with the current branch. | Rebuild from current `dev`; keep the old head on a backup ref. | +| Security | P0 | Submission validation ran in browsers and could be bypassed with direct PocketBase writes. | Enforce validation, ownership, account state, immutable fields, and upload limits in PocketBase hooks and rules. | +| Moderation | P0 | There were no moderator roles, posting bans, disabled accounts, least-privilege actions, or durable moderation history. | Add roles, account states, explicit moderation routes, a staff UI, and append-only actions. | +| Architecture | P1 | Docs and Next.js defined separate record types, queries, mutations, sorting, voting, and error behavior. | Add `@homarr/workshop` as the only public contract and API client. | +| Performance | P1 | The browser downloaded every submission and full content, then searched, sorted, and filtered locally. | Server-side pagination/filtering/sorting; summary views exclude content. | +| Product | P1 | Website and app flows were disconnected and sharing still depended on manual content transfer. | Add contextual install and direct share actions backed by canonical exports. | +| UX/UI | P1 | A large bespoke Docusaurus component set and a separate Mantine modal produced inconsistent interaction and state behavior. | Use each host's native components with shared flows, copy rules, contracts, and states. | +| Accessibility | P1 | Several custom interactive elements bypassed semantic controls and touch targets; focus and reduced-motion behavior was incomplete. | Require semantic controls, visible focus, keyboard operation, 44px touch targets, AA contrast, and reduced motion. | +| Operations | P1 | A third-party PocketBase image and incomplete bootstrap docs made builds and recovery non-deterministic. | Build a pinned official binary with checksum verification; document health, backup, restore, OAuth, and upgrades. | +| Testing | P1 | Tests covered a narrow client schema and did not exercise API rules, roles, auth states, moderation, or cross-surface flows. | Add contract, backend permission-matrix, UI, and end-to-end coverage. | +| Documentation | P2 | Documentation advertised a supported self-hosted Workshop even though V2 is a central Homarr service. | Document the central service and local-development overrides only. | + +## Decisions + +- Public name: **Workshop**. +- Launch content: custom widgets (`homarr-custom-widget-v2`) and CSS (`homarr-custom-css-v1`). +- Identity: GitHub OAuth only. +- Community features: votes and reports; comments are removed. +- Hosting: one Homarr-operated image and origin at `homarr.dev`; PocketBase serves both compiled docs and `/api`. +- Resilience: the public shell and successfully loaded query data are cached independently; the rest of Homarr never depends on Workshop availability. +- Publishing: immediate, with source inspection, reporting, and staff removal. +- Staff: GitHub-authenticated moderators/admins use `/workshop/admin`; PocketBase superusers are bootstrap/emergency access. + +## Launch acceptance + +- [x] Anonymous users can browse, search, filter, inspect, copy, and download without receiving full content in listing responses. +- [x] Members can publish, update, delete, vote, and report; ownership and schemas are enforced server-side. +- [x] Posting-banned users cannot create or update submissions but can browse, vote, and report. +- [x] Disabled accounts cannot authenticate or write, including with an existing token. +- [x] Moderators can resolve reports, remove submissions, posting-ban users, and disable/restore non-admin users. +- [x] Only admins can change roles; staff actions are append-only and attributable. +- [x] Homarr can export/share local widgets and CSS and validate/confirm Workshop installs. +- [x] Website and app implement loading, empty, offline, validation, permission, and destructive-action states. +- [x] Keyboard semantics, mobile layout, dark mode, reduced motion, and horizontal overflow received a browser smoke test. +- [x] Clean PocketBase startup, migration, health, backup, and restore procedures are reproducible and documented. +- [x] Documentation and Workshop API are built into one reproducible image and served from `homarr.dev` without production cross-origin requests. +- [ ] Add automated authenticated cross-surface E2E coverage before changing the PR from draft to ready. + +## Deferred after V2 + +Comments, private Workshop deployments, automated approval queues, follows, collections, notifications, arbitrary package types, historical version installation, and personalized recommendations. + +## Exit scorecard + +The baseline has three P0, seven P1, and one P2 findings. The rewrite closes every product, security, architecture, moderation, performance, operations, documentation, UX, and DX finding. One P1 release-verification item remains: automated authenticated E2E across both public and in-app surfaces. Keep PR #5984 in draft until it lands. + +| Gate | Result | Evidence | +| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Branch ancestry | Pass | Rebuilt from `dev` at `ba7718bde`; prototype preserved at `origin/feat/widget-store-prototype-backup`. | +| Contract | Pass | Five Vitest cases cover widget/CSS fixtures, filenames, limits, screenshot validation, and round trips. | +| Backend | Pass | Disposable PocketBase integration matrix covers roles, account states, ownership forgery, schema rejection, duplicate votes, private reports, escalation, deletion cascades, and audit snapshots. | +| Backend operations | Pass | PocketBase 0.39.6 runs unprivileged and builds with checksum verification; clean boot and a cloned legacy SQLite volume both migrate and become healthy. | +| Type safety | Pass | Scoped Turbo typecheck passes for Workshop, Docs, and Next.js. | +| Lint | Pass | Scoped lint exits clean; remaining warnings predate the rewrite. Workshop has zero warnings. | +| Format | Pass | Every changed supported file passes `oxfmt --check`; repository-wide Docs formatting still reports pre-existing drift. | +| Production builds | Pass | Docusaurus and Next.js production builds pass under Node 24.18.0. | +| Browser smoke | Pass | Desktop/mobile discovery and the disconnected API state render without uncaught errors; cached data remains readable and retry is explicit. | +| Automated cross-surface E2E | Open P1 | Authenticated website-to-Homarr publish/install and moderator flows still need browser automation. | + +Final implementation scores (10 is best): security 9, architecture/DX 9, moderation 9, performance 9, responsive UI 9, theming 9, accessibility 8, automated testing 7. Accessibility remains below 9 until authenticated dialogs and destructive flows receive a full keyboard/screen-reader pass. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..51adbe482 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,23 @@ +# Workshop design system + +Workshop is a trustworthy community workbench inside Homarr, not a separate storefront brand. + +## Visual direction + +- Reuse the host surface: Docusaurus tokens on `homarr.dev`, Mantine components inside Homarr. +- Use Homarr red only for primary action and focus; warnings use restrained amber and errors use red with explanatory text. +- Keep surfaces flat, bordered, and readable. Cards may lift by 3px on pointer hover; no gradients, glass panels, or decorative dashboards. +- Typography follows each host. Headings are compact and confident; body copy stays warm, concrete, and short. +- Spacing follows an 8px rhythm, 12-16px corners, and 44px minimum controls. + +## Interaction direction + +- The catalog progression is discover → inspect → confirm → use. +- Every remote state has a named loading, empty, cached, unavailable, validation, permission, and success outcome. +- Community source is never executed on the website. Installation always follows source inspection and local validation. +- Motion is limited to 150-200ms hover/state feedback and skeleton loading, with reduced-motion fallbacks. +- Native dialogs or Mantine modals own focus, Escape, backdrop dismissal, and keyboard order. + +## Voice + +Clear, dependable, and community-minded. Explain what remains usable during failures and provide one direct recovery action. Avoid playful language during moderation, destructive actions, or service failures. diff --git a/apps/docs/docs/management/workshop/index.mdx b/apps/docs/docs/management/workshop/index.mdx index 0906496da..0f33e13c2 100644 --- a/apps/docs/docs/management/workshop/index.mdx +++ b/apps/docs/docs/management/workshop/index.mdx @@ -21,6 +21,8 @@ In Homarr, open **Management → Workshop** to browse, search, filter, inspect, Recently loaded listings and details are saved in the browser for up to 24 hours. If Workshop is temporarily unreachable, Homarr shows the saved catalog with a clear offline notice; browsing the rest of Homarr is unaffected. Publishing, voting, reporting, and installation remain unavailable until the connection returns. +The public site also caches pages you have visited. If your network drops, a previously opened Workshop or documentation page can still load while the saved catalog remains on the device. Use **Check again** after connectivity returns. + - Widgets are validated against `homarr-custom-widget-v2` before installation. API keys and other secrets are not included and must be configured locally. - CSS is loaded into the board editor for review. It is not saved until you choose **Save changes**. - The public website never executes submitted widget templates or CSS. @@ -39,4 +41,4 @@ Signed-in members can vote to help useful creations rank higher. Report maliciou Workshop accounts use GitHub OAuth and are separate from users in a self-hosted Homarr instance. Workshop moderators can remove submissions, prevent an account from publishing, disable an account, and resolve reports. These actions require a reason and are recorded. -The Workshop is a central service operated by Homarr at `workshop.homarr.dev`. Running the backend locally is supported for contributors developing or testing this feature, not as a private production marketplace. +The Workshop is a central service operated by Homarr at `homarr.dev`. The documentation website and Workshop API ship in one image and use the same origin. Running it locally is supported for contributors developing or testing this feature, not as a private production marketplace. diff --git a/apps/docs/docusaurus.config.ts b/apps/docs/docusaurus.config.ts index abeafee4a..c1b2719cd 100644 --- a/apps/docs/docusaurus.config.ts +++ b/apps/docs/docusaurus.config.ts @@ -3,10 +3,9 @@ import type { Config } from "@docusaurus/types"; import { themes as prismThemes } from "prism-react-renderer"; const a11yEmoji = require("@fec/remark-a11y-emoji"); -const workshopApiUrl = - process.env.NODE_ENV !== "production" - ? (process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? "https://workshop.homarr.dev") - : "https://workshop.homarr.dev"; +// Production uses PocketBase's own origin. The explicit override exists only +// for the standalone Docusaurus development server. +const workshopApiUrl = process.env.WORKSHOP_API_URL ?? process.env.NEXT_PUBLIC_WORKSHOP_API_URL ?? ""; const config: Config = { title: "Homarr documentation", @@ -310,6 +309,7 @@ const config: Config = { ], "docusaurus-plugin-image-zoom", require.resolve("./plugins/validate-docs-coverage"), + require.resolve("./plugins/offline-manifest.mjs"), function disableExpensiveBundlerOptimizationPlugin() { return { name: "disable-expensive-bundler-optimizations", diff --git a/apps/docs/plugins/offline-manifest.mjs b/apps/docs/plugins/offline-manifest.mjs new file mode 100644 index 000000000..91663af9c --- /dev/null +++ b/apps/docs/plugins/offline-manifest.mjs @@ -0,0 +1,26 @@ +import { readdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +const collectFiles = async (directory, root = directory) => { + const entries = await readdir(directory, { withFileTypes: true }); + const files = await Promise.all( + entries.map((entry) => { + const absolutePath = path.join(directory, entry.name); + return entry.isDirectory() ? collectFiles(absolutePath, root) : path.relative(root, absolutePath); + }), + ); + return files.flat(); +}; + +export default function offlineManifestPlugin() { + return { + name: "workshop-offline-manifest", + async postBuild({ outDir }) { + const assetsDirectory = path.join(outDir, "assets"); + const assets = (await collectFiles(assetsDirectory)) + .filter((file) => /\.(?:css|js|woff2?)$/.test(file)) + .map((file) => `/assets/${file.split(path.sep).join("/")}`); + await writeFile(path.join(outDir, "workshop-assets.json"), JSON.stringify([...assets, "/img/logo.png"])); + }, + }; +} diff --git a/apps/docs/src/components/workshop/WorkshopAdmin.tsx b/apps/docs/src/components/workshop/WorkshopAdmin.tsx index c63697b35..fb06f05fe 100644 --- a/apps/docs/src/components/workshop/WorkshopAdmin.tsx +++ b/apps/docs/src/components/workshop/WorkshopAdmin.tsx @@ -1,16 +1,11 @@ import React, { useEffect, useMemo, useState } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { - WORKSHOP_API_URL, - WorkshopClient, - type WorkshopAccountState, - type WorkshopRole, - type WorkshopUser, -} from "@homarr/workshop"; +import { WorkshopClient, type WorkshopAccountState, type WorkshopRole, type WorkshopUser } from "@homarr/workshop"; import { WorkshopQueryProvider } from "./WorkshopQueryProvider"; +import { WorkshopDialog, WorkshopServiceState } from "./WorkshopUi"; import styles from "./workshop.module.css"; type PendingAction = @@ -29,9 +24,10 @@ export function WorkshopAdmin() { function WorkshopAdminContent() { const { siteConfig } = useDocusaurusContext(); const client = useMemo( - () => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) ?? WORKSHOP_API_URL), + () => new WorkshopClient((siteConfig.customFields?.workshopApiUrl as string | undefined) || window.location.origin), [siteConfig], ); + const queryClient = useQueryClient(); const [user, setUser] = useState(null); const [pending, setPending] = useState(null); const [error, setError] = useState(""); @@ -53,6 +49,24 @@ function WorkshopAdminContent() { const reports = moderationQuery.data?.reports ?? []; const users = moderationQuery.data?.users ?? []; const actions = moderationQuery.data?.actions ?? []; + const actionMutation = useMutation({ + mutationKey: ["workshop", "moderation", "action"], + mutationFn: async ({ action, reason }: { action: PendingAction; reason: string }) => { + if (action.kind === "report") await client.resolveReport(action.id, action.status, reason); + else if (action.kind === "state") await client.updateAccountState(action.id, action.state, reason); + else await client.updateRole(action.id, action.role, reason); + }, + onSuccess: async () => { + setPending(null); + await queryClient.invalidateQueries({ queryKey: ["workshop", "moderation"] }); + }, + }); + const signInMutation = useMutation({ + mutationKey: ["workshop", "auth", "moderator"], + mutationFn: () => client.signInWithGitHub(), + onSuccess: setUser, + onError: (caught) => setError(caught instanceof Error ? caught.message : "GitHub sign-in is unavailable"), + }); useEffect(() => client.subscribeToAuth(setUser), [client]); useEffect(() => { @@ -83,16 +97,13 @@ function WorkshopAdminContent() { )} ); @@ -115,10 +126,11 @@ function WorkshopAdminContent() { {(error || moderationQuery.isError) && ( -
- {error || "Moderation data is unavailable. Try again when Workshop reconnects."}{" "} - {moderationQuery.isError && } -
+ void moderationQuery.refetch()} + /> )}

Open reports

@@ -258,13 +270,7 @@ function WorkshopAdminContent() { setPending(null)} - onConfirm={async (reason) => { - if (pending.kind === "report") await client.resolveReport(pending.id, pending.status, reason); - else if (pending.kind === "state") await client.updateAccountState(pending.id, pending.state, reason); - else await client.updateRole(pending.id, pending.role, reason); - setPending(null); - await moderationQuery.refetch(); - }} + onConfirm={(reason) => actionMutation.mutateAsync({ action: pending, reason })} /> )} @@ -293,45 +299,43 @@ function ReasonDialog({ const [error, setError] = useState(""); const [saving, setSaving] = useState(false); return ( -
- -

Confirm moderation action

-

- This will apply {action.kind} to the selected record. -

-
{ - event.preventDefault(); - setSaving(true); - try { - await onConfirm(reason); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Action failed"); - setSaving(false); - } - }} - > -