Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ceb11e7f9 | |||
| 6c92b6358d | |||
| 520f917723 | |||
| d1dd77bc1e | |||
| 0be45b6513 | |||
| 4b1274e34a | |||
| 2f66fff993 | |||
| bcae59626f | |||
| f851305d78 | |||
| 743b177c89 | |||
| 68a81ec775 | |||
| 63f0bdbab6 | |||
| 4648614556 | |||
| 53f01acb0c |
@@ -14,6 +14,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
|
- name: Security audit (fails on any prod or high/critical finding)
|
||||||
|
run: |
|
||||||
|
pnpm audit --prod
|
||||||
|
pnpm audit --audit-level high
|
||||||
|
|
||||||
- name: Install Docker CLI
|
- name: Install Docker CLI
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
@@ -30,34 +38,35 @@ jobs:
|
|||||||
FULL_SHA=$(git rev-parse HEAD)
|
FULL_SHA=$(git rev-parse HEAD)
|
||||||
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
IMAGE="git.kubebase.de/${{ gitea.repository }}"
|
||||||
DATE_STAMP=$(date -u +"%Y%m%d")
|
DATE_STAMP=$(date -u +"%Y%m%d")
|
||||||
|
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
||||||
|
TAGS="-t ${IMAGE}:latest"
|
||||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
VERSION="${{ gitea.ref_name }}"
|
VERSION="${{ gitea.ref_name }}"
|
||||||
VERSION_TAG="${{ gitea.ref_name }}"
|
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
|
||||||
else
|
|
||||||
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
|
|
||||||
VERSION_TAG="nightly-${DATE_STAMP}"
|
|
||||||
fi
|
fi
|
||||||
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
|
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
--build-arg COMMIT_SHA="$FULL_SHA" \
|
--build-arg COMMIT_SHA="$FULL_SHA" \
|
||||||
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
$TAGS .
|
$TAGS .
|
||||||
docker push "${IMAGE}:sha-${SHA}"
|
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
docker push "${IMAGE}:${VERSION_TAG}"
|
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||||
|
docker push "${IMAGE}:${VERSION}"
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Update k8s manifest in admin/apps
|
- name: Update k8s manifest in admin/apps
|
||||||
|
if: gitea.ref_type == 'tag'
|
||||||
env:
|
env:
|
||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
SHA=$(git rev-parse --short HEAD)
|
||||||
|
NEWTAG="${{ gitea.ref_name }}"
|
||||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||||
cd /tmp/apps
|
cd /tmp/apps
|
||||||
cd apps/system/toolrate/overlays/k3s
|
cd apps/system/toolrate/overlays/k3s
|
||||||
sed -i "s|newTag: .*|newTag: sha-${SHA}|" kustomization.yaml
|
sed -i "s|newTag: .*|newTag: ${NEWTAG}|" kustomization.yaml
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@git.kubebase.de"
|
git config user.email "actions@git.kubebase.de"
|
||||||
git add -A
|
git add -A
|
||||||
git diff --cached --quiet || git commit -m "chore: update toolrate image to sha-${SHA}"
|
git diff --cached --quiet || git commit -m "chore: update toolrate image to ${NEWTAG}"
|
||||||
git push
|
git push
|
||||||
|
|||||||
@@ -47,3 +47,6 @@ Thumbs.db
|
|||||||
# Replit
|
# Replit
|
||||||
.cache/
|
.cache/
|
||||||
.local/
|
.local/
|
||||||
|
|
||||||
|
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
|
||||||
|
/artifacts/toolrate/public/docs/
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
auto-install-peers=false
|
auto-install-peers=false
|
||||||
strict-peer-dependencies=false
|
strict-peer-dependencies=false
|
||||||
|
save-exact=true
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
modules = ["nodejs-24", "postgresql-16"]
|
|
||||||
|
|
||||||
[deployment]
|
|
||||||
router = "application"
|
|
||||||
deploymentTarget = "autoscale"
|
|
||||||
|
|
||||||
[deployment.postBuild]
|
|
||||||
args = ["pnpm", "store", "prune"]
|
|
||||||
env = { "CI" = "true" }
|
|
||||||
|
|
||||||
[workflows]
|
|
||||||
runButton = "Project"
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
stack = "PNPM_WORKSPACE"
|
|
||||||
expertMode = true
|
|
||||||
|
|
||||||
[postMerge]
|
|
||||||
path = "scripts/post-merge.sh"
|
|
||||||
timeoutMs = 20000
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8080
|
|
||||||
externalPort = 80
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8081
|
|
||||||
externalPort = 8081
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 26015
|
|
||||||
externalPort = 3000
|
|
||||||
|
|
||||||
[nix]
|
|
||||||
channel = "stable-25_05"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The format of this file is identical to `.dockerignore`.
|
|
||||||
# It is used to reduce the size of deployed images to make the process of publishing faster.
|
|
||||||
|
|
||||||
# No need to store the pnpm store twice.
|
|
||||||
.local
|
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24.18.1-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
# 1. Alle Projektdateien in den Container bringen
|
# 1. Alle Projektdateien in den Container bringen
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -17,10 +17,10 @@ ENV PORT=8080
|
|||||||
|
|
||||||
RUN pnpm -r --if-present run build
|
RUN pnpm -r --if-present run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24.18.1-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY lib/db/package.json lib/db/
|
COPY lib/db/package.json lib/db/
|
||||||
|
|||||||
+38
-35
@@ -2,10 +2,30 @@
|
|||||||
|
|
||||||
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
||||||
|
|
||||||
## Run & Operate
|
## Stack
|
||||||
|
|
||||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 8080)
|
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||||
- `pnpm --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
- API: Express 5 + express-session + connect-pg-simple
|
||||||
|
- DB: PostgreSQL + Drizzle ORM
|
||||||
|
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
||||||
|
- API codegen: Orval (from OpenAPI spec)
|
||||||
|
- Build: esbuild (CJS bundle)
|
||||||
|
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `lib/api-spec` — OpenAPI contract (source of truth)
|
||||||
|
- `lib/api-zod` — generated Zod schemas from the spec
|
||||||
|
- `lib/api-client-react` — generated React Query hooks
|
||||||
|
- `lib/db` — Drizzle schema + DB access
|
||||||
|
- `artifacts/api-server` — Express backend
|
||||||
|
- `artifacts/toolrate` — React frontend
|
||||||
|
- `artifacts/mockup-sandbox` — component preview
|
||||||
|
|
||||||
|
## Run & develop
|
||||||
|
|
||||||
|
- `pnpm --filter @workspace/api-server run dev` — run the API server
|
||||||
|
- `pnpm --filter @workspace/toolrate run dev` — run the frontend
|
||||||
- `pnpm run typecheck` — full typecheck across all packages
|
- `pnpm run typecheck` — full typecheck across all packages
|
||||||
- `pnpm run build` — typecheck + build all packages
|
- `pnpm run build` — typecheck + build all packages
|
||||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
||||||
@@ -13,29 +33,16 @@ Community platform for listing and rating developer/productivity tools by useful
|
|||||||
|
|
||||||
## Required env vars
|
## Required env vars
|
||||||
|
|
||||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
- `DATABASE_URL` — Postgres connection string
|
||||||
- `SESSION_SECRET` — Session signing secret (already set)
|
- `SESSION_SECRET` — session signing secret
|
||||||
|
- `VOTER_SECRET` — secret for voter submissions
|
||||||
|
- `LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD` — local admin login
|
||||||
|
|
||||||
## Keycloak (optional)
|
## Auth
|
||||||
|
|
||||||
Set these to enable login:
|
- Session-based auth (not JWT), sessions stored in Postgres via connect-pg-simple.
|
||||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
- Write operations (create/update/delete tools, submit ratings) require auth; reads are public.
|
||||||
- `KEYCLOAK_REALM` — realm name
|
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
|
||||||
- `KEYCLOAK_CLIENT_ID` — client ID
|
|
||||||
- `KEYCLOAK_CLIENT_SECRET` — client secret
|
|
||||||
- `APP_URL` — public base URL for OAuth callback (optional, auto-detected if omitted)
|
|
||||||
|
|
||||||
Without these, the app runs in read-only mode (browsing and viewing ratings works, submitting tools/ratings requires login).
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
|
||||||
- API: Express 5 + openid-client (Keycloak OIDC) + express-session + connect-pg-simple
|
|
||||||
- DB: PostgreSQL + Drizzle ORM
|
|
||||||
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
|
||||||
- API codegen: Orval (from OpenAPI spec)
|
|
||||||
- Build: esbuild (CJS bundle)
|
|
||||||
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
||||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||||
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
||||||
- `artifacts/api-server/src/routes/auth.ts` — Keycloak OIDC login/callback/logout/me
|
- `artifacts/api-server/src/routes/auth.ts` — login/logout/me
|
||||||
- `artifacts/toolrate/src/pages/` — frontend pages
|
- `artifacts/toolrate/src/pages/` — frontend pages
|
||||||
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||||
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
||||||
@@ -54,9 +61,9 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
|
|
||||||
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
||||||
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
||||||
- Write operations (create/update/delete tools, submit ratings) require auth. Reads are public.
|
- Write operations (create/update/delete tools, submit votes) require auth. Reads are public.
|
||||||
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
||||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
|
||||||
|
|
||||||
## Product
|
## Product
|
||||||
|
|
||||||
@@ -66,16 +73,12 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
||||||
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
||||||
|
|
||||||
## User preferences
|
## Deployment
|
||||||
|
|
||||||
_Populate as you build — explicit user instructions worth remembering across sessions._
|
- Container image: `git.kubebase.de/admin/tool-evaluator:<tag>` (pushed by CI)
|
||||||
|
- Deployed to k3s via the `apps` repo (kustomize), served at `https://toolr.kubebase.de`
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
||||||
- Auth routes use PKCE — the code_verifier is stored in the session, not in-memory state.
|
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
|
||||||
- `trust proxy: 1` is set on Express so that session cookies work correctly behind Replit's reverse proxy.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
|
|
||||||
- See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
kind = "api"
|
|
||||||
previewPath = "/api" # TODO - should be excluded from preview in the first place
|
|
||||||
title = "API Server"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "3B4_FFSkEVBkAeYMFRJ2e"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8080
|
|
||||||
name = "API Server"
|
|
||||||
paths = ["/api"]
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/api-server run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
|
|
||||||
[services.production.build]
|
|
||||||
args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"]
|
|
||||||
|
|
||||||
[services.production.build.env]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.run]
|
|
||||||
# we don't run through pnpm to make startup faster in production
|
|
||||||
args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"]
|
|
||||||
|
|
||||||
[services.production.run.env]
|
|
||||||
PORT = "8080"
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.health.startup]
|
|
||||||
path = "/api/healthz"
|
|
||||||
@@ -12,29 +12,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"zod": "catalog:",
|
"connect-pg-simple": "10.0.0",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"cookie-parser": "1.4.7",
|
||||||
"cookie-parser": "^1.4.7",
|
"cors": "2.8.6",
|
||||||
"cors": "^2.8.6",
|
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"express": "^5.2.1",
|
"express": "5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-rate-limit": "8.6.1",
|
||||||
"openid-client": "^5.7.1",
|
"express-session": "1.19.0",
|
||||||
"pino": "^9.14.0",
|
"openid-client": "6.8.4",
|
||||||
"pino-http": "^10.5.0"
|
"pino": "10.3.1",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/connect-pg-simple": "7.0.3",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cors": "2.8.19",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/express": "5.0.6",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/express-session": "^1.19.0",
|
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-pino": "^2.3.3",
|
"esbuild-plugin-pino": "2.3.3",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"thread-stream": "3.1.0"
|
"thread-stream": "4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
export const loginRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 10,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "Too many login attempts, please try again later." },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 5,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req: Request): string => String(req.session.user?.sub ?? req.ip ?? "unknown"),
|
||||||
|
message: { error: "Too many password attempts, please try again later." },
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
|
||||||
|
|
||||||
|
export function getCsrfToken(req: Request): string {
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = crypto.randomBytes(24).toString("hex");
|
||||||
|
}
|
||||||
|
return req.session.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csrfProtection(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provided = req.headers["x-csrf-token"];
|
||||||
|
const token = getCsrfToken(req);
|
||||||
|
if (typeof provided === "string" && provided && provided === token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({ error: "CSRF token missing or invalid" });
|
||||||
|
}
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
import { Router, type IRouter, type Request } from "express";
|
import { Router, type IRouter, type Request } from "express";
|
||||||
import { Issuer, generators, type Client } from "openid-client";
|
import {
|
||||||
|
discovery,
|
||||||
|
randomPKCECodeVerifier,
|
||||||
|
calculatePKCECodeChallenge,
|
||||||
|
randomState,
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
authorizationCodeGrant,
|
||||||
|
fetchUserInfo,
|
||||||
|
buildEndSessionUrl,
|
||||||
|
skipSubjectCheck,
|
||||||
|
type Configuration,
|
||||||
|
} from "openid-client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { eq, and, inArray, isNull } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { getEntitlements, requireFeature } from "../middleware/feature";
|
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||||
|
import { getCsrfToken } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
let cachedClient: Client | null = null;
|
let cachedConfig: Configuration | null = null;
|
||||||
|
|
||||||
function isOidcConfigured(): boolean {
|
function isOidcConfigured(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
@@ -36,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClient(): Promise<Client | null> {
|
async function getClient(): Promise<Configuration | null> {
|
||||||
if (cachedClient) return cachedClient;
|
if (cachedConfig) return cachedConfig;
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL;
|
const keycloakUrl = process.env.KEYCLOAK_URL;
|
||||||
const realm = process.env.KEYCLOAK_REALM;
|
const realm = process.env.KEYCLOAK_REALM;
|
||||||
@@ -49,14 +63,9 @@ async function getClient(): Promise<Client | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
|
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
|
||||||
cachedClient = new issuer.Client({
|
return cachedConfig;
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
response_types: ["code"],
|
|
||||||
});
|
|
||||||
return cachedClient;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Failed to discover Keycloak issuer");
|
logger.error({ err }, "Failed to discover Keycloak issuer");
|
||||||
return null;
|
return null;
|
||||||
@@ -107,7 +116,11 @@ router.get("/auth/mode", (_req, res): void => {
|
|||||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
router.get("/auth/csrf", (req, res): void => {
|
||||||
|
res.json({ token: getCsrfToken(req) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||||
if (isOidcConfigured()) {
|
if (isOidcConfigured()) {
|
||||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||||
return;
|
return;
|
||||||
@@ -169,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeVerifier = generators.codeVerifier();
|
const codeVerifier = randomPKCECodeVerifier();
|
||||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||||
const state = generators.state();
|
const state = randomState();
|
||||||
|
|
||||||
req.session.codeVerifier = codeVerifier;
|
req.session.codeVerifier = codeVerifier;
|
||||||
req.session.oidcState = state;
|
req.session.oidcState = state;
|
||||||
@@ -180,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
const url = client.authorizationUrl({
|
const url = buildAuthorizationUrl(client, {
|
||||||
scope: "openid email profile",
|
scope: "openid email profile",
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
@@ -188,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.redirect(url);
|
res.redirect(url.href);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/auth/callback", async (req, res): Promise<void> => {
|
router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||||
@@ -214,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
|||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = client.callbackParams(req);
|
const tokens = await authorizationCodeGrant(
|
||||||
const tokenSet = await client.callback(redirectUri, params, {
|
client,
|
||||||
code_verifier: codeVerifier,
|
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
|
||||||
state,
|
{ pkceCodeVerifier: codeVerifier, expectedState: state },
|
||||||
});
|
);
|
||||||
|
|
||||||
const userinfo = await client.userinfo(tokenSet.access_token!);
|
const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
|
||||||
const dbUser = await upsertUserFromOidc(userinfo);
|
const dbUser = await upsertUserFromOidc(userinfo);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -252,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
|
|||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
|
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
if (client && client.issuer.metadata.end_session_endpoint) {
|
if (client && client.serverMetadata().end_session_endpoint) {
|
||||||
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
|
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
|
||||||
res.redirect(logoutUrl);
|
res.redirect(logoutUrl.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +292,58 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
|
||||||
|
const client = await getClient();
|
||||||
|
if (!client) {
|
||||||
|
res.json({ url: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const realm = client.serverMetadata().issuer ?? "";
|
||||||
|
res.json({ url: `${realm}/account/password` });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dbUser.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
res.status(400).json({ error: "New password must differ from current password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||||
|
res.status(400).json({ error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||||
|
|
||||||
async function resolveDbUser(u: SessionUser) {
|
async function resolveDbUser(u: SessionUser) {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import usersRouter from "./users";
|
|||||||
import auditRouter from "./audit";
|
import auditRouter from "./audit";
|
||||||
import costsRouter from "./costs";
|
import costsRouter from "./costs";
|
||||||
import adminRouter from "./admin";
|
import adminRouter from "./admin";
|
||||||
|
import { csrfProtection } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
router.use(csrfProtection);
|
||||||
|
|
||||||
router.use(authRouter);
|
router.use(authRouter);
|
||||||
router.use(healthRouter);
|
router.use(healthRouter);
|
||||||
router.use(toolsRouter);
|
router.use(toolsRouter);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
|
||||||
if (category) {
|
if (category) {
|
||||||
query = query.where(eq(toolsTable.category, category));
|
conditions.push(eq(toolsTable.category, category));
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||||
}
|
}
|
||||||
if (tagList.length > 0) {
|
if (tagList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
if (featureList.length > 0) {
|
if (featureList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = db.select().from(toolsTable).where(and(...conditions));
|
||||||
|
|
||||||
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
||||||
|
|
||||||
const toolIds = tools.map((t) => t.id);
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
|||||||
tier: Tier.optional(),
|
tier: Tier.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
tier: usersTable.tier,
|
tier: usersTable.tier,
|
||||||
|
authProvider: usersTable.authProvider,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(usersTable)
|
.insert(usersTable)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
+1
@@ -14,5 +14,6 @@ declare module "express-session" {
|
|||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
oidcState?: string;
|
oidcState?: string;
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
kind = "design"
|
|
||||||
previewPath = "/__mockup"
|
|
||||||
title = "Canvas"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "XegfDyZt7HqfW2Bb8Ghoy"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8081
|
|
||||||
name = "Component Preview Server"
|
|
||||||
paths = ["/__mockup"]
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "8081"
|
|
||||||
BASE_PATH = "/__mockup"
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/mockup-sandbox run dev"
|
|
||||||
@@ -10,34 +10,34 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
@@ -45,29 +45,30 @@
|
|||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "5.0.0",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.4",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
)
|
)
|
||||||
ChartLegendContent.displayName = "ChartLegend"
|
ChartLegendContent.displayName = "ChartLegend"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
function getPayloadConfigFromPayload(
|
function getPayloadConfigFromPayload(
|
||||||
config: ChartConfig,
|
config: ChartConfig,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
kind = "web"
|
|
||||||
previewPath = "/"
|
|
||||||
title = "ToolRate — Tool-Bewertungsplattform"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "artifacts/toolrate"
|
|
||||||
router = "path"
|
|
||||||
|
|
||||||
[[integratedSkills]]
|
|
||||||
name = "react-vite"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
name = "web"
|
|
||||||
paths = [ "/" ]
|
|
||||||
localPort = 26015
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/toolrate run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
build = [ "pnpm", "--filter", "@workspace/toolrate", "run", "build" ]
|
|
||||||
publicDir = "artifacts/toolrate/dist/public"
|
|
||||||
serve = "static"
|
|
||||||
|
|
||||||
[[services.production.rewrites]]
|
|
||||||
from = "/*"
|
|
||||||
to = "/index.html"
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "26015"
|
|
||||||
BASE_PATH = "/"
|
|
||||||
@@ -4,44 +4,44 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --config vite.config.ts --host 0.0.0.0",
|
"dev": "node ../../scripts/src/generate-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
|
||||||
"build": "vite build --config vite.config.ts",
|
"build": "node ../../scripts/src/generate-docs.mjs && vite build --config vite.config.ts",
|
||||||
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.1.5",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.4",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.7",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.7",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.3",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.7",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.3",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.2.4",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.3",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.2.4",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.1.4",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.4",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.7",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.3",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-dev-banner": "catalog:",
|
"@replit/vite-plugin-dev-banner": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-virtual": "catalog:",
|
"@tanstack/react-virtual": "catalog:",
|
||||||
@@ -52,29 +52,32 @@
|
|||||||
"@workspace/api-client-react": "workspace:*",
|
"@workspace/api-client-react": "workspace:*",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"dompurify": "catalog:",
|
||||||
|
"embla-carousel-react": "8.6.0",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "26.3.6",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"marked": "catalog:",
|
||||||
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "17.0.11",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "5.7.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.2",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Switch, Route, Router as WouterRouter } from "wouter";
|
import { Switch, Route, Router as WouterRouter } from "wouter";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -18,6 +20,7 @@ import Trash from "@/pages/trash";
|
|||||||
import Compare from "@/pages/compare";
|
import Compare from "@/pages/compare";
|
||||||
import Watchlist from "@/pages/watchlist";
|
import Watchlist from "@/pages/watchlist";
|
||||||
import Login from "@/pages/login";
|
import Login from "@/pages/login";
|
||||||
|
import Docs from "@/pages/docs";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -44,12 +47,16 @@ function Router() {
|
|||||||
<Route path="/admin" component={Admin} />
|
<Route path="/admin" component={Admin} />
|
||||||
<Route path="/admin/redundancy" component={Redundancy} />
|
<Route path="/admin/redundancy" component={Redundancy} />
|
||||||
<Route path="/trash" component={Trash} />
|
<Route path="/trash" component={Trash} />
|
||||||
|
<Route path="/docs/*?" component={Docs} />
|
||||||
<Route component={NotFound} />
|
<Route component={NotFound} />
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCsrfToken();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -37,6 +37,31 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
|
|||||||
crumbs.push({ label: t("compare.title") });
|
crumbs.push({ label: t("compare.title") });
|
||||||
} else if (location.startsWith("/analytics")) {
|
} else if (location.startsWith("/analytics")) {
|
||||||
crumbs.push({ label: t("nav.analytics") });
|
crumbs.push({ label: t("nav.analytics") });
|
||||||
|
} else if (location.startsWith("/docs")) {
|
||||||
|
crumbs.push({ href: "/docs", label: t("docs.title") });
|
||||||
|
const m = location.replace(/^\/docs\/?/, "");
|
||||||
|
if (m.startsWith("handbook/")) {
|
||||||
|
crumbs.push({ href: "/docs/handbook", label: t("docs.guides") });
|
||||||
|
const slug = m.replace(/^handbook\//, "").split("#")[0];
|
||||||
|
if (slug) crumbs.push({ label: slug });
|
||||||
|
} else if (m.startsWith("reference/")) {
|
||||||
|
crumbs.push({ href: "/docs/reference/endpoints", label: t("docs.reference") });
|
||||||
|
const rest = m.replace(/^reference\//, "").split("#")[0];
|
||||||
|
if (rest.startsWith("schemas/")) {
|
||||||
|
crumbs.push({ label: t("docs.schemas") });
|
||||||
|
const name = rest.replace(/^schemas\//, "");
|
||||||
|
if (name) crumbs.push({ label: name });
|
||||||
|
} else {
|
||||||
|
const tag = rest.replace(/^endpoints\//, "");
|
||||||
|
if (tag) crumbs.push({ label: tag });
|
||||||
|
}
|
||||||
|
} else if (m.startsWith("releases/")) {
|
||||||
|
crumbs.push({ href: "/docs/releases", label: t("docs.releases") });
|
||||||
|
const version = m.replace(/^releases\//, "").split("#")[0];
|
||||||
|
if (version) crumbs.push({ label: version });
|
||||||
|
} else if (m) {
|
||||||
|
crumbs.push({ label: m });
|
||||||
|
}
|
||||||
} else if (location.startsWith("/login")) {
|
} else if (location.startsWith("/login")) {
|
||||||
crumbs.push({ label: t("auth.signIn") });
|
crumbs.push({ label: t("auth.signIn") });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { HelpCircle } from "lucide-react";
|
||||||
|
import { Link } from "wouter";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
|
export function FieldHelp({
|
||||||
|
schema,
|
||||||
|
field,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
schema: string;
|
||||||
|
field: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const label = children ?? field;
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href={`/docs/reference/schemas/${schema}#${field}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
aria-label={`Help: ${label}`}
|
||||||
|
data-testid={`help-${schema}-${field}`}
|
||||||
|
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-3.5 w-3.5" />
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{label} — Details in der Dokumentation</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Trash2, Bookmark, Search } from "lucide-react";
|
import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
|
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
|
||||||
const { data: version } = useGetVersion({
|
const { data: version } = useGetVersion({
|
||||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
});
|
});
|
||||||
@@ -38,10 +38,8 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
const mainLinks = [
|
const mainLinks = [
|
||||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
|
||||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
{ href: "/docs", label: t("nav.docs"), icon: FileText },
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<"input">, "type">) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<Input type={visible ? "text" : "password"} className="pr-9" {...props} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="absolute right-0 top-0 flex h-9 w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={visible ? "Hide password" : "Show password"}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bookmark, LogIn, LogOut, Trash2, KeyRound } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function initials(name?: string | null): string {
|
function initials(name?: string | null): string {
|
||||||
@@ -32,6 +38,17 @@ export function UserMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const changePassword = useChangeMyPassword();
|
||||||
|
const { data: passwordRedirect } = useGetPasswordRedirect({
|
||||||
|
query: { queryKey: getGetPasswordRedirectQueryKey(), enabled: isAuthenticated && !!user && !user.isLocal },
|
||||||
|
});
|
||||||
|
|
||||||
const showWatchlist = hasFeature("watchlist");
|
const showWatchlist = hasFeature("watchlist");
|
||||||
const showTrash = hasFeature("trash");
|
const showTrash = hasFeature("trash");
|
||||||
@@ -39,9 +56,9 @@ export function UserMenu() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:gap-0">
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:hidden" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,12 +68,12 @@ export function UserMenu() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2"
|
className="w-full gap-2 group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center"
|
||||||
onClick={() => login(location)}
|
onClick={() => login(location)}
|
||||||
data-testid="button-login"
|
data-testid="button-login"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
{t("auth.signIn")}
|
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,12 +84,13 @@ export function UserMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-3 px-3 py-2 h-auto"
|
className="w-full justify-start gap-3 px-3 py-2 h-auto group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1"
|
||||||
data-testid="button-user-menu"
|
data-testid="button-user-menu"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
@@ -80,7 +98,7 @@ export function UserMenu() {
|
|||||||
{initials(displayName)}
|
{initials(displayName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="min-w-0 flex-1 text-left">
|
<span className="min-w-0 flex-1 text-left group-data-[collapsible=icon]:hidden">
|
||||||
<span className="block text-sm font-medium truncate">{displayName}</span>
|
<span className="block text-sm font-medium truncate">{displayName}</span>
|
||||||
<span className="block text-[11px] text-muted-foreground truncate">
|
<span className="block text-[11px] text-muted-foreground truncate">
|
||||||
{user.email ?? tier}
|
{user.email ?? tier}
|
||||||
@@ -122,6 +140,16 @@ export function UserMenu() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPwOpen(true);
|
||||||
|
}}
|
||||||
|
data-testid="button-change-password"
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-2 h-4 w-4" />
|
||||||
|
{t("auth.changePassword")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -132,5 +160,97 @@ export function UserMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!user?.isLocal ? (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.oidcPasswordHint")}</p>
|
||||||
|
{passwordRedirect?.url && (
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<a href={passwordRedirect.url} target="_blank" rel="noreferrer">
|
||||||
|
<KeyRound className="w-4 h-4 mr-2" />
|
||||||
|
{t("auth.manageInIdp")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.currentPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
data-testid="input-current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.newPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="min. 6 characters"
|
||||||
|
data-testid="input-new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.confirmPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
data-testid="input-confirm-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPwOpen(false)}>{t("common.cancel")}</Button>
|
||||||
|
{user?.isLocal && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast({ title: t("auth.pwMismatch"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast({ title: t("auth.pwTooShort"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePassword.mutate(
|
||||||
|
{ data: { currentPassword, newPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("auth.pwChanged") });
|
||||||
|
setPwOpen(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetPasswordRedirectQueryKey() });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const code = (err.data as { error?: string } | null)?.error;
|
||||||
|
if (code === "oidc") {
|
||||||
|
toast({ title: t("auth.oidcPasswordHint"), variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: t("auth.pwChangeFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||||
|
data-testid="button-submit-change-password"
|
||||||
|
>
|
||||||
|
{changePassword.isPending ? "…" : t("auth.save")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"trash": "Papierkorb",
|
"trash": "Papierkorb",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundanz",
|
"redundancy": "Redundanz",
|
||||||
|
"docs": "Doku",
|
||||||
"search": "Tools suchen…"
|
"search": "Tools suchen…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -24,7 +25,17 @@
|
|||||||
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||||
"notAuthenticated": "Nicht angemeldet",
|
"notAuthenticated": "Nicht angemeldet",
|
||||||
"loginSubtitle": "Melde dich bei deinem Konto an",
|
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||||
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren"
|
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren",
|
||||||
|
"changePassword": "Passwort ändern",
|
||||||
|
"currentPassword": "Aktuelles Passwort",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"pwMismatch": "Passwörter stimmen nicht überein",
|
||||||
|
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
|
"pwChanged": "Passwort geändert",
|
||||||
|
"pwChangeFailed": "Passwort konnte nicht geändert werden",
|
||||||
|
"oidcPasswordHint": "Ihr Passwort wird vom Identity-Provider (Keycloak) verwaltet.",
|
||||||
|
"manageInIdp": "In Keycloak verwalten"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -117,7 +128,12 @@
|
|||||||
"relatedTools": "Ähnliche Tools",
|
"relatedTools": "Ähnliche Tools",
|
||||||
"costs": "Kosten",
|
"costs": "Kosten",
|
||||||
"addCost": "Kosten hinzufügen",
|
"addCost": "Kosten hinzufügen",
|
||||||
"recentRatings": "Letzte Bewertungen"
|
"recentRatings": "Letzte Bewertungen",
|
||||||
|
"deleteConfirmTitle": "Dieses Tool löschen?",
|
||||||
|
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
|
||||||
|
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleting": "Löschen…",
|
||||||
|
"deleteAction": "Löschen"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Tools vergleichen",
|
"title": "Tools vergleichen",
|
||||||
@@ -166,6 +182,24 @@
|
|||||||
"text": "Diese Seite existiert nicht.",
|
"text": "Diese Seite existiert nicht.",
|
||||||
"backHome": "Zurück zur Startseite"
|
"backHome": "Zurück zur Startseite"
|
||||||
},
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Dokumentation",
|
||||||
|
"subtitle": "Version-gebundene Dokumentation — Release-Notes, Endpunkte und Datenfelder je Version.",
|
||||||
|
"backToIndex": "Alle Releases",
|
||||||
|
"noDocs": "Keine Dokumentation für diesen Pfad verfügbar.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Aktuell",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Dokumentation",
|
||||||
|
"guides": "Handbuch",
|
||||||
|
"endpoints": "Endpunkte",
|
||||||
|
"schemas": "Datenmodelle",
|
||||||
|
"releases": "Release-Notes",
|
||||||
|
"reference": "API-Referenz",
|
||||||
|
"referenceIntro": "Automatisch aus der OpenAPI-Spezifikation generiert — alle Endpunkte und Datenfelder der aktuellen Version.",
|
||||||
|
"onThisPage": "Auf dieser Seite",
|
||||||
|
"searchPlaceholder": "Doku durchsuchen…"
|
||||||
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigation",
|
"navigate": "Navigation",
|
||||||
"recent": "Zuletzt besucht",
|
"recent": "Zuletzt besucht",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"trash": "Trash",
|
"trash": "Trash",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"redundancy": "Redundancy",
|
"redundancy": "Redundancy",
|
||||||
|
"docs": "Docs",
|
||||||
"search": "Search tools…"
|
"search": "Search tools…"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -24,7 +25,17 @@
|
|||||||
"invalidCredentials": "Invalid username or password",
|
"invalidCredentials": "Invalid username or password",
|
||||||
"notAuthenticated": "Not authenticated",
|
"notAuthenticated": "Not authenticated",
|
||||||
"loginSubtitle": "Sign in to your account",
|
"loginSubtitle": "Sign in to your account",
|
||||||
"loginDescription": "Enter your credentials to continue"
|
"loginDescription": "Enter your credentials to continue",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"pwMismatch": "Passwords do not match",
|
||||||
|
"pwTooShort": "Password must be at least 8 characters",
|
||||||
|
"pwChanged": "Password changed",
|
||||||
|
"pwChangeFailed": "Could not change password",
|
||||||
|
"oidcPasswordHint": "Your password is managed by the identity provider (Keycloak).",
|
||||||
|
"manageInIdp": "Manage in Keycloak"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -117,7 +128,12 @@
|
|||||||
"relatedTools": "Related Tools",
|
"relatedTools": "Related Tools",
|
||||||
"costs": "Costs",
|
"costs": "Costs",
|
||||||
"addCost": "Add Cost",
|
"addCost": "Add Cost",
|
||||||
"recentRatings": "Recent Ratings"
|
"recentRatings": "Recent Ratings",
|
||||||
|
"deleteConfirmTitle": "Delete this tool?",
|
||||||
|
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
|
||||||
|
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"deleteAction": "Delete"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Compare Tools",
|
"title": "Compare Tools",
|
||||||
@@ -166,6 +182,24 @@
|
|||||||
"text": "This page doesn't exist.",
|
"text": "This page doesn't exist.",
|
||||||
"backHome": "Back to Home"
|
"backHome": "Back to Home"
|
||||||
},
|
},
|
||||||
|
"docs": {
|
||||||
|
"title": "Documentation",
|
||||||
|
"subtitle": "Version-bound documentation — release notes, endpoints and data fields per version.",
|
||||||
|
"backToIndex": "All releases",
|
||||||
|
"noDocs": "No documentation available for this path.",
|
||||||
|
"version": "Version",
|
||||||
|
"latest": "Latest",
|
||||||
|
"repo": "Repository",
|
||||||
|
"nav": "Documentation",
|
||||||
|
"guides": "Guide",
|
||||||
|
"endpoints": "Endpoints",
|
||||||
|
"schemas": "Data models",
|
||||||
|
"releases": "Release notes",
|
||||||
|
"reference": "API reference",
|
||||||
|
"referenceIntro": "Generated automatically from the OpenAPI spec — all endpoints and data fields of the current version.",
|
||||||
|
"onThisPage": "On this page",
|
||||||
|
"searchPlaceholder": "Search docs…"
|
||||||
|
},
|
||||||
"command": {
|
"command": {
|
||||||
"navigate": "Navigate",
|
"navigate": "Navigate",
|
||||||
"recent": "Recent",
|
"recent": "Recent",
|
||||||
|
|||||||
@@ -265,6 +265,25 @@
|
|||||||
*/
|
*/
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
|
|
||||||
|
/* Documentation heading anchors (mkdocs style "¶" links) */
|
||||||
|
.docs-prose :is(h1, h2, h3, h4) {
|
||||||
|
scroll-margin-top: 6rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-prose .docs-anchor::after {
|
||||||
|
content: "¶";
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
font-size: 0.8em;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-prose :is(h1, h2, h3, h4):hover .docs-anchor::after {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide ugly search cancel button in Chrome until we can style it properly */
|
/* Hide ugly search cancel button in Chrome until we can style it properly */
|
||||||
input[type="search"]::-webkit-search-cancel-button {
|
input[type="search"]::-webkit-search-cancel-button {
|
||||||
@apply hidden;
|
@apply hidden;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { setCsrfTokenGetter } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
setCsrfTokenGetter(() => token);
|
||||||
|
|
||||||
|
export function getCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCsrfToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/csrf`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { token?: string };
|
||||||
|
token = data.token ?? null;
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useCreateUser,
|
useCreateUser,
|
||||||
useUpdateUser,
|
useUpdateUser,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
|
useSetUserPassword,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
useGetVersion,
|
useGetVersion,
|
||||||
getListUsersQueryKey,
|
getListUsersQueryKey,
|
||||||
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
@@ -35,7 +37,8 @@ export default function Admin() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
@@ -58,6 +61,7 @@ export default function Admin() {
|
|||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
|
const setUserPassword = useSetUserPassword();
|
||||||
|
|
||||||
if (!authLoading && !isAdmin) {
|
if (!authLoading && !isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +114,26 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetUserPassword = () => {
|
||||||
|
if (!editUser || !editPassword) return;
|
||||||
|
if (editPassword.length < 6) {
|
||||||
|
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserPassword.mutate(
|
||||||
|
{ id: editUser.id, data: { password: editPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
|
||||||
|
setEditPassword("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteUser = () => {
|
const handleDeleteUser = () => {
|
||||||
if (!deleteConfirm) return;
|
if (!deleteConfirm) return;
|
||||||
deleteUser.mutate(
|
deleteUser.mutate(
|
||||||
@@ -206,7 +230,7 @@ export default function Admin() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -344,7 +368,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email (Optional)</Label>
|
<Label>Email (Optional)</Label>
|
||||||
@@ -385,7 +409,7 @@ export default function Admin() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||||
@@ -422,15 +446,40 @@ export default function Admin() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{editUser?.authProvider !== "oidc" ? (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label>Set Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
|
placeholder="min. 6 characters"
|
||||||
|
data-testid="input-set-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
|
||||||
</div>
|
</div>
|
||||||
<DialogFooter>
|
) : (
|
||||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
||||||
|
Password is managed by the identity provider (Keycloak). Reset it there.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateUser}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
{editUser?.authProvider !== "oidc" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSetUserPassword}
|
||||||
|
disabled={setUserPassword.isPending || !editPassword}
|
||||||
|
>
|
||||||
|
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -0,0 +1,953 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Link, useLocation } from "wouter";
|
||||||
|
import { Marked } from "marked";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Layout } from "@/components/layout";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
|
import {
|
||||||
|
BookOpen,
|
||||||
|
CalendarDays,
|
||||||
|
ExternalLink,
|
||||||
|
FileText,
|
||||||
|
GitBranch,
|
||||||
|
HelpCircle,
|
||||||
|
Library,
|
||||||
|
Search,
|
||||||
|
Server,
|
||||||
|
Tag,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
|
||||||
|
const REPO_URL = "https://git.kubebase.de/admin/tool-evaluator";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type FieldType = { kind: "ref" | "type" | "array"; value: string };
|
||||||
|
|
||||||
|
type Parameter = {
|
||||||
|
name: string;
|
||||||
|
in: string;
|
||||||
|
required: boolean;
|
||||||
|
type: FieldType;
|
||||||
|
description: string;
|
||||||
|
constraints: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Endpoint = {
|
||||||
|
operationId: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
parameters: Parameter[];
|
||||||
|
requestBody: { required: boolean; schema: FieldType } | null;
|
||||||
|
responses: { status: string; description: string; schema: FieldType }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type TagGroup = { name: string; description: string; endpoints: Endpoint[] };
|
||||||
|
|
||||||
|
type Field = {
|
||||||
|
name: string;
|
||||||
|
type: FieldType;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
constraints: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SchemaModel = { name: string; description: string; fields: Field[] };
|
||||||
|
|
||||||
|
type Reference = { tags: TagGroup[]; schemas: SchemaModel[] };
|
||||||
|
|
||||||
|
type ReleaseDoc = {
|
||||||
|
version: string;
|
||||||
|
file: string;
|
||||||
|
title: string;
|
||||||
|
date: string | null;
|
||||||
|
hasReference: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HandbookPage = { slug: string; file: string; title: string; order: number };
|
||||||
|
|
||||||
|
type SearchEntry = { title: string; href: string; kind: string; text: string };
|
||||||
|
|
||||||
|
type Heading = { id: string; text: string; level: number };
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data hooks
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fetchJson<T>(url: string): Promise<T> {
|
||||||
|
return fetch(url).then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useJson<T>(url: string | null) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url) {
|
||||||
|
setData(null);
|
||||||
|
setError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setData(null);
|
||||||
|
setError(false);
|
||||||
|
fetchJson<T>(url)
|
||||||
|
.then((d) => {
|
||||||
|
if (!cancelled) setData(d);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
return { data, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMarkdown(file: string | null) {
|
||||||
|
const [state, setState] = useState<{ html: string; headings: Heading[] } | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file) {
|
||||||
|
setState(null);
|
||||||
|
setError(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setState(null);
|
||||||
|
setError(false);
|
||||||
|
fetch(`${DOCS_BASE}/${file}`)
|
||||||
|
.then((res) => {
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
return res.text();
|
||||||
|
})
|
||||||
|
.then((md) => {
|
||||||
|
const rendered = renderMarkdown(md);
|
||||||
|
if (!cancelled) setState(rendered);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setError(true);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [file]);
|
||||||
|
|
||||||
|
return { ...state, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Markdown rendering with heading anchors + TOC
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function slugify(text: string): string {
|
||||||
|
return text
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^\w\s-]/g, "")
|
||||||
|
.replace(/[\s_]+/g, "-")
|
||||||
|
.replace(/-+/g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(md: string): { html: string; headings: Heading[] } {
|
||||||
|
const headings: Heading[] = [];
|
||||||
|
const seen = new Map<string, number>();
|
||||||
|
|
||||||
|
const renderer = {
|
||||||
|
heading({ tokens, depth }: { tokens: { raw: string; text?: string }[]; depth: number }) {
|
||||||
|
const text = tokens.map((t) => t.text ?? t.raw).join("");
|
||||||
|
let id = slugify(text);
|
||||||
|
const count = seen.get(id) ?? 0;
|
||||||
|
seen.set(id, count + 1);
|
||||||
|
if (count > 0) id = `${id}-${count}`;
|
||||||
|
headings.push({ id, text, level: depth });
|
||||||
|
return `<h${depth} id="${id}"><a href="#${id}" class="docs-anchor" aria-hidden="true"></a>${text}</h${depth}>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const marked = new Marked({ gfm: true, async: false, renderer });
|
||||||
|
const html = marked.parse(md) as string;
|
||||||
|
return { html: DOMPurify.sanitize(html), headings };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Version resolution
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function parseDocsPath(location: string) {
|
||||||
|
const rest = location.replace(/^\/docs\/?/, "");
|
||||||
|
const segments = rest.split("/").filter(Boolean);
|
||||||
|
const versionRe = /^v\d+\.\d+\.\d+$/;
|
||||||
|
if (segments.length > 0 && versionRe.test(segments[0])) {
|
||||||
|
return { version: segments[0], path: segments.slice(1) };
|
||||||
|
}
|
||||||
|
return { version: null, path: segments };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field type helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function fieldTypeLabel(t: FieldType): string {
|
||||||
|
if (t.kind === "array") return `${t.value}[]`;
|
||||||
|
return t.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLinkableType(t: FieldType): boolean {
|
||||||
|
return t.kind === "ref" || (t.kind === "array" && /^[A-Z]/.test(t.value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTypeHref(t: FieldType): string | null {
|
||||||
|
if (t.kind === "ref") return `/docs/reference/schemas/${t.value}`;
|
||||||
|
if (t.kind === "array" && /^[A-Z]/.test(t.value)) return `/docs/reference/schemas/${t.value}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHOD_STYLES: Record<string, string> = {
|
||||||
|
GET: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-400",
|
||||||
|
POST: "bg-blue-500/15 text-blue-700 dark:text-blue-400",
|
||||||
|
PATCH: "bg-amber-500/15 text-amber-700 dark:text-amber-400",
|
||||||
|
PUT: "bg-indigo-500/15 text-indigo-700 dark:text-indigo-400",
|
||||||
|
DELETE: "bg-red-500/15 text-red-700 dark:text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Sub-views
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DocsHeader({
|
||||||
|
versions,
|
||||||
|
activeVersion,
|
||||||
|
onVersionChange,
|
||||||
|
onSearchChange,
|
||||||
|
}: {
|
||||||
|
versions: ReleaseDoc[];
|
||||||
|
activeVersion: string | null;
|
||||||
|
onVersionChange: (v: string | null) => void;
|
||||||
|
onSearchChange?: (q: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 border-b pb-4">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<BookOpen className="h-5 w-5 text-primary shrink-0" />
|
||||||
|
<h1 className="text-xl font-bold tracking-tight truncate">{t("docs.title")}</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{onSearchChange && (
|
||||||
|
<div className="relative hidden md:block">
|
||||||
|
<Search className="h-4 w-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("docs.searchPlaceholder")}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-8 w-52"
|
||||||
|
data-testid="input-docs-search"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{versions.length > 0 && (
|
||||||
|
<Select
|
||||||
|
value={activeVersion ?? "latest"}
|
||||||
|
onValueChange={(v) => onVersionChange(v === "latest" ? null : v)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[140px]" data-testid="select-docs-version">
|
||||||
|
<SelectValue placeholder={t("docs.version")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="latest">
|
||||||
|
{activeVersion === null ? `✓ ${t("docs.latest")}` : t("docs.latest")}
|
||||||
|
</SelectItem>
|
||||||
|
{versions.map((v) => (
|
||||||
|
<SelectItem key={v.version} value={v.version}>
|
||||||
|
{activeVersion === v.version ? `✓ ${v.version}` : v.version}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="icon" asChild data-testid="button-docs-repo" title={t("docs.repo")}>
|
||||||
|
<a href={REPO_URL} target="_blank" rel="noreferrer">
|
||||||
|
<GitBranch className="h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocsNav({
|
||||||
|
version,
|
||||||
|
handbook,
|
||||||
|
reference,
|
||||||
|
versions,
|
||||||
|
}: {
|
||||||
|
version: string | null;
|
||||||
|
handbook: HandbookPage[] | null;
|
||||||
|
reference: Reference | null;
|
||||||
|
versions: ReleaseDoc[];
|
||||||
|
}) {
|
||||||
|
const [location] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const navLink = (href: string) => {
|
||||||
|
const active = location === href || (href !== "/docs" && location.startsWith(href));
|
||||||
|
return active;
|
||||||
|
};
|
||||||
|
|
||||||
|
const groups: { label: string; icon: LucideIcon; items: { href: string; label: string; active: boolean }[] }[] = [];
|
||||||
|
|
||||||
|
if (handbook && handbook.length > 0 && version === null) {
|
||||||
|
groups.push({
|
||||||
|
label: t("docs.guides"),
|
||||||
|
icon: BookOpen,
|
||||||
|
items: handbook.map((p) => ({
|
||||||
|
href: `/docs/handbook/${p.slug}`,
|
||||||
|
label: p.title,
|
||||||
|
active: navLink(`/docs/handbook/${p.slug}`),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reference) {
|
||||||
|
groups.push({
|
||||||
|
label: t("docs.endpoints"),
|
||||||
|
icon: Server,
|
||||||
|
items: reference.tags.map((tag) => ({
|
||||||
|
href: `/docs/reference/endpoints/${tag.name}`,
|
||||||
|
label: tag.name,
|
||||||
|
active: navLink(`/docs/reference/endpoints/${tag.name}`),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
groups.push({
|
||||||
|
label: t("docs.schemas"),
|
||||||
|
icon: Library,
|
||||||
|
items: reference.schemas.map((s) => ({
|
||||||
|
href: `/docs/reference/schemas/${s.name}`,
|
||||||
|
label: s.name,
|
||||||
|
active: navLink(`/docs/reference/schemas/${s.name}`),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.push({
|
||||||
|
label: t("docs.releases"),
|
||||||
|
icon: Tag,
|
||||||
|
items: versions.map((v) => ({
|
||||||
|
href: v.version === (version ?? versions[0]?.version) && version !== null
|
||||||
|
? `/docs/releases/${v.version}`
|
||||||
|
: `/docs/releases/${v.version}`,
|
||||||
|
label: v.version,
|
||||||
|
active: navLink(`/docs/releases/${v.version}`),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="space-y-6" aria-label={t("docs.nav")}>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<div key={group.label}>
|
||||||
|
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
<group.icon className="h-3.5 w-3.5" />
|
||||||
|
{group.label}
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={`block rounded-md px-2 py-1.5 text-sm transition-colors ${
|
||||||
|
item.active
|
||||||
|
? "bg-accent text-accent-foreground font-medium"
|
||||||
|
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="truncate block">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Toc({ headings, title }: { headings: Heading[]; title?: string }) {
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headings.length === 0) return;
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isIntersecting) setActiveId(entry.target.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ rootMargin: "-80px 0px -70% 0px" },
|
||||||
|
);
|
||||||
|
for (const h of headings) {
|
||||||
|
const el = document.getElementById(h.id);
|
||||||
|
if (el) observer.observe(el);
|
||||||
|
}
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [headings]);
|
||||||
|
|
||||||
|
if (headings.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="hidden xl:block" aria-label={t("docs.onThisPage")}>
|
||||||
|
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{title ?? t("docs.onThisPage")}
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1 border-l">
|
||||||
|
{headings.map((h) => (
|
||||||
|
<li key={h.id} style={{ paddingLeft: `${Math.min(h.level - 1, 2)}rem` }}>
|
||||||
|
<a
|
||||||
|
href={`#${h.id}`}
|
||||||
|
className={`block border-l -ml-px px-2 py-0.5 text-xs transition-colors ${
|
||||||
|
activeId === h.id
|
||||||
|
? "border-primary text-foreground font-medium"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{h.text}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkdownView({ file }: { file: string | null }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { html, headings, error } = useMarkdown(file);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl">
|
||||||
|
{error ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||||
|
) : !html ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="docs-prose prose dark:prose-invert max-w-none"
|
||||||
|
dangerouslySetInnerHTML={{ __html: html }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Toc headings={headings ?? []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTypeChip({ type }: { type: FieldType }) {
|
||||||
|
const href = resolveTypeHref(type);
|
||||||
|
const label = fieldTypeLabel(type);
|
||||||
|
if (href) {
|
||||||
|
return (
|
||||||
|
<Link href={href} className="inline-flex">
|
||||||
|
<Badge variant="secondary" className="font-mono hover:bg-accent">
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <Badge variant="secondary" className="font-mono">{label}</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function FieldTable({ fields }: { fields: Field[] }) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-3 py-2 font-semibold">Feld</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Typ</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<tr key={f.name} id={f.name} className="border-b last:border-0 align-top">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<a href={`#${f.name}`} className="font-mono text-primary hover:underline">
|
||||||
|
{f.name}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<FieldTypeChip type={f.type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{f.required ? (
|
||||||
|
<Badge className="bg-primary/10 text-primary border-primary/20">required</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="text-muted-foreground">{f.description}</div>
|
||||||
|
{f.constraints && (
|
||||||
|
<div className="mt-0.5 text-xs text-muted-foreground/70 font-mono">{f.constraints}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SchemaView({ schema }: { schema: SchemaModel }) {
|
||||||
|
const headings: Heading[] = schema.fields.map((f) => ({ id: f.name, text: f.name, level: 2 }));
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight font-mono">{schema.name}</h1>
|
||||||
|
{schema.description && <p className="text-muted-foreground">{schema.description}</p>}
|
||||||
|
</div>
|
||||||
|
<FieldTable fields={schema.fields} />
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<HelpCircle className="h-3.5 w-3.5 inline mr-1" />
|
||||||
|
Hinweis: Formular-Felder verlinken per ?-Icon direkt zu den jeweiligen Zeilen dieser Tabelle.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toc headings={headings} title="Felder" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndpointTagView({ tag }: { tag: TagGroup }) {
|
||||||
|
const headings: Heading[] = tag.endpoints.map((e) => ({
|
||||||
|
id: e.operationId,
|
||||||
|
text: `${e.method} ${e.path}`,
|
||||||
|
level: 2,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl space-y-8">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{tag.name}</h1>
|
||||||
|
{tag.description && <p className="text-muted-foreground">{tag.description}</p>}
|
||||||
|
</div>
|
||||||
|
{tag.endpoints.map((ep) => (
|
||||||
|
<section key={ep.operationId} id={ep.operationId} className="scroll-mt-20">
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<Badge className={`font-mono ${METHOD_STYLES[ep.method] ?? "bg-muted text-muted-foreground"}`}>
|
||||||
|
{ep.method}
|
||||||
|
</Badge>
|
||||||
|
<code className="font-mono text-sm">{ep.path}</code>
|
||||||
|
<a href={`#${ep.operationId}`} className="ml-auto text-muted-foreground hover:text-foreground">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-1 text-lg font-semibold">{ep.summary}</h2>
|
||||||
|
{ep.description && <p className="mb-3 text-sm text-muted-foreground">{ep.description}</p>}
|
||||||
|
|
||||||
|
{ep.parameters.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">Parameter</p>
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-3 py-2 font-semibold">Name</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">In</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Typ</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Pflicht</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ep.parameters.map((p) => (
|
||||||
|
<tr key={`${p.name}-${p.in}`} className="border-b last:border-0">
|
||||||
|
<td className="px-3 py-1.5 font-mono">{p.name}</td>
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground">{p.in}</td>
|
||||||
|
<td className="px-3 py-1.5"><FieldTypeChip type={p.type} /></td>
|
||||||
|
<td className="px-3 py-1.5">
|
||||||
|
{p.required ? <Badge className="bg-primary/10 text-primary border-primary/20">req</Badge> : "–"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground">
|
||||||
|
{p.description}
|
||||||
|
{p.constraints && <span className="block font-mono text-xs">{p.constraints}</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ep.requestBody && (
|
||||||
|
<div className="mb-3 rounded-lg border bg-muted/30 p-3">
|
||||||
|
<p className="mb-1 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Request Body {ep.requestBody.required && <Badge className="ml-1">required</Badge>}
|
||||||
|
</p>
|
||||||
|
<FieldTypeChip type={ep.requestBody.schema} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-lg border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/50 text-left text-xs uppercase tracking-wider text-muted-foreground">
|
||||||
|
<th className="px-3 py-2 font-semibold">Status</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Beschreibung</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Schema</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ep.responses.map((r) => (
|
||||||
|
<tr key={r.status} className="border-b last:border-0">
|
||||||
|
<td className="px-3 py-1.5 font-mono">{r.status}</td>
|
||||||
|
<td className="px-3 py-1.5 text-muted-foreground">{r.description}</td>
|
||||||
|
<td className="px-3 py-1.5"><FieldTypeChip type={r.schema} /></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Toc headings={headings} title="Endpunkte" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleasesView({ versions }: { versions: ReleaseDoc[] }) {
|
||||||
|
const [, setLocation] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("docs.releases")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{versions.map((v) => (
|
||||||
|
<button
|
||||||
|
key={v.version}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setLocation(`/docs/releases/${v.version}`)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<FileText className="h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">{v.title}</span>
|
||||||
|
<Badge variant="secondary">{v.version}</Badge>
|
||||||
|
</div>
|
||||||
|
{v.date && (
|
||||||
|
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<CalendarDays className="h-3 w-3" />
|
||||||
|
{new Date(`${v.date}T00:00:00`).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{v.hasReference && <Badge className="bg-primary/10 text-primary">API-Referenz</Badge>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toc headings={[]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReleaseNoteView({ version }: { version: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl">
|
||||||
|
<div className="mb-4 flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Tag className="h-4 w-4" />
|
||||||
|
<span className="font-mono">{version}</span>
|
||||||
|
<a
|
||||||
|
href={`${REPO_URL}/tags/${version}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<MarkdownView file={`releases/${version}.md`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HandbookView({ slug }: { slug: string }) {
|
||||||
|
const handbookFile = `${slug}.md`;
|
||||||
|
return <MarkdownView file={`handbook/${handbookFile}`} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Search overlay
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function useDocsSearch(query: string) {
|
||||||
|
const { data, error } = useJson<SearchEntry[]>(query ? `${DOCS_BASE}/search.json` : null);
|
||||||
|
const results = useMemo(() => {
|
||||||
|
if (!query.trim() || !data) return [];
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
return data
|
||||||
|
.filter(
|
||||||
|
(e) =>
|
||||||
|
e.title.toLowerCase().includes(q) ||
|
||||||
|
e.text.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
.slice(0, 25);
|
||||||
|
}, [query, data]);
|
||||||
|
|
||||||
|
return { results, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchOverlay({ query, onClose }: { query: string; onClose: () => void }) {
|
||||||
|
const { results } = useDocsSearch(query);
|
||||||
|
if (!query.trim()) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-lg border bg-card p-2 shadow-md max-h-96 overflow-auto">
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="px-3 py-2 text-sm text-muted-foreground">Keine Treffer</p>
|
||||||
|
) : (
|
||||||
|
results.map((r) => (
|
||||||
|
<Link
|
||||||
|
key={r.href}
|
||||||
|
href={r.href}
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-start gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<span className="shrink-0">
|
||||||
|
{r.kind === "endpoint" && <Server className="h-4 w-4 text-emerald-500" />}
|
||||||
|
{r.kind === "field" && <Library className="h-4 w-4 text-blue-500" />}
|
||||||
|
{r.kind === "guide" && <BookOpen className="h-4 w-4 text-amber-500" />}
|
||||||
|
{r.kind === "release" && <Tag className="h-4 w-4 text-primary" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block font-medium truncate">{r.title}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground truncate">{r.text.slice(0, 80)}</span>
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Main component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export default function Docs() {
|
||||||
|
const [location, setLocation] = useLocation();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
|
const { version, path } = useMemo(() => parseDocsPath(location), [location]);
|
||||||
|
|
||||||
|
const { data: versionInfo } = useGetVersion({
|
||||||
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: releases, error: releasesError } = useJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`);
|
||||||
|
const { data: handbook, error: handbookError } = useJson<HandbookPage[]>(
|
||||||
|
version === null ? `${DOCS_BASE}/handbook/index.json` : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCurrentVersion =
|
||||||
|
version === null ||
|
||||||
|
(versionInfo?.version && versionInfo.version !== "dev" && version === versionInfo.version) ||
|
||||||
|
(version !== null && (!versionInfo?.version || versionInfo.version === "dev"));
|
||||||
|
|
||||||
|
const refUrl = version === null
|
||||||
|
? `${DOCS_BASE}/reference.json`
|
||||||
|
: releases?.find((r) => r.version === version)?.hasReference
|
||||||
|
? `${DOCS_BASE}/versions/${version}.json`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { data: reference, error: refError } = useJson<Reference>(refUrl);
|
||||||
|
|
||||||
|
// Current version detection: prefer running version, fallback newest documented
|
||||||
|
const currentVersion = useMemo(() => {
|
||||||
|
if (releases && releases.length > 0) {
|
||||||
|
if (versionInfo?.version && versionInfo.version !== "dev") {
|
||||||
|
const match = releases.find((r) => r.version === versionInfo.version);
|
||||||
|
if (match) return match.version;
|
||||||
|
}
|
||||||
|
return releases[0].version;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [releases, versionInfo]);
|
||||||
|
|
||||||
|
// Redirect old-style /docs/vX.Y.Z to /docs/releases/vX.Y.Z
|
||||||
|
useEffect(() => {
|
||||||
|
if (version !== null && path.length === 0) {
|
||||||
|
setLocation(`/docs/releases/${version}`, { replace: true });
|
||||||
|
}
|
||||||
|
}, [version, path, setLocation]);
|
||||||
|
|
||||||
|
const handleVersionChange = (v: string | null) => {
|
||||||
|
setSearchQuery("");
|
||||||
|
if (v === null || v === currentVersion) {
|
||||||
|
setLocation("/docs");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLocation(`/docs/releases/${v}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- route resolution ----
|
||||||
|
const section = path[0] ?? "home";
|
||||||
|
const param = path[1];
|
||||||
|
|
||||||
|
let content: React.ReactNode = null;
|
||||||
|
|
||||||
|
if (version !== null && path.length === 0) {
|
||||||
|
content = <ReleaseNoteView version={version} />;
|
||||||
|
} else if (section === "home") {
|
||||||
|
content =
|
||||||
|
handbook && handbook.length > 0 ? (
|
||||||
|
<HandbookView slug={handbook[0].slug} />
|
||||||
|
) : releases && releases.length > 0 ? (
|
||||||
|
<ReleasesView versions={releases} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||||
|
);
|
||||||
|
} else if (section === "handbook" && param) {
|
||||||
|
content = <HandbookView slug={param} />;
|
||||||
|
} else if (section === "reference" && param === "endpoints" && path[2]) {
|
||||||
|
const tag = reference?.tags.find((tg) => tg.name === path[2]);
|
||||||
|
content = tag ? (
|
||||||
|
<EndpointTagView tag={tag} />
|
||||||
|
) : refError || (reference && !tag) ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
);
|
||||||
|
} else if (section === "reference" && param === "schemas" && path[2]) {
|
||||||
|
const schema = reference?.schemas.find((s) => s.name === path[2]);
|
||||||
|
content = schema ? (
|
||||||
|
<SchemaView schema={schema} />
|
||||||
|
) : refError || (reference && !schema) ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
|
||||||
|
) : (
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
);
|
||||||
|
} else if (section === "reference") {
|
||||||
|
content = (
|
||||||
|
<div className="flex flex-col lg:flex-row gap-8">
|
||||||
|
<div className="flex-1 min-w-0 max-w-3xl space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">{t("docs.reference")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("docs.referenceIntro")}</p>
|
||||||
|
{!reference && !refError && <Skeleton className="h-64 w-full" />}
|
||||||
|
{reference && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 text-lg font-semibold">{t("docs.endpoints")}</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{reference.tags.map((tg) => (
|
||||||
|
<Link
|
||||||
|
key={tg.name}
|
||||||
|
href={`/docs/reference/endpoints/${tg.name}`}
|
||||||
|
className="rounded-lg border p-3 text-sm hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{tg.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{tg.endpoints.length} {t("docs.endpoints")}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-1 text-lg font-semibold">{t("docs.schemas")}</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{reference.schemas.map((s) => (
|
||||||
|
<Link
|
||||||
|
key={s.name}
|
||||||
|
href={`/docs/reference/schemas/${s.name}`}
|
||||||
|
className="rounded-lg border p-3 text-sm font-mono hover:bg-accent/50"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Toc headings={[]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else if (section === "releases" && param) {
|
||||||
|
content = <ReleaseNoteView version={param} />;
|
||||||
|
} else if (section === "releases") {
|
||||||
|
content = releases ? <ReleasesView versions={releases} /> : <Skeleton className="h-64 w-full" />;
|
||||||
|
} else {
|
||||||
|
content = <p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const showSearch = version === null && section !== "releases";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Layout>
|
||||||
|
<div className="mx-auto max-w-6xl space-y-6 pb-10">
|
||||||
|
<DocsHeader
|
||||||
|
versions={releases ?? []}
|
||||||
|
activeVersion={version}
|
||||||
|
onVersionChange={handleVersionChange}
|
||||||
|
onSearchChange={showSearch ? setSearchQuery : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showSearch && <SearchOverlay query={searchQuery} onClose={() => setSearchQuery("")} />}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-[240px_minmax(0,1fr)] gap-8">
|
||||||
|
<aside className="hidden lg:block">
|
||||||
|
<div className="sticky top-20 max-h-[calc(100vh-6rem)] overflow-auto">
|
||||||
|
<DocsNav
|
||||||
|
version={version}
|
||||||
|
handbook={handbook}
|
||||||
|
reference={reference}
|
||||||
|
versions={releases ?? []}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<div className="min-w-0">{content}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -22,10 +22,18 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-8 pb-8">
|
<div className="space-y-8 pb-8">
|
||||||
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
||||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Stats Banner */}
|
{/* Stats Banner */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Wrench, AlertCircle } from "lucide-react";
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
@@ -32,6 +34,7 @@ export default function Login() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
void loadCsrfToken();
|
||||||
setLocation(returnTo);
|
setLocation(returnTo);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -76,9 +79,8 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<Input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import {
|
|||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { customFetch } from "@workspace/api-client-react";
|
import { customFetch } from "@workspace/api-client-react";
|
||||||
import { recordRecentTool } from "@/lib/recent-tools";
|
import { recordRecentTool } from "@/lib/recent-tools";
|
||||||
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
|
||||||
const ratingSchema = z.object({
|
const ratingSchema = z.object({
|
||||||
usefulness: z.number().min(1).max(5),
|
usefulness: z.number().min(1).max(5),
|
||||||
@@ -776,7 +777,10 @@ export default function ToolDetail() {
|
|||||||
name="usefulness"
|
name="usefulness"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("detail.usefulness")}</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.usefulness")}
|
||||||
|
<FieldHelp schema="RatingInput" field="usefulness">{t("detail.usefulness")}</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -794,7 +798,10 @@ export default function ToolDetail() {
|
|||||||
name="usability"
|
name="usability"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t("detail.usability")}</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
{t("detail.usability")}
|
||||||
|
<FieldHelp schema="RatingInput" field="usability">{t("detail.usability")}</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<RatingStars
|
<RatingStars
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -814,7 +821,10 @@ export default function ToolDetail() {
|
|||||||
name="comment"
|
name="comment"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Comment (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Comment (Optional)
|
||||||
|
<FieldHelp schema="RatingInput" field="comment">Comment</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What do you think about this tool?"
|
placeholder="What do you think about this tool?"
|
||||||
@@ -832,7 +842,10 @@ export default function ToolDetail() {
|
|||||||
name="reviewerName"
|
name="reviewerName"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Name (Optional)
|
||||||
|
<FieldHelp schema="RatingInput" field="reviewerName">Name</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Anonymous" {...field} />
|
<Input placeholder="Anonymous" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -914,23 +927,23 @@ export default function ToolDetail() {
|
|||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{hasTrash ? (
|
{hasTrash ? (
|
||||||
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</>
|
<>{t("detail.deleteToTrash", { name: tool?.name })}</>
|
||||||
) : (
|
) : (
|
||||||
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</>
|
<>{t("detail.deletePermanent", { name: tool?.name })}</>
|
||||||
)}
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleteTool.isPending}
|
disabled={deleteTool.isPending}
|
||||||
>
|
>
|
||||||
{deleteTool.isPending ? "Deleting…" : "Delete"}
|
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
const toolSchema = z.object({
|
||||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
@@ -171,7 +172,10 @@ export default function ToolEdit() {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Name
|
||||||
|
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="Tool name" {...field} />
|
<Input placeholder="Tool name" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -184,7 +188,10 @@ export default function ToolEdit() {
|
|||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Category
|
||||||
|
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
<CategoryCombobox value={field.value} onChange={field.onChange} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -199,7 +206,10 @@ export default function ToolEdit() {
|
|||||||
name="websiteUrl"
|
name="websiteUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Website URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Website URL (Optional)
|
||||||
|
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://..." type="url" {...field} />
|
<Input placeholder="https://..." type="url" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -213,7 +223,10 @@ export default function ToolEdit() {
|
|||||||
name="iconUrl"
|
name="iconUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Icon / Logo URL (Optional)
|
||||||
|
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
@@ -246,7 +259,10 @@ export default function ToolEdit() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Description
|
||||||
|
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do?"
|
placeholder="What does this tool do?"
|
||||||
@@ -262,7 +278,10 @@ export default function ToolEdit() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
Features
|
||||||
|
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
<p className="text-sm text-muted-foreground">Key capabilities of this tool. Existing features from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendFeature({ value: "" })}>
|
||||||
@@ -306,7 +325,10 @@ export default function ToolEdit() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
Tags
|
||||||
|
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
<p className="text-sm text-muted-foreground">Keywords for this tool. Existing tags from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
<Button type="button" variant="outline" size="sm" onClick={() => appendTag({ value: "" })}>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { CategoryCombobox } from "@/components/category-combobox";
|
|||||||
import { FeatureInput } from "@/components/feature-input";
|
import { FeatureInput } from "@/components/feature-input";
|
||||||
import { TagInput } from "@/components/tag-input";
|
import { TagInput } from "@/components/tag-input";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { FieldHelp } from "@/components/field-help";
|
||||||
|
|
||||||
const toolSchema = z.object({
|
const toolSchema = z.object({
|
||||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
@@ -134,7 +135,10 @@ export default function ToolNew() {
|
|||||||
name="name"
|
name="name"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Name</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Name
|
||||||
|
<FieldHelp schema="ToolInput" field="name">Name</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
<Input placeholder="e.g. React, Next.js, Postgres" {...field} data-testid="input-tool-name" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -148,7 +152,10 @@ export default function ToolNew() {
|
|||||||
name="category"
|
name="category"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Category</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Category
|
||||||
|
<FieldHelp schema="ToolInput" field="category">Category</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<CategoryCombobox
|
<CategoryCombobox
|
||||||
value={field.value}
|
value={field.value}
|
||||||
@@ -166,7 +173,10 @@ export default function ToolNew() {
|
|||||||
name="websiteUrl"
|
name="websiteUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Website URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Website URL (Optional)
|
||||||
|
<FieldHelp schema="ToolInput" field="websiteUrl">Website URL</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
<Input placeholder="https://..." type="url" {...field} data-testid="input-tool-url" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
@@ -180,7 +190,10 @@ export default function ToolNew() {
|
|||||||
name="iconUrl"
|
name="iconUrl"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Icon / Logo URL (Optional)</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Icon / Logo URL (Optional)
|
||||||
|
<FieldHelp schema="ToolInput" field="iconUrl">Icon / Logo URL</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
<div className="w-9 h-9 rounded-md border bg-muted flex items-center justify-center overflow-hidden shrink-0">
|
||||||
@@ -214,7 +227,10 @@ export default function ToolNew() {
|
|||||||
name="description"
|
name="description"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Description</FormLabel>
|
<FormLabel className="inline-flex items-center gap-1.5">
|
||||||
|
Description
|
||||||
|
<FieldHelp schema="ToolInput" field="description">Description</FieldHelp>
|
||||||
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="What does this tool do? Why do people use it?"
|
placeholder="What does this tool do? Why do people use it?"
|
||||||
@@ -231,7 +247,10 @@ export default function ToolNew() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Features</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
Features
|
||||||
|
<FieldHelp schema="ToolInput" field="features">Features</FieldHelp>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
<p className="text-sm text-muted-foreground">List key capabilities. Existing features from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -284,7 +303,10 @@ export default function ToolNew() {
|
|||||||
<div className="space-y-4 pt-4 border-t">
|
<div className="space-y-4 pt-4 border-t">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Tags</h3>
|
<h3 className="text-lg font-medium inline-flex items-center gap-1.5">
|
||||||
|
Tags
|
||||||
|
<FieldHelp schema="ToolInput" field="tags">Tags</FieldHelp>
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
<p className="text-sm text-muted-foreground">Keywords to help find this tool. Existing tags from other tools are selectable.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal } from "lucide-react";
|
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal, Plus } from "lucide-react";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -234,7 +234,10 @@ export default function ToolsBrowse() {
|
|||||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Dokumentation (Handbuch, API-Referenz, Release-Notes)
|
||||||
|
|
||||||
|
Die App zeigt unter `/docs` eine MkDocs-artige Doku-Seite mit drei Bereichen:
|
||||||
|
|
||||||
|
- **Handbuch** (`docs/handbook/*.md`) — von Hand gepflegte Anleitungen
|
||||||
|
- **API-Referenz** (`lib/api-spec/openapi.yaml`) — automatisch generierte
|
||||||
|
Endpunkte & Datenfelder (Schema-Detailseiten mit Feld-Ankern; die `?`-Icons
|
||||||
|
in Formularen verlinken auf diese Felder)
|
||||||
|
- **Release-Notes** (`docs/releases/vX.Y.Z.md`) — pro Release
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
- `docs/handbook/` — Handbuch-Seiten mit Frontmatter (`title`, `order`)
|
||||||
|
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
|
||||||
|
- `docs/releases/vX.Y.Z.md` — Notes pro Release
|
||||||
|
- `docs/releases/vX.Y.Z/reference.json` — API-Snapshot der jeweiligen Version
|
||||||
|
|
||||||
|
## Generator
|
||||||
|
|
||||||
|
`scripts/src/generate-docs.mjs` wird beim Frontend-Build (und `dev`) automatisch
|
||||||
|
ausgeführt und schreibt die Artefakte nach `artifacts/toolrate/public/docs/`:
|
||||||
|
|
||||||
|
- `reference.json` (aktuelle API), `search.json` (Suchindex),
|
||||||
|
`index.json` (Releases), `handbook/*.md` + `handbook/index.json`
|
||||||
|
- `releases/vX.Y.Z.md` und `versions/vX.Y.Z.json` (API-Snapshots alter Versionen)
|
||||||
|
|
||||||
|
Manuell aufrufbar:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node scripts/src/generate-docs.mjs # Build-Modus
|
||||||
|
node scripts/src/generate-docs.mjs --snapshot v0.9.0 # Snapshot für neue Version
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow beim Release
|
||||||
|
|
||||||
|
1. **Version taggen** wie bisher (`git tag vX.Y.Z`, CI baut und deployed).
|
||||||
|
2. **`docs/releases/vX.Y.Z.md` anlegen** — Vorlage aus `TEMPLATE.md` kopieren,
|
||||||
|
Entwurf aus der Git-Historie ableiten:
|
||||||
|
```sh
|
||||||
|
git log --oneline vX.Y.Z-1..vX.Y.Z
|
||||||
|
```
|
||||||
|
(API-Delta anhand `lib/api-spec/openapi.yaml` prüfen.)
|
||||||
|
3. **API-Snapshot erzeugen:** `node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`
|
||||||
|
erzeugt `docs/releases/vX.Y.Z/reference.json`.
|
||||||
|
4. **Committen & pushen.** Der Build kopiert die Dokumentation automatisch nach
|
||||||
|
`artifacts/toolrate/public/docs/` und generiert `index.json`.
|
||||||
|
|
||||||
|
> Hinweis: Alle Dateien unter `artifacts/toolrate/public/docs/` sind
|
||||||
|
> Build-Artefakte und werden bei jedem Build neu generiert — nicht von Hand
|
||||||
|
> bearbeiten. Einzige Quellen sind `docs/handbook/`, `docs/releases/` und
|
||||||
|
> `lib/api-spec/openapi.yaml`.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Dependency Policy
|
||||||
|
|
||||||
|
How this workspace keeps npm dependencies current, safe and reproducible.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Exact pins** – Every direct dependency in `package.json` is pinned to an
|
||||||
|
exact version (`1.2.3`, never `^1.2.3`). `save-exact=true` is set in
|
||||||
|
`.npmrc` so `pnpm add` follows this rule automatically.
|
||||||
|
2. **Committed lockfile** – `pnpm-lock.yaml` is committed. CI and the Docker
|
||||||
|
build install with `--frozen-lockfile`, so builds are reproducible.
|
||||||
|
3. **Security gates** – CI runs `pnpm audit --prod` (fails on any finding) and
|
||||||
|
`pnpm audit --audit-level high` (fails on high/critical). A non-zero exit
|
||||||
|
blocks the release pipeline.
|
||||||
|
4. **Supply-chain protection** – `minimumReleaseAge: 1440` (1 day) in
|
||||||
|
`pnpm-workspace.yaml` blocks freshly published versions. Do not lower or
|
||||||
|
disable it; only allowlist trusted publishers via
|
||||||
|
`minimumReleaseAgeExclude` for urgent security fixes.
|
||||||
|
5. **Pinned critical packages** – `react`, `react-dom` and `esbuild` are
|
||||||
|
intentionally excluded from automated updates (see `renovate.json`). Bump
|
||||||
|
them deliberately, one release at a time, with a test pass.
|
||||||
|
|
||||||
|
## Update cadence
|
||||||
|
|
||||||
|
| Frequency | Scope | Who |
|
||||||
|
|-----------|-------|-----|
|
||||||
|
| Weekly | Patch + minor (grouped by Renovate) | Renovate PR, human merge after green CI |
|
||||||
|
| Monthly | One major version at a time | Human, own commit + release tag |
|
||||||
|
| As needed | Security advisories | Immediate fix + patch release |
|
||||||
|
| Yearly | Infrastructure review (Node LTS, Postgres major, k3s) | Human |
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Patch/minor**: merge freely once CI (typecheck + build + audit) is green.
|
||||||
|
- **Major**: never bundle multiple majors into one release. One major per
|
||||||
|
commit so regressions can be bisected to the responsible change.
|
||||||
|
- **"Safe intermediate"**: if a package has a newer major that is not yet
|
||||||
|
absorbed, stay on the latest patch/minor of the *current* major line. The
|
||||||
|
exact-pin guarantees we never float into a new major by accident.
|
||||||
|
- **Node/Infra**: Node base image and pnpm version in the `Dockerfile` are
|
||||||
|
pinned exactly. Update them together with a build + live smoke test.
|
||||||
|
- **Postgres**: image tag is managed in the `admin/apps` repository
|
||||||
|
(`apps/system/toolrate`). Major upgrades run through a backup/restore flow.
|
||||||
|
|
||||||
|
## Process for applying updates
|
||||||
|
|
||||||
|
1. `pnpm install` to refresh the lockfile.
|
||||||
|
2. Regenerate clients if the API changed: `pnpm --filter @workspace/api-spec run codegen`.
|
||||||
|
3. Run `pnpm run typecheck`, `pnpm run build` (with `PORT=8080 BASE_PATH=/`),
|
||||||
|
and `pnpm audit`.
|
||||||
|
4. Fix any code that the new majors require (the common ones are
|
||||||
|
`openid-client`, `recharts`, `react-day-picker`, `date-fns`,
|
||||||
|
`@hookform/resolvers`, `react-resizable-panels`).
|
||||||
|
5. Commit, tag `vX.Y.Z`, push. CI builds, audits and deploys to k3s.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
title: Administration & Papierkorb
|
||||||
|
order: 7
|
||||||
|
---
|
||||||
|
|
||||||
|
# Administration & Papierkorb
|
||||||
|
|
||||||
|
Diese Bereiche sind nur für **Administrator:innen** sichtbar und nutzbar.
|
||||||
|
|
||||||
|
## Nutzerverwaltung
|
||||||
|
|
||||||
|
Unter **Admin → Nutzer** kannst du:
|
||||||
|
|
||||||
|
- **Lokale Nutzer anlegen** (Benutzername, Passwort, E-Mail, Rolle, Tier).
|
||||||
|
- **Rollen/Tier ändern** (`admin`/`user`, `free`/`premium`/`enterprise`).
|
||||||
|
- **Passwörter zurücksetzen** (nur lokale Nutzer).
|
||||||
|
- **Nutzer löschen**.
|
||||||
|
|
||||||
|
Zugehörige Endpunkte (Admin-only):
|
||||||
|
|
||||||
|
- [`GET /users`](/docs/reference/endpoints/users#listusers)
|
||||||
|
- [`POST /users`](/docs/reference/endpoints/users#createuser)
|
||||||
|
- [`PATCH /users/{id}`](/docs/reference/endpoints/users#updateuser)
|
||||||
|
- [`DELETE /users/{id}`](/docs/reference/endpoints/users#deleteuser)
|
||||||
|
- [`PATCH /users/{id}/password`](/docs/reference/endpoints/users#setuserpassword)
|
||||||
|
|
||||||
|
## Audit-Log
|
||||||
|
|
||||||
|
Das **Audit-Log** protokolliert sicherheitsrelevante Änderungen (wer hat wann
|
||||||
|
was geändert). Es ist über
|
||||||
|
[`GET /audit-logs`](/docs/reference/endpoints/audit#listauditlogs) abrufbar und
|
||||||
|
filterbar nach Entitätstyp, Entitäts-ID und Limit.
|
||||||
|
|
||||||
|
## Papierkorb (Trash)
|
||||||
|
|
||||||
|
Tools werden nicht sofort gelöscht, sondern zuerst **soft gelöscht** (in den
|
||||||
|
Papierkorb verschoben):
|
||||||
|
|
||||||
|
- **Liste:** [`GET /tools/trash`](/docs/reference/endpoints/tools#listtrashedtools)
|
||||||
|
- **In den Papierkorb verschieben:** [`POST /tools/trash`](/docs/reference/endpoints/tools#trashtools)
|
||||||
|
- **Wiederherstellen:** [`POST /tools/trash/restore`](/docs/reference/endpoints/tools#restoretools)
|
||||||
|
- **Endgültig löschen (einzeln):** [`DELETE /tools/trash`](/docs/reference/endpoints/tools#deletetrashedtools)
|
||||||
|
- **Papierkorb leeren:** [`POST /tools/trash/empty`](/docs/reference/endpoints/tools#emptytrash)
|
||||||
|
|
||||||
|
> Die Aufbewahrungsfrist des Papierkorbs (in Tagen) ist im
|
||||||
|
> [`VersionInfo`](/docs/reference/schemas/versioninfo)-Schema als
|
||||||
|
> `trashRetentionDays` verfügbar.
|
||||||
|
|
||||||
|
## Felder im Überblick
|
||||||
|
|
||||||
|
### User
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | integer | Eindeutige Nutzer-ID. |
|
||||||
|
| `username` | string | Anmeldename. |
|
||||||
|
| `email` | string? | E-Mail-Adresse. |
|
||||||
|
| `role` | string | `admin` oder `user`. |
|
||||||
|
| `tier` | string | `free`, `premium` oder `enterprise`. |
|
||||||
|
| `authProvider` | string | `local` oder `oidc`. |
|
||||||
|
| `createdAt` | date-time | Erstellungszeitpunkt. |
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
title: Analytics
|
||||||
|
order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Analytics
|
||||||
|
|
||||||
|
Der Bereich **Analytics** fasst die Plattform-Statistiken zusammen — für alle
|
||||||
|
Nutzer:innen ohne Einschränkung sichtbar.
|
||||||
|
|
||||||
|
## Übersicht
|
||||||
|
|
||||||
|
| Widget | Quelle (Endpunkt) | Inhalt |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Plattform-Kennzahlen** | [`GET /analytics/summary`](/docs/reference/endpoints/analytics#getanalyticssummary) | Gesamtzahl Tools, Bewertungen, Durchschnittswerte, Kategorienzahl. |
|
||||||
|
| **Top-Tools** | [`GET /analytics/top-tools`](/docs/reference/endpoints/analytics#gettoppertools) | Bestbewertete Tools nach wählbarer Metrik (Nützlichkeit, Bedienbarkeit, kombiniert). |
|
||||||
|
| **Nach Kategorie** | [`GET /analytics/by-category`](/docs/reference/endpoints/analytics#getanalyticsbycategory) | Kennzahlen je Kategorie. |
|
||||||
|
| **Verteilung** | [`GET /analytics/rating-distribution`](/docs/reference/endpoints/analytics#getratingdistribution) | Verteilung der Bewertungswerte (optional je Tool). |
|
||||||
|
|
||||||
|
## Datenfelder
|
||||||
|
|
||||||
|
### AnalyticsSummary
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `totalTools` | integer | Anzahl aller Tools. |
|
||||||
|
| `totalRatings` | integer | Anzahl aller Bewertungen. |
|
||||||
|
| `avgUsefulness` | number? | Durchschnittliche Nützlichkeit. |
|
||||||
|
| `avgUsability` | number? | Durchschnittliche Bedienbarkeit. |
|
||||||
|
| `avgCombined` | number? | Durchschnitt kombinierter Wert. |
|
||||||
|
| `categoriesCount` | integer | Anzahl der Kategorien. |
|
||||||
|
| `mostRatedTool` | ToolWithStats | Das meistbewertete Tool. |
|
||||||
|
|
||||||
|
Vollständige Feldlisten: [`AnalyticsSummary`](/docs/reference/schemas/analyticssummary),
|
||||||
|
[`TopToolEntry`](/docs/reference/schemas/topptoolentry),
|
||||||
|
[`CategoryStats`](/docs/reference/schemas/categorystats),
|
||||||
|
[`RatingDistribution`](/docs/reference/schemas/ratingdistribution).
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---
|
||||||
|
title: Bewertungen
|
||||||
|
order: 4
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bewertungen
|
||||||
|
|
||||||
|
Bewertungen sind das Herz von toolr: Sie zeigen, wie nützlich und wie gut
|
||||||
|
bedienbar ein Tool in den Augen der Community ist.
|
||||||
|
|
||||||
|
## Wie funktioniert die Bewertung?
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools vergibst du zwei Werte (1–5 Sterne):
|
||||||
|
|
||||||
|
- **Nützlichkeit** — Wie gut löst das Tool sein Kernproblem?
|
||||||
|
- **Bedienbarkeit** — Wie einfach ist es zu bedienen?
|
||||||
|
|
||||||
|
Optional kannst du einen **Kommentar** und deinen **Namen** hinterlassen. Die
|
||||||
|
Eingabefelder entsprechen dem Schema
|
||||||
|
[`RatingInput`](/docs/reference/schemas/ratinginput).
|
||||||
|
|
||||||
|
## Was passiert mit meiner Bewertung?
|
||||||
|
|
||||||
|
- Deine Bewertung wird sofort gespeichert und in den Durchschnittswerten des
|
||||||
|
Tools berücksichtigt.
|
||||||
|
- Jede Bewertung ist über [`GET /tools/{id}/ratings`](/docs/reference/endpoints/ratings#listtoolratings)
|
||||||
|
abrufbar.
|
||||||
|
- Der **Rating-Verlauf** über die Zeit ist über
|
||||||
|
[`GET /tools/{id}/rating-history`](/docs/reference/endpoints/tools#gettoolratinghistory)
|
||||||
|
einsehbar (grafisch auf der Detailseite).
|
||||||
|
|
||||||
|
## Datenfelder
|
||||||
|
|
||||||
|
Eine Bewertung besteht aus diesen Feldern
|
||||||
|
([`Rating`](/docs/reference/schemas/rating)):
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `id` | integer | Eindeutige ID der Bewertung. |
|
||||||
|
| `toolId` | integer | ID des bewerteten Tools. |
|
||||||
|
| `usefulness` | integer (1–5) | Nützlichkeitsbewertung. |
|
||||||
|
| `usability` | integer (1–5) | Bedienbarkeitsbewertung. |
|
||||||
|
| `comment` | string? | Optionaler Kommentar. |
|
||||||
|
| `reviewerName` | string? | Optionaler Anzeigename des Bewerters. |
|
||||||
|
| `createdAt` | date-time | Zeitpunkt der Bewertung. |
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
---
|
||||||
|
title: Datenmodell & Felder
|
||||||
|
order: 8
|
||||||
|
---
|
||||||
|
|
||||||
|
# Datenmodell & Felder
|
||||||
|
|
||||||
|
Dieses Handbuch erklärt die zentralen Objekte von toolr auf verständliche
|
||||||
|
Weise. Die **vollständige, maschinell generierte Feld-Referenz** findest du in
|
||||||
|
der [Referenz](/docs/reference/schemas/tool) — dort sind alle Typen, Pflicht-
|
||||||
|
angaben und Constraints der aktuellen Version dokumentiert.
|
||||||
|
|
||||||
|
> Die Referenz ist **versionsgebunden**: Über das Versions-Dropdown oben
|
||||||
|
> kannst du ältere API-Stände einsehen.
|
||||||
|
|
||||||
|
## Die wichtigsten Objekte
|
||||||
|
|
||||||
|
### Tool
|
||||||
|
|
||||||
|
Ein Tool ist der zentrale Eintrag im Katalog
|
||||||
|
([Feld-Referenz](/docs/reference/schemas/tool)):
|
||||||
|
|
||||||
|
| Feld | Bedeutung |
|
||||||
|
| --- | --- |
|
||||||
|
| `id` | Eindeutige ID. |
|
||||||
|
| `name` | Anzeigename. |
|
||||||
|
| `description` | Kurzbeschreibung. |
|
||||||
|
| `category` | Kategorie-Zuordnung. |
|
||||||
|
| `websiteUrl` / `iconUrl` | Offizielle Website bzw. Logo-Link (optional). |
|
||||||
|
| `createdBy` | Nutzer, der das Tool angelegt hat (optional). |
|
||||||
|
| `features` / `tags` | Listen von Schlüsselfähigkeiten bzw. Schlagwörtern. |
|
||||||
|
| `createdAt` / `updatedAt` | Zeitstempel. |
|
||||||
|
| `deletedAt` / `deletedBy` | Soft-Delete-Informationen (Papierkorb). |
|
||||||
|
|
||||||
|
> **ToolWithStats** erweitert `Tool` um die Aggregatwerte `ratingCount`,
|
||||||
|
> `avgUsefulness`, `avgUsability` und `avgCombined` (siehe
|
||||||
|
> [Feld-Referenz](/docs/reference/schemas/toolwithstats)).
|
||||||
|
|
||||||
|
### Rating
|
||||||
|
|
||||||
|
Eine Bewertung (`Rating`) besteht aus `usefulness` und `usability` (jeweils
|
||||||
|
1–5) sowie optionalem Kommentar und Bewerternamen. Details unter
|
||||||
|
[Bewertungen](/docs/handbook/bewertungen).
|
||||||
|
|
||||||
|
### User / AuthUser
|
||||||
|
|
||||||
|
- **User** (Admin-Sicht): `id`, `username`, `email`, `role`, `tier`,
|
||||||
|
`authProvider`, `createdAt` — siehe [Administration](/docs/handbook/administration).
|
||||||
|
- **AuthUser** (Eigenansicht): `sub`, `email`, `name`, `preferredUsername`,
|
||||||
|
`role`, `tier`, `entitlements`, `isLocal`.
|
||||||
|
|
||||||
|
### VersionInfo
|
||||||
|
|
||||||
|
`GET /version` liefert `version`, `commitSha`, `buildDate` und
|
||||||
|
`trashRetentionDays` (siehe [Feld-Referenz](/docs/reference/schemas/versioninfo)).
|
||||||
|
|
||||||
|
## Referenz selber durchsuchen
|
||||||
|
|
||||||
|
Nutze das **Suchfeld** in der Doku-Seitenleiste: Es durchsucht Handbuch,
|
||||||
|
Endpunkt- und Feldbeschreibungen und springt direkt zum passenden Anker.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
---
|
||||||
|
title: Erste Schritte
|
||||||
|
order: 2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Erste Schritte
|
||||||
|
|
||||||
|
Diese Seite führt dich durch die wichtigsten Abläufe in toolr — vom ersten
|
||||||
|
Besuch bis zum Anlegen und Bewerten eines Tools.
|
||||||
|
|
||||||
|
## 1. Anmelden
|
||||||
|
|
||||||
|
Die meisten Aktionen (Tool anlegen, bewerten, Watchlist) erfordern ein
|
||||||
|
Konto. Klicke oben rechts auf **Anmelden**. Je nach Konfiguration der Instanz
|
||||||
|
hast du zwei Möglichkeiten:
|
||||||
|
|
||||||
|
- **Lokale Konten:** Benutzername + Passwort. Der Zugang wird von einem Admin
|
||||||
|
angelegt (siehe [Administration](/docs/handbook/administration)).
|
||||||
|
- **OIDC (SSO):** Anmelden mit dem konfigurierten Identitätsanbieter.
|
||||||
|
|
||||||
|
Welcher Modus aktiv ist, steht im [Endpunkt
|
||||||
|
`GET /auth/mode`](/docs/reference/endpoints/auth#getauthmode).
|
||||||
|
|
||||||
|
## 2. Tools finden
|
||||||
|
|
||||||
|
Öffne den Bereich **Tools durchsuchen**:
|
||||||
|
|
||||||
|
- **Suchen** — Volltextsuche über Name & Beschreibung.
|
||||||
|
- **Filtern** — nach Kategorie, Tags und Features; zusätzlich
|
||||||
|
Mindestbewertung (`minRating`).
|
||||||
|
- **Sortieren** — nach Aktualität, Top-Bewertung, meistbewertet, Name
|
||||||
|
(auf-/absteigend) oder letztem Update.
|
||||||
|
|
||||||
|
Die Such-, Filter- und Sortierparameter entsprechen den Query-Parametern von
|
||||||
|
[`GET /tools`](/docs/reference/endpoints/tools#listtools).
|
||||||
|
|
||||||
|
## 3. Tool anlegen
|
||||||
|
|
||||||
|
Gehe auf **Tool hinzufügen** und fülle das Formular aus. Details zu jedem Feld
|
||||||
|
findest du im [Handbuch "Tool anlegen"](/docs/handbook/tool-anlegen) und in der
|
||||||
|
[Feld-Referenz](/docs/reference/schemas/toolinput).
|
||||||
|
|
||||||
|
## 4. Bewerten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools kannst du **Nützlichkeit** und
|
||||||
|
**Bedienbarkeit** (jeweils 1–5) vergeben und optional einen Kommentar
|
||||||
|
hinterlassen. Deine Bewertung fließt sofort in die Statistiken ein.
|
||||||
|
|
||||||
|
## 5. Weiterführend
|
||||||
|
|
||||||
|
- [Tools vergleichen](/docs/handbook/vergleichen)
|
||||||
|
- [Watchlist](/docs/handbook/watchlist)
|
||||||
|
- [Analytics](/docs/handbook/analytics)
|
||||||
|
- [Administration & Papierkorb](/docs/handbook/administration)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
---
|
||||||
|
title: Überblick
|
||||||
|
order: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Willkommen bei toolr
|
||||||
|
|
||||||
|
toolr ist eine Plattform zum **Entdecken, Bewerten und Vergleichen von
|
||||||
|
Entwicklungstools**. Nutzer:innen pflegen einen gemeinsamen Katalog von Tools,
|
||||||
|
vergeben Bewertungen (Nützlichkeit & Bedienbarkeit) und nutzen Statistiken, um
|
||||||
|
die richtige Wahl zu treffen.
|
||||||
|
|
||||||
|
## Was kannst du mit toolr tun?
|
||||||
|
|
||||||
|
| Funktion | Beschreibung | Sichtbarkeit |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Tools durchsuchen** | Katalog filtern, sortieren und durchsuchen | Alle |
|
||||||
|
| **Tool anlegen** | Neues Tool mit Beschreibung, Kategorie, Features & Tags eintragen | Angemeldet |
|
||||||
|
| **Bewerten** | Nützlichkeit & Bedienbarkeit (1–5) plus Kommentar vergeben | Angemeldet |
|
||||||
|
| **Vergleichen** | Tools nebeneinander gegenüberstellen | Premium |
|
||||||
|
| **Watchlist** | Tools als Favoriten speichern | Premium |
|
||||||
|
| **Analytics** | Statistiken, Top-Tools, Verteilungen | Alle |
|
||||||
|
| **Admin** | Nutzerverwaltung, Audit-Log, Papierkorb | Admin |
|
||||||
|
| **Trash** | Soft-gelöschte Tools wiederherstellen oder endgültig löschen | Admin |
|
||||||
|
|
||||||
|
## Funktionen & Felder im Detail
|
||||||
|
|
||||||
|
Die **Referenz** ist automatisch aus der OpenAPI-Spezifikation generiert und
|
||||||
|
deckt damit garantiert *alle* Endpunkte und Datenfelder der aktuellen Version
|
||||||
|
ab:
|
||||||
|
|
||||||
|
- [Endpunkte](/docs/reference/endpoints/tools) — jede API-Operation mit
|
||||||
|
Parametern und Antwort-Schemas.
|
||||||
|
- [Datenmodell & Felder](/docs/reference/schemas/tool) — jedes Feld mit Typ,
|
||||||
|
Pflichtstatus und Bedeutung.
|
||||||
|
|
||||||
|
> Die Referenz ist **versionsgebunden**: Wähle oben rechts eine ältere Version,
|
||||||
|
> um den API-Stand dieses Releases zu sehen.
|
||||||
|
|
||||||
|
## Erste Schritte
|
||||||
|
|
||||||
|
- Neu hier? Starte mit dem [Erste-Schritte-Guide](/docs/handbook/getting-started).
|
||||||
|
- Möchtest du ein Tool eintragen? Siehe [Tool anlegen](/docs/handbook/tool-anlegen).
|
||||||
|
- Formulare zeigen neben jedem Feld ein **Hilfe-Icon (?)**, das direkt zur
|
||||||
|
Erklärung des Felds in der Doku springt.
|
||||||
|
|
||||||
|
## Wo ist der Quellcode?
|
||||||
|
|
||||||
|
Über das **Repository-Logo oben rechts** gelangst du direkt zum Quellcode auf
|
||||||
|
GitHub.
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
---
|
||||||
|
title: Tool anlegen & bearbeiten
|
||||||
|
order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Tool anlegen & bearbeiten
|
||||||
|
|
||||||
|
## Neues Tool anlegen
|
||||||
|
|
||||||
|
Unter **Tool hinzufügen** legst du ein neues Tool an. Die Felder entsprechen
|
||||||
|
dem Eingabeschema [`ToolInput`](/docs/reference/schemas/toolinput):
|
||||||
|
|
||||||
|
| Feld | Pflicht | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Name** | ja | Anzeigename des Tools (min. 2 Zeichen). |
|
||||||
|
| **Beschreibung** | ja | Was tut das Tool, warum nutzen es Leute? (min. 10 Zeichen) |
|
||||||
|
| **Kategorie** | ja | Zugeordnete Kategorie (aus bestehenden Kategorien wählbar). |
|
||||||
|
| **Website-URL** | nein | Offizielle Website (`https://…`). |
|
||||||
|
| **Icon-/Logo-URL** | nein | Direktlink zu einem Logo-Bild. |
|
||||||
|
| **Features** | nein | Schlüsselfähigkeiten, z. B. „Echtzeit-Kollaboration“. Bereits bekannte Features sind auswählbar. |
|
||||||
|
| **Tags** | nein | Schlagwörter zum Auffinden. Bereits bekannte Tags sind auswählbar. |
|
||||||
|
|
||||||
|
> Hinter jedem Label findest du ein **Hilfe-Icon (?)** — es verlinkt direkt
|
||||||
|
> zur Feldbeschreibung in dieser Doku.
|
||||||
|
|
||||||
|
### Hinweise
|
||||||
|
|
||||||
|
- **Features & Tags** sind Listen. Über **+ Feature / + Tag** fügst du weitere
|
||||||
|
Einträge hinzu; über das ✕-Symbol entfernst du sie.
|
||||||
|
- URLs müssen absolut und gültig sein.
|
||||||
|
- Leere Einträge in Feature-/Tag-Listen werden beim Speichern verworfen.
|
||||||
|
|
||||||
|
## Tool bearbeiten
|
||||||
|
|
||||||
|
Auf der Detailseite eines Tools öffnet **Bearbeiten** das Formular mit den
|
||||||
|
aktuellen Werten. Du kannst Name, Beschreibung, Kategorie, URLs, Features und
|
||||||
|
Tags ändern. Nur angemeldete Nutzer:innen können Tools bearbeiten.
|
||||||
|
|
||||||
|
## Tool löschen
|
||||||
|
|
||||||
|
Über die Detailseite kannst du ein Tool **in den Papierkorb verschieben**
|
||||||
|
(Soft-Delete). Es verschwindet aus dem Katalog, bleibt aber im Papierkorb
|
||||||
|
erhalten. Siehe [Administration & Papierkorb](/docs/handbook/administration).
|
||||||
|
|
||||||
|
## Zugehörige Endpunkte
|
||||||
|
|
||||||
|
- [`POST /tools`](/docs/reference/endpoints/tools#createtool) — Tool anlegen
|
||||||
|
- [`PATCH /tools/{id}`](/docs/reference/endpoints/tools#updatetool) — Tool bearbeiten
|
||||||
|
- [`DELETE /tools/{id}`](/docs/reference/endpoints/tools#deletetool) — Tool löschen
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
title: Vergleichen & Watchlist
|
||||||
|
order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vergleichen & Watchlist
|
||||||
|
|
||||||
|
Diese Funktionen sind für **Premium-Nutzer:innen** verfügbar.
|
||||||
|
|
||||||
|
## Tools vergleichen
|
||||||
|
|
||||||
|
Mit **Vergleichen** stellst du mehrere Tools nebeneinander und siehst deren
|
||||||
|
Daten auf einen Blick — ideal für die Tool-Auswahl.
|
||||||
|
|
||||||
|
1. Füge Tools über die **Vergleichsleiste** (Vergleichs-Icon auf Karten) hinzu.
|
||||||
|
2. Öffne den Bereich **Vergleichen**. Die Tools erscheinen in der gewählten
|
||||||
|
Reihenfolge.
|
||||||
|
3. Die Vergleichsansicht zeigt pro Tool die wichtigsten Felder und
|
||||||
|
Durchschnittswerte.
|
||||||
|
|
||||||
|
Der zugrunde liegende Endpunkt ist
|
||||||
|
[`GET /compare`](/docs/reference/endpoints/tools#listcomparetools) mit dem
|
||||||
|
Parameter `ids` (kommagetrennt). Ohne Premium-Berechtigung liefert er `403`.
|
||||||
|
|
||||||
|
## Watchlist
|
||||||
|
|
||||||
|
Die **Watchlist** ist deine persönliche Favoritenliste:
|
||||||
|
|
||||||
|
- **Hinzufügen/Entfernen:** Nutze das Lesezeichen-Symbol auf der
|
||||||
|
Tool-Karte oder der Detailseite.
|
||||||
|
- Die gespeicherte Reihenfolge bleibt erhalten.
|
||||||
|
- Sie wird über [`GET /auth/me/watchlist`](/docs/reference/endpoints/auth#getmewatchlist)
|
||||||
|
geladen; das Setzen erfolgt über
|
||||||
|
[`PUT /auth/me/preferences`](/docs/reference/endpoints/auth#updatemepreferences)
|
||||||
|
(Feld `watchlist`).
|
||||||
|
|
||||||
|
### Feld `watchlist`
|
||||||
|
|
||||||
|
Das Feld `watchlist` in [`UserPreferences`](/docs/reference/schemas/userpreferences)
|
||||||
|
ist ein Array von Tool-IDs in gespeicherter Reihenfolge:
|
||||||
|
|
||||||
|
| Feld | Typ | Bedeutung |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `view` | string | Ansichtsmodus (`grid`, `table`, `rows`). |
|
||||||
|
| `density` | string | Dichte (`cozy`, `compact`). |
|
||||||
|
| `watchlist` | integer[] | Tool-IDs in Favoriten-Reihenfolge. |
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# vX.Y.Z — Release Notes
|
||||||
|
|
||||||
|
> Template für neue Release-Dokumentationen. Eine Kopie pro Release unter
|
||||||
|
> `docs/releases/vX.Y.Z.md` anlegen, Platzhalter ersetzen, Abschnitte die
|
||||||
|
> nicht zutreffen entfernen. Die Seite wird unter `/docs/vX.Y.Z` in der App
|
||||||
|
> angezeigt.
|
||||||
|
|
||||||
|
**Datum:** YYYY-MM-DD · **Tag:** [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
|
||||||
|
|
||||||
|
## Neue Features
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## API-Änderungen
|
||||||
|
|
||||||
|
- ... (neue/geänderte/entfernte Endpunkte — siehe `lib/api-spec/openapi.yaml`)
|
||||||
|
|
||||||
|
## Betrieb / Upgrade
|
||||||
|
|
||||||
|
- **Env-Vars:** ... (neu/geändert/entfernt)
|
||||||
|
- **Migration:** ... (Datenbank-/Schema-Änderungen, Schritte für den Betreiber)
|
||||||
|
- **Breaking Changes:** ... (nur wenn vorhanden)
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- ...
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`<short-sha>`](https://git.kubebase.de/admin/tool-evaluator/commit/<short-sha>)
|
||||||
|
- Tag: [`vX.Y.Z`](https://git.kubebase.de/admin/tool-evaluator/tags/vX.Y.Z)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# v0.6.0 — Release Notes
|
||||||
|
|
||||||
|
**Datum:** 2026-08-03 · **Tag:** [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
|
||||||
|
|
||||||
|
## Neue Features
|
||||||
|
|
||||||
|
- Vollständige Modernisierung aller Abhängigkeiten auf die aktuellen Hauptversionen
|
||||||
|
(TypeScript 7, Vite 8, React 19.2, openid-client 6, zod 4, recharts 3, react-day-picker 10).
|
||||||
|
|
||||||
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
|
- CI-Build durch `allowBuilds`-Konfiguration für pnpm 11 repariert
|
||||||
|
(Build-Scripts für esbuild & Co. werden nicht mehr blockiert).
|
||||||
|
- Image-Tagging vereinfacht: nur noch `latest` und `v*`-Tags, keine `nightly-*`/`sha-*`-Tags.
|
||||||
|
- Alle Dependencies exakt gepinnt; automatische Updates via Renovate vorbereitet
|
||||||
|
(`renovate.json`, `docs/dependency-policy.md`).
|
||||||
|
|
||||||
|
## API-Änderungen
|
||||||
|
|
||||||
|
- Keine Breaking Changes an der API. openid-client intern auf v6 migriert
|
||||||
|
(auth-Fluss verhält sich identisch).
|
||||||
|
|
||||||
|
## Betrieb / Upgrade
|
||||||
|
|
||||||
|
- **Env-Vars:** unverändert. Node-Image auf `node:24.18.1-alpine` gepinnt.
|
||||||
|
- **Migration:** keine Datenbank-Migration erforderlich.
|
||||||
|
- **Breaking Changes:** keine.
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- `typedoc` (indirekte orval-Abhängigkeit) zeigt eine Peer-Dependency-Warnung
|
||||||
|
(erwartet TypeScript 5.x/6.x, installiert ist 7.x) — harmlos für Build & Laufzeit.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`2f66fff`](https://git.kubebase.de/admin/tool-evaluator/commit/2f66fff)
|
||||||
|
- Tag: [`v0.6.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.6.0)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# v0.7.0 — Release Notes
|
||||||
|
|
||||||
|
**Datum:** 2026-08-03 · **Tag:** [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
|
||||||
|
|
||||||
|
## Neue Features
|
||||||
|
|
||||||
|
- Version-gebundene **Release-Dokumentation** in der App unter `/docs`
|
||||||
|
(Index + Detailseite je Version, Markdown aus `docs/releases/`).
|
||||||
|
- Generiertes Release-Vorlage (`docs/releases/TEMPLATE.md`) und
|
||||||
|
Sync-Schritt für den Frontend-Build.
|
||||||
|
|
||||||
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
|
- `tsx` auf 4.23.4 angehoben — letzte veraltete Abhängigkeit im Workspace
|
||||||
|
(`pnpm outdated -r` ist jetzt leer).
|
||||||
|
|
||||||
|
## API-Änderungen
|
||||||
|
|
||||||
|
- Keine Breaking Changes an der API.
|
||||||
|
|
||||||
|
## Betrieb / Upgrade
|
||||||
|
|
||||||
|
- **Env-Vars:** unverändert.
|
||||||
|
- **Migration:** keine.
|
||||||
|
- **Breaking Changes:** keine.
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- Die Doku ist bisher auf Release-Notes beschränkt; eine vollständige
|
||||||
|
API-/Feld-Referenz folgt in v0.8.0.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`520f917`](https://git.kubebase.de/admin/tool-evaluator/commit/520f917)
|
||||||
|
- Tag: [`v0.7.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.7.0)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,46 @@
|
|||||||
|
# v0.8.0 — Release Notes
|
||||||
|
|
||||||
|
**Datum:** 2026-08-03 · **Tag:** [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
||||||
|
|
||||||
|
## Neue Features
|
||||||
|
|
||||||
|
- **Vollständige Dokumentations-Site** in mkdocs-Optik unter `/docs`:
|
||||||
|
- **Handbuch** mit verständlichen Erklärungen zu allen Features
|
||||||
|
(Erste Schritte, Tool anlegen, Bewertungen, Vergleichen, Watchlist,
|
||||||
|
Analytics, Administration, Datenmodell).
|
||||||
|
- **Automatisch generierte Referenz** aus `lib/api-spec/openapi.yaml`:
|
||||||
|
alle Endpunkte und Datenfelder (Typ, Pflichtstatus, Constraints) —
|
||||||
|
damit ist garantiert, dass *jedes* Feature dokumentiert ist.
|
||||||
|
- **Suche** über Handbuch, Endpunkte und Felder.
|
||||||
|
- **Versions-Dropdown**: ältere Releases behalten ihre vollständige
|
||||||
|
Feld-/Endpunkt-Referenz als Snapshot.
|
||||||
|
- **Repo-Link** oben rechts zur Quelle.
|
||||||
|
- **Hilfe-Buttons (?) in Formularen** (NetBox-Stil): neben jedem Feld
|
||||||
|
springt ein Icon direkt zur Feldbeschreibung in der Doku.
|
||||||
|
|
||||||
|
## Fixes & Verbesserungen
|
||||||
|
|
||||||
|
- Doku-Generator `scripts/src/generate-docs.mjs` ersetzt den bisherigen
|
||||||
|
`sync-release-docs.mjs` (OpenAPI-Parsing, Handbuch, Suchindex, Snapshots).
|
||||||
|
- Dokumentation für v0.7.0 nachgezogen.
|
||||||
|
|
||||||
|
## API-Änderungen
|
||||||
|
|
||||||
|
- Keine Breaking Changes an der API.
|
||||||
|
|
||||||
|
## Betrieb / Upgrade
|
||||||
|
|
||||||
|
- **Env-Vars:** unverändert.
|
||||||
|
- **Migration:** keine.
|
||||||
|
- **Breaking Changes:** keine.
|
||||||
|
|
||||||
|
## Bekannte Einschränkungen
|
||||||
|
|
||||||
|
- Handbuch & Referenz gelten für die aktuelle Version; ältere Versionen
|
||||||
|
zeigen ihre Release-Notes und einen Referenz-Snapshot, sofern beim Release
|
||||||
|
erzeugt (`node scripts/src/generate-docs.mjs --snapshot vX.Y.Z`).
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- Commit: [`6c92b63`](https://git.kubebase.de/admin/tool-evaluator/commit/6c92b63)
|
||||||
|
- Tag: [`v0.8.0`](https://git.kubebase.de/admin/tool-evaluator/tags/v0.8.0)
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
|
|||||||
|
|
||||||
let _baseUrl: string | null = null;
|
let _baseUrl: string | null = null;
|
||||||
let _authTokenGetter: AuthTokenGetter | null = null;
|
let _authTokenGetter: AuthTokenGetter | null = null;
|
||||||
|
let _csrfTokenGetter: (() => string | null) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a base URL that is prepended to every relative request URL
|
* Set a base URL that is prepended to every relative request URL
|
||||||
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
|
|||||||
_authTokenGetter = getter;
|
_authTokenGetter = getter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a getter that supplies a CSRF token. Before every state-changing
|
||||||
|
* fetch an `X-CSRF-Token` header is attached when the getter returns a value.
|
||||||
|
* Pass `null` to clear the getter.
|
||||||
|
*/
|
||||||
|
export function setCsrfTokenGetter(getter: (() => string | null) | null): void {
|
||||||
|
_csrfTokenGetter = getter;
|
||||||
|
}
|
||||||
|
|
||||||
function isRequest(input: RequestInfo | URL): input is Request {
|
function isRequest(input: RequestInfo | URL): input is Request {
|
||||||
return typeof Request !== "undefined" && input instanceof Request;
|
return typeof Request !== "undefined" && input instanceof Request;
|
||||||
}
|
}
|
||||||
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
|
|||||||
headers.set("accept", DEFAULT_JSON_ACCEPT);
|
headers.set("accept", DEFAULT_JSON_ACCEPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach CSRF token for state-changing requests, unless one is already set.
|
||||||
|
if (_csrfTokenGetter && !headers.has("x-csrf-token")) {
|
||||||
|
const csrf = _csrfTokenGetter();
|
||||||
|
if (csrf) {
|
||||||
|
headers.set("x-csrf-token", csrf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach bearer token when an auth getter is configured and no
|
// Attach bearer token when an auth getter is configured and no
|
||||||
// Authorization header has been explicitly provided.
|
// Authorization header has been explicitly provided.
|
||||||
if (_authTokenGetter && !headers.has("authorization")) {
|
if (_authTokenGetter && !headers.has("authorization")) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -30,6 +30,10 @@ export interface AuthMode {
|
|||||||
mode: AuthModeMode;
|
mode: AuthModeMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CsrfToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalLoginInput {
|
export interface LocalLoginInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -52,6 +56,14 @@ export const UserTier = {
|
|||||||
enterprise: 'enterprise',
|
enterprise: 'enterprise',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserAuthProvider = {
|
||||||
|
local: 'local',
|
||||||
|
oidc: 'oidc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -59,6 +71,7 @@ export interface User {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
tier?: UserTier;
|
tier?: UserTier;
|
||||||
|
authProvider?: UserAuthProvider;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +124,23 @@ export interface UserRoleUpdate {
|
|||||||
tier?: UserRoleUpdateTier;
|
tier?: UserRoleUpdateTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: number;
|
id: number;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
export * from "./generated/api";
|
export * from "./generated/api";
|
||||||
export * from "./generated/api.schemas";
|
export * from "./generated/api.schemas";
|
||||||
export { setBaseUrl, setAuthTokenGetter, customFetch } from "./custom-fetch";
|
export { setBaseUrl, setAuthTokenGetter, setCsrfTokenGetter, customFetch } from "./custom-fetch";
|
||||||
export type { AuthTokenGetter } from "./custom-fetch";
|
export type { AuthTokenGetter } from "./custom-fetch";
|
||||||
|
|||||||
@@ -570,6 +570,19 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AuthMode"
|
$ref: "#/components/schemas/AuthMode"
|
||||||
|
|
||||||
|
/auth/csrf:
|
||||||
|
get:
|
||||||
|
operationId: getCsrfToken
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get a CSRF token for state-changing requests
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: CSRF token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CsrfToken"
|
||||||
|
|
||||||
/auth/login:
|
/auth/login:
|
||||||
post:
|
post:
|
||||||
operationId: localLogin
|
operationId: localLogin
|
||||||
@@ -614,6 +627,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/me/password:
|
||||||
|
post:
|
||||||
|
operationId: changeMyPassword
|
||||||
|
tags: [auth]
|
||||||
|
summary: Change own password (local users only)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ChangePasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed
|
||||||
|
"400":
|
||||||
|
description: Invalid input or wrong current password
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/password-redirect:
|
||||||
|
get:
|
||||||
|
operationId: getPasswordRedirect
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get redirect URL for managing credentials in the identity provider
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Redirect URL (null in local mode)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PasswordRedirect"
|
||||||
|
|
||||||
/auth/me/preferences:
|
/auth/me/preferences:
|
||||||
get:
|
get:
|
||||||
operationId: getMePreferences
|
operationId: getMePreferences
|
||||||
@@ -783,6 +848,51 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/users/{id}/password:
|
||||||
|
patch:
|
||||||
|
operationId: setUserPassword
|
||||||
|
tags: [users]
|
||||||
|
summary: Set/reset a user's password (admin only, local users only)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SetPasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password updated
|
||||||
|
"400":
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/audit-logs:
|
/audit-logs:
|
||||||
get:
|
get:
|
||||||
operationId: listAuditLogs
|
operationId: listAuditLogs
|
||||||
@@ -845,6 +955,13 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [oidc, local]
|
enum: [oidc, local]
|
||||||
|
|
||||||
|
CsrfToken:
|
||||||
|
type: object
|
||||||
|
required: [token]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
|
||||||
LocalLoginInput:
|
LocalLoginInput:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password]
|
required: [username, password]
|
||||||
@@ -870,6 +987,10 @@ components:
|
|||||||
tier:
|
tier:
|
||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
authProvider:
|
||||||
|
type: string
|
||||||
|
enum: [local, oidc]
|
||||||
|
default: local
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -903,6 +1024,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
|
||||||
|
ChangePasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [currentPassword, newPassword]
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 6
|
||||||
|
|
||||||
|
SetPasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [password]
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 6
|
||||||
|
|
||||||
|
PasswordRedirect:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: ["string", "null"]
|
||||||
|
|
||||||
AuditLog:
|
AuditLog:
|
||||||
type: object
|
type: object
|
||||||
required: [id, entityType, action, userId, username, createdAt]
|
required: [id, entityType, action, userId, username, createdAt]
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
|
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"orval": "^8.9.1"
|
"orval": "8.23.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -25,7 +25,7 @@ export const GetVersionResponse = zod.object({
|
|||||||
"version": zod.string(),
|
"version": zod.string(),
|
||||||
"commitSha": zod.string().nullish(),
|
"commitSha": zod.string().nullish(),
|
||||||
"buildDate": zod.string().nullish(),
|
"buildDate": zod.string().nullish(),
|
||||||
"trashRetentionDays": zod.number().optional()
|
"trashRetentionDays": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export const ListToolsQueryParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ListToolsResponseItem = zod.object({
|
export const ListToolsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -58,7 +58,7 @@ export const ListToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -84,16 +84,8 @@ export const CreateToolBody = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional()
|
"tags": zod.array(zod.string()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const CreateToolResponse = zod.object({
|
||||||
/**
|
"id": zod.int(),
|
||||||
* @summary Compare tools side by side (premium)
|
|
||||||
*/
|
|
||||||
export const ListCompareToolsQueryParams = zod.object({
|
|
||||||
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ListCompareToolsResponseItem = zod.object({
|
|
||||||
"id": zod.number(),
|
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -104,7 +96,31 @@ export const ListCompareToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"deletedAt": zod.coerce.date().nullish(),
|
||||||
|
"deletedBy": zod.string().nullish()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const ListCompareToolsQueryParams = zod.object({
|
||||||
|
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListCompareToolsResponseItem = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"websiteUrl": zod.string().nullish(),
|
||||||
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
|
"features": zod.array(zod.string()).optional(),
|
||||||
|
"tags": zod.array(zod.string()).optional(),
|
||||||
|
"createdAt": zod.coerce.date(),
|
||||||
|
"updatedAt": zod.coerce.date(),
|
||||||
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -116,7 +132,7 @@ export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem)
|
|||||||
* @summary Get a tool's rating history over time
|
* @summary Get a tool's rating history over time
|
||||||
*/
|
*/
|
||||||
export const GetToolRatingHistoryParams = zod.object({
|
export const GetToolRatingHistoryParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetToolRatingHistoryResponseItem = zod.object({
|
export const GetToolRatingHistoryResponseItem = zod.object({
|
||||||
@@ -132,11 +148,11 @@ export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryRespon
|
|||||||
* @summary Get a tool by ID
|
* @summary Get a tool by ID
|
||||||
*/
|
*/
|
||||||
export const GetToolParams = zod.object({
|
export const GetToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetToolResponse = zod.object({
|
export const GetToolResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -147,7 +163,7 @@ export const GetToolResponse = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -158,7 +174,7 @@ export const GetToolResponse = zod.object({
|
|||||||
* @summary Update a tool
|
* @summary Update a tool
|
||||||
*/
|
*/
|
||||||
export const UpdateToolParams = zod.object({
|
export const UpdateToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -175,7 +191,7 @@ export const UpdateToolBody = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateToolResponse = zod.object({
|
export const UpdateToolResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -195,9 +211,11 @@ export const UpdateToolResponse = zod.object({
|
|||||||
* @summary Delete a tool
|
* @summary Delete a tool
|
||||||
*/
|
*/
|
||||||
export const DeleteToolParams = zod.object({
|
export const DeleteToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteToolResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List trashed (soft-deleted) tools
|
* @summary List trashed (soft-deleted) tools
|
||||||
@@ -207,7 +225,7 @@ export const ListTrashedToolsQueryParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ListTrashedToolsResponseItem = zod.object({
|
export const ListTrashedToolsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -232,11 +250,11 @@ export const trashToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const TrashToolsBody = zod.object({
|
export const TrashToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(trashToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(trashToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const TrashToolsResponse = zod.object({
|
export const TrashToolsResponse = zod.object({
|
||||||
"trashed": zod.number().optional()
|
"trashed": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -248,9 +266,11 @@ export const deleteTrashedToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const DeleteTrashedToolsBody = zod.object({
|
export const DeleteTrashedToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(deleteTrashedToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(deleteTrashedToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteTrashedToolsResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Restore trashed tools
|
* @summary Restore trashed tools
|
||||||
@@ -260,11 +280,11 @@ export const restoreToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const RestoreToolsBody = zod.object({
|
export const RestoreToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(restoreToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(restoreToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const RestoreToolsResponse = zod.object({
|
export const RestoreToolsResponse = zod.object({
|
||||||
"restored": zod.number().optional()
|
"restored": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +292,7 @@ export const RestoreToolsResponse = zod.object({
|
|||||||
* @summary Permanently delete all trashed tools (admin)
|
* @summary Permanently delete all trashed tools (admin)
|
||||||
*/
|
*/
|
||||||
export const EmptyTrashResponse = zod.object({
|
export const EmptyTrashResponse = zod.object({
|
||||||
"deleted": zod.number().optional()
|
"deleted": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +300,7 @@ export const EmptyTrashResponse = zod.object({
|
|||||||
* @summary List ratings for a tool
|
* @summary List ratings for a tool
|
||||||
*/
|
*/
|
||||||
export const ListToolRatingsParams = zod.object({
|
export const ListToolRatingsParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const listToolRatingsResponseUsefulnessMax = 5;
|
export const listToolRatingsResponseUsefulnessMax = 5;
|
||||||
@@ -290,10 +310,10 @@ export const listToolRatingsResponseUsabilityMax = 5;
|
|||||||
|
|
||||||
|
|
||||||
export const ListToolRatingsResponseItem = zod.object({
|
export const ListToolRatingsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"toolId": zod.number(),
|
"toolId": zod.int(),
|
||||||
"usefulness": zod.number().min(1).max(listToolRatingsResponseUsefulnessMax),
|
"usefulness": zod.int().min(1).max(listToolRatingsResponseUsefulnessMax),
|
||||||
"usability": zod.number().min(1).max(listToolRatingsResponseUsabilityMax),
|
"usability": zod.int().min(1).max(listToolRatingsResponseUsabilityMax),
|
||||||
"comment": zod.string().nullish(),
|
"comment": zod.string().nullish(),
|
||||||
"reviewerName": zod.string().nullish(),
|
"reviewerName": zod.string().nullish(),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
@@ -305,7 +325,7 @@ export const ListToolRatingsResponse = zod.array(ListToolRatingsResponseItem)
|
|||||||
* @summary Submit a rating for a tool
|
* @summary Submit a rating for a tool
|
||||||
*/
|
*/
|
||||||
export const CreateRatingParams = zod.object({
|
export const CreateRatingParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createRatingBodyUsefulnessMax = 5;
|
export const createRatingBodyUsefulnessMax = 5;
|
||||||
@@ -315,25 +335,41 @@ export const createRatingBodyUsabilityMax = 5;
|
|||||||
|
|
||||||
|
|
||||||
export const CreateRatingBody = zod.object({
|
export const CreateRatingBody = zod.object({
|
||||||
"usefulness": zod.number().min(1).max(createRatingBodyUsefulnessMax),
|
"usefulness": zod.int().min(1).max(createRatingBodyUsefulnessMax),
|
||||||
"usability": zod.number().min(1).max(createRatingBodyUsabilityMax),
|
"usability": zod.int().min(1).max(createRatingBodyUsabilityMax),
|
||||||
"comment": zod.string().optional(),
|
"comment": zod.string().optional(),
|
||||||
"reviewerName": zod.string().optional()
|
"reviewerName": zod.string().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createRatingResponseUsefulnessMax = 5;
|
||||||
|
|
||||||
|
export const createRatingResponseUsabilityMax = 5;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const CreateRatingResponse = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"toolId": zod.int(),
|
||||||
|
"usefulness": zod.int().min(1).max(createRatingResponseUsefulnessMax),
|
||||||
|
"usability": zod.int().min(1).max(createRatingResponseUsabilityMax),
|
||||||
|
"comment": zod.string().nullish(),
|
||||||
|
"reviewerName": zod.string().nullish(),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Overall platform statistics
|
* @summary Overall platform statistics
|
||||||
*/
|
*/
|
||||||
export const GetAnalyticsSummaryResponse = zod.object({
|
export const GetAnalyticsSummaryResponse = zod.object({
|
||||||
"totalTools": zod.number(),
|
"totalTools": zod.int(),
|
||||||
"totalRatings": zod.number(),
|
"totalRatings": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable(),
|
"avgCombined": zod.number().nullable(),
|
||||||
"categoriesCount": zod.number(),
|
"categoriesCount": zod.int(),
|
||||||
"mostRatedTool": zod.object({
|
"mostRatedTool": zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -344,7 +380,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -356,13 +392,13 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
|||||||
* @summary Top-rated tools
|
* @summary Top-rated tools
|
||||||
*/
|
*/
|
||||||
export const GetTopToolsQueryParams = zod.object({
|
export const GetTopToolsQueryParams = zod.object({
|
||||||
"limit": zod.coerce.number().optional(),
|
"limit": zod.coerce.number().int().optional(),
|
||||||
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
|
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetTopToolsResponseItem = zod.object({
|
export const GetTopToolsResponseItem = zod.object({
|
||||||
"tool": zod.object({
|
"tool": zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -373,13 +409,13 @@ export const GetTopToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
}),
|
}),
|
||||||
"score": zod.number(),
|
"score": zod.number(),
|
||||||
"ratingCount": zod.number()
|
"ratingCount": zod.int()
|
||||||
})
|
})
|
||||||
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
||||||
|
|
||||||
@@ -389,8 +425,8 @@ export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
|||||||
*/
|
*/
|
||||||
export const GetAnalyticsByCategoryResponseItem = zod.object({
|
export const GetAnalyticsByCategoryResponseItem = zod.object({
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"toolCount": zod.number(),
|
"toolCount": zod.int(),
|
||||||
"totalRatings": zod.number(),
|
"totalRatings": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable()
|
"avgUsability": zod.number().nullable()
|
||||||
})
|
})
|
||||||
@@ -401,17 +437,17 @@ export const GetAnalyticsByCategoryResponse = zod.array(GetAnalyticsByCategoryRe
|
|||||||
* @summary Distribution of rating scores across the platform
|
* @summary Distribution of rating scores across the platform
|
||||||
*/
|
*/
|
||||||
export const GetRatingDistributionQueryParams = zod.object({
|
export const GetRatingDistributionQueryParams = zod.object({
|
||||||
"toolId": zod.coerce.number().optional()
|
"toolId": zod.coerce.number().int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetRatingDistributionResponse = zod.object({
|
export const GetRatingDistributionResponse = zod.object({
|
||||||
"usefulness": zod.array(zod.object({
|
"usefulness": zod.array(zod.object({
|
||||||
"score": zod.number(),
|
"score": zod.int(),
|
||||||
"count": zod.number()
|
"count": zod.int()
|
||||||
})),
|
})),
|
||||||
"usability": zod.array(zod.object({
|
"usability": zod.array(zod.object({
|
||||||
"score": zod.number(),
|
"score": zod.int(),
|
||||||
"count": zod.number()
|
"count": zod.int()
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -445,6 +481,14 @@ export const GetAuthModeResponse = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a CSRF token for state-changing requests
|
||||||
|
*/
|
||||||
|
export const GetCsrfTokenResponse = zod.object({
|
||||||
|
"token": zod.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Local username/password login
|
* @summary Local username/password login
|
||||||
*/
|
*/
|
||||||
@@ -480,13 +524,37 @@ export const GetMeResponse = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change own password (local users only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const changeMyPasswordBodyNewPasswordMin = 6;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const ChangeMyPasswordBody = zod.object({
|
||||||
|
"currentPassword": zod.string().min(1),
|
||||||
|
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChangeMyPasswordResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get redirect URL for managing credentials in the identity provider
|
||||||
|
*/
|
||||||
|
export const GetPasswordRedirectResponse = zod.object({
|
||||||
|
"url": zod.string().nullable()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get current user's browse preferences
|
* @summary Get current user's browse preferences
|
||||||
*/
|
*/
|
||||||
export const GetMePreferencesResponse = zod.object({
|
export const GetMePreferencesResponse = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -496,13 +564,13 @@ export const GetMePreferencesResponse = zod.object({
|
|||||||
export const UpdateMePreferencesBody = zod.object({
|
export const UpdateMePreferencesBody = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateMePreferencesResponse = zod.object({
|
export const UpdateMePreferencesResponse = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -510,7 +578,7 @@ export const UpdateMePreferencesResponse = zod.object({
|
|||||||
* @summary Get current user's watchlist tools (premium)
|
* @summary Get current user's watchlist tools (premium)
|
||||||
*/
|
*/
|
||||||
export const GetMeWatchlistResponseItem = zod.object({
|
export const GetMeWatchlistResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -521,7 +589,7 @@ export const GetMeWatchlistResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -532,12 +600,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
|
|||||||
/**
|
/**
|
||||||
* @summary List all local users (admin only)
|
* @summary List all local users (admin only)
|
||||||
*/
|
*/
|
||||||
|
export const listUsersResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const ListUsersResponseItem = zod.object({
|
export const ListUsersResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||||
@@ -560,12 +631,24 @@ export const CreateUserBody = zod.object({
|
|||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createUserResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
|
export const CreateUserResponse = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"username": zod.string(),
|
||||||
|
"email": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(createUserResponseAuthProviderDefault),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Update user role (admin only)
|
* @summary Update user role (admin only)
|
||||||
*/
|
*/
|
||||||
export const UpdateUserParams = zod.object({
|
export const UpdateUserParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateUserBody = zod.object({
|
export const UpdateUserBody = zod.object({
|
||||||
@@ -573,12 +656,15 @@ export const UpdateUserBody = zod.object({
|
|||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateUserResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const UpdateUserResponse = zod.object({
|
export const UpdateUserResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -587,23 +673,43 @@ export const UpdateUserResponse = zod.object({
|
|||||||
* @summary Delete a user (admin only)
|
* @summary Delete a user (admin only)
|
||||||
*/
|
*/
|
||||||
export const DeleteUserParams = zod.object({
|
export const DeleteUserParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteUserResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Set/reset a user's password (admin only, local users only)
|
||||||
|
*/
|
||||||
|
export const SetUserPasswordParams = zod.object({
|
||||||
|
"id": zod.coerce.number().int()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setUserPasswordBodyPasswordMin = 6;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const SetUserPasswordBody = zod.object({
|
||||||
|
"password": zod.string().min(setUserPasswordBodyPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SetUserPasswordResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List audit log entries (admin only)
|
* @summary List audit log entries (admin only)
|
||||||
*/
|
*/
|
||||||
export const ListAuditLogsQueryParams = zod.object({
|
export const ListAuditLogsQueryParams = zod.object({
|
||||||
"entityType": zod.coerce.string().optional(),
|
"entityType": zod.coerce.string().optional(),
|
||||||
"entityId": zod.coerce.number().optional(),
|
"entityId": zod.coerce.number().int().optional(),
|
||||||
"limit": zod.coerce.number().optional()
|
"limit": zod.coerce.number().int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ListAuditLogsResponseItem = zod.object({
|
export const ListAuditLogsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"entityType": zod.string(),
|
"entityType": zod.string(),
|
||||||
"entityId": zod.number().nullish(),
|
"entityId": zod.int().nullish(),
|
||||||
"action": zod.string(),
|
"action": zod.string(),
|
||||||
"userId": zod.string(),
|
"userId": zod.string(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CsrfToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -14,6 +14,8 @@ export * from './authUser';
|
|||||||
export * from './authUserRole';
|
export * from './authUserRole';
|
||||||
export * from './authUserTier';
|
export * from './authUserTier';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
|
export * from './changePasswordInput';
|
||||||
|
export * from './csrfToken';
|
||||||
export * from './emptyTrash200';
|
export * from './emptyTrash200';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
@@ -26,12 +28,14 @@ export * from './listToolsParams';
|
|||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
export * from './listTrashedToolsParams';
|
export * from './listTrashedToolsParams';
|
||||||
export * from './localLoginInput';
|
export * from './localLoginInput';
|
||||||
|
export * from './passwordRedirect';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
export * from './ratingHistoryItem';
|
export * from './ratingHistoryItem';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
export * from './restoreTools200';
|
export * from './restoreTools200';
|
||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
|
export * from './setPasswordInput';
|
||||||
export * from './tool';
|
export * from './tool';
|
||||||
export * from './toolInput';
|
export * from './toolInput';
|
||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
@@ -40,6 +44,7 @@ export * from './topToolEntry';
|
|||||||
export * from './trashTools200';
|
export * from './trashTools200';
|
||||||
export * from './trashToolsInput';
|
export * from './trashToolsInput';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './userAuthProvider';
|
||||||
export * from './userCreateInput';
|
export * from './userCreateInput';
|
||||||
export * from './userCreateInputRole';
|
export * from './userCreateInputRole';
|
||||||
export * from './userCreateInputTier';
|
export * from './userCreateInputTier';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user