Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,8 @@ stage/
prime/
.craft/
*.snap

# Generated by `pnpm --filter admin gen:api` from src/node/hooks/express/openapi.ts.
# Regenerated by build/test/dev scripts; not committed.
/admin/src/api/schema.d.ts
/admin/src/api/version.ts
78 changes: 57 additions & 21 deletions admin/README.md
Original file line number Diff line number Diff line change
@@ -1,30 +1,66 @@
# React + TypeScript + Vite
# Admin UI

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Vite + React 19 single-page app served at `/admin`. Talks to the backend over
socket.io for the existing settings / plugins / pads pages, and (when
endpoints are added to the OpenAPI spec) over a typed REST client.

Currently, two official plugins are available:
## Scripts

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
| Script | What it does |
| -------------------- | -------------------------------------------------------- |
| `pnpm dev` | `gen:api` + Vite dev server (expects backend on :9001). |
| `pnpm gen:api` | Regenerates `src/api/{schema.d.ts,version.ts}` from the OpenAPI spec. |
| `pnpm build` | `gen:api` + `tsc` + `vite build`. |
| `pnpm build-copy` | Same, but writes into `../src/templates/admin`. |
| `pnpm test` | `gen:api` + smoke tests for the API client wiring. |
| `pnpm lint` | ESLint. |

## Expanding the ESLint configuration
## Typed API client

If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
The admin uses [`openapi-typescript`] to generate types from
`src/node/hooks/express/openapi.ts`, [`openapi-fetch`] for typed requests, and
[`openapi-react-query`] for TanStack Query bindings.

- Configure the top-level `parserOptions` property like this:
[`openapi-typescript`]: https://github.com/openapi-ts/openapi-typescript
[`openapi-fetch`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-fetch
[`openapi-react-query`]: https://github.com/openapi-ts/openapi-typescript/tree/main/packages/openapi-react-query

```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
### Generated files

`admin/src/api/schema.d.ts` and `admin/src/api/version.ts` are generated by
`gen:api` and gitignored — never commit them. They are produced by:

```sh
pnpm --filter admin gen:api
```

`admin/scripts/gen-api.mjs` loads `src/node/hooks/express/openapi.ts`, calls
`generateDefinitionForVersion` for the latest API version, pipes the JSON
through `openapi-typescript` to produce `schema.d.ts`, and emits a runtime
constant `LATEST_API_VERSION` (read from `info.version` in the spec) to
`version.ts` so `client.ts` can build the right `/api/<version>/` baseUrl.

`gen:api` runs as the first step of `dev`, `build`, `build-copy`, and
`test`, so a fresh checkout produces the generated files automatically when
any of those scripts is invoked. After modifying any of the following, the
next `pnpm <dev|build|test>` will refresh the generated files; you can also
run `gen:api` directly:

- `src/node/hooks/express/openapi.ts`
- `src/node/handler/APIHandler.ts` (changes to `latestApiVersion`)
- the resource definitions referenced by `openapi.ts`

### Using the client

```tsx
import { $api } from './api/client';

const SettingsPanel = () => {
const { data } = $api.useQuery('get', '/admin/settings'); // example
return <pre>{JSON.stringify(data, null, 2)}</pre>;
};
```

- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list
The admin endpoints are not yet present in the OpenAPI spec — this client is
in place to support upcoming work (see issue #7638 follow-up). For now, it is
exercised only by the smoke test.
Comment on lines +64 to +66
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. schema.d.ts missing /admin-auth paths 📎 Requirement gap ≡ Correctness

The generated admin OpenAPI schema/types do not include any /admin-auth/* operations, so the typed
client cannot cover the admin authentication endpoints the UI uses. This fails the requirement that
/admin-auth/* be represented in the OpenAPI output used for codegen.
Agent Prompt
## Issue description
The OpenAPI-generated admin types do not include `/admin-auth/*` operations, so the typed client cannot be used for existing admin auth calls.

## Issue Context
The admin schema is generated from the OpenAPI hook output, and the compliance requirement for this PR expects `/admin-auth/*` endpoints to be included in that output so `admin/src/api/schema.d.ts` exposes typed paths/operations.

## Fix Focus Areas
- src/node/hooks/express/openapi.ts[422-575]
- admin/src/api/schema.d.ts[9-31]
- admin/README.md[65-67]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

18 changes: 13 additions & 5 deletions admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
"version": "2.7.3",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"dev": "pnpm gen:api && vite",
"gen:api": "node scripts/gen-api.mjs",
"build": "pnpm gen:api && tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"build-copy": "tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview"
"build-copy": "pnpm gen:api && tsc && vite build --outDir ../src/templates/admin --emptyOutDir",
"preview": "vite preview",
"test": "pnpm gen:api && tsx --test src/api/__tests__/client.test.ts"
},
"dependencies": {
"@radix-ui/react-switch": "^1.2.6"
"@radix-ui/react-switch": "^1.2.6",
"@tanstack/react-query": "^5.100.9",
"@tanstack/react-query-devtools": "^5.100.9",
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4"
},
"devDependencies": {
"@radix-ui/react-dialog": "^1.1.15",
Expand All @@ -28,12 +34,14 @@
"i18next": "^26.0.9",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.14.0",
"openapi-typescript": "^7.13.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-hook-form": "^7.75.0",
"react-i18next": "^17.0.6",
"react-router-dom": "^7.15.0",
"socket.io-client": "^4.8.3",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vite": "^8.0.10",
"vite-plugin-babel": "^1.6.0",
Expand Down
46 changes: 46 additions & 0 deletions admin/scripts/dump-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// admin/scripts/dump-spec.ts
//
// Imports the OpenAPI spec builder from the etherpad source and writes the
// flat-style spec for the latest API version as JSON to the file path passed
// as argv[2]. Invoked by admin/scripts/gen-api.mjs via `tsx`.
//
// Why a file argument instead of stdout: importing `openapi.ts` triggers
// `Settings` init, which configures log4js to write INFO/WARN lines to
// stdout. Capturing stdout would mix logs with JSON.

import { writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';

const outFile = process.argv[2];
if (!outFile) {
process.stderr.write('Usage: tsx scripts/dump-spec.ts <output-path>\n');
process.exit(2);
}

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '..', '..');

const apiHandlerPath = path.join(repoRoot, 'src', 'node', 'handler', 'APIHandler.ts');
const openapiPath = path.join(repoRoot, 'src', 'node', 'hooks', 'express', 'openapi.ts');

// `openapi.ts` and `APIHandler.ts` use CommonJS-style `exports.*`. Under tsx's
// ESM dynamic import, the whole `module.exports` is exposed as `default`.
type ApiHandlerModule = { latestApiVersion: string };
type OpenApiModule = {
generateDefinitionForVersion: (version: string, style?: string) => unknown;
APIPathStyle: { FLAT: string; REST: string };
};

const apiHandlerMod = await import(pathToFileURL(apiHandlerPath).href);
const openapiMod = await import(pathToFileURL(openapiPath).href);

const apiHandler = (apiHandlerMod.default ?? apiHandlerMod) as ApiHandlerModule;
const openapi = (openapiMod.default ?? openapiMod) as OpenApiModule;

const spec = openapi.generateDefinitionForVersion(
apiHandler.latestApiVersion,
openapi.APIPathStyle.FLAT,
);

writeFileSync(path.resolve(outFile), JSON.stringify(spec, null, 2), 'utf8');
78 changes: 78 additions & 0 deletions admin/scripts/gen-api.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// admin/scripts/gen-api.mjs
//
// Regenerates admin/src/api/schema.d.ts from the live OpenAPI spec exported
// by src/node/hooks/express/openapi.ts. Run via `pnpm --filter admin gen:api`.

import { spawnSync } from 'node:child_process';
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const here = path.dirname(fileURLToPath(import.meta.url));
const adminRoot = path.resolve(here, '..');
const outFile = path.join(adminRoot, 'src', 'api', 'schema.d.ts');

const tmpDir = mkdtempSync(path.join(tmpdir(), 'etherpad-openapi-'));
const specPath = path.join(tmpDir, 'spec.json');

// On Windows pnpm resolves to pnpm.cmd, which spawnSync can only find via a
// shell. Use shell on Windows only to avoid Node's DEP0190 warning elsewhere.
// Every argument here is fixed (no user input) so the shell:true variant is
// not an injection risk.
const spawnOpts = {
cwd: adminRoot,
stdio: 'inherit',
shell: process.platform === 'win32',
};

try {
const dump = spawnSync(
'pnpm',
['exec', 'tsx', 'scripts/dump-spec.ts', specPath],
spawnOpts,
);
if (dump.status !== 0) {
console.error(`dump-spec.ts failed with exit code ${dump.status}`);
process.exit(dump.status ?? 1);
}

const gen = spawnSync(
'pnpm',
['exec', 'openapi-typescript', specPath, '-o', outFile],
spawnOpts,
);
Comment on lines +16 to +44
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Windows gen:api arg splitting 🐞 Bug ☼ Reliability

admin/scripts/gen-api.mjs uses shell: true on Windows while passing file paths (tmpdir + repo
paths) as arguments; if those paths contain spaces (common in Windows user temp dirs), cmd.exe
tokenization can break the pnpm exec ... <path> calls and make pnpm gen:api fail.
Agent Prompt
### Issue description
`admin/scripts/gen-api.mjs` sets `shell: process.platform === 'win32'` and then calls `spawnSync('pnpm', [..., specPath], spawnOpts)`. On Windows, enabling the shell can cause argument parsing issues for paths that include spaces (common for user temp directories), breaking `pnpm gen:api`.

### Issue Context
The script enables `shell` only to make `pnpm` resolvable on Windows. This can be solved without `shell: true` by invoking the correct executable name on Windows.

### Fix Focus Areas
- admin/scripts/gen-api.mjs[19-54]

### Suggested change
- Use `const pnpmCmd = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';`
- Keep `shell: false` (or omit it) in `spawnOpts`.
- Replace both `spawnSync('pnpm', ...)` calls with `spawnSync(pnpmCmd, ...)`.
This avoids cmd.exe parsing entirely while still working on Windows.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

if (gen.status !== 0) {
console.error(`openapi-typescript failed with exit code ${gen.status}`);
process.exit(gen.status ?? 1);
}

const header =
`// GENERATED — do not edit. Run \`pnpm --filter admin gen:api\` to regenerate.\n` +
`// Source: src/node/hooks/express/openapi.ts (#7638)\n\n`;
const body = readFileSync(outFile, 'utf8');
writeFileSync(outFile, header + body, 'utf8');

// Emit a runtime-side version constant so client.ts can build the right
// baseUrl. Generated paths are unprefixed (e.g. "/createGroup"), but the
// backend mounts the FLAT-style spec under /api/<version>/.
const spec = JSON.parse(readFileSync(specPath, 'utf8'));
const apiVersion = spec?.info?.version;
if (typeof apiVersion !== 'string' || apiVersion.length === 0) {
console.error('OpenAPI spec is missing info.version; cannot emit version.ts');
process.exit(1);
}
const versionFile = path.join(adminRoot, 'src', 'api', 'version.ts');
writeFileSync(
versionFile,
header +
`export const LATEST_API_VERSION = ${JSON.stringify(apiVersion)};\n` +
`export const API_BASE_URL = \`/api/\${LATEST_API_VERSION}\`;\n`,
'utf8',
);

console.log(`Wrote ${path.relative(process.cwd(), outFile)}`);
console.log(`Wrote ${path.relative(process.cwd(), versionFile)}`);
} finally {
rmSync(tmpDir, { recursive: true, force: true });
}
40 changes: 40 additions & 0 deletions admin/src/api/QueryProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// admin/src/api/QueryProvider.tsx
//
// TanStack Query provider for the admin UI. Devtools are loaded lazily and
// only in dev builds so they don't ship to production.

import { lazy, Suspense, useState, type ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const Devtools = import.meta.env.DEV
? lazy(() =>
import('@tanstack/react-query-devtools').then((m) => ({
default: m.ReactQueryDevtools,
})),
)
: null;

export const QueryProvider = ({ children }: { children: ReactNode }) => {
const [client] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000,
refetchOnWindowFocus: true,
},
},
}),
);

return (
<QueryClientProvider client={client}>
{children}
{Devtools && (
<Suspense fallback={null}>
<Devtools initialIsOpen={false} />
</Suspense>
)}
</QueryClientProvider>
);
};
16 changes: 16 additions & 0 deletions admin/src/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// admin/src/api/__tests__/client.test.ts
//
// Smoke test that the OpenAPI client module loads and exposes the expected
// surface. Catches toolchain wiring regressions (missing peer deps,
// generator output that doesn't export `paths`, etc.).

import { test } from 'node:test';
import assert from 'node:assert/strict';

test('client module exports fetchClient and $api', async () => {
const mod = await import('../client.ts');
assert.ok(mod.fetchClient, 'fetchClient export is present');
assert.ok(mod.$api, '$api export is present');
assert.equal(typeof mod.fetchClient.GET, 'function', 'fetchClient.GET is a function');
assert.equal(typeof mod.$api.useQuery, 'function', '$api.useQuery is a function');
});
12 changes: 12 additions & 0 deletions admin/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// admin/src/api/client.ts
//
// Typed HTTP client and TanStack Query hooks derived from the generated
// OpenAPI schema. Regenerate the schema with `pnpm --filter admin gen:api`.

import createClient from 'openapi-fetch';
import createQueryHooks from 'openapi-react-query';
import type { paths } from './schema';
import { API_BASE_URL } from './version';

export const fetchClient = createClient<paths>({ baseUrl: API_BASE_URL });
export const $api = createQueryHooks(fetchClient);
15 changes: 9 additions & 6 deletions admin/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {PadPage} from "./pages/PadPage.tsx";
import {ToastDialog} from "./utils/Toast.tsx";
import {ShoutPage} from "./pages/ShoutPage.tsx";
import {UpdatePage} from "./pages/UpdatePage.tsx";
import {QueryProvider} from './api/QueryProvider.tsx';

const router = createBrowserRouter(createRoutesFromElements(
<><Route element={<App/>}>
Expand All @@ -34,11 +35,13 @@ const router = createBrowserRouter(createRoutesFromElements(

ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<I18nextProvider i18n={i18n}>
<Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
<QueryProvider>
<I18nextProvider i18n={i18n}>
<Toast.Provider>
<ToastDialog/>
<RouterProvider router={router}/>
</Toast.Provider>
</I18nextProvider>
</QueryProvider>
</React.StrictMode>,
)
1 change: 1 addition & 0 deletions admin/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"exclude": ["src/**/__tests__/**"],
"references": [{ "path": "./tsconfig.node.json" }]
}
Loading
Loading