Compare commits

...

9 Commits

Author SHA1 Message Date
opencode 520f917723 feat(docs): version-bound release documentation served at /docs
Build & Push Docker Image / build (push) Successful in 2m35s
Adds a docs pipeline so each release has a version-bound Markdown
document (docs/releases/vX.Y.Z.md) rendered publicly in the app:

- sync-release-docs.mjs copies docs/releases/*.md into the toolrate
  public dir and generates index.json before every dev/build
- /docs lists all releases; /docs/:version renders the sanitized
  Markdown (marked + DOMPurify, typography styles)
- template + workflow documented in docs/README.md
- current release (v0.6.0) documented as the first entry
2026-08-03 16:41:08 +02:00
opencode d1dd77bc1e chore(deps): bump tsx to 4.23.4 (only remaining outdated package)
Build & Push Docker Image / build (push) Successful in 3m56s
2026-08-03 15:33:17 +02:00
opencode 0be45b6513 ci: drop nightly and sha image tags, deploy only on v* tags
Build & Push Docker Image / build (push) Successful in 2m18s
Branch pushes now build and push only 'latest'; tag pushes add the
v*-tag and update the k8s manifest. Removes the daily nightly-* and
per-commit sha-* tags that accumulated registry storage.
2026-08-03 14:10:03 +02:00
opencode 4b1274e34a fix(ci): use pnpm 11 allowBuilds instead of onlyBuiltDependencies
Build & Push Docker Image / build (push) Successful in 2m40s
pnpm 11 treats ignored build scripts as a hard error during fresh
installs (CI/Docker), while the old onlyBuiltDependencies list is
deprecated. The stray 'allowBuilds: esbuild: set this to true or false'
placeholder was invalid YAML semantics and blocked the esbuild
postinstall, failing 'pnpm install --frozen-lockfile' in the Docker
builder stage (ERR_PNPM_IGNORED_BUILDS).
2026-08-03 13:06:43 +02:00
opencode 2f66fff993 chore(deps): upgrade all dependencies to latest and pin exact
Build & Push Docker Image / build (push) Failing after 1m18s
Policy: every direct dependency is now an exact pin; lockfile + --frozen-lockfile keep builds reproducible; save-exact=true enforced.

Toolchain:
- pnpm 10.26 -> 11.18 (lockfile migrated; supportedArchitectures moved to pnpm-workspace.yaml)
- typescript 5.9 -> 7.0, vite 7 -> 8, @vitejs/plugin-react 5 -> 6
- esbuild override 0.27.3 -> 0.28.1 (closes GHSA-g7r4-m6w7-qqqr); added @esbuild/darwin-arm64 for local dev
- orval 8.9 -> 8.23, regenerated clients

Backend (prod image):
- openid-client 5.7 -> 6.8 (rewritten OIDC flow in routes/auth.ts: discovery + functional API, PKCE/state, fetchUserInfo, buildEndSessionUrl)
- pino 9 -> 10, pino-http 10 -> 11, thread-stream 3 -> 4
- zod 3.25 -> 4.4 (catalog; supported by drizzle-zod 0.8.3)
- pg 8.20 -> 8.22; removed deprecated @types/bcryptjs

Frontend:
- react/react-dom 19.1.0 -> 19.2.8 (catalog pin lifted; expo note removed)
- react-day-picker 9 -> 10 (table classname -> month_grid)
- recharts 2 -> 3 (TooltipContentProps + DefaultLegendContentProps typing; safe keys)
- react-resizable-panels 2 -> 4 (Group/Separator rename)
- date-fns 3 -> 4, @hookform/resolvers 3 -> 5, lucide-react 0.545 -> 1.28
- all @radix-ui/*, tailwind, types, and remaining patch/minor deps bumped to latest

Security/process:
- overrides for body-parser >=2.3.0 (GHSA-v422-hmwv-36x6) + markdown-it/linkify-it/brace-expansion/fast-uri (dev tooling)
- pnpm audit now reports 0 vulnerabilities (prod and full)
- CI audit gate added to build.yaml; docs/dependency-policy.md; renovate.json
- Dockerfile pins node:24.18.1-alpine and pnpm@11.18.0
2026-08-03 12:56:00 +02:00
opencode bcae59626f feat(security): add CSRF protection for all state-changing API routes
Build & Push Docker Image / build (push) Successful in 2m16s
- Synchronizer token stored in session; GET /auth/csrf to obtain it
- csrfProtection middleware requires X-CSRF-Token on non-safe methods
- customFetch injects the header via setCsrfTokenGetter
- toolrate boot loads token; reload after local login (session regenerate)
- OpenAPI GET /auth/csrf + CsrfToken schema, orval regenerated
2026-08-03 10:30:13 +02:00
opencode f851305d78 fix(tools): OR'ed where clauses now AND'd so search excludes soft-deleted tools; localize delete confirm dialog
Build & Push Docker Image / build (push) Successful in 2m25s
2026-08-03 08:48:08 +02:00
opencode 743b177c89 fix(auth): password min length 6 + show/hide toggle on password inputs
Build & Push Docker Image / build (push) Successful in 2m21s
2026-08-03 08:01:52 +02:00
opencode 68a81ec775 feat(auth): password change (self + admin reset) with rate limiting; dedupe watchlist to user menu
Build & Push Docker Image / build (push) Successful in 2m19s
2026-08-03 07:43:24 +02:00
101 changed files with 4645 additions and 2387 deletions
+15 -11
View File
@@ -14,6 +14,14 @@ jobs:
steps:
- 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
run: |
apt-get update -qq
@@ -30,33 +38,29 @@ jobs:
FULL_SHA=$(git rev-parse HEAD)
IMAGE="git.kubebase.de/${{ gitea.repository }}"
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
VERSION="${{ gitea.ref_name }}"
VERSION_TAG="${{ gitea.ref_name }}"
else
VERSION="dev-$(date -u +"%Y%m%d-%H%M")"
VERSION_TAG="nightly-${DATE_STAMP}"
TAGS="${TAGS} -t ${IMAGE}:${VERSION}"
fi
TAGS="-t ${IMAGE}:sha-${SHA} -t ${IMAGE}:latest -t ${IMAGE}:${VERSION_TAG}"
docker build --no-cache \
--build-arg COMMIT_SHA="$FULL_SHA" \
--build-arg BUILD_DATE="$(date -u +"%Y-%m-%dT%H:%M:%SZ")" \
--build-arg VERSION="$VERSION" \
$TAGS .
docker push "${IMAGE}:sha-${SHA}"
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
if: gitea.ref_type == 'tag'
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
SHA=$(git rev-parse --short HEAD)
if [ "${{ gitea.ref_type }}" = "tag" ]; then
NEWTAG="${{ gitea.ref_name }}"
else
NEWTAG="sha-${SHA}"
fi
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
cd /tmp/apps
cd apps/system/toolrate/overlays/k3s
+3
View File
@@ -47,3 +47,6 @@ Thumbs.db
# Replit
.cache/
.local/
# Generated release docs (produced by scripts/src/sync-release-docs.mjs during build)
/artifacts/toolrate/public/docs/
+1
View File
@@ -1,2 +1,3 @@
auto-install-peers=false
strict-peer-dependencies=false
save-exact=true
+4 -4
View File
@@ -1,7 +1,7 @@
FROM node:24-alpine AS builder
FROM node:24.18.1-alpine AS builder
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
COPY . .
@@ -17,10 +17,10 @@ ENV PORT=8080
RUN pnpm -r --if-present run build
FROM node:24-alpine AS runner
FROM node:24.18.1-alpine AS runner
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 lib/db/package.json lib/db/
+20 -20
View File
@@ -12,29 +12,29 @@
"dependencies": {
"@workspace/api-zod": "workspace:*",
"@workspace/db": "workspace:*",
"bcryptjs": "^3.0.3",
"zod": "catalog:",
"connect-pg-simple": "^10.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.6",
"bcryptjs": "3.0.3",
"connect-pg-simple": "10.0.0",
"cookie-parser": "1.4.7",
"cors": "2.8.6",
"drizzle-orm": "catalog:",
"express": "^5.2.1",
"express-session": "^1.19.0",
"openid-client": "^5.7.1",
"pino": "^9.14.0",
"pino-http": "^10.5.0"
"express": "5.2.1",
"express-rate-limit": "8.6.1",
"express-session": "1.19.0",
"openid-client": "6.8.4",
"pino": "10.3.1",
"pino-http": "11.0.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/bcryptjs": "^3.0.0",
"@types/connect-pg-simple": "^7.0.3",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.19",
"@types/express": "^5.0.6",
"@types/express-session": "^1.19.0",
"@types/connect-pg-simple": "7.0.3",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/express": "5.0.6",
"@types/express-session": "1.19.0",
"@types/node": "catalog:",
"esbuild": "0.27.3",
"esbuild-plugin-pino": "^2.3.3",
"pino-pretty": "^13.1.3",
"thread-stream": "3.1.0"
"esbuild": "0.28.1",
"esbuild-plugin-pino": "2.3.3",
"pino-pretty": "13.1.3",
"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" });
}
+92 -27
View File
@@ -1,15 +1,29 @@
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 { eq, and, inArray, isNull } from "drizzle-orm";
import { z } from "zod";
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
import { logger } from "../lib/logger";
import { writeAuditLog } from "../lib/audit";
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
import { getEntitlements, requireFeature } from "../middleware/feature";
import { getCsrfToken } from "../middleware/csrf";
const router: IRouter = Router();
let cachedClient: Client | null = null;
let cachedConfig: Configuration | null = null;
function isOidcConfigured(): boolean {
return !!(
@@ -36,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
}
}
async function getClient(): Promise<Client | null> {
if (cachedClient) return cachedClient;
async function getClient(): Promise<Configuration | null> {
if (cachedConfig) return cachedConfig;
const keycloakUrl = process.env.KEYCLOAK_URL;
const realm = process.env.KEYCLOAK_REALM;
@@ -49,14 +63,9 @@ async function getClient(): Promise<Client | null> {
}
try {
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
const issuer = await Issuer.discover(issuerUrl);
cachedClient = new issuer.Client({
client_id: clientId,
client_secret: clientSecret,
response_types: ["code"],
});
return cachedClient;
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
return cachedConfig;
} catch (err) {
logger.error({ err }, "Failed to discover Keycloak issuer");
return null;
@@ -107,7 +116,11 @@ router.get("/auth/mode", (_req, res): void => {
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()) {
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
return;
@@ -169,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
return;
}
const codeVerifier = generators.codeVerifier();
const codeChallenge = generators.codeChallenge(codeVerifier);
const state = generators.state();
const codeVerifier = randomPKCECodeVerifier();
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
const state = randomState();
req.session.codeVerifier = codeVerifier;
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 url = client.authorizationUrl({
const url = buildAuthorizationUrl(client, {
scope: "openid email profile",
code_challenge: codeChallenge,
code_challenge_method: "S256",
@@ -188,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
state,
});
res.redirect(url);
res.redirect(url.href);
});
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`;
try {
const params = client.callbackParams(req);
const tokenSet = await client.callback(redirectUri, params, {
code_verifier: codeVerifier,
state,
});
const tokens = await authorizationCodeGrant(
client,
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
{ 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);
await new Promise<void>((resolve, reject) => {
@@ -252,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
req.session.destroy(() => {});
const client = await getClient();
if (client && client.issuer.metadata.end_session_endpoint) {
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl);
if (client && client.serverMetadata().end_session_endpoint) {
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
res.redirect(logoutUrl.href);
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"]>;
async function resolveDbUser(u: SessionUser) {
+3
View File
@@ -8,9 +8,12 @@ import usersRouter from "./users";
import auditRouter from "./audit";
import costsRouter from "./costs";
import adminRouter from "./admin";
import { csrfProtection } from "../middleware/csrf";
const router: IRouter = Router();
router.use(csrfProtection);
router.use(authRouter);
router.use(healthRouter);
router.use(toolsRouter);
+8 -6
View File
@@ -1,5 +1,5 @@
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 { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
import {
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
const tagList = (tags ?? "").split(",").map((t) => t.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) {
query = query.where(eq(toolsTable.category, category));
conditions.push(eq(toolsTable.category, category));
}
if (search) {
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) {
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) {
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 toolIds = tools.map((t) => t.id);
+43 -1
View File
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
import { db, usersTable } from "@workspace/db";
import { requireAdmin } from "../middleware/auth";
import { writeAuditLog } from "../lib/audit";
import { passwordRateLimit } from "../lib/rate-limit";
import { z } from "zod";
const router: IRouter = Router();
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
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> => {
const users = await db
.select({
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
email: usersTable.email,
role: usersTable.role,
tier: usersTable.tier,
authProvider: usersTable.authProvider,
createdAt: usersTable.createdAt,
})
.from(usersTable)
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
return;
}
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
const [user] = await db
.insert(usersTable)
.values({
+1
View File
@@ -14,5 +14,6 @@ declare module "express-session" {
codeVerifier?: string;
returnTo?: string;
oidcState?: string;
csrfToken?: string;
}
}
+44 -43
View File
@@ -10,34 +10,34 @@
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/vite": "catalog:",
@@ -45,29 +45,30 @@
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "catalog:",
"chokidar": "^4.0.3",
"chokidar": "5.0.0",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"fast-glob": "^3.3.3",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"embla-carousel-react": "8.6.0",
"fast-glob": "3.3.3",
"framer-motion": "catalog:",
"input-otp": "^1.4.2",
"input-otp": "1.4.2",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"next-themes": "0.4.6",
"react": "catalog:",
"react-day-picker": "^9.14.0",
"react-day-picker": "10.0.1",
"react-dom": "catalog:",
"react-hook-form": "^7.75.0",
"react-resizable-panels": "^2.1.9",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"react-hook-form": "7.84.0",
"react-is": "19.2.8",
"react-resizable-panels": "4.12.2",
"recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"tailwindcss-animate": "1.0.7",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"vite": "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",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"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"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return (
<div
key={item.dataKey}
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn(
"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"
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
)
ChartLegendContent.displayName = "ChartLegend"
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.Group
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
)}
{...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
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
)}
{...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+50 -47
View File
@@ -4,44 +4,44 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --config vite.config.ts --host 0.0.0.0",
"build": "vite build --config vite.config.ts",
"dev": "node ../../scripts/src/sync-release-docs.mjs && vite --config vite.config.ts --host 0.0.0.0",
"build": "node ../../scripts/src/sync-release-docs.mjs && vite build --config vite.config.ts",
"serve": "vite preview --config vite.config.ts --host 0.0.0.0",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.4",
"@radix-ui/react-alert-dialog": "^1.1.7",
"@radix-ui/react-aspect-ratio": "^1.1.3",
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.1.5",
"@radix-ui/react-collapsible": "^1.1.4",
"@radix-ui/react-context-menu": "^2.2.7",
"@radix-ui/react-dialog": "^1.1.7",
"@radix-ui/react-dropdown-menu": "^2.1.7",
"@radix-ui/react-hover-card": "^1.1.7",
"@radix-ui/react-label": "^2.1.3",
"@radix-ui/react-menubar": "^1.1.7",
"@radix-ui/react-navigation-menu": "^1.2.6",
"@radix-ui/react-popover": "^1.1.7",
"@radix-ui/react-progress": "^1.1.3",
"@radix-ui/react-radio-group": "^1.2.4",
"@radix-ui/react-scroll-area": "^1.2.4",
"@radix-ui/react-select": "^2.1.7",
"@radix-ui/react-separator": "^1.1.3",
"@radix-ui/react-slider": "^1.2.4",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-switch": "^1.1.4",
"@radix-ui/react-tabs": "^1.1.4",
"@radix-ui/react-toast": "^1.2.7",
"@radix-ui/react-toggle": "^1.1.3",
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@hookform/resolvers": "5.7.1",
"@radix-ui/react-accordion": "1.2.20",
"@radix-ui/react-alert-dialog": "1.1.23",
"@radix-ui/react-aspect-ratio": "1.1.15",
"@radix-ui/react-avatar": "1.2.6",
"@radix-ui/react-checkbox": "1.3.11",
"@radix-ui/react-collapsible": "1.1.20",
"@radix-ui/react-context-menu": "2.3.7",
"@radix-ui/react-dialog": "1.1.23",
"@radix-ui/react-dropdown-menu": "2.1.24",
"@radix-ui/react-hover-card": "1.1.23",
"@radix-ui/react-label": "2.1.15",
"@radix-ui/react-menubar": "1.1.24",
"@radix-ui/react-navigation-menu": "1.2.22",
"@radix-ui/react-popover": "1.1.23",
"@radix-ui/react-progress": "1.1.16",
"@radix-ui/react-radio-group": "1.4.7",
"@radix-ui/react-scroll-area": "1.2.18",
"@radix-ui/react-select": "2.3.7",
"@radix-ui/react-separator": "1.1.15",
"@radix-ui/react-slider": "1.4.7",
"@radix-ui/react-slot": "1.3.3",
"@radix-ui/react-switch": "1.3.7",
"@radix-ui/react-tabs": "1.1.21",
"@radix-ui/react-toast": "1.2.23",
"@radix-ui/react-toggle": "1.1.18",
"@radix-ui/react-toggle-group": "1.1.19",
"@radix-ui/react-tooltip": "1.2.16",
"@replit/vite-plugin-cartographer": "catalog:",
"@replit/vite-plugin-dev-banner": "catalog:",
"@replit/vite-plugin-runtime-error-modal": "catalog:",
"@tailwindcss/typography": "^0.5.15",
"@tailwindcss/typography": "0.5.20",
"@tailwindcss/vite": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-virtual": "catalog:",
@@ -52,29 +52,32 @@
"@workspace/api-client-react": "workspace:*",
"class-variance-authority": "catalog:",
"clsx": "catalog:",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"cmdk": "1.1.1",
"date-fns": "4.4.0",
"dompurify": "catalog:",
"embla-carousel-react": "8.6.0",
"framer-motion": "catalog:",
"i18next": "^26.3.6",
"input-otp": "^1.4.2",
"i18next": "26.3.6",
"input-otp": "1.4.2",
"lucide-react": "catalog:",
"next-themes": "^0.4.6",
"marked": "catalog:",
"next-themes": "0.4.6",
"react": "catalog:",
"react-day-picker": "^9.11.1",
"react-day-picker": "10.0.1",
"react-dom": "catalog:",
"react-hook-form": "^7.55.0",
"react-i18next": "^17.0.11",
"react-icons": "^5.4.0",
"react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2",
"sonner": "^2.0.7",
"react-hook-form": "7.84.0",
"react-i18next": "17.0.11",
"react-icons": "5.7.0",
"react-is": "19.2.8",
"react-resizable-panels": "4.12.2",
"recharts": "3.10.1",
"sonner": "2.0.7",
"tailwind-merge": "catalog:",
"tailwindcss": "catalog:",
"tw-animate-css": "^1.4.0",
"vaul": "^1.1.2",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
"vite": "catalog:",
"wouter": "^3.3.5",
"wouter": "catalog:",
"zod": "catalog:"
}
}
+8
View File
@@ -1,7 +1,9 @@
import { Switch, Route, Router as WouterRouter } from "wouter";
import { useEffect } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nextProvider } from "react-i18next";
import i18n from "@/i18n";
import { loadCsrfToken } from "@/lib/csrf";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -18,6 +20,7 @@ import Trash from "@/pages/trash";
import Compare from "@/pages/compare";
import Watchlist from "@/pages/watchlist";
import Login from "@/pages/login";
import Docs from "@/pages/docs";
import NotFound from "@/pages/not-found";
const queryClient = new QueryClient({
@@ -44,12 +47,17 @@ function Router() {
<Route path="/admin" component={Admin} />
<Route path="/admin/redundancy" component={Redundancy} />
<Route path="/trash" component={Trash} />
<Route path="/docs" component={Docs} />
<Route path="/docs/:version" component={Docs} />
<Route component={NotFound} />
</Switch>
);
}
function App() {
useEffect(() => {
void loadCsrfToken();
}, []);
return (
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
@@ -37,6 +37,10 @@ function buildCrumbs(location: string, t: TFunction): Crumb[] {
crumbs.push({ label: t("compare.title") });
} else if (location.startsWith("/analytics")) {
crumbs.push({ label: t("nav.analytics") });
} else if (location.startsWith("/docs")) {
crumbs.push({ href: "/docs", label: t("nav.docs") });
const match = location.match(/^\/docs\/(.+)$/);
if (match) crumbs.push({ label: match[1] });
} else if (location.startsWith("/login")) {
crumbs.push({ label: t("auth.signIn") });
}
+3 -3
View File
@@ -1,5 +1,5 @@
import { Link, useLocation } from "wouter";
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Bookmark, Search } from "lucide-react";
import { LayoutDashboard, Wrench, BarChart3, FileText, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useAuth } from "@/hooks/use-auth";
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
@@ -30,7 +30,7 @@ import {
export function Layout({ children }: { children: React.ReactNode }) {
const [location] = useLocation();
const { t } = useTranslation();
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
const { data: version } = useGetVersion({
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
});
@@ -39,7 +39,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
{ 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 },
];
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",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
month_grid: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"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<
HTMLDivElement,
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
RechartsPrimitive.TooltipContentProps &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
return (
<div
key={item.dataKey}
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
className={cn(
"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"
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
const ResizablePanelGroup = ({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
<ResizablePrimitive.PanelGroup
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
<ResizablePrimitive.Group
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
)}
{...props}
@@ -24,12 +24,12 @@ const ResizableHandle = ({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) => (
<ResizablePrimitive.PanelResizeHandle
<ResizablePrimitive.Separator
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
)}
{...props}
@@ -39,7 +39,7 @@ const ResizableHandle = ({
<GripVertical className="h-2.5 w-2.5" />
</div>
)}
</ResizablePrimitive.PanelResizeHandle>
</ResizablePrimitive.Separator>
)
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
+121 -1
View File
@@ -14,7 +14,13 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} 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";
function initials(name?: string | null): string {
@@ -32,6 +38,17 @@ export function UserMenu() {
const { t } = useTranslation();
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
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 showTrash = hasFeature("trash");
@@ -67,6 +84,7 @@ export function UserMenu() {
}
return (
<>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
@@ -122,6 +140,16 @@ export function UserMenu() {
<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
className="text-destructive focus:text-destructive"
onClick={logout}
@@ -132,5 +160,97 @@ export function UserMenu() {
</DropdownMenuItem>
</DropdownMenuContent>
</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>
</>
);
}
+24 -2
View File
@@ -12,6 +12,7 @@
"trash": "Papierkorb",
"admin": "Admin",
"redundancy": "Redundanz",
"docs": "Doku",
"search": "Tools suchen…"
},
"auth": {
@@ -24,7 +25,17 @@
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
"notAuthenticated": "Nicht angemeldet",
"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": {
"cancel": "Abbrechen",
@@ -117,7 +128,12 @@
"relatedTools": "Ähnliche Tools",
"costs": "Kosten",
"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": {
"title": "Tools vergleichen",
@@ -166,6 +182,12 @@
"text": "Diese Seite existiert nicht.",
"backHome": "Zurück zur Startseite"
},
"docs": {
"title": "Versionsdokumentation",
"subtitle": "Version-gebundene Dokumentation je Release — was ist neu, was hat sich geändert und was beim Upgrade zu beachten ist.",
"backToIndex": "Alle Releases",
"noDocs": "Noch keine Release-Dokumentation verfügbar."
},
"command": {
"navigate": "Navigation",
"recent": "Zuletzt besucht",
+24 -2
View File
@@ -12,6 +12,7 @@
"trash": "Trash",
"admin": "Admin",
"redundancy": "Redundancy",
"docs": "Docs",
"search": "Search tools…"
},
"auth": {
@@ -24,7 +25,17 @@
"invalidCredentials": "Invalid username or password",
"notAuthenticated": "Not authenticated",
"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": {
"cancel": "Cancel",
@@ -117,7 +128,12 @@
"relatedTools": "Related Tools",
"costs": "Costs",
"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": {
"title": "Compare Tools",
@@ -166,6 +182,12 @@
"text": "This page doesn't exist.",
"backHome": "Back to Home"
},
"docs": {
"title": "Release Documentation",
"subtitle": "Version-bound documentation for each release — what's new, what changed, and what to know when upgrading.",
"backToIndex": "All releases",
"noDocs": "No release documentation available yet."
},
"command": {
"navigate": "Navigate",
"recent": "Recent",
+27
View File
@@ -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;
}
}
+55 -6
View File
@@ -5,6 +5,7 @@ import {
useCreateUser,
useUpdateUser,
useDeleteUser,
useSetUserPassword,
useListAuditLogs,
useGetVersion,
getListUsersQueryKey,
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Layout } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/password-input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -35,7 +37,8 @@ export default function Admin() {
const queryClient = useQueryClient();
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 [newUsername, setNewUsername] = useState("");
@@ -58,6 +61,7 @@ export default function Admin() {
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const setUserPassword = useSetUserPassword();
if (!authLoading && !isAdmin) {
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 = () => {
if (!deleteConfirm) return;
deleteUser.mutate(
@@ -206,7 +230,7 @@ export default function Admin() {
variant="ghost"
size="icon"
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" />
</Button>
@@ -344,7 +368,7 @@ export default function Admin() {
</div>
<div className="space-y-2">
<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 className="space-y-2">
<Label>Email (Optional)</Label>
@@ -385,7 +409,7 @@ export default function Admin() {
</DialogContent>
</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">
<DialogHeader>
<DialogTitle>Edit User {editUser?.username}</DialogTitle>
@@ -422,15 +446,40 @@ export default function Admin() {
</SelectContent>
</Select>
</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>
<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
onClick={handleUpdateUser}
disabled={updateUser.isPending}
>
Save
</Button>
{editUser?.authProvider !== "oidc" && (
<Button
variant="outline"
onClick={handleSetUserPassword}
disabled={setUserPassword.isPending || !editPassword}
>
{setUserPassword.isPending ? "Setting…" : "Set Password"}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+197
View File
@@ -0,0 +1,197 @@
import { useEffect, useState } from "react";
import { Link, useLocation, useParams } from "wouter";
import { marked } from "marked";
import DOMPurify from "dompurify";
import { useTranslation } from "react-i18next";
import { Layout } from "@/components/layout";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { FileText, CalendarDays, Tag, ArrowLeft, ExternalLink } from "lucide-react";
type ReleaseDoc = {
version: string;
file: string;
title: string;
date: string | null;
};
const DOCS_BASE = `${import.meta.env.BASE_URL.replace(/\/$/, "")}/docs`;
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 useReleases() {
const [releases, setReleases] = useState<ReleaseDoc[] | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let cancelled = false;
fetchJson<ReleaseDoc[]>(`${DOCS_BASE}/index.json`)
.then((data) => {
if (!cancelled) setReleases(data);
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, []);
return { releases, error };
}
function useReleaseMarkdown(file: string) {
const [html, setHtml] = useState<string | null>(null);
const [error, setError] = useState<boolean>(false);
useEffect(() => {
let cancelled = false;
setHtml(null);
setError(false);
fetch(`${DOCS_BASE}/${encodeURIComponent(file)}`)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
})
.then(async (md) => {
const rendered = await marked.parse(md, { async: false, gfm: true });
if (!cancelled) setHtml(DOMPurify.sanitize(rendered));
})
.catch(() => {
if (!cancelled) setError(true);
});
return () => {
cancelled = true;
};
}, [file]);
return { html, error };
}
function DocsIndex() {
const { t } = useTranslation();
const { releases, error } = useReleases();
const [, setLocation] = useLocation();
return (
<Layout>
<div className="space-y-6 pb-10">
<div>
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("docs.title")}</h1>
<p className="text-muted-foreground">{t("docs.subtitle")}</p>
</div>
{error && (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
)}
{!releases && !error && (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
)}
{releases && releases.length === 0 && (
<p className="text-sm text-muted-foreground">{t("docs.noDocs")}</p>
)}
{releases && releases.length > 0 && (
<div className="space-y-3">
{releases.map((release) => (
<Card key={release.version} className="hover:bg-accent/50 transition-colors">
<CardContent className="p-0">
<button
type="button"
onClick={() => setLocation(`/docs/${release.version}`)}
className="flex w-full items-center gap-4 p-4 text-left"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<FileText className="h-5 w-5 text-primary" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{release.title}</span>
<Badge variant="secondary">{release.version}</Badge>
</div>
{release.date && (
<p className="mt-0.5 flex items-center gap-1.5 text-sm text-muted-foreground">
<CalendarDays className="h-3.5 w-3.5" />
{new Date(`${release.date}T00:00:00`).toLocaleDateString()}
</p>
)}
</div>
<ExternalLink className="h-4 w-4 shrink-0 text-muted-foreground" />
</button>
</CardContent>
</Card>
))}
</div>
)}
</div>
</Layout>
);
}
function DocsDetail() {
const { t } = useTranslation();
const params = useParams<{ version: string }>();
const version = params.version;
const { releases, error: indexError } = useReleases();
const release = releases?.find((r) => r.version === version);
const { html, error: mdError } = useReleaseMarkdown(release?.file ?? `${version}.md`);
return (
<Layout>
<div className="space-y-6 pb-10">
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" size="sm" asChild>
<Link href="/docs">
<ArrowLeft className="h-4 w-4 mr-1" />
{t("docs.backToIndex")}
</Link>
</Button>
{release && (
<a
href={`https://git.kubebase.de/admin/tool-evaluator/tags/${release.version}`}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
>
<Tag className="h-4 w-4" /> {release.version}
</a>
)}
</div>
{mdError || (release && !html) ? (
<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>
</Layout>
);
}
export default function Docs() {
const [location] = useLocation();
const match = location.match(/^\/docs\/(.+)$/);
return match ? <DocsDetail /> : <DocsIndex />;
}
+4 -2
View File
@@ -5,6 +5,8 @@ import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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 { ThemeToggle } from "@/components/theme-toggle";
import { Wrench, AlertCircle } from "lucide-react";
@@ -32,6 +34,7 @@ export default function Login() {
{
onSuccess: () => {
queryClient.invalidateQueries();
void loadCsrfToken();
setLocation(returnTo);
},
onError: (err) => {
@@ -76,9 +79,8 @@ export default function Login() {
</div>
<div className="space-y-2">
<Label htmlFor="password">{t("auth.password")}</Label>
<Input
<PasswordInput
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
+5 -5
View File
@@ -914,23 +914,23 @@ export default function ToolDetail() {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{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>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={handleDelete}
disabled={deleteTool.isPending}
>
{deleteTool.isPending ? "Deleting" : "Delete"}
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+38
View File
@@ -0,0 +1,38 @@
# Release-Dokumentation
Jeder Release hat eine version-gebundene Dokumentation unter
`docs/releases/`. Die Dokumentation wird öffentlich in der App unter
`/docs` (Index) und `/docs/<version>` (Detail) angezeigt.
## Struktur
- `docs/releases/TEMPLATE.md` — Vorlage für neue Releases
- `docs/releases/vX.Y.Z.md` — Dokumentation pro Release (eine Datei je Version)
## Inhalt
Pro Release wird abgedeckt (kombiniert):
- **Changelog:** Neue Features, Fixes & Verbesserungen
- **API-Änderungen:** Neue/geänderte/entfernte Endpunkte (Delta zur Vorversion)
- **Betrieb / Upgrade:** Env-Vars, DB-Migrationen, Breaking Changes
## 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 seit dem letzten Tag
ableiten:
```sh
git log --oneline vX.Y.Z-1..vX.Y.Z
```
(Funktions-/Fix-Commits in die passenden Abschnitte übernehmen, API-Delta
anhand `lib/api-spec/openapi.yaml` prüfen.)
3. **Committen & pushen.** Der Sync-Schritt (`scripts/sync-release-docs.mjs`)
kopiert die Markdown-Dateien beim Frontend-Build automatisch nach
`artifacts/toolrate/public/docs/` und generiert `index.json`. Dadurch sind
die Releases im Deployment als `/docs/...` verfügbar.
> Hinweis: `index.json` und die kopierten Dateien unter
> `artifacts/toolrate/public/docs/` sind Build-Artefakte und werden bei jedem
> Build neu generiert — nicht von Hand bearbeiten.
+54
View File
@@ -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.
+35
View File
@@ -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)
+37
View File
@@ -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)
+18
View File
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
let _baseUrl: string | 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
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
_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 {
return typeof Request !== "undefined" && input instanceof Request;
}
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
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
// Authorization header has been explicitly provided.
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.
* Api
* ToolRate API — Tool listing and rating platform
@@ -30,6 +30,10 @@ export interface AuthMode {
mode: AuthModeMode;
}
export interface CsrfToken {
token: string;
}
export interface LocalLoginInput {
username: string;
password: string;
@@ -52,6 +56,14 @@ export const UserTier = {
enterprise: 'enterprise',
} as const;
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
export const UserAuthProvider = {
local: 'local',
oidc: 'oidc',
} as const;
export interface User {
id: number;
username: string;
@@ -59,6 +71,7 @@ export interface User {
email?: string | null;
role: UserRole;
tier?: UserTier;
authProvider?: UserAuthProvider;
createdAt: string;
}
@@ -111,6 +124,23 @@ export interface UserRoleUpdate {
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 {
id: number;
entityType: string;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
export * from "./generated/api";
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";
+147
View File
@@ -570,6 +570,19 @@ paths:
schema:
$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:
post:
operationId: localLogin
@@ -614,6 +627,58 @@ paths:
schema:
$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:
get:
operationId: getMePreferences
@@ -783,6 +848,51 @@ paths:
"204":
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:
get:
operationId: listAuditLogs
@@ -845,6 +955,13 @@ components:
type: string
enum: [oidc, local]
CsrfToken:
type: object
required: [token]
properties:
token:
type: string
LocalLoginInput:
type: object
required: [username, password]
@@ -870,6 +987,10 @@ components:
tier:
type: string
enum: [free, premium, enterprise]
authProvider:
type: string
enum: [local, oidc]
default: local
createdAt:
type: string
format: date-time
@@ -903,6 +1024,32 @@ components:
type: string
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:
type: object
required: [id, entityType, action, userId, username, createdAt]
+1 -1
View File
@@ -6,6 +6,6 @@
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
},
"devDependencies": {
"orval": "^8.9.1"
"orval": "8.23.0"
}
}
+172 -66
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
@@ -25,7 +25,7 @@ export const GetVersionResponse = zod.object({
"version": zod.string(),
"commitSha": 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({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -58,7 +58,7 @@ export const ListToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.number(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable()
@@ -84,16 +84,8 @@ export const CreateToolBody = zod.object({
"tags": zod.array(zod.string()).optional()
})
/**
* @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(),
export const CreateToolResponse = zod.object({
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -104,7 +96,31 @@ export const ListCompareToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": 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(),
"avgUsability": 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
*/
export const GetToolRatingHistoryParams = zod.object({
"id": zod.coerce.number()
"id": zod.coerce.number().int()
})
export const GetToolRatingHistoryResponseItem = zod.object({
@@ -132,11 +148,11 @@ export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryRespon
* @summary Get a tool by ID
*/
export const GetToolParams = zod.object({
"id": zod.coerce.number()
"id": zod.coerce.number().int()
})
export const GetToolResponse = zod.object({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -147,7 +163,7 @@ export const GetToolResponse = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.number(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable()
@@ -158,7 +174,7 @@ export const GetToolResponse = zod.object({
* @summary Update a tool
*/
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({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -195,9 +211,11 @@ export const UpdateToolResponse = zod.object({
* @summary Delete a tool
*/
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
@@ -207,7 +225,7 @@ export const ListTrashedToolsQueryParams = zod.object({
})
export const ListTrashedToolsResponseItem = zod.object({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -232,11 +250,11 @@ export const trashToolsBodyIdsMax = 500;
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({
"trashed": zod.number().optional()
"trashed": zod.int().optional()
})
@@ -248,9 +266,11 @@ export const deleteTrashedToolsBodyIdsMax = 500;
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
@@ -260,11 +280,11 @@ export const restoreToolsBodyIdsMax = 500;
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({
"restored": zod.number().optional()
"restored": zod.int().optional()
})
@@ -272,7 +292,7 @@ export const RestoreToolsResponse = zod.object({
* @summary Permanently delete all trashed tools (admin)
*/
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
*/
export const ListToolRatingsParams = zod.object({
"id": zod.coerce.number()
"id": zod.coerce.number().int()
})
export const listToolRatingsResponseUsefulnessMax = 5;
@@ -290,10 +310,10 @@ export const listToolRatingsResponseUsabilityMax = 5;
export const ListToolRatingsResponseItem = zod.object({
"id": zod.number(),
"toolId": zod.number(),
"usefulness": zod.number().min(1).max(listToolRatingsResponseUsefulnessMax),
"usability": zod.number().min(1).max(listToolRatingsResponseUsabilityMax),
"id": zod.int(),
"toolId": zod.int(),
"usefulness": zod.int().min(1).max(listToolRatingsResponseUsefulnessMax),
"usability": zod.int().min(1).max(listToolRatingsResponseUsabilityMax),
"comment": zod.string().nullish(),
"reviewerName": zod.string().nullish(),
"createdAt": zod.coerce.date()
@@ -305,7 +325,7 @@ export const ListToolRatingsResponse = zod.array(ListToolRatingsResponseItem)
* @summary Submit a rating for a tool
*/
export const CreateRatingParams = zod.object({
"id": zod.coerce.number()
"id": zod.coerce.number().int()
})
export const createRatingBodyUsefulnessMax = 5;
@@ -315,25 +335,41 @@ export const createRatingBodyUsabilityMax = 5;
export const CreateRatingBody = zod.object({
"usefulness": zod.number().min(1).max(createRatingBodyUsefulnessMax),
"usability": zod.number().min(1).max(createRatingBodyUsabilityMax),
"usefulness": zod.int().min(1).max(createRatingBodyUsefulnessMax),
"usability": zod.int().min(1).max(createRatingBodyUsabilityMax),
"comment": 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
*/
export const GetAnalyticsSummaryResponse = zod.object({
"totalTools": zod.number(),
"totalRatings": zod.number(),
"totalTools": zod.int(),
"totalRatings": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable(),
"categoriesCount": zod.number(),
"categoriesCount": zod.int(),
"mostRatedTool": zod.object({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -344,7 +380,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.number(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable()
@@ -356,13 +392,13 @@ export const GetAnalyticsSummaryResponse = zod.object({
* @summary Top-rated tools
*/
export const GetTopToolsQueryParams = zod.object({
"limit": zod.coerce.number().optional(),
"limit": zod.coerce.number().int().optional(),
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
})
export const GetTopToolsResponseItem = zod.object({
"tool": zod.object({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -373,13 +409,13 @@ export const GetTopToolsResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.number(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable()
}),
"score": zod.number(),
"ratingCount": zod.number()
"ratingCount": zod.int()
})
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
@@ -389,8 +425,8 @@ export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
*/
export const GetAnalyticsByCategoryResponseItem = zod.object({
"category": zod.string(),
"toolCount": zod.number(),
"totalRatings": zod.number(),
"toolCount": zod.int(),
"totalRatings": zod.int(),
"avgUsefulness": 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
*/
export const GetRatingDistributionQueryParams = zod.object({
"toolId": zod.coerce.number().optional()
"toolId": zod.coerce.number().int().optional()
})
export const GetRatingDistributionResponse = zod.object({
"usefulness": zod.array(zod.object({
"score": zod.number(),
"count": zod.number()
"score": zod.int(),
"count": zod.int()
})),
"usability": zod.array(zod.object({
"score": zod.number(),
"count": zod.number()
"score": zod.int(),
"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
*/
@@ -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
*/
export const GetMePreferencesResponse = zod.object({
"view": zod.enum(['grid', 'table', 'rows']).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({
"view": zod.enum(['grid', 'table', 'rows']).optional(),
"density": zod.enum(['cozy', 'compact']).optional(),
"watchlist": zod.array(zod.number()).optional()
"watchlist": zod.array(zod.int()).optional()
})
export const UpdateMePreferencesResponse = zod.object({
"view": zod.enum(['grid', 'table', 'rows']).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)
*/
export const GetMeWatchlistResponseItem = zod.object({
"id": zod.number(),
"id": zod.int(),
"name": zod.string(),
"description": zod.string(),
"category": zod.string(),
@@ -521,7 +589,7 @@ export const GetMeWatchlistResponseItem = zod.object({
"tags": zod.array(zod.string()).optional(),
"createdAt": zod.coerce.date(),
"updatedAt": zod.coerce.date(),
"ratingCount": zod.number(),
"ratingCount": zod.int(),
"avgUsefulness": zod.number().nullable(),
"avgUsability": zod.number().nullable(),
"avgCombined": zod.number().nullable()
@@ -532,12 +600,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
/**
* @summary List all local users (admin only)
*/
export const listUsersResponseAuthProviderDefault = `local`;
export const ListUsersResponseItem = zod.object({
"id": zod.number(),
"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(listUsersResponseAuthProviderDefault),
"createdAt": zod.coerce.date()
})
export const ListUsersResponse = zod.array(ListUsersResponseItem)
@@ -560,12 +631,24 @@ export const CreateUserBody = zod.object({
"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)
*/
export const UpdateUserParams = zod.object({
"id": zod.coerce.number()
"id": zod.coerce.number().int()
})
export const UpdateUserBody = zod.object({
@@ -573,12 +656,15 @@ export const UpdateUserBody = zod.object({
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
})
export const updateUserResponseAuthProviderDefault = `local`;
export const UpdateUserResponse = zod.object({
"id": zod.number(),
"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(updateUserResponseAuthProviderDefault),
"createdAt": zod.coerce.date()
})
@@ -587,23 +673,43 @@ export const UpdateUserResponse = zod.object({
* @summary Delete a user (admin only)
*/
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)
*/
export const ListAuditLogsQueryParams = zod.object({
"entityType": zod.coerce.string().optional(),
"entityId": zod.coerce.number().optional(),
"limit": zod.coerce.number().optional()
"entityId": zod.coerce.number().int().optional(),
"limit": zod.coerce.number().int().optional()
})
export const ListAuditLogsResponseItem = zod.object({
"id": zod.number(),
"id": zod.int(),
"entityType": zod.string(),
"entityId": zod.number().nullish(),
"entityId": zod.int().nullish(),
"action": zod.string(),
"userId": 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.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+6 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
@@ -14,6 +14,8 @@ export * from './authUser';
export * from './authUserRole';
export * from './authUserTier';
export * from './categoryStats';
export * from './changePasswordInput';
export * from './csrfToken';
export * from './emptyTrash200';
export * from './errorResponse';
export * from './getRatingDistributionParams';
@@ -26,12 +28,14 @@ export * from './listToolsParams';
export * from './listToolsSort';
export * from './listTrashedToolsParams';
export * from './localLoginInput';
export * from './passwordRedirect';
export * from './rating';
export * from './ratingDistribution';
export * from './ratingHistoryItem';
export * from './ratingInput';
export * from './restoreTools200';
export * from './scoreBucket';
export * from './setPasswordInput';
export * from './tool';
export * from './toolInput';
export * from './toolUpdate';
@@ -40,6 +44,7 @@ export * from './topToolEntry';
export * from './trashTools200';
export * from './trashToolsInput';
export * from './user';
export * from './userAuthProvider';
export * from './userCreateInput';
export * from './userCreateInputRole';
export * from './userCreateInputTier';
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+3 -1
View File
@@ -1,10 +1,11 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* ToolRate API — Tool listing and rating platform
* OpenAPI spec version: 0.1.0
*/
import type { UserAuthProvider } from './userAuthProvider';
import type { UserRole } from './userRole';
import type { UserTier } from './userTier';
@@ -15,5 +16,6 @@ export interface User {
email?: string | null;
role: UserRole;
tier?: UserTier;
authProvider?: UserAuthProvider;
createdAt: Date;
}
@@ -0,0 +1,15 @@
/**
* 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 type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
export const UserAuthProvider = {
local: 'local',
oidc: 'oidc',
} as const;
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* 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.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Generated by orval v8.9.1 🍺
* Generated by orval v8.23.0 🍺
* Do not edit manually.
* Api
* 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.
* Api
* ToolRate API — Tool listing and rating platform
+4 -4
View File
@@ -13,13 +13,13 @@
},
"dependencies": {
"drizzle-orm": "catalog:",
"drizzle-zod": "^0.8.3",
"pg": "^8.20.0",
"drizzle-zod": "0.8.3",
"pg": "8.22.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"drizzle-kit": "^0.31.10"
"@types/pg": "8.20.3",
"drizzle-kit": "0.31.10"
}
}
+6 -17
View File
@@ -10,24 +10,13 @@
},
"private": true,
"devDependencies": {
"prettier": "^3.8.3",
"typescript": "~5.9.3"
},
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"linux"
],
"cpu": [
"current",
"x64"
]
}
"prettier": "3.9.6",
"typescript": "7.0.2"
},
"optionalDependencies": {
"@rollup/rollup-darwin-arm64": "^4.62.4",
"@tailwindcss/oxide-darwin-arm64": "^4.3.3",
"lightningcss-darwin-arm64": "^1.33.0"
"@esbuild/darwin-arm64": "0.28.1",
"@rollup/rollup-darwin-arm64": "4.62.4",
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
"lightningcss-darwin-arm64": "1.33.0"
}
}
+2561 -1928
View File
File diff suppressed because it is too large Load Diff
+50 -32
View File
@@ -27,6 +27,14 @@
# ============================================================================
minimumReleaseAge: 1440
supportedArchitectures:
os:
- current
- linux
cpu:
- current
- x64
minimumReleaseAgeExclude:
# Exclude @replit scoped packages from the minimum release age check.
# These are published by Replit and trusted — the supply-chain attack vector
@@ -41,39 +49,42 @@ packages:
- scripts
catalog:
'@replit/vite-plugin-cartographer': ^0.5.1
'@replit/vite-plugin-dev-banner': ^0.1.1
'@replit/vite-plugin-runtime-error-modal': ^0.0.6
'@tailwindcss/vite': ^4.1.14
'@tanstack/react-query': ^5.90.21
'@tanstack/react-virtual': ^3.13.6
'@types/node': ^25.3.3
'@types/react': ^19.2.0
'@types/react-dom': ^19.2.0
'@vitejs/plugin-react': ^5.0.4
class-variance-authority: ^0.7.1
clsx: ^2.1.1
drizzle-orm: ^0.45.2
framer-motion: ^12.23.24
lucide-react: ^0.545.0
# Must be this exact version because expo requires it
react: 19.1.0
# Must be this exact version because expo requires it
react-dom: 19.1.0
tailwind-merge: ^3.3.1
tailwindcss: ^4.1.14
tsx: ^4.21.0
vite: ^7.3.4
wouter: ^3.3.5
zod: ^3.25.76
'@replit/vite-plugin-cartographer': 0.6.1
'@replit/vite-plugin-dev-banner': 0.1.2
'@replit/vite-plugin-runtime-error-modal': 0.0.6
'@tailwindcss/vite': 4.3.3
'@tanstack/react-query': 5.101.4
'@tanstack/react-virtual': 3.14.9
'@types/node': 26.1.2
'@types/react': 19.2.18
'@types/react-dom': 19.2.4
'@vitejs/plugin-react': 6.0.5
class-variance-authority: 0.7.1
clsx: 2.1.1
dompurify: 3.4.12
drizzle-orm: 0.45.2
framer-motion: 12.43.0
lucide-react: 1.28.0
marked: 18.0.7
react: 19.2.8
react-dom: 19.2.8
tailwind-merge: 3.6.0
tailwindcss: 4.3.3
tsx: 4.23.4
vite: 8.2.0
wouter: 3.10.0
zod: 4.4.3
autoInstallPeers: false
onlyBuiltDependencies:
- '@swc/core'
- esbuild
- msw
- unrs-resolver
# pnpm 11: allowBuilds (map) replaces onlyBuiltDependencies (list).
# Build scripts are blocked by default (supply-chain defense). Only allow
# scripts for packages that genuinely need a postinstall to work.
allowBuilds:
'@swc/core': true
esbuild: true
msw: true
unrs-resolver: true
overrides:
# replit uses linux-x64 only, we can exclude all other platforms
@@ -157,7 +168,14 @@ overrides:
"@expo/ngrok-bin>@expo/ngrok-bin-win32-ia32": "-"
"@expo/ngrok-bin>@expo/ngrok-bin-win32-x64": "-"
# drizzle-kit uses esbuild internally on an older version that's vulnerable, this overrides it
"@esbuild-kit/esm-loader": "npm:tsx@^4.21.0"
esbuild: "0.27.3"
"@esbuild-kit/esm-loader": "npm:tsx@4.23.1"
esbuild: "0.28.1"
# Fix GHSA-q8mj-m7cp-5q26: qs DoS via stringify with null/undefined in comma-format arrays
qs: ">=6.15.2"
# Fix GHSA-v422-hmwv-36x6: body-parser DoS via invalid limit value (express dep)
body-parser: ">=2.3.0"
# Build-time tooling (orval/typedoc) advisories: force patched versions
markdown-it: ">=14.1.2"
linkify-it: ">=5.0.2"
brace-expansion: ">=5.0.8"
fast-uri: ">=3.1.4"
+29
View File
@@ -0,0 +1,29 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":maintainLockFilesWeekly",
":semanticCommitTypeAll(chore)"
],
"packageRules": [
{
"matchDepTypes": ["dependencies", "devDependencies", "peerDependencies"],
"matchUpdateTypes": ["patch", "minor"],
"groupName": "non-breaking updates",
"minimumReleaseAge": "1 day"
},
{
"matchUpdateTypes": ["major"],
"groupName": "major updates",
"minimumReleaseAge": "7 days",
"labels": ["major-update"],
"prPriority": 5
},
{
"matchPackageNames": ["react", "react-dom", "esbuild"],
"enabled": false
}
],
"schedule": ["on the first day of the week"],
"lockFileMaintenance": { "enabled": true }
}

Some files were not shown because too many files have changed in this diff Show More