Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b1274e34a | |||
| 2f66fff993 | |||
| bcae59626f | |||
| f851305d78 | |||
| 743b177c89 | |||
| 68a81ec775 | |||
| 63f0bdbab6 | |||
| 4648614556 | |||
| 53f01acb0c |
@@ -14,6 +14,14 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
|
- name: Security audit (fails on any prod or high/critical finding)
|
||||||
|
run: |
|
||||||
|
pnpm audit --prod
|
||||||
|
pnpm audit --audit-level high
|
||||||
|
|
||||||
- name: Install Docker CLI
|
- name: Install Docker CLI
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
apt-get update -qq
|
||||||
@@ -52,12 +60,17 @@ jobs:
|
|||||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
SHA=$(git rev-parse --short HEAD)
|
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
|
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||||
cd /tmp/apps
|
cd /tmp/apps
|
||||||
cd apps/system/toolrate/overlays/k3s
|
cd apps/system/toolrate/overlays/k3s
|
||||||
sed -i "s|newTag: .*|newTag: sha-${SHA}|" kustomization.yaml
|
sed -i "s|newTag: .*|newTag: ${NEWTAG}|" kustomization.yaml
|
||||||
git config user.name "Gitea Actions"
|
git config user.name "Gitea Actions"
|
||||||
git config user.email "actions@git.kubebase.de"
|
git config user.email "actions@git.kubebase.de"
|
||||||
git add -A
|
git add -A
|
||||||
git diff --cached --quiet || git commit -m "chore: update toolrate image to sha-${SHA}"
|
git diff --cached --quiet || git commit -m "chore: update toolrate image to ${NEWTAG}"
|
||||||
git push
|
git push
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
auto-install-peers=false
|
auto-install-peers=false
|
||||||
strict-peer-dependencies=false
|
strict-peer-dependencies=false
|
||||||
|
save-exact=true
|
||||||
|
|||||||
@@ -1,35 +0,0 @@
|
|||||||
modules = ["nodejs-24", "postgresql-16"]
|
|
||||||
|
|
||||||
[deployment]
|
|
||||||
router = "application"
|
|
||||||
deploymentTarget = "autoscale"
|
|
||||||
|
|
||||||
[deployment.postBuild]
|
|
||||||
args = ["pnpm", "store", "prune"]
|
|
||||||
env = { "CI" = "true" }
|
|
||||||
|
|
||||||
[workflows]
|
|
||||||
runButton = "Project"
|
|
||||||
|
|
||||||
[agent]
|
|
||||||
stack = "PNPM_WORKSPACE"
|
|
||||||
expertMode = true
|
|
||||||
|
|
||||||
[postMerge]
|
|
||||||
path = "scripts/post-merge.sh"
|
|
||||||
timeoutMs = 20000
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8080
|
|
||||||
externalPort = 80
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 8081
|
|
||||||
externalPort = 8081
|
|
||||||
|
|
||||||
[[ports]]
|
|
||||||
localPort = 26015
|
|
||||||
externalPort = 3000
|
|
||||||
|
|
||||||
[nix]
|
|
||||||
channel = "stable-25_05"
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
# The format of this file is identical to `.dockerignore`.
|
|
||||||
# It is used to reduce the size of deployed images to make the process of publishing faster.
|
|
||||||
|
|
||||||
# No need to store the pnpm store twice.
|
|
||||||
.local
|
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
FROM node:24-alpine AS builder
|
FROM node:24.18.1-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
# 1. Alle Projektdateien in den Container bringen
|
# 1. Alle Projektdateien in den Container bringen
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -17,10 +17,10 @@ ENV PORT=8080
|
|||||||
|
|
||||||
RUN pnpm -r --if-present run build
|
RUN pnpm -r --if-present run build
|
||||||
|
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24.18.1-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN npm install -g pnpm@10.26.1
|
RUN npm install -g pnpm@11.18.0
|
||||||
|
|
||||||
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
|
||||||
COPY lib/db/package.json lib/db/
|
COPY lib/db/package.json lib/db/
|
||||||
|
|||||||
+38
-35
@@ -2,10 +2,30 @@
|
|||||||
|
|
||||||
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
Community platform for listing and rating developer/productivity tools by usefulness and usability.
|
||||||
|
|
||||||
## Run & Operate
|
## Stack
|
||||||
|
|
||||||
- `pnpm --filter @workspace/api-server run dev` — run the API server (port 8080)
|
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||||
- `pnpm --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
- API: Express 5 + express-session + connect-pg-simple
|
||||||
|
- DB: PostgreSQL + Drizzle ORM
|
||||||
|
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
||||||
|
- API codegen: Orval (from OpenAPI spec)
|
||||||
|
- Build: esbuild (CJS bundle)
|
||||||
|
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
||||||
|
|
||||||
|
## Packages
|
||||||
|
|
||||||
|
- `lib/api-spec` — OpenAPI contract (source of truth)
|
||||||
|
- `lib/api-zod` — generated Zod schemas from the spec
|
||||||
|
- `lib/api-client-react` — generated React Query hooks
|
||||||
|
- `lib/db` — Drizzle schema + DB access
|
||||||
|
- `artifacts/api-server` — Express backend
|
||||||
|
- `artifacts/toolrate` — React frontend
|
||||||
|
- `artifacts/mockup-sandbox` — component preview
|
||||||
|
|
||||||
|
## Run & develop
|
||||||
|
|
||||||
|
- `pnpm --filter @workspace/api-server run dev` — run the API server
|
||||||
|
- `pnpm --filter @workspace/toolrate run dev` — run the frontend
|
||||||
- `pnpm run typecheck` — full typecheck across all packages
|
- `pnpm run typecheck` — full typecheck across all packages
|
||||||
- `pnpm run build` — typecheck + build all packages
|
- `pnpm run build` — typecheck + build all packages
|
||||||
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
- `pnpm --filter @workspace/api-spec run codegen` — regenerate API hooks and Zod schemas from the OpenAPI spec
|
||||||
@@ -13,29 +33,16 @@ Community platform for listing and rating developer/productivity tools by useful
|
|||||||
|
|
||||||
## Required env vars
|
## Required env vars
|
||||||
|
|
||||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
- `DATABASE_URL` — Postgres connection string
|
||||||
- `SESSION_SECRET` — Session signing secret (already set)
|
- `SESSION_SECRET` — session signing secret
|
||||||
|
- `VOTER_SECRET` — secret for voter submissions
|
||||||
|
- `LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD` — local admin login
|
||||||
|
|
||||||
## Keycloak (optional)
|
## Auth
|
||||||
|
|
||||||
Set these to enable login:
|
- Session-based auth (not JWT), sessions stored in Postgres via connect-pg-simple.
|
||||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
- Write operations (create/update/delete tools, submit ratings) require auth; reads are public.
|
||||||
- `KEYCLOAK_REALM` — realm name
|
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
|
||||||
- `KEYCLOAK_CLIENT_ID` — client ID
|
|
||||||
- `KEYCLOAK_CLIENT_SECRET` — client secret
|
|
||||||
- `APP_URL` — public base URL for OAuth callback (optional, auto-detected if omitted)
|
|
||||||
|
|
||||||
Without these, the app runs in read-only mode (browsing and viewing ratings works, submitting tools/ratings requires login).
|
|
||||||
|
|
||||||
## Stack
|
|
||||||
|
|
||||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
|
||||||
- API: Express 5 + openid-client (Keycloak OIDC) + express-session + connect-pg-simple
|
|
||||||
- DB: PostgreSQL + Drizzle ORM
|
|
||||||
- Validation: Zod (`zod/v4`), `drizzle-zod`
|
|
||||||
- API codegen: Orval (from OpenAPI spec)
|
|
||||||
- Build: esbuild (CJS bundle)
|
|
||||||
- Frontend: React 19 + Vite + TanStack Query + wouter + shadcn/ui + recharts
|
|
||||||
|
|
||||||
## Where things live
|
## Where things live
|
||||||
|
|
||||||
@@ -45,7 +52,7 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
- `lib/api-zod/src/generated/` — generated Zod schemas (do not edit)
|
||||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||||
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
- `artifacts/api-server/src/middleware/auth.ts` — `requireAuth` middleware
|
||||||
- `artifacts/api-server/src/routes/auth.ts` — Keycloak OIDC login/callback/logout/me
|
- `artifacts/api-server/src/routes/auth.ts` — login/logout/me
|
||||||
- `artifacts/toolrate/src/pages/` — frontend pages
|
- `artifacts/toolrate/src/pages/` — frontend pages
|
||||||
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
- `artifacts/toolrate/src/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||||
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
- `artifacts/toolrate/src/hooks/use-auth.ts` — auth state hook
|
||||||
@@ -54,9 +61,9 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
|
|
||||||
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
- Contract-first: OpenAPI spec → codegen → typed hooks + Zod schemas. Never hand-write fetch calls.
|
||||||
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
- Session-based auth (not JWT) — sessions stored in Postgres via connect-pg-simple.
|
||||||
- Write operations (create/update/delete tools, submit ratings) require auth. Reads are public.
|
- Write operations (create/update/delete tools, submit votes) require auth. Reads are public.
|
||||||
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
- Category and feature autocomplete are client-side filtered against live API data (no separate index).
|
||||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
|
||||||
|
|
||||||
## Product
|
## Product
|
||||||
|
|
||||||
@@ -66,16 +73,12 @@ Without these, the app runs in read-only mode (browsing and viewing ratings work
|
|||||||
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
- Analytics dashboard: top tools chart, category breakdown, score distribution histograms
|
||||||
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
- Grafana integration: all `/api/analytics/*` endpoints return clean JSON
|
||||||
|
|
||||||
## User preferences
|
## Deployment
|
||||||
|
|
||||||
_Populate as you build — explicit user instructions worth remembering across sessions._
|
- Container image: `git.kubebase.de/admin/tool-evaluator:<tag>` (pushed by CI)
|
||||||
|
- Deployed to k3s via the `apps` repo (kustomize), served at `https://toolr.kubebase.de`
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
- Orval clears the output folder during codegen — transient HMR errors in the dev server are normal and auto-recover.
|
||||||
- Auth routes use PKCE — the code_verifier is stored in the session, not in-memory state.
|
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
|
||||||
- `trust proxy: 1` is set on Express so that session cookies work correctly behind Replit's reverse proxy.
|
|
||||||
|
|
||||||
## Pointers
|
|
||||||
|
|
||||||
- See the `pnpm-workspace` skill for workspace structure, TypeScript setup, and package details
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
kind = "api"
|
|
||||||
previewPath = "/api" # TODO - should be excluded from preview in the first place
|
|
||||||
title = "API Server"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "3B4_FFSkEVBkAeYMFRJ2e"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8080
|
|
||||||
name = "API Server"
|
|
||||||
paths = ["/api"]
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/api-server run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
|
|
||||||
[services.production.build]
|
|
||||||
args = ["pnpm", "--filter", "@workspace/api-server", "run", "build"]
|
|
||||||
|
|
||||||
[services.production.build.env]
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.run]
|
|
||||||
# we don't run through pnpm to make startup faster in production
|
|
||||||
args = ["node", "--enable-source-maps", "artifacts/api-server/dist/index.mjs"]
|
|
||||||
|
|
||||||
[services.production.run.env]
|
|
||||||
PORT = "8080"
|
|
||||||
NODE_ENV = "production"
|
|
||||||
|
|
||||||
[services.production.health.startup]
|
|
||||||
path = "/api/healthz"
|
|
||||||
@@ -12,29 +12,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@workspace/api-zod": "workspace:*",
|
"@workspace/api-zod": "workspace:*",
|
||||||
"@workspace/db": "workspace:*",
|
"@workspace/db": "workspace:*",
|
||||||
"bcryptjs": "^3.0.3",
|
"bcryptjs": "3.0.3",
|
||||||
"zod": "catalog:",
|
"connect-pg-simple": "10.0.0",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"cookie-parser": "1.4.7",
|
||||||
"cookie-parser": "^1.4.7",
|
"cors": "2.8.6",
|
||||||
"cors": "^2.8.6",
|
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"express": "^5.2.1",
|
"express": "5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-rate-limit": "8.6.1",
|
||||||
"openid-client": "^5.7.1",
|
"express-session": "1.19.0",
|
||||||
"pino": "^9.14.0",
|
"openid-client": "6.8.4",
|
||||||
"pino-http": "^10.5.0"
|
"pino": "10.3.1",
|
||||||
|
"pino-http": "11.0.0",
|
||||||
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/connect-pg-simple": "7.0.3",
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cookie-parser": "^1.4.10",
|
"@types/cors": "2.8.19",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/express": "5.0.6",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/express-session": "^1.19.0",
|
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"esbuild": "0.27.3",
|
"esbuild": "0.28.1",
|
||||||
"esbuild-plugin-pino": "^2.3.3",
|
"esbuild-plugin-pino": "2.3.3",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "13.1.3",
|
||||||
"thread-stream": "3.1.0"
|
"thread-stream": "4.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import rateLimit from "express-rate-limit";
|
||||||
|
import type { Request } from "express";
|
||||||
|
|
||||||
|
export const loginRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 10,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
message: { error: "Too many login attempts, please try again later." },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const passwordRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000,
|
||||||
|
limit: 5,
|
||||||
|
standardHeaders: "draft-7",
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: (req: Request): string => String(req.session.user?.sub ?? req.ip ?? "unknown"),
|
||||||
|
message: { error: "Too many password attempts, please try again later." },
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import type { Request, Response, NextFunction } from "express";
|
||||||
|
|
||||||
|
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS", "TRACE"]);
|
||||||
|
|
||||||
|
export function getCsrfToken(req: Request): string {
|
||||||
|
if (!req.session.csrfToken) {
|
||||||
|
req.session.csrfToken = crypto.randomBytes(24).toString("hex");
|
||||||
|
}
|
||||||
|
return req.session.csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function csrfProtection(req: Request, res: Response, next: NextFunction): void {
|
||||||
|
if (SAFE_METHODS.has(req.method.toUpperCase())) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const provided = req.headers["x-csrf-token"];
|
||||||
|
const token = getCsrfToken(req);
|
||||||
|
if (typeof provided === "string" && provided && provided === token) {
|
||||||
|
next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.status(403).json({ error: "CSRF token missing or invalid" });
|
||||||
|
}
|
||||||
@@ -1,15 +1,29 @@
|
|||||||
import { Router, type IRouter, type Request } from "express";
|
import { Router, type IRouter, type Request } from "express";
|
||||||
import { Issuer, generators, type Client } from "openid-client";
|
import {
|
||||||
|
discovery,
|
||||||
|
randomPKCECodeVerifier,
|
||||||
|
calculatePKCECodeChallenge,
|
||||||
|
randomState,
|
||||||
|
buildAuthorizationUrl,
|
||||||
|
authorizationCodeGrant,
|
||||||
|
fetchUserInfo,
|
||||||
|
buildEndSessionUrl,
|
||||||
|
skipSubjectCheck,
|
||||||
|
type Configuration,
|
||||||
|
} from "openid-client";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { eq, and, inArray, isNull } from "drizzle-orm";
|
import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||||
import { logger } from "../lib/logger";
|
import { logger } from "../lib/logger";
|
||||||
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { getEntitlements, requireFeature } from "../middleware/feature";
|
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||||
|
import { getCsrfToken } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
let cachedClient: Client | null = null;
|
let cachedConfig: Configuration | null = null;
|
||||||
|
|
||||||
function isOidcConfigured(): boolean {
|
function isOidcConfigured(): boolean {
|
||||||
return !!(
|
return !!(
|
||||||
@@ -36,8 +50,8 @@ function isSafeReturnTo(value: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClient(): Promise<Client | null> {
|
async function getClient(): Promise<Configuration | null> {
|
||||||
if (cachedClient) return cachedClient;
|
if (cachedConfig) return cachedConfig;
|
||||||
|
|
||||||
const keycloakUrl = process.env.KEYCLOAK_URL;
|
const keycloakUrl = process.env.KEYCLOAK_URL;
|
||||||
const realm = process.env.KEYCLOAK_REALM;
|
const realm = process.env.KEYCLOAK_REALM;
|
||||||
@@ -49,14 +63,9 @@ async function getClient(): Promise<Client | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const issuerUrl = `${keycloakUrl}/realms/${realm}`;
|
const issuerUrl = new URL(`${keycloakUrl}/realms/${realm}`);
|
||||||
const issuer = await Issuer.discover(issuerUrl);
|
cachedConfig = await discovery(issuerUrl, clientId, clientSecret);
|
||||||
cachedClient = new issuer.Client({
|
return cachedConfig;
|
||||||
client_id: clientId,
|
|
||||||
client_secret: clientSecret,
|
|
||||||
response_types: ["code"],
|
|
||||||
});
|
|
||||||
return cachedClient;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error({ err }, "Failed to discover Keycloak issuer");
|
logger.error({ err }, "Failed to discover Keycloak issuer");
|
||||||
return null;
|
return null;
|
||||||
@@ -107,7 +116,11 @@ router.get("/auth/mode", (_req, res): void => {
|
|||||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
router.get("/auth/csrf", (req, res): void => {
|
||||||
|
res.json({ token: getCsrfToken(req) });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||||
if (isOidcConfigured()) {
|
if (isOidcConfigured()) {
|
||||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||||
return;
|
return;
|
||||||
@@ -169,9 +182,9 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codeVerifier = generators.codeVerifier();
|
const codeVerifier = randomPKCECodeVerifier();
|
||||||
const codeChallenge = generators.codeChallenge(codeVerifier);
|
const codeChallenge = await calculatePKCECodeChallenge(codeVerifier);
|
||||||
const state = generators.state();
|
const state = randomState();
|
||||||
|
|
||||||
req.session.codeVerifier = codeVerifier;
|
req.session.codeVerifier = codeVerifier;
|
||||||
req.session.oidcState = state;
|
req.session.oidcState = state;
|
||||||
@@ -180,7 +193,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
const url = client.authorizationUrl({
|
const url = buildAuthorizationUrl(client, {
|
||||||
scope: "openid email profile",
|
scope: "openid email profile",
|
||||||
code_challenge: codeChallenge,
|
code_challenge: codeChallenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
@@ -188,7 +201,7 @@ router.get("/auth/login", async (req, res): Promise<void> => {
|
|||||||
state,
|
state,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.redirect(url);
|
res.redirect(url.href);
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get("/auth/callback", async (req, res): Promise<void> => {
|
router.get("/auth/callback", async (req, res): Promise<void> => {
|
||||||
@@ -214,13 +227,13 @@ router.get("/auth/callback", async (req, res): Promise<void> => {
|
|||||||
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
const redirectUri = `${getBaseUrl(req)}/api/auth/callback`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const params = client.callbackParams(req);
|
const tokens = await authorizationCodeGrant(
|
||||||
const tokenSet = await client.callback(redirectUri, params, {
|
client,
|
||||||
code_verifier: codeVerifier,
|
new URL(req.originalUrl ?? "/", getBaseUrl(req)),
|
||||||
state,
|
{ pkceCodeVerifier: codeVerifier, expectedState: state },
|
||||||
});
|
);
|
||||||
|
|
||||||
const userinfo = await client.userinfo(tokenSet.access_token!);
|
const userinfo = await fetchUserInfo(client, tokens.access_token, skipSubjectCheck);
|
||||||
const dbUser = await upsertUserFromOidc(userinfo);
|
const dbUser = await upsertUserFromOidc(userinfo);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
@@ -252,9 +265,9 @@ router.get("/auth/logout", async (req, res): Promise<void> => {
|
|||||||
req.session.destroy(() => {});
|
req.session.destroy(() => {});
|
||||||
|
|
||||||
const client = await getClient();
|
const client = await getClient();
|
||||||
if (client && client.issuer.metadata.end_session_endpoint) {
|
if (client && client.serverMetadata().end_session_endpoint) {
|
||||||
const logoutUrl = client.endSessionUrl({ post_logout_redirect_uri: getBaseUrl(req) });
|
const logoutUrl = buildEndSessionUrl(client, { post_logout_redirect_uri: getBaseUrl(req) });
|
||||||
res.redirect(logoutUrl);
|
res.redirect(logoutUrl.href);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,6 +292,58 @@ router.get("/auth/me", async (req, res): Promise<void> => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get("/auth/password-redirect", async (req, res): Promise<void> => {
|
||||||
|
const client = await getClient();
|
||||||
|
if (!client) {
|
||||||
|
res.json({ url: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const realm = client.serverMetadata().issuer ?? "";
|
||||||
|
res.json({ url: `${realm}/account/password` });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ChangePasswordSchema = z.object({
|
||||||
|
currentPassword: z.string().min(1),
|
||||||
|
newPassword: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
res.status(401).json({ error: "Not authenticated" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dbUser = await resolveDbUser(req.session.user);
|
||||||
|
if (!dbUser) {
|
||||||
|
res.status(401).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dbUser.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { currentPassword, newPassword } = parsed.data;
|
||||||
|
if (currentPassword === newPassword) {
|
||||||
|
res.status(400).json({ error: "New password must differ from current password" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||||
|
res.status(400).json({ error: "Current password is incorrect" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||||
|
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||||
|
});
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||||
|
|
||||||
async function resolveDbUser(u: SessionUser) {
|
async function resolveDbUser(u: SessionUser) {
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import usersRouter from "./users";
|
|||||||
import auditRouter from "./audit";
|
import auditRouter from "./audit";
|
||||||
import costsRouter from "./costs";
|
import costsRouter from "./costs";
|
||||||
import adminRouter from "./admin";
|
import adminRouter from "./admin";
|
||||||
|
import { csrfProtection } from "../middleware/csrf";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
|
router.use(csrfProtection);
|
||||||
|
|
||||||
router.use(authRouter);
|
router.use(authRouter);
|
||||||
router.use(healthRouter);
|
router.use(healthRouter);
|
||||||
router.use(toolsRouter);
|
router.use(toolsRouter);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Router, type IRouter } from "express";
|
import { Router, type IRouter } from "express";
|
||||||
import { eq, desc, asc, sql, and, not, isNull, inArray } from "drizzle-orm";
|
import { eq, desc, asc, sql, and, not, isNull, inArray, type SQL } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
import { db, toolsTable, ratingsTable, toolRelationsTable } from "@workspace/db";
|
||||||
import {
|
import {
|
||||||
@@ -57,21 +57,23 @@ router.get("/tools", async (req, res): Promise<void> => {
|
|||||||
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
const tagList = (tags ?? "").split(",").map((t) => t.trim()).filter(Boolean);
|
||||||
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
const featureList = (features ?? "").split(",").map((f) => f.trim()).filter(Boolean);
|
||||||
|
|
||||||
let query = db.select().from(toolsTable).where(isNull(toolsTable.deletedAt)).$dynamic();
|
const conditions: SQL[] = [isNull(toolsTable.deletedAt)];
|
||||||
if (category) {
|
if (category) {
|
||||||
query = query.where(eq(toolsTable.category, category));
|
conditions.push(eq(toolsTable.category, category));
|
||||||
}
|
}
|
||||||
if (search) {
|
if (search) {
|
||||||
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
const escaped = search.replace(/[%_\\]/g, (m) => `\\${m}`);
|
||||||
query = query.where(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
conditions.push(sql`${toolsTable.name} ilike ${`%${escaped}%`} escape '\\'`);
|
||||||
}
|
}
|
||||||
if (tagList.length > 0) {
|
if (tagList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.tags} @> ARRAY[${sql.join(tagList.map((t) => sql`${t}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
if (featureList.length > 0) {
|
if (featureList.length > 0) {
|
||||||
query = query.where(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
conditions.push(sql`${toolsTable.features} @> ARRAY[${sql.join(featureList.map((f) => sql`${f}`), sql`, `)}]::text[]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const query = db.select().from(toolsTable).where(and(...conditions));
|
||||||
|
|
||||||
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
const tools = await query.orderBy(desc(toolsTable.createdAt));
|
||||||
|
|
||||||
const toolIds = tools.map((t) => t.id);
|
const toolIds = tools.map((t) => t.id);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
|||||||
import { db, usersTable } from "@workspace/db";
|
import { db, usersTable } from "@workspace/db";
|
||||||
import { requireAdmin } from "../middleware/auth";
|
import { requireAdmin } from "../middleware/auth";
|
||||||
import { writeAuditLog } from "../lib/audit";
|
import { writeAuditLog } from "../lib/audit";
|
||||||
|
import { passwordRateLimit } from "../lib/rate-limit";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
|||||||
tier: Tier.optional(),
|
tier: Tier.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SetPasswordSchema = z.object({
|
||||||
|
password: z.string().min(6),
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||||
|
const id = parseInt(String(req.params.id), 10);
|
||||||
|
if (isNaN(id)) {
|
||||||
|
res.status(400).json({ error: "Invalid user id" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
res.status(400).json({ error: parsed.error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [target] = await db
|
||||||
|
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||||
|
.from(usersTable)
|
||||||
|
.where(eq(usersTable.id, id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
res.status(404).json({ error: "User not found" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target.authProvider !== "local") {
|
||||||
|
res.status(422).json({ error: "oidc" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
|
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||||
|
|
||||||
|
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||||
|
res.sendStatus(204);
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||||
const users = await db
|
const users = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
email: usersTable.email,
|
email: usersTable.email,
|
||||||
role: usersTable.role,
|
role: usersTable.role,
|
||||||
tier: usersTable.tier,
|
tier: usersTable.tier,
|
||||||
|
authProvider: usersTable.authProvider,
|
||||||
createdAt: usersTable.createdAt,
|
createdAt: usersTable.createdAt,
|
||||||
})
|
})
|
||||||
.from(usersTable)
|
.from(usersTable)
|
||||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
.insert(usersTable)
|
.insert(usersTable)
|
||||||
.values({
|
.values({
|
||||||
|
|||||||
+1
@@ -14,5 +14,6 @@ declare module "express-session" {
|
|||||||
codeVerifier?: string;
|
codeVerifier?: string;
|
||||||
returnTo?: string;
|
returnTo?: string;
|
||||||
oidcState?: string;
|
oidcState?: string;
|
||||||
|
csrfToken?: string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
kind = "design"
|
|
||||||
previewPath = "/__mockup"
|
|
||||||
title = "Canvas"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "XegfDyZt7HqfW2Bb8Ghoy"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
localPort = 8081
|
|
||||||
name = "Component Preview Server"
|
|
||||||
paths = ["/__mockup"]
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "8081"
|
|
||||||
BASE_PATH = "/__mockup"
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/mockup-sandbox run dev"
|
|
||||||
@@ -10,34 +10,34 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.12",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.15",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.8",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.16",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.15",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.8",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.3.8",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.2.6",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.3.6",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.2.6",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.13",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.15",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
@@ -45,29 +45,30 @@
|
|||||||
"@types/react": "catalog:",
|
"@types/react": "catalog:",
|
||||||
"@types/react-dom": "catalog:",
|
"@types/react-dom": "catalog:",
|
||||||
"@vitejs/plugin-react": "catalog:",
|
"@vitejs/plugin-react": "catalog:",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "5.0.0",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"fast-glob": "^3.3.3",
|
"fast-glob": "3.3.3",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.14.0",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-resizable-panels": "^2.1.9",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.4",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "1.0.7",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import * as RechartsPrimitive from "recharts"
|
|||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
const THEMES = { light: "", dark: ".dark" } as const
|
const THEMES = { light: "", dark: ".dark" } as const
|
||||||
|
|
||||||
export type ChartConfig = {
|
export type ChartConfig = {
|
||||||
@@ -101,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -191,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -260,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
@@ -317,6 +318,7 @@ const ChartLegendContent = React.forwardRef<
|
|||||||
)
|
)
|
||||||
ChartLegendContent.displayName = "ChartLegend"
|
ChartLegendContent.displayName = "ChartLegend"
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
function getPayloadConfigFromPayload(
|
function getPayloadConfigFromPayload(
|
||||||
config: ChartConfig,
|
config: ChartConfig,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
kind = "web"
|
|
||||||
previewPath = "/"
|
|
||||||
title = "ToolRate — Tool-Bewertungsplattform"
|
|
||||||
version = "1.0.0"
|
|
||||||
id = "artifacts/toolrate"
|
|
||||||
router = "path"
|
|
||||||
|
|
||||||
[[integratedSkills]]
|
|
||||||
name = "react-vite"
|
|
||||||
version = "1.0.0"
|
|
||||||
|
|
||||||
[[services]]
|
|
||||||
name = "web"
|
|
||||||
paths = [ "/" ]
|
|
||||||
localPort = 26015
|
|
||||||
|
|
||||||
[services.development]
|
|
||||||
run = "pnpm --filter @workspace/toolrate run dev"
|
|
||||||
|
|
||||||
[services.production]
|
|
||||||
build = [ "pnpm", "--filter", "@workspace/toolrate", "run", "build" ]
|
|
||||||
publicDir = "artifacts/toolrate/dist/public"
|
|
||||||
serve = "static"
|
|
||||||
|
|
||||||
[[services.production.rewrites]]
|
|
||||||
from = "/*"
|
|
||||||
to = "/index.html"
|
|
||||||
|
|
||||||
[services.env]
|
|
||||||
PORT = "26015"
|
|
||||||
BASE_PATH = "/"
|
|
||||||
@@ -10,38 +10,38 @@
|
|||||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "5.7.1",
|
||||||
"@radix-ui/react-accordion": "^1.2.4",
|
"@radix-ui/react-accordion": "1.2.20",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.7",
|
"@radix-ui/react-alert-dialog": "1.1.23",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.3",
|
"@radix-ui/react-aspect-ratio": "1.1.15",
|
||||||
"@radix-ui/react-avatar": "^1.1.4",
|
"@radix-ui/react-avatar": "1.2.6",
|
||||||
"@radix-ui/react-checkbox": "^1.1.5",
|
"@radix-ui/react-checkbox": "1.3.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.4",
|
"@radix-ui/react-collapsible": "1.1.20",
|
||||||
"@radix-ui/react-context-menu": "^2.2.7",
|
"@radix-ui/react-context-menu": "2.3.7",
|
||||||
"@radix-ui/react-dialog": "^1.1.7",
|
"@radix-ui/react-dialog": "1.1.23",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.7",
|
"@radix-ui/react-dropdown-menu": "2.1.24",
|
||||||
"@radix-ui/react-hover-card": "^1.1.7",
|
"@radix-ui/react-hover-card": "1.1.23",
|
||||||
"@radix-ui/react-label": "^2.1.3",
|
"@radix-ui/react-label": "2.1.15",
|
||||||
"@radix-ui/react-menubar": "^1.1.7",
|
"@radix-ui/react-menubar": "1.1.24",
|
||||||
"@radix-ui/react-navigation-menu": "^1.2.6",
|
"@radix-ui/react-navigation-menu": "1.2.22",
|
||||||
"@radix-ui/react-popover": "^1.1.7",
|
"@radix-ui/react-popover": "1.1.23",
|
||||||
"@radix-ui/react-progress": "^1.1.3",
|
"@radix-ui/react-progress": "1.1.16",
|
||||||
"@radix-ui/react-radio-group": "^1.2.4",
|
"@radix-ui/react-radio-group": "1.4.7",
|
||||||
"@radix-ui/react-scroll-area": "^1.2.4",
|
"@radix-ui/react-scroll-area": "1.2.18",
|
||||||
"@radix-ui/react-select": "^2.1.7",
|
"@radix-ui/react-select": "2.3.7",
|
||||||
"@radix-ui/react-separator": "^1.1.3",
|
"@radix-ui/react-separator": "1.1.15",
|
||||||
"@radix-ui/react-slider": "^1.2.4",
|
"@radix-ui/react-slider": "1.4.7",
|
||||||
"@radix-ui/react-slot": "^1.2.0",
|
"@radix-ui/react-slot": "1.3.3",
|
||||||
"@radix-ui/react-switch": "^1.1.4",
|
"@radix-ui/react-switch": "1.3.7",
|
||||||
"@radix-ui/react-tabs": "^1.1.4",
|
"@radix-ui/react-tabs": "1.1.21",
|
||||||
"@radix-ui/react-toast": "^1.2.7",
|
"@radix-ui/react-toast": "1.2.23",
|
||||||
"@radix-ui/react-toggle": "^1.1.3",
|
"@radix-ui/react-toggle": "1.1.18",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "1.1.19",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "1.2.16",
|
||||||
"@replit/vite-plugin-cartographer": "catalog:",
|
"@replit/vite-plugin-cartographer": "catalog:",
|
||||||
"@replit/vite-plugin-dev-banner": "catalog:",
|
"@replit/vite-plugin-dev-banner": "catalog:",
|
||||||
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
"@replit/vite-plugin-runtime-error-modal": "catalog:",
|
||||||
"@tailwindcss/typography": "^0.5.15",
|
"@tailwindcss/typography": "0.5.20",
|
||||||
"@tailwindcss/vite": "catalog:",
|
"@tailwindcss/vite": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@tanstack/react-virtual": "catalog:",
|
"@tanstack/react-virtual": "catalog:",
|
||||||
@@ -52,29 +52,30 @@
|
|||||||
"@workspace/api-client-react": "workspace:*",
|
"@workspace/api-client-react": "workspace:*",
|
||||||
"class-variance-authority": "catalog:",
|
"class-variance-authority": "catalog:",
|
||||||
"clsx": "catalog:",
|
"clsx": "catalog:",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "4.4.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "8.6.0",
|
||||||
"framer-motion": "catalog:",
|
"framer-motion": "catalog:",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "26.3.6",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"lucide-react": "catalog:",
|
"lucide-react": "catalog:",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
"react-day-picker": "^9.11.1",
|
"react-day-picker": "10.0.1",
|
||||||
"react-dom": "catalog:",
|
"react-dom": "catalog:",
|
||||||
"react-hook-form": "^7.55.0",
|
"react-hook-form": "7.84.0",
|
||||||
"react-i18next": "^17.0.11",
|
"react-i18next": "17.0.11",
|
||||||
"react-icons": "^5.4.0",
|
"react-icons": "5.7.0",
|
||||||
"react-resizable-panels": "^2.1.7",
|
"react-is": "19.2.8",
|
||||||
"recharts": "^2.15.2",
|
"react-resizable-panels": "4.12.2",
|
||||||
"sonner": "^2.0.7",
|
"recharts": "3.10.1",
|
||||||
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "catalog:",
|
"tailwind-merge": "catalog:",
|
||||||
"tailwindcss": "catalog:",
|
"tailwindcss": "catalog:",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "1.1.2",
|
||||||
"vite": "catalog:",
|
"vite": "catalog:",
|
||||||
"wouter": "^3.3.5",
|
"wouter": "catalog:",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Switch, Route, Router as WouterRouter } from "wouter";
|
import { Switch, Route, Router as WouterRouter } from "wouter";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { I18nextProvider } from "react-i18next";
|
import { I18nextProvider } from "react-i18next";
|
||||||
import i18n from "@/i18n";
|
import i18n from "@/i18n";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
|
|
||||||
@@ -50,6 +52,9 @@ function Router() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCsrfToken();
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import { LayoutDashboard, Wrench, PlusCircle, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Trash2, Bookmark, Search } from "lucide-react";
|
import { LayoutDashboard, Wrench, BarChart3, LogIn, LogOut, ShieldCheck, AlertTriangle, Search } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||||
@@ -30,7 +30,7 @@ import {
|
|||||||
export function Layout({ children }: { children: React.ReactNode }) {
|
export function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const [location] = useLocation();
|
const [location] = useLocation();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
|
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
|
||||||
const { data: version } = useGetVersion({
|
const { data: version } = useGetVersion({
|
||||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||||
});
|
});
|
||||||
@@ -38,10 +38,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
const mainLinks = [
|
const mainLinks = [
|
||||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||||
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
{ href: "/tools", label: t("nav.browseTools"), icon: Wrench },
|
||||||
{ href: "/tools/new", label: t("nav.addTool"), icon: PlusCircle },
|
|
||||||
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
{ href: "/analytics", label: t("nav.analytics"), icon: BarChart3 },
|
||||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
|
||||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminLinks = [
|
const adminLinks = [
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function PasswordInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: Omit<React.ComponentProps<"input">, "type">) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className={cn("relative", className)}>
|
||||||
|
<Input type={visible ? "text" : "password"} className="pr-9" {...props} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="absolute right-0 top-0 flex h-9 w-9 items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
aria-label={visible ? "Hide password" : "Show password"}
|
||||||
|
tabIndex={-1}
|
||||||
|
>
|
||||||
|
{visible ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -84,7 +84,7 @@ function Calendar({
|
|||||||
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
: "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5",
|
||||||
defaultClassNames.caption_label
|
defaultClassNames.caption_label
|
||||||
),
|
),
|
||||||
table: "w-full border-collapse",
|
month_grid: "w-full border-collapse",
|
||||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||||
weekday: cn(
|
weekday: cn(
|
||||||
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
"text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal",
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip
|
|||||||
|
|
||||||
const ChartTooltipContent = React.forwardRef<
|
const ChartTooltipContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
RechartsPrimitive.TooltipContentProps &
|
||||||
React.ComponentProps<"div"> & {
|
React.ComponentProps<"div"> & {
|
||||||
hideLabel?: boolean
|
hideLabel?: boolean
|
||||||
hideIndicator?: boolean
|
hideIndicator?: boolean
|
||||||
@@ -192,7 +192,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.dataKey}
|
key={typeof item.dataKey === "string" || typeof item.dataKey === "number" ? item.dataKey : index}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
indicator === "dot" && "items-center"
|
indicator === "dot" && "items-center"
|
||||||
@@ -261,7 +261,7 @@ const ChartLegend = RechartsPrimitive.Legend
|
|||||||
const ChartLegendContent = React.forwardRef<
|
const ChartLegendContent = React.forwardRef<
|
||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
React.ComponentProps<"div"> &
|
React.ComponentProps<"div"> &
|
||||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
Pick<RechartsPrimitive.DefaultLegendContentProps, "payload" | "verticalAlign"> & {
|
||||||
hideIcon?: boolean
|
hideIcon?: boolean
|
||||||
nameKey?: string
|
nameKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { cn } from "@/lib/utils"
|
|||||||
const ResizablePanelGroup = ({
|
const ResizablePanelGroup = ({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
|
}: React.ComponentProps<typeof ResizablePrimitive.Group>) => (
|
||||||
<ResizablePrimitive.PanelGroup
|
<ResizablePrimitive.Group
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
"flex h-full w-full data-[group-orientation=vertical]:flex-col",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -24,12 +24,12 @@ const ResizableHandle = ({
|
|||||||
withHandle,
|
withHandle,
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
|
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||||
withHandle?: boolean
|
withHandle?: boolean
|
||||||
}) => (
|
}) => (
|
||||||
<ResizablePrimitive.PanelResizeHandle
|
<ResizablePrimitive.Separator
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
"relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[group-orientation=vertical]:h-px data-[group-orientation=vertical]:w-full data-[group-orientation=vertical]:after:left-0 data-[group-orientation=vertical]:after:h-1 data-[group-orientation=vertical]:after:w-full data-[group-orientation=vertical]:after:-translate-y-1/2 data-[group-orientation=vertical]:after:translate-x-0 [&[data-group-orientation=vertical]>div]:rotate-90",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -39,7 +39,7 @@ const ResizableHandle = ({
|
|||||||
<GripVertical className="h-2.5 w-2.5" />
|
<GripVertical className="h-2.5 w-2.5" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ResizablePrimitive.PanelResizeHandle>
|
</ResizablePrimitive.Separator>
|
||||||
)
|
)
|
||||||
|
|
||||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||||
|
|||||||
@@ -14,7 +14,13 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Bookmark, LogIn, LogOut, Trash2, KeyRound } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
function initials(name?: string | null): string {
|
function initials(name?: string | null): string {
|
||||||
@@ -32,6 +38,17 @@ export function UserMenu() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [pwOpen, setPwOpen] = useState(false);
|
||||||
|
const [currentPassword, setCurrentPassword] = useState("");
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const { toast } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const changePassword = useChangeMyPassword();
|
||||||
|
const { data: passwordRedirect } = useGetPasswordRedirect({
|
||||||
|
query: { queryKey: getGetPasswordRedirectQueryKey(), enabled: isAuthenticated && !!user && !user.isLocal },
|
||||||
|
});
|
||||||
|
|
||||||
const showWatchlist = hasFeature("watchlist");
|
const showWatchlist = hasFeature("watchlist");
|
||||||
const showTrash = hasFeature("trash");
|
const showTrash = hasFeature("trash");
|
||||||
@@ -39,9 +56,9 @@ export function UserMenu() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<div className="flex items-center gap-3 px-3 py-2 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-0 group-data-[collapsible=icon]:gap-0">
|
||||||
<Skeleton className="h-8 w-8 rounded-full" />
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
<Skeleton className="h-4 w-24" />
|
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:hidden" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -51,12 +68,12 @@ export function UserMenu() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full gap-2"
|
className="w-full gap-2 group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center"
|
||||||
onClick={() => login(location)}
|
onClick={() => login(location)}
|
||||||
data-testid="button-login"
|
data-testid="button-login"
|
||||||
>
|
>
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
{t("auth.signIn")}
|
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -67,12 +84,13 @@ export function UserMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
<>
|
||||||
|
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-full justify-start gap-3 px-3 py-2 h-auto"
|
className="w-full justify-start gap-3 px-3 py-2 h-auto group-data-[collapsible=icon]:w-auto group-data-[collapsible=icon]:gap-0 group-data-[collapsible=icon]:justify-center group-data-[collapsible=icon]:px-1"
|
||||||
data-testid="button-user-menu"
|
data-testid="button-user-menu"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8">
|
<Avatar className="h-8 w-8">
|
||||||
@@ -80,7 +98,7 @@ export function UserMenu() {
|
|||||||
{initials(displayName)}
|
{initials(displayName)}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="min-w-0 flex-1 text-left">
|
<span className="min-w-0 flex-1 text-left group-data-[collapsible=icon]:hidden">
|
||||||
<span className="block text-sm font-medium truncate">{displayName}</span>
|
<span className="block text-sm font-medium truncate">{displayName}</span>
|
||||||
<span className="block text-[11px] text-muted-foreground truncate">
|
<span className="block text-[11px] text-muted-foreground truncate">
|
||||||
{user.email ?? tier}
|
{user.email ?? tier}
|
||||||
@@ -122,6 +140,16 @@ export function UserMenu() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPwOpen(true);
|
||||||
|
}}
|
||||||
|
data-testid="button-change-password"
|
||||||
|
>
|
||||||
|
<KeyRound className="mr-2 h-4 w-4" />
|
||||||
|
{t("auth.changePassword")}
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
@@ -132,5 +160,97 @@ export function UserMenu() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
{!user?.isLocal ? (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">{t("auth.oidcPasswordHint")}</p>
|
||||||
|
{passwordRedirect?.url && (
|
||||||
|
<Button asChild className="w-full">
|
||||||
|
<a href={passwordRedirect.url} target="_blank" rel="noreferrer">
|
||||||
|
<KeyRound className="w-4 h-4 mr-2" />
|
||||||
|
{t("auth.manageInIdp")}
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.currentPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={currentPassword}
|
||||||
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||||
|
data-testid="input-current-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.newPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={newPassword}
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
placeholder="min. 6 characters"
|
||||||
|
data-testid="input-new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{t("auth.confirmPassword")}</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
data-testid="input-confirm-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setPwOpen(false)}>{t("common.cancel")}</Button>
|
||||||
|
{user?.isLocal && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
toast({ title: t("auth.pwMismatch"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (newPassword.length < 6) {
|
||||||
|
toast({ title: t("auth.pwTooShort"), variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePassword.mutate(
|
||||||
|
{ data: { currentPassword, newPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: t("auth.pwChanged") });
|
||||||
|
setPwOpen(false);
|
||||||
|
setCurrentPassword("");
|
||||||
|
setNewPassword("");
|
||||||
|
setConfirmPassword("");
|
||||||
|
queryClient.invalidateQueries({ queryKey: getGetPasswordRedirectQueryKey() });
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const code = (err.data as { error?: string } | null)?.error;
|
||||||
|
if (code === "oidc") {
|
||||||
|
toast({ title: t("auth.oidcPasswordHint"), variant: "destructive" });
|
||||||
|
} else {
|
||||||
|
toast({ title: t("auth.pwChangeFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||||
|
data-testid="button-submit-change-password"
|
||||||
|
>
|
||||||
|
{changePassword.isPending ? "…" : t("auth.save")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,17 @@
|
|||||||
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||||
"notAuthenticated": "Nicht angemeldet",
|
"notAuthenticated": "Nicht angemeldet",
|
||||||
"loginSubtitle": "Melde dich bei deinem Konto an",
|
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||||
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren"
|
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren",
|
||||||
|
"changePassword": "Passwort ändern",
|
||||||
|
"currentPassword": "Aktuelles Passwort",
|
||||||
|
"newPassword": "Neues Passwort",
|
||||||
|
"confirmPassword": "Passwort bestätigen",
|
||||||
|
"pwMismatch": "Passwörter stimmen nicht überein",
|
||||||
|
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||||
|
"pwChanged": "Passwort geändert",
|
||||||
|
"pwChangeFailed": "Passwort konnte nicht geändert werden",
|
||||||
|
"oidcPasswordHint": "Ihr Passwort wird vom Identity-Provider (Keycloak) verwaltet.",
|
||||||
|
"manageInIdp": "In Keycloak verwalten"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
@@ -117,7 +127,12 @@
|
|||||||
"relatedTools": "Ähnliche Tools",
|
"relatedTools": "Ähnliche Tools",
|
||||||
"costs": "Kosten",
|
"costs": "Kosten",
|
||||||
"addCost": "Kosten hinzufügen",
|
"addCost": "Kosten hinzufügen",
|
||||||
"recentRatings": "Letzte Bewertungen"
|
"recentRatings": "Letzte Bewertungen",
|
||||||
|
"deleteConfirmTitle": "Dieses Tool löschen?",
|
||||||
|
"deleteToTrash": "Dies verschiebt {{name}} in den Papierkorb. Es kann später wiederhergestellt werden.",
|
||||||
|
"deletePermanent": "Dies entfernt {{name}} dauerhaft inklusive aller Bewertungen. Das kann nicht rückgängig gemacht werden.",
|
||||||
|
"deleting": "Löschen…",
|
||||||
|
"deleteAction": "Löschen"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Tools vergleichen",
|
"title": "Tools vergleichen",
|
||||||
|
|||||||
@@ -24,7 +24,17 @@
|
|||||||
"invalidCredentials": "Invalid username or password",
|
"invalidCredentials": "Invalid username or password",
|
||||||
"notAuthenticated": "Not authenticated",
|
"notAuthenticated": "Not authenticated",
|
||||||
"loginSubtitle": "Sign in to your account",
|
"loginSubtitle": "Sign in to your account",
|
||||||
"loginDescription": "Enter your credentials to continue"
|
"loginDescription": "Enter your credentials to continue",
|
||||||
|
"changePassword": "Change password",
|
||||||
|
"currentPassword": "Current password",
|
||||||
|
"newPassword": "New password",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"pwMismatch": "Passwords do not match",
|
||||||
|
"pwTooShort": "Password must be at least 8 characters",
|
||||||
|
"pwChanged": "Password changed",
|
||||||
|
"pwChangeFailed": "Could not change password",
|
||||||
|
"oidcPasswordHint": "Your password is managed by the identity provider (Keycloak).",
|
||||||
|
"manageInIdp": "Manage in Keycloak"
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
@@ -117,7 +127,12 @@
|
|||||||
"relatedTools": "Related Tools",
|
"relatedTools": "Related Tools",
|
||||||
"costs": "Costs",
|
"costs": "Costs",
|
||||||
"addCost": "Add Cost",
|
"addCost": "Add Cost",
|
||||||
"recentRatings": "Recent Ratings"
|
"recentRatings": "Recent Ratings",
|
||||||
|
"deleteConfirmTitle": "Delete this tool?",
|
||||||
|
"deleteToTrash": "This will move {{name}} to the trash. It can be restored later.",
|
||||||
|
"deletePermanent": "This will permanently remove {{name}} and all its ratings. This cannot be undone.",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"deleteAction": "Delete"
|
||||||
},
|
},
|
||||||
"compare": {
|
"compare": {
|
||||||
"title": "Compare Tools",
|
"title": "Compare Tools",
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { setCsrfTokenGetter } from "@workspace/api-client-react";
|
||||||
|
|
||||||
|
let token: string | null = null;
|
||||||
|
|
||||||
|
setCsrfTokenGetter(() => token);
|
||||||
|
|
||||||
|
export function getCsrfToken(): string | null {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadCsrfToken(): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/auth/csrf`, {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { token?: string };
|
||||||
|
token = data.token ?? null;
|
||||||
|
return token;
|
||||||
|
} catch {
|
||||||
|
token = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
useCreateUser,
|
useCreateUser,
|
||||||
useUpdateUser,
|
useUpdateUser,
|
||||||
useDeleteUser,
|
useDeleteUser,
|
||||||
|
useSetUserPassword,
|
||||||
useListAuditLogs,
|
useListAuditLogs,
|
||||||
useGetVersion,
|
useGetVersion,
|
||||||
getListUsersQueryKey,
|
getListUsersQueryKey,
|
||||||
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
|
|||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
@@ -35,7 +37,8 @@ export default function Admin() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
|
||||||
|
const [editPassword, setEditPassword] = useState("");
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||||
|
|
||||||
const [newUsername, setNewUsername] = useState("");
|
const [newUsername, setNewUsername] = useState("");
|
||||||
@@ -58,6 +61,7 @@ export default function Admin() {
|
|||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
const updateUser = useUpdateUser();
|
const updateUser = useUpdateUser();
|
||||||
const deleteUser = useDeleteUser();
|
const deleteUser = useDeleteUser();
|
||||||
|
const setUserPassword = useSetUserPassword();
|
||||||
|
|
||||||
if (!authLoading && !isAdmin) {
|
if (!authLoading && !isAdmin) {
|
||||||
return (
|
return (
|
||||||
@@ -110,6 +114,26 @@ export default function Admin() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSetUserPassword = () => {
|
||||||
|
if (!editUser || !editPassword) return;
|
||||||
|
if (editPassword.length < 6) {
|
||||||
|
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserPassword.mutate(
|
||||||
|
{ id: editUser.id, data: { password: editPassword } },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
|
||||||
|
setEditPassword("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeleteUser = () => {
|
const handleDeleteUser = () => {
|
||||||
if (!deleteConfirm) return;
|
if (!deleteConfirm) return;
|
||||||
deleteUser.mutate(
|
deleteUser.mutate(
|
||||||
@@ -206,7 +230,7 @@ export default function Admin() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
|
||||||
>
|
>
|
||||||
<Pencil className="w-3.5 h-3.5" />
|
<Pencil className="w-3.5 h-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -344,7 +368,7 @@ export default function Admin() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Password</Label>
|
<Label>Password</Label>
|
||||||
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>Email (Optional)</Label>
|
<Label>Email (Optional)</Label>
|
||||||
@@ -385,7 +409,7 @@ export default function Admin() {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||||
<DialogContent className="sm:max-w-sm">
|
<DialogContent className="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||||
@@ -422,15 +446,40 @@ export default function Admin() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{editUser?.authProvider !== "oidc" ? (
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label>Set Password</Label>
|
||||||
|
<PasswordInput
|
||||||
|
value={editPassword}
|
||||||
|
onChange={(e) => setEditPassword(e.target.value)}
|
||||||
|
placeholder="min. 6 characters"
|
||||||
|
data-testid="input-set-password"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div 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>
|
</div>
|
||||||
<DialogFooter>
|
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleUpdateUser}
|
onClick={handleUpdateUser}
|
||||||
disabled={updateUser.isPending}
|
disabled={updateUser.isPending}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
{editUser?.authProvider !== "oidc" && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleSetUserPassword}
|
||||||
|
disabled={setUserPassword.isPending || !editPassword}
|
||||||
|
>
|
||||||
|
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Layout } from "@/components/layout";
|
import { Layout } from "@/components/layout";
|
||||||
import { ToolCard } from "@/components/tool-card";
|
import { ToolCard } from "@/components/tool-card";
|
||||||
import { Star, Wrench, MessageSquare, ArrowRight } from "lucide-react";
|
import { Star, Wrench, MessageSquare, ArrowRight, Plus } from "lucide-react";
|
||||||
import { Link } from "wouter";
|
import { Link } from "wouter";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -22,9 +22,17 @@ export default function Home() {
|
|||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<div className="space-y-8 pb-8">
|
<div className="space-y-8 pb-8">
|
||||||
<div>
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||||
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
<div>
|
||||||
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
<h1 className="text-3xl font-bold tracking-tight mb-2">{t("home.welcome")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("home.tagline")}</p>
|
||||||
|
</div>
|
||||||
|
<Button asChild>
|
||||||
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Banner */}
|
{/* Stats Banner */}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { PasswordInput } from "@/components/password-input";
|
||||||
|
import { loadCsrfToken } from "@/lib/csrf";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { ThemeToggle } from "@/components/theme-toggle";
|
import { ThemeToggle } from "@/components/theme-toggle";
|
||||||
import { Wrench, AlertCircle } from "lucide-react";
|
import { Wrench, AlertCircle } from "lucide-react";
|
||||||
@@ -32,6 +34,7 @@ export default function Login() {
|
|||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
|
void loadCsrfToken();
|
||||||
setLocation(returnTo);
|
setLocation(returnTo);
|
||||||
},
|
},
|
||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
@@ -76,9 +79,8 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||||
<Input
|
<PasswordInput
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
|
|||||||
@@ -914,23 +914,23 @@ export default function ToolDetail() {
|
|||||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Delete this tool?</AlertDialogTitle>
|
<AlertDialogTitle>{t("detail.deleteConfirmTitle")}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>
|
<AlertDialogDescription>
|
||||||
{hasTrash ? (
|
{hasTrash ? (
|
||||||
<>This will move <span className="font-medium">{tool?.name}</span> to the trash. It can be restored later.</>
|
<>{t("detail.deleteToTrash", { name: tool?.name })}</>
|
||||||
) : (
|
) : (
|
||||||
<>This will permanently remove <span className="font-medium">{tool?.name}</span> and all its ratings. This cannot be undone.</>
|
<>{t("detail.deletePermanent", { name: tool?.name })}</>
|
||||||
)}
|
)}
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
<AlertDialogAction
|
<AlertDialogAction
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={deleteTool.isPending}
|
disabled={deleteTool.isPending}
|
||||||
>
|
>
|
||||||
{deleteTool.isPending ? "Deleting…" : "Delete"}
|
{deleteTool.isPending ? t("detail.deleting") : t("detail.deleteAction")}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal } from "lucide-react";
|
import { Search, Wrench, X, ArrowUp, ArrowDown, ArrowUpDown, SlidersHorizontal, Plus } from "lucide-react";
|
||||||
import { Link, useLocation, useSearch } from "wouter";
|
import { Link, useLocation, useSearch } from "wouter";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
@@ -234,7 +234,10 @@ export default function ToolsBrowse() {
|
|||||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button asChild>
|
<Button asChild>
|
||||||
<Link href="/tools/new">{t("browse.addTool")}</Link>
|
<Link href="/tools/new">
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
{t("browse.addTool")}
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -17,6 +17,7 @@ const DEFAULT_JSON_ACCEPT = "application/json, application/problem+json";
|
|||||||
|
|
||||||
let _baseUrl: string | null = null;
|
let _baseUrl: string | null = null;
|
||||||
let _authTokenGetter: AuthTokenGetter | null = null;
|
let _authTokenGetter: AuthTokenGetter | null = null;
|
||||||
|
let _csrfTokenGetter: (() => string | null) | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set a base URL that is prepended to every relative request URL
|
* Set a base URL that is prepended to every relative request URL
|
||||||
@@ -44,6 +45,15 @@ export function setAuthTokenGetter(getter: AuthTokenGetter | null): void {
|
|||||||
_authTokenGetter = getter;
|
_authTokenGetter = getter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a getter that supplies a CSRF token. Before every state-changing
|
||||||
|
* fetch an `X-CSRF-Token` header is attached when the getter returns a value.
|
||||||
|
* Pass `null` to clear the getter.
|
||||||
|
*/
|
||||||
|
export function setCsrfTokenGetter(getter: (() => string | null) | null): void {
|
||||||
|
_csrfTokenGetter = getter;
|
||||||
|
}
|
||||||
|
|
||||||
function isRequest(input: RequestInfo | URL): input is Request {
|
function isRequest(input: RequestInfo | URL): input is Request {
|
||||||
return typeof Request !== "undefined" && input instanceof Request;
|
return typeof Request !== "undefined" && input instanceof Request;
|
||||||
}
|
}
|
||||||
@@ -349,6 +359,14 @@ export async function customFetch<T = unknown>(
|
|||||||
headers.set("accept", DEFAULT_JSON_ACCEPT);
|
headers.set("accept", DEFAULT_JSON_ACCEPT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach CSRF token for state-changing requests, unless one is already set.
|
||||||
|
if (_csrfTokenGetter && !headers.has("x-csrf-token")) {
|
||||||
|
const csrf = _csrfTokenGetter();
|
||||||
|
if (csrf) {
|
||||||
|
headers.set("x-csrf-token", csrf);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach bearer token when an auth getter is configured and no
|
// Attach bearer token when an auth getter is configured and no
|
||||||
// Authorization header has been explicitly provided.
|
// Authorization header has been explicitly provided.
|
||||||
if (_authTokenGetter && !headers.has("authorization")) {
|
if (_authTokenGetter && !headers.has("authorization")) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -30,6 +30,10 @@ export interface AuthMode {
|
|||||||
mode: AuthModeMode;
|
mode: AuthModeMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CsrfToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LocalLoginInput {
|
export interface LocalLoginInput {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -52,6 +56,14 @@ export const UserTier = {
|
|||||||
enterprise: 'enterprise',
|
enterprise: 'enterprise',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
|
||||||
|
|
||||||
|
|
||||||
|
export const UserAuthProvider = {
|
||||||
|
local: 'local',
|
||||||
|
oidc: 'oidc',
|
||||||
|
} as const;
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -59,6 +71,7 @@ export interface User {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
tier?: UserTier;
|
tier?: UserTier;
|
||||||
|
authProvider?: UserAuthProvider;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +124,23 @@ export interface UserRoleUpdate {
|
|||||||
tier?: UserRoleUpdateTier;
|
tier?: UserRoleUpdateTier;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditLog {
|
export interface AuditLog {
|
||||||
id: number;
|
id: number;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
export * from "./generated/api";
|
export * from "./generated/api";
|
||||||
export * from "./generated/api.schemas";
|
export * from "./generated/api.schemas";
|
||||||
export { setBaseUrl, setAuthTokenGetter, customFetch } from "./custom-fetch";
|
export { setBaseUrl, setAuthTokenGetter, setCsrfTokenGetter, customFetch } from "./custom-fetch";
|
||||||
export type { AuthTokenGetter } from "./custom-fetch";
|
export type { AuthTokenGetter } from "./custom-fetch";
|
||||||
|
|||||||
@@ -570,6 +570,19 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/AuthMode"
|
$ref: "#/components/schemas/AuthMode"
|
||||||
|
|
||||||
|
/auth/csrf:
|
||||||
|
get:
|
||||||
|
operationId: getCsrfToken
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get a CSRF token for state-changing requests
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: CSRF token
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/CsrfToken"
|
||||||
|
|
||||||
/auth/login:
|
/auth/login:
|
||||||
post:
|
post:
|
||||||
operationId: localLogin
|
operationId: localLogin
|
||||||
@@ -614,6 +627,58 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/me/password:
|
||||||
|
post:
|
||||||
|
operationId: changeMyPassword
|
||||||
|
tags: [auth]
|
||||||
|
summary: Change own password (local users only)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ChangePasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password changed
|
||||||
|
"400":
|
||||||
|
description: Invalid input or wrong current password
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"401":
|
||||||
|
description: Not authenticated
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
|
/auth/password-redirect:
|
||||||
|
get:
|
||||||
|
operationId: getPasswordRedirect
|
||||||
|
tags: [auth]
|
||||||
|
summary: Get redirect URL for managing credentials in the identity provider
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Redirect URL (null in local mode)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/PasswordRedirect"
|
||||||
|
|
||||||
/auth/me/preferences:
|
/auth/me/preferences:
|
||||||
get:
|
get:
|
||||||
operationId: getMePreferences
|
operationId: getMePreferences
|
||||||
@@ -783,6 +848,51 @@ paths:
|
|||||||
"204":
|
"204":
|
||||||
description: Deleted
|
description: Deleted
|
||||||
|
|
||||||
|
/users/{id}/password:
|
||||||
|
patch:
|
||||||
|
operationId: setUserPassword
|
||||||
|
tags: [users]
|
||||||
|
summary: Set/reset a user's password (admin only, local users only)
|
||||||
|
parameters:
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: integer
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/SetPasswordInput"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Password updated
|
||||||
|
"400":
|
||||||
|
description: Validation error
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"404":
|
||||||
|
description: User not found
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"422":
|
||||||
|
description: OIDC user - password is managed by the identity provider
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
"429":
|
||||||
|
description: Too many attempts
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/audit-logs:
|
/audit-logs:
|
||||||
get:
|
get:
|
||||||
operationId: listAuditLogs
|
operationId: listAuditLogs
|
||||||
@@ -845,6 +955,13 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [oidc, local]
|
enum: [oidc, local]
|
||||||
|
|
||||||
|
CsrfToken:
|
||||||
|
type: object
|
||||||
|
required: [token]
|
||||||
|
properties:
|
||||||
|
token:
|
||||||
|
type: string
|
||||||
|
|
||||||
LocalLoginInput:
|
LocalLoginInput:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password]
|
required: [username, password]
|
||||||
@@ -870,6 +987,10 @@ components:
|
|||||||
tier:
|
tier:
|
||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
authProvider:
|
||||||
|
type: string
|
||||||
|
enum: [local, oidc]
|
||||||
|
default: local
|
||||||
createdAt:
|
createdAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
@@ -903,6 +1024,32 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
enum: [free, premium, enterprise]
|
enum: [free, premium, enterprise]
|
||||||
|
|
||||||
|
ChangePasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [currentPassword, newPassword]
|
||||||
|
properties:
|
||||||
|
currentPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
newPassword:
|
||||||
|
type: string
|
||||||
|
minLength: 6
|
||||||
|
|
||||||
|
SetPasswordInput:
|
||||||
|
type: object
|
||||||
|
required: [password]
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
minLength: 6
|
||||||
|
|
||||||
|
PasswordRedirect:
|
||||||
|
type: object
|
||||||
|
required: [url]
|
||||||
|
properties:
|
||||||
|
url:
|
||||||
|
type: ["string", "null"]
|
||||||
|
|
||||||
AuditLog:
|
AuditLog:
|
||||||
type: object
|
type: object
|
||||||
required: [id, entityType, action, userId, username, createdAt]
|
required: [id, entityType, action, userId, username, createdAt]
|
||||||
|
|||||||
@@ -6,6 +6,6 @@
|
|||||||
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
|
"codegen": "orval --config ./orval.config.ts && pnpm -w run typecheck:libs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"orval": "^8.9.1"
|
"orval": "8.23.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -25,7 +25,7 @@ export const GetVersionResponse = zod.object({
|
|||||||
"version": zod.string(),
|
"version": zod.string(),
|
||||||
"commitSha": zod.string().nullish(),
|
"commitSha": zod.string().nullish(),
|
||||||
"buildDate": zod.string().nullish(),
|
"buildDate": zod.string().nullish(),
|
||||||
"trashRetentionDays": zod.number().optional()
|
"trashRetentionDays": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -47,7 +47,7 @@ export const ListToolsQueryParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ListToolsResponseItem = zod.object({
|
export const ListToolsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -58,7 +58,7 @@ export const ListToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -84,16 +84,8 @@ export const CreateToolBody = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional()
|
"tags": zod.array(zod.string()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const CreateToolResponse = zod.object({
|
||||||
/**
|
"id": zod.int(),
|
||||||
* @summary Compare tools side by side (premium)
|
|
||||||
*/
|
|
||||||
export const ListCompareToolsQueryParams = zod.object({
|
|
||||||
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
|
||||||
})
|
|
||||||
|
|
||||||
export const ListCompareToolsResponseItem = zod.object({
|
|
||||||
"id": zod.number(),
|
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -104,7 +96,31 @@ export const ListCompareToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"deletedAt": zod.coerce.date().nullish(),
|
||||||
|
"deletedBy": zod.string().nullish()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Compare tools side by side (premium)
|
||||||
|
*/
|
||||||
|
export const ListCompareToolsQueryParams = zod.object({
|
||||||
|
"ids": zod.coerce.string().describe('Comma-separated tool ids')
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ListCompareToolsResponseItem = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"name": zod.string(),
|
||||||
|
"description": zod.string(),
|
||||||
|
"category": zod.string(),
|
||||||
|
"websiteUrl": zod.string().nullish(),
|
||||||
|
"iconUrl": zod.string().nullish(),
|
||||||
|
"createdBy": zod.string().nullish(),
|
||||||
|
"features": zod.array(zod.string()).optional(),
|
||||||
|
"tags": zod.array(zod.string()).optional(),
|
||||||
|
"createdAt": zod.coerce.date(),
|
||||||
|
"updatedAt": zod.coerce.date(),
|
||||||
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -116,7 +132,7 @@ export const ListCompareToolsResponse = zod.array(ListCompareToolsResponseItem)
|
|||||||
* @summary Get a tool's rating history over time
|
* @summary Get a tool's rating history over time
|
||||||
*/
|
*/
|
||||||
export const GetToolRatingHistoryParams = zod.object({
|
export const GetToolRatingHistoryParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetToolRatingHistoryResponseItem = zod.object({
|
export const GetToolRatingHistoryResponseItem = zod.object({
|
||||||
@@ -132,11 +148,11 @@ export const GetToolRatingHistoryResponse = zod.array(GetToolRatingHistoryRespon
|
|||||||
* @summary Get a tool by ID
|
* @summary Get a tool by ID
|
||||||
*/
|
*/
|
||||||
export const GetToolParams = zod.object({
|
export const GetToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetToolResponse = zod.object({
|
export const GetToolResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -147,7 +163,7 @@ export const GetToolResponse = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -158,7 +174,7 @@ export const GetToolResponse = zod.object({
|
|||||||
* @summary Update a tool
|
* @summary Update a tool
|
||||||
*/
|
*/
|
||||||
export const UpdateToolParams = zod.object({
|
export const UpdateToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -175,7 +191,7 @@ export const UpdateToolBody = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateToolResponse = zod.object({
|
export const UpdateToolResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -195,9 +211,11 @@ export const UpdateToolResponse = zod.object({
|
|||||||
* @summary Delete a tool
|
* @summary Delete a tool
|
||||||
*/
|
*/
|
||||||
export const DeleteToolParams = zod.object({
|
export const DeleteToolParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteToolResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List trashed (soft-deleted) tools
|
* @summary List trashed (soft-deleted) tools
|
||||||
@@ -207,7 +225,7 @@ export const ListTrashedToolsQueryParams = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const ListTrashedToolsResponseItem = zod.object({
|
export const ListTrashedToolsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -232,11 +250,11 @@ export const trashToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const TrashToolsBody = zod.object({
|
export const TrashToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(trashToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(trashToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const TrashToolsResponse = zod.object({
|
export const TrashToolsResponse = zod.object({
|
||||||
"trashed": zod.number().optional()
|
"trashed": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -248,9 +266,11 @@ export const deleteTrashedToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const DeleteTrashedToolsBody = zod.object({
|
export const DeleteTrashedToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(deleteTrashedToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(deleteTrashedToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteTrashedToolsResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Restore trashed tools
|
* @summary Restore trashed tools
|
||||||
@@ -260,11 +280,11 @@ export const restoreToolsBodyIdsMax = 500;
|
|||||||
|
|
||||||
|
|
||||||
export const RestoreToolsBody = zod.object({
|
export const RestoreToolsBody = zod.object({
|
||||||
"ids": zod.array(zod.number()).min(1).max(restoreToolsBodyIdsMax)
|
"ids": zod.array(zod.int()).min(1).max(restoreToolsBodyIdsMax)
|
||||||
})
|
})
|
||||||
|
|
||||||
export const RestoreToolsResponse = zod.object({
|
export const RestoreToolsResponse = zod.object({
|
||||||
"restored": zod.number().optional()
|
"restored": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -272,7 +292,7 @@ export const RestoreToolsResponse = zod.object({
|
|||||||
* @summary Permanently delete all trashed tools (admin)
|
* @summary Permanently delete all trashed tools (admin)
|
||||||
*/
|
*/
|
||||||
export const EmptyTrashResponse = zod.object({
|
export const EmptyTrashResponse = zod.object({
|
||||||
"deleted": zod.number().optional()
|
"deleted": zod.int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -280,7 +300,7 @@ export const EmptyTrashResponse = zod.object({
|
|||||||
* @summary List ratings for a tool
|
* @summary List ratings for a tool
|
||||||
*/
|
*/
|
||||||
export const ListToolRatingsParams = zod.object({
|
export const ListToolRatingsParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const listToolRatingsResponseUsefulnessMax = 5;
|
export const listToolRatingsResponseUsefulnessMax = 5;
|
||||||
@@ -290,10 +310,10 @@ export const listToolRatingsResponseUsabilityMax = 5;
|
|||||||
|
|
||||||
|
|
||||||
export const ListToolRatingsResponseItem = zod.object({
|
export const ListToolRatingsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"toolId": zod.number(),
|
"toolId": zod.int(),
|
||||||
"usefulness": zod.number().min(1).max(listToolRatingsResponseUsefulnessMax),
|
"usefulness": zod.int().min(1).max(listToolRatingsResponseUsefulnessMax),
|
||||||
"usability": zod.number().min(1).max(listToolRatingsResponseUsabilityMax),
|
"usability": zod.int().min(1).max(listToolRatingsResponseUsabilityMax),
|
||||||
"comment": zod.string().nullish(),
|
"comment": zod.string().nullish(),
|
||||||
"reviewerName": zod.string().nullish(),
|
"reviewerName": zod.string().nullish(),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
@@ -305,7 +325,7 @@ export const ListToolRatingsResponse = zod.array(ListToolRatingsResponseItem)
|
|||||||
* @summary Submit a rating for a tool
|
* @summary Submit a rating for a tool
|
||||||
*/
|
*/
|
||||||
export const CreateRatingParams = zod.object({
|
export const CreateRatingParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const createRatingBodyUsefulnessMax = 5;
|
export const createRatingBodyUsefulnessMax = 5;
|
||||||
@@ -315,25 +335,41 @@ export const createRatingBodyUsabilityMax = 5;
|
|||||||
|
|
||||||
|
|
||||||
export const CreateRatingBody = zod.object({
|
export const CreateRatingBody = zod.object({
|
||||||
"usefulness": zod.number().min(1).max(createRatingBodyUsefulnessMax),
|
"usefulness": zod.int().min(1).max(createRatingBodyUsefulnessMax),
|
||||||
"usability": zod.number().min(1).max(createRatingBodyUsabilityMax),
|
"usability": zod.int().min(1).max(createRatingBodyUsabilityMax),
|
||||||
"comment": zod.string().optional(),
|
"comment": zod.string().optional(),
|
||||||
"reviewerName": zod.string().optional()
|
"reviewerName": zod.string().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createRatingResponseUsefulnessMax = 5;
|
||||||
|
|
||||||
|
export const createRatingResponseUsabilityMax = 5;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const CreateRatingResponse = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"toolId": zod.int(),
|
||||||
|
"usefulness": zod.int().min(1).max(createRatingResponseUsefulnessMax),
|
||||||
|
"usability": zod.int().min(1).max(createRatingResponseUsabilityMax),
|
||||||
|
"comment": zod.string().nullish(),
|
||||||
|
"reviewerName": zod.string().nullish(),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Overall platform statistics
|
* @summary Overall platform statistics
|
||||||
*/
|
*/
|
||||||
export const GetAnalyticsSummaryResponse = zod.object({
|
export const GetAnalyticsSummaryResponse = zod.object({
|
||||||
"totalTools": zod.number(),
|
"totalTools": zod.int(),
|
||||||
"totalRatings": zod.number(),
|
"totalRatings": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable(),
|
"avgCombined": zod.number().nullable(),
|
||||||
"categoriesCount": zod.number(),
|
"categoriesCount": zod.int(),
|
||||||
"mostRatedTool": zod.object({
|
"mostRatedTool": zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -344,7 +380,7 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -356,13 +392,13 @@ export const GetAnalyticsSummaryResponse = zod.object({
|
|||||||
* @summary Top-rated tools
|
* @summary Top-rated tools
|
||||||
*/
|
*/
|
||||||
export const GetTopToolsQueryParams = zod.object({
|
export const GetTopToolsQueryParams = zod.object({
|
||||||
"limit": zod.coerce.number().optional(),
|
"limit": zod.coerce.number().int().optional(),
|
||||||
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
|
"metric": zod.enum(['usefulness', 'usability', 'combined']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetTopToolsResponseItem = zod.object({
|
export const GetTopToolsResponseItem = zod.object({
|
||||||
"tool": zod.object({
|
"tool": zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -373,13 +409,13 @@ export const GetTopToolsResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
}),
|
}),
|
||||||
"score": zod.number(),
|
"score": zod.number(),
|
||||||
"ratingCount": zod.number()
|
"ratingCount": zod.int()
|
||||||
})
|
})
|
||||||
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
||||||
|
|
||||||
@@ -389,8 +425,8 @@ export const GetTopToolsResponse = zod.array(GetTopToolsResponseItem)
|
|||||||
*/
|
*/
|
||||||
export const GetAnalyticsByCategoryResponseItem = zod.object({
|
export const GetAnalyticsByCategoryResponseItem = zod.object({
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
"toolCount": zod.number(),
|
"toolCount": zod.int(),
|
||||||
"totalRatings": zod.number(),
|
"totalRatings": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable()
|
"avgUsability": zod.number().nullable()
|
||||||
})
|
})
|
||||||
@@ -401,17 +437,17 @@ export const GetAnalyticsByCategoryResponse = zod.array(GetAnalyticsByCategoryRe
|
|||||||
* @summary Distribution of rating scores across the platform
|
* @summary Distribution of rating scores across the platform
|
||||||
*/
|
*/
|
||||||
export const GetRatingDistributionQueryParams = zod.object({
|
export const GetRatingDistributionQueryParams = zod.object({
|
||||||
"toolId": zod.coerce.number().optional()
|
"toolId": zod.coerce.number().int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const GetRatingDistributionResponse = zod.object({
|
export const GetRatingDistributionResponse = zod.object({
|
||||||
"usefulness": zod.array(zod.object({
|
"usefulness": zod.array(zod.object({
|
||||||
"score": zod.number(),
|
"score": zod.int(),
|
||||||
"count": zod.number()
|
"count": zod.int()
|
||||||
})),
|
})),
|
||||||
"usability": zod.array(zod.object({
|
"usability": zod.array(zod.object({
|
||||||
"score": zod.number(),
|
"score": zod.int(),
|
||||||
"count": zod.number()
|
"count": zod.int()
|
||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -445,6 +481,14 @@ export const GetAuthModeResponse = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get a CSRF token for state-changing requests
|
||||||
|
*/
|
||||||
|
export const GetCsrfTokenResponse = zod.object({
|
||||||
|
"token": zod.string()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Local username/password login
|
* @summary Local username/password login
|
||||||
*/
|
*/
|
||||||
@@ -480,13 +524,37 @@ export const GetMeResponse = zod.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Change own password (local users only)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const changeMyPasswordBodyNewPasswordMin = 6;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const ChangeMyPasswordBody = zod.object({
|
||||||
|
"currentPassword": zod.string().min(1),
|
||||||
|
"newPassword": zod.string().min(changeMyPasswordBodyNewPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChangeMyPasswordResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Get redirect URL for managing credentials in the identity provider
|
||||||
|
*/
|
||||||
|
export const GetPasswordRedirectResponse = zod.object({
|
||||||
|
"url": zod.string().nullable()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Get current user's browse preferences
|
* @summary Get current user's browse preferences
|
||||||
*/
|
*/
|
||||||
export const GetMePreferencesResponse = zod.object({
|
export const GetMePreferencesResponse = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -496,13 +564,13 @@ export const GetMePreferencesResponse = zod.object({
|
|||||||
export const UpdateMePreferencesBody = zod.object({
|
export const UpdateMePreferencesBody = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateMePreferencesResponse = zod.object({
|
export const UpdateMePreferencesResponse = zod.object({
|
||||||
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
"view": zod.enum(['grid', 'table', 'rows']).optional(),
|
||||||
"density": zod.enum(['cozy', 'compact']).optional(),
|
"density": zod.enum(['cozy', 'compact']).optional(),
|
||||||
"watchlist": zod.array(zod.number()).optional()
|
"watchlist": zod.array(zod.int()).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -510,7 +578,7 @@ export const UpdateMePreferencesResponse = zod.object({
|
|||||||
* @summary Get current user's watchlist tools (premium)
|
* @summary Get current user's watchlist tools (premium)
|
||||||
*/
|
*/
|
||||||
export const GetMeWatchlistResponseItem = zod.object({
|
export const GetMeWatchlistResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"name": zod.string(),
|
"name": zod.string(),
|
||||||
"description": zod.string(),
|
"description": zod.string(),
|
||||||
"category": zod.string(),
|
"category": zod.string(),
|
||||||
@@ -521,7 +589,7 @@ export const GetMeWatchlistResponseItem = zod.object({
|
|||||||
"tags": zod.array(zod.string()).optional(),
|
"tags": zod.array(zod.string()).optional(),
|
||||||
"createdAt": zod.coerce.date(),
|
"createdAt": zod.coerce.date(),
|
||||||
"updatedAt": zod.coerce.date(),
|
"updatedAt": zod.coerce.date(),
|
||||||
"ratingCount": zod.number(),
|
"ratingCount": zod.int(),
|
||||||
"avgUsefulness": zod.number().nullable(),
|
"avgUsefulness": zod.number().nullable(),
|
||||||
"avgUsability": zod.number().nullable(),
|
"avgUsability": zod.number().nullable(),
|
||||||
"avgCombined": zod.number().nullable()
|
"avgCombined": zod.number().nullable()
|
||||||
@@ -532,12 +600,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
|
|||||||
/**
|
/**
|
||||||
* @summary List all local users (admin only)
|
* @summary List all local users (admin only)
|
||||||
*/
|
*/
|
||||||
|
export const listUsersResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const ListUsersResponseItem = zod.object({
|
export const ListUsersResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||||
@@ -560,12 +631,24 @@ export const CreateUserBody = zod.object({
|
|||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const createUserResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
|
export const CreateUserResponse = zod.object({
|
||||||
|
"id": zod.int(),
|
||||||
|
"username": zod.string(),
|
||||||
|
"email": zod.string().nullish(),
|
||||||
|
"role": zod.enum(['admin', 'user']),
|
||||||
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(createUserResponseAuthProviderDefault),
|
||||||
|
"createdAt": zod.coerce.date()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary Update user role (admin only)
|
* @summary Update user role (admin only)
|
||||||
*/
|
*/
|
||||||
export const UpdateUserParams = zod.object({
|
export const UpdateUserParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const UpdateUserBody = zod.object({
|
export const UpdateUserBody = zod.object({
|
||||||
@@ -573,12 +656,15 @@ export const UpdateUserBody = zod.object({
|
|||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const updateUserResponseAuthProviderDefault = `local`;
|
||||||
|
|
||||||
export const UpdateUserResponse = zod.object({
|
export const UpdateUserResponse = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
"email": zod.string().nullish(),
|
"email": zod.string().nullish(),
|
||||||
"role": zod.enum(['admin', 'user']),
|
"role": zod.enum(['admin', 'user']),
|
||||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||||
|
"authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault),
|
||||||
"createdAt": zod.coerce.date()
|
"createdAt": zod.coerce.date()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -587,23 +673,43 @@ export const UpdateUserResponse = zod.object({
|
|||||||
* @summary Delete a user (admin only)
|
* @summary Delete a user (admin only)
|
||||||
*/
|
*/
|
||||||
export const DeleteUserParams = zod.object({
|
export const DeleteUserParams = zod.object({
|
||||||
"id": zod.coerce.number()
|
"id": zod.coerce.number().int()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DeleteUserResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @summary Set/reset a user's password (admin only, local users only)
|
||||||
|
*/
|
||||||
|
export const SetUserPasswordParams = zod.object({
|
||||||
|
"id": zod.coerce.number().int()
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setUserPasswordBodyPasswordMin = 6;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export const SetUserPasswordBody = zod.object({
|
||||||
|
"password": zod.string().min(setUserPasswordBodyPasswordMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const SetUserPasswordResponse = zod.void()
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @summary List audit log entries (admin only)
|
* @summary List audit log entries (admin only)
|
||||||
*/
|
*/
|
||||||
export const ListAuditLogsQueryParams = zod.object({
|
export const ListAuditLogsQueryParams = zod.object({
|
||||||
"entityType": zod.coerce.string().optional(),
|
"entityType": zod.coerce.string().optional(),
|
||||||
"entityId": zod.coerce.number().optional(),
|
"entityId": zod.coerce.number().int().optional(),
|
||||||
"limit": zod.coerce.number().optional()
|
"limit": zod.coerce.number().int().optional()
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ListAuditLogsResponseItem = zod.object({
|
export const ListAuditLogsResponseItem = zod.object({
|
||||||
"id": zod.number(),
|
"id": zod.int(),
|
||||||
"entityType": zod.string(),
|
"entityType": zod.string(),
|
||||||
"entityId": zod.number().nullish(),
|
"entityId": zod.int().nullish(),
|
||||||
"action": zod.string(),
|
"action": zod.string(),
|
||||||
"userId": zod.string(),
|
"userId": zod.string(),
|
||||||
"username": zod.string(),
|
"username": zod.string(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ChangePasswordInput {
|
||||||
|
/** @minLength 1 */
|
||||||
|
currentPassword: string;
|
||||||
|
/** @minLength 6 */
|
||||||
|
newPassword: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface CsrfToken {
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
@@ -14,6 +14,8 @@ export * from './authUser';
|
|||||||
export * from './authUserRole';
|
export * from './authUserRole';
|
||||||
export * from './authUserTier';
|
export * from './authUserTier';
|
||||||
export * from './categoryStats';
|
export * from './categoryStats';
|
||||||
|
export * from './changePasswordInput';
|
||||||
|
export * from './csrfToken';
|
||||||
export * from './emptyTrash200';
|
export * from './emptyTrash200';
|
||||||
export * from './errorResponse';
|
export * from './errorResponse';
|
||||||
export * from './getRatingDistributionParams';
|
export * from './getRatingDistributionParams';
|
||||||
@@ -26,12 +28,14 @@ export * from './listToolsParams';
|
|||||||
export * from './listToolsSort';
|
export * from './listToolsSort';
|
||||||
export * from './listTrashedToolsParams';
|
export * from './listTrashedToolsParams';
|
||||||
export * from './localLoginInput';
|
export * from './localLoginInput';
|
||||||
|
export * from './passwordRedirect';
|
||||||
export * from './rating';
|
export * from './rating';
|
||||||
export * from './ratingDistribution';
|
export * from './ratingDistribution';
|
||||||
export * from './ratingHistoryItem';
|
export * from './ratingHistoryItem';
|
||||||
export * from './ratingInput';
|
export * from './ratingInput';
|
||||||
export * from './restoreTools200';
|
export * from './restoreTools200';
|
||||||
export * from './scoreBucket';
|
export * from './scoreBucket';
|
||||||
|
export * from './setPasswordInput';
|
||||||
export * from './tool';
|
export * from './tool';
|
||||||
export * from './toolInput';
|
export * from './toolInput';
|
||||||
export * from './toolUpdate';
|
export * from './toolUpdate';
|
||||||
@@ -40,6 +44,7 @@ export * from './topToolEntry';
|
|||||||
export * from './trashTools200';
|
export * from './trashTools200';
|
||||||
export * from './trashToolsInput';
|
export * from './trashToolsInput';
|
||||||
export * from './user';
|
export * from './user';
|
||||||
|
export * from './userAuthProvider';
|
||||||
export * from './userCreateInput';
|
export * from './userCreateInput';
|
||||||
export * from './userCreateInputRole';
|
export * from './userCreateInputRole';
|
||||||
export * from './userCreateInputTier';
|
export * from './userCreateInputTier';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PasswordRedirect {
|
||||||
|
/** @nullable */
|
||||||
|
url: string | null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Generated by orval v8.23.0 🍺
|
||||||
|
* Do not edit manually.
|
||||||
|
* Api
|
||||||
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
* OpenAPI spec version: 0.1.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SetPasswordInput {
|
||||||
|
/** @minLength 6 */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
* OpenAPI spec version: 0.1.0
|
* OpenAPI spec version: 0.1.0
|
||||||
*/
|
*/
|
||||||
|
import type { UserAuthProvider } from './userAuthProvider';
|
||||||
import type { UserRole } from './userRole';
|
import type { UserRole } from './userRole';
|
||||||
import type { UserTier } from './userTier';
|
import type { UserTier } from './userTier';
|
||||||
|
|
||||||
@@ -15,5 +16,6 @@ export interface User {
|
|||||||
email?: string | null;
|
email?: string | null;
|
||||||
role: UserRole;
|
role: UserRole;
|
||||||
tier?: UserTier;
|
tier?: UserTier;
|
||||||
|
authProvider?: UserAuthProvider;
|
||||||
createdAt: Date;
|
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.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Generated by orval v8.9.1 🍺
|
* Generated by orval v8.23.0 🍺
|
||||||
* Do not edit manually.
|
* Do not edit manually.
|
||||||
* Api
|
* Api
|
||||||
* ToolRate API — Tool listing and rating platform
|
* ToolRate API — Tool listing and rating platform
|
||||||
|
|||||||
+4
-4
@@ -13,13 +13,13 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"drizzle-orm": "catalog:",
|
"drizzle-orm": "catalog:",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "0.8.3",
|
||||||
"pg": "^8.20.0",
|
"pg": "8.22.0",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/pg": "^8.20.0",
|
"@types/pg": "8.20.3",
|
||||||
"drizzle-kit": "^0.31.10"
|
"drizzle-kit": "0.31.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-17
@@ -10,24 +10,13 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"prettier": "^3.8.3",
|
"prettier": "3.9.6",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "7.0.2"
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"supportedArchitectures": {
|
|
||||||
"os": [
|
|
||||||
"current",
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"cpu": [
|
|
||||||
"current",
|
|
||||||
"x64"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-darwin-arm64": "^4.62.4",
|
"@esbuild/darwin-arm64": "0.28.1",
|
||||||
"@tailwindcss/oxide-darwin-arm64": "^4.3.3",
|
"@rollup/rollup-darwin-arm64": "4.62.4",
|
||||||
"lightningcss-darwin-arm64": "^1.33.0"
|
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||||
|
"lightningcss-darwin-arm64": "1.33.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2520
-1930
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user