Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 743b177c89 | |||
| 68a81ec775 | |||
| 63f0bdbab6 | |||
| 4648614556 | |||
| 53f01acb0c |
@@ -52,12 +52,17 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
SHA=$(git rev-parse --short HEAD)
|
||||
if [ "${{ gitea.ref_type }}" = "tag" ]; then
|
||||
NEWTAG="${{ gitea.ref_name }}"
|
||||
else
|
||||
NEWTAG="sha-${SHA}"
|
||||
fi
|
||||
git clone "https://admin:${GITEA_TOKEN}@git.kubebase.de/admin/apps.git" /tmp/apps
|
||||
cd /tmp/apps
|
||||
cd apps/system/toolrate/overlays/k3s
|
||||
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.email "actions@git.kubebase.de"
|
||||
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
|
||||
|
||||
@@ -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
|
||||
+38
-35
@@ -2,10 +2,30 @@
|
||||
|
||||
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 --filter @workspace/toolrate run dev` — run the frontend (port 26015)
|
||||
- pnpm workspaces, Node.js 24, TypeScript 5.9
|
||||
- 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 build` — typecheck + build all packages
|
||||
- `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
|
||||
|
||||
- `DATABASE_URL` — Postgres connection string (auto-provisioned)
|
||||
- `SESSION_SECRET` — Session signing secret (already set)
|
||||
- `DATABASE_URL` — Postgres connection string
|
||||
- `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:
|
||||
- `KEYCLOAK_URL` — e.g. `https://auth.example.com`
|
||||
- `KEYCLOAK_REALM` — realm name
|
||||
- `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
|
||||
- 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.
|
||||
- Login via local admin user (`LOCAL_ADMIN_*`). Keycloak/OIDC support exists as an optional legacy path.
|
||||
|
||||
## 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)
|
||||
- `artifacts/api-server/src/routes/` — Express route handlers
|
||||
- `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/components/` — shared components (layout, tool-card, category-combobox, feature-input)
|
||||
- `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.
|
||||
- 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).
|
||||
- Grafana can consume `/api/analytics/*` endpoints directly via JSON datasource plugin.
|
||||
- Grafana can consume `/api/analytics/*` endpoints directly via the JSON datasource plugin.
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
- 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.
|
||||
- `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
|
||||
- Auth uses PKCE — the `code_verifier` is stored in the session, not in-memory state.
|
||||
@@ -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"
|
||||
@@ -13,16 +13,17 @@
|
||||
"@workspace/api-zod": "workspace:*",
|
||||
"@workspace/db": "workspace:*",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"zod": "catalog:",
|
||||
"connect-pg-simple": "^10.0.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"drizzle-orm": "catalog:",
|
||||
"express": "^5.2.1",
|
||||
"express-rate-limit": "^8.6.1",
|
||||
"express-session": "^1.19.0",
|
||||
"openid-client": "^5.7.1",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0"
|
||||
"pino-http": "^10.5.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.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." },
|
||||
});
|
||||
@@ -5,6 +5,8 @@ import { eq, and, inArray, isNull } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db, usersTable, toolsTable, ratingsTable } from "@workspace/db";
|
||||
import { logger } from "../lib/logger";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
import { loginRateLimit, passwordRateLimit } from "../lib/rate-limit";
|
||||
import { getEntitlements, requireFeature } from "../middleware/feature";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -107,7 +109,7 @@ router.get("/auth/mode", (_req, res): void => {
|
||||
res.json({ mode: isOidcConfigured() ? "oidc" : "local" });
|
||||
});
|
||||
|
||||
router.post("/auth/login", async (req, res): Promise<void> => {
|
||||
router.post("/auth/login", loginRateLimit, async (req, res): Promise<void> => {
|
||||
if (isOidcConfigured()) {
|
||||
res.status(400).json({ error: "Use OIDC login when Keycloak is configured." });
|
||||
return;
|
||||
@@ -279,6 +281,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.issuer.metadata.issuer ?? "";
|
||||
res.json({ url: `${realm}/account/password` });
|
||||
});
|
||||
|
||||
const ChangePasswordSchema = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(6),
|
||||
});
|
||||
|
||||
router.post("/auth/me/password", passwordRateLimit, async (req, res): Promise<void> => {
|
||||
if (!req.session.user) {
|
||||
res.status(401).json({ error: "Not authenticated" });
|
||||
return;
|
||||
}
|
||||
const dbUser = await resolveDbUser(req.session.user);
|
||||
if (!dbUser) {
|
||||
res.status(401).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
if (dbUser.authProvider !== "local") {
|
||||
res.status(422).json({ error: "oidc" });
|
||||
return;
|
||||
}
|
||||
const parsed = ChangePasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const { currentPassword, newPassword } = parsed.data;
|
||||
if (currentPassword === newPassword) {
|
||||
res.status(400).json({ error: "New password must differ from current password" });
|
||||
return;
|
||||
}
|
||||
if (!dbUser.passwordHash || !(await bcrypt.compare(currentPassword, dbUser.passwordHash))) {
|
||||
res.status(400).json({ error: "Current password is incorrect" });
|
||||
return;
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(newPassword, 12);
|
||||
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, dbUser.id));
|
||||
await writeAuditLog(req, "user", dbUser.id, "change_password", {});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
req.session.regenerate((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
type SessionUser = NonNullable<import("express-session").SessionData["user"]>;
|
||||
|
||||
async function resolveDbUser(u: SessionUser) {
|
||||
|
||||
@@ -4,6 +4,7 @@ import bcrypt from "bcryptjs";
|
||||
import { db, usersTable } from "@workspace/db";
|
||||
import { requireAdmin } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
import { passwordRateLimit } from "../lib/rate-limit";
|
||||
import { z } from "zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
@@ -23,6 +24,46 @@ const UserUpdateSchema = z.object({
|
||||
tier: Tier.optional(),
|
||||
});
|
||||
|
||||
const SetPasswordSchema = z.object({
|
||||
password: z.string().min(6),
|
||||
});
|
||||
|
||||
router.patch("/users/:id/password", requireAdmin, passwordRateLimit, async (req, res): Promise<void> => {
|
||||
const id = parseInt(String(req.params.id), 10);
|
||||
if (isNaN(id)) {
|
||||
res.status(400).json({ error: "Invalid user id" });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = SetPasswordSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
|
||||
const [target] = await db
|
||||
.select({ id: usersTable.id, authProvider: usersTable.authProvider, username: usersTable.username })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!target) {
|
||||
res.status(404).json({ error: "User not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.authProvider !== "local") {
|
||||
res.status(422).json({ error: "oidc" });
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
await db.update(usersTable).set({ passwordHash }).where(eq(usersTable.id, id));
|
||||
|
||||
await writeAuditLog(req, "user", id, "set_password", { username: target.username });
|
||||
res.sendStatus(204);
|
||||
});
|
||||
|
||||
router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
const users = await db
|
||||
.select({
|
||||
@@ -31,6 +72,7 @@ router.get("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
email: usersTable.email,
|
||||
role: usersTable.role,
|
||||
tier: usersTable.tier,
|
||||
authProvider: usersTable.authProvider,
|
||||
createdAt: usersTable.createdAt,
|
||||
})
|
||||
.from(usersTable)
|
||||
@@ -56,7 +98,7 @@ router.post("/users", requireAdmin, async (req, res): Promise<void> => {
|
||||
return;
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 10);
|
||||
const passwordHash = await bcrypt.hash(parsed.data.password, 12);
|
||||
const [user] = await db
|
||||
.insert(usersTable)
|
||||
.values({
|
||||
|
||||
@@ -1,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"
|
||||
@@ -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 = "/"
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { useAuth } from "@/hooks/use-auth";
|
||||
import { useGetVersion, getGetVersionQueryKey } from "@workspace/api-client-react";
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const [location] = useLocation();
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated, isLoading, isAdmin, hasFeature, login, logout } = useAuth();
|
||||
const { isAuthenticated, isLoading, isAdmin, login, logout } = useAuth();
|
||||
const { data: version } = useGetVersion({
|
||||
query: { queryKey: getGetVersionQueryKey(), staleTime: Infinity, retry: false },
|
||||
});
|
||||
@@ -38,10 +38,7 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const mainLinks = [
|
||||
{ href: "/", label: t("nav.home"), icon: LayoutDashboard },
|
||||
{ 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 },
|
||||
...(hasFeature("watchlist") ? [{ href: "/watchlist", label: t("nav.watchlist"), icon: Bookmark }] : []),
|
||||
...(hasFeature("trash") ? [{ href: "/trash", label: t("nav.trash"), icon: Trash2 }] : []),
|
||||
];
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,13 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Bookmark, LogIn, LogOut, Trash2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useChangeMyPassword, useGetPasswordRedirect, getGetPasswordRedirectQueryKey } from "@workspace/api-client-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Bookmark, LogIn, LogOut, Trash2, KeyRound } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function initials(name?: string | null): string {
|
||||
@@ -32,6 +38,17 @@ export function UserMenu() {
|
||||
const { t } = useTranslation();
|
||||
const { user, isLoading, isAuthenticated, isAdmin, tier, hasFeature, login, logout } = useAuth();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pwOpen, setPwOpen] = useState(false);
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const changePassword = useChangeMyPassword();
|
||||
const { data: passwordRedirect } = useGetPasswordRedirect({
|
||||
query: { queryKey: getGetPasswordRedirectQueryKey(), enabled: isAuthenticated && !!user && !user.isLocal },
|
||||
});
|
||||
|
||||
const showWatchlist = hasFeature("watchlist");
|
||||
const showTrash = hasFeature("trash");
|
||||
@@ -39,9 +56,9 @@ export function UserMenu() {
|
||||
|
||||
if (isLoading) {
|
||||
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-4 w-24" />
|
||||
<Skeleton className="h-4 w-24 group-data-[collapsible=icon]:hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -51,12 +68,12 @@ export function UserMenu() {
|
||||
<Button
|
||||
variant="outline"
|
||||
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)}
|
||||
data-testid="button-login"
|
||||
>
|
||||
<LogIn className="w-4 h-4" />
|
||||
{t("auth.signIn")}
|
||||
<span className="group-data-[collapsible=icon]:hidden">{t("auth.signIn")}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -67,12 +84,13 @@ export function UserMenu() {
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
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"
|
||||
>
|
||||
<Avatar className="h-8 w-8">
|
||||
@@ -80,7 +98,7 @@ export function UserMenu() {
|
||||
{initials(displayName)}
|
||||
</AvatarFallback>
|
||||
</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-[11px] text-muted-foreground truncate">
|
||||
{user.email ?? tier}
|
||||
@@ -122,6 +140,16 @@ export function UserMenu() {
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
setPwOpen(true);
|
||||
}}
|
||||
data-testid="button-change-password"
|
||||
>
|
||||
<KeyRound className="mr-2 h-4 w-4" />
|
||||
{t("auth.changePassword")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={logout}
|
||||
@@ -132,5 +160,97 @@ export function UserMenu() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{!user?.isLocal ? (
|
||||
<div className="space-y-4 py-2">
|
||||
<p className="text-sm text-muted-foreground">{t("auth.oidcPasswordHint")}</p>
|
||||
{passwordRedirect?.url && (
|
||||
<Button asChild className="w-full">
|
||||
<a href={passwordRedirect.url} target="_blank" rel="noreferrer">
|
||||
<KeyRound className="w-4 h-4 mr-2" />
|
||||
{t("auth.manageInIdp")}
|
||||
</a>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("auth.currentPassword")}</Label>
|
||||
<PasswordInput
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
data-testid="input-current-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("auth.newPassword")}</Label>
|
||||
<PasswordInput
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder="min. 6 characters"
|
||||
data-testid="input-new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>{t("auth.confirmPassword")}</Label>
|
||||
<PasswordInput
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
data-testid="input-confirm-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setPwOpen(false)}>{t("common.cancel")}</Button>
|
||||
{user?.isLocal && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
toast({ title: t("auth.pwMismatch"), variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
toast({ title: t("auth.pwTooShort"), variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
changePassword.mutate(
|
||||
{ data: { currentPassword, newPassword } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("auth.pwChanged") });
|
||||
setPwOpen(false);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
queryClient.invalidateQueries({ queryKey: getGetPasswordRedirectQueryKey() });
|
||||
},
|
||||
onError: (err) => {
|
||||
const code = (err.data as { error?: string } | null)?.error;
|
||||
if (code === "oidc") {
|
||||
toast({ title: t("auth.oidcPasswordHint"), variant: "destructive" });
|
||||
} else {
|
||||
toast({ title: t("auth.pwChangeFailed"), description: err.data?.error ?? err.message, variant: "destructive" });
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
disabled={changePassword.isPending || !currentPassword || !newPassword || !confirmPassword}
|
||||
data-testid="button-submit-change-password"
|
||||
>
|
||||
{changePassword.isPending ? "…" : t("auth.save")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,17 @@
|
||||
"invalidCredentials": "Ungültiger Benutzername oder ungültiges Passwort",
|
||||
"notAuthenticated": "Nicht angemeldet",
|
||||
"loginSubtitle": "Melde dich bei deinem Konto an",
|
||||
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren"
|
||||
"loginDescription": "Gib deine Zugangsdaten ein, um fortzufahren",
|
||||
"changePassword": "Passwort ändern",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"newPassword": "Neues Passwort",
|
||||
"confirmPassword": "Passwort bestätigen",
|
||||
"pwMismatch": "Passwörter stimmen nicht überein",
|
||||
"pwTooShort": "Passwort muss mindestens 8 Zeichen haben",
|
||||
"pwChanged": "Passwort geändert",
|
||||
"pwChangeFailed": "Passwort konnte nicht geändert werden",
|
||||
"oidcPasswordHint": "Ihr Passwort wird vom Identity-Provider (Keycloak) verwaltet.",
|
||||
"manageInIdp": "In Keycloak verwalten"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Abbrechen",
|
||||
|
||||
@@ -24,7 +24,17 @@
|
||||
"invalidCredentials": "Invalid username or password",
|
||||
"notAuthenticated": "Not authenticated",
|
||||
"loginSubtitle": "Sign in to your account",
|
||||
"loginDescription": "Enter your credentials to continue"
|
||||
"loginDescription": "Enter your credentials to continue",
|
||||
"changePassword": "Change password",
|
||||
"currentPassword": "Current password",
|
||||
"newPassword": "New password",
|
||||
"confirmPassword": "Confirm password",
|
||||
"pwMismatch": "Passwords do not match",
|
||||
"pwTooShort": "Password must be at least 8 characters",
|
||||
"pwChanged": "Password changed",
|
||||
"pwChangeFailed": "Could not change password",
|
||||
"oidcPasswordHint": "Your password is managed by the identity provider (Keycloak).",
|
||||
"manageInIdp": "Manage in Keycloak"
|
||||
},
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useSetUserPassword,
|
||||
useListAuditLogs,
|
||||
useGetVersion,
|
||||
getListUsersQueryKey,
|
||||
@@ -16,6 +17,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
@@ -35,7 +37,8 @@ export default function Admin() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string } | null>(null);
|
||||
const [editUser, setEditUser] = useState<{ id: number; username: string; role: string; tier: string; authProvider?: string } | null>(null);
|
||||
const [editPassword, setEditPassword] = useState("");
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ id: number; username: string } | null>(null);
|
||||
|
||||
const [newUsername, setNewUsername] = useState("");
|
||||
@@ -58,6 +61,7 @@ export default function Admin() {
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
const setUserPassword = useSetUserPassword();
|
||||
|
||||
if (!authLoading && !isAdmin) {
|
||||
return (
|
||||
@@ -110,6 +114,26 @@ export default function Admin() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleSetUserPassword = () => {
|
||||
if (!editUser || !editPassword) return;
|
||||
if (editPassword.length < 6) {
|
||||
toast({ title: "Password too short", description: "Minimum 6 characters.", variant: "destructive" });
|
||||
return;
|
||||
}
|
||||
setUserPassword.mutate(
|
||||
{ id: editUser.id, data: { password: editPassword } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: "Password updated", description: `Password for ${editUser.username} has been set.` });
|
||||
setEditPassword("");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast({ title: "Failed to set password", description: (err.data as { error?: string } | null)?.error ?? err.message, variant: "destructive" });
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleDeleteUser = () => {
|
||||
if (!deleteConfirm) return;
|
||||
deleteUser.mutate(
|
||||
@@ -206,7 +230,7 @@ export default function Admin() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free" })}
|
||||
onClick={() => setEditUser({ id: u.id, username: u.username, role: u.role, tier: u.tier ?? "free", authProvider: u.authProvider })}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
@@ -344,7 +368,7 @@ export default function Admin() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<Input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||
<PasswordInput value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="min. 6 characters" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email (Optional)</Label>
|
||||
@@ -385,7 +409,7 @@ export default function Admin() {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!editUser} onOpenChange={(open) => !open && setEditUser(null)}>
|
||||
<Dialog open={!!editUser} onOpenChange={(open) => { if (!open) { setEditUser(null); setEditPassword(""); } }}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||
@@ -422,15 +446,40 @@ export default function Admin() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{editUser?.authProvider !== "oidc" ? (
|
||||
<div className="space-y-2 border-t pt-4">
|
||||
<Label>Set Password</Label>
|
||||
<PasswordInput
|
||||
value={editPassword}
|
||||
onChange={(e) => setEditPassword(e.target.value)}
|
||||
placeholder="min. 6 characters"
|
||||
data-testid="input-set-password"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Resets the user's password immediately.</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditUser(null)}>Cancel</Button>
|
||||
) : (
|
||||
<div className="space-y-2 border-t pt-4 text-sm text-muted-foreground">
|
||||
Password is managed by the identity provider (Keycloak). Reset it there.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter className="flex-col sm:flex-row sm:justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => { setEditUser(null); setEditPassword(""); }}>Cancel</Button>
|
||||
<Button
|
||||
onClick={handleUpdateUser}
|
||||
disabled={updateUser.isPending}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
{editUser?.authProvider !== "oidc" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSetUserPassword}
|
||||
disabled={setUserPassword.isPending || !editPassword}
|
||||
>
|
||||
{setUserPassword.isPending ? "Setting…" : "Set Password"}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/com
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Layout } from "@/components/layout";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -22,10 +22,18 @@ export default function Home() {
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-8 pb-8">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{/* Stats Banner */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
import { Wrench, AlertCircle } from "lucide-react";
|
||||
@@ -76,9 +77,8 @@ export default function Login() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t("auth.password")}</Label>
|
||||
<Input
|
||||
<PasswordInput
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
|
||||
@@ -25,7 +25,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
@@ -234,7 +234,10 @@ export default function ToolsBrowse() {
|
||||
<p className="text-muted-foreground">{t("browse.subtitle")}</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ export const UserTier = {
|
||||
enterprise: 'enterprise',
|
||||
} as const;
|
||||
|
||||
export type UserAuthProvider = typeof UserAuthProvider[keyof typeof UserAuthProvider];
|
||||
|
||||
|
||||
export const UserAuthProvider = {
|
||||
local: 'local',
|
||||
oidc: 'oidc',
|
||||
} as const;
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -59,6 +67,7 @@ export interface User {
|
||||
email?: string | null;
|
||||
role: UserRole;
|
||||
tier?: UserTier;
|
||||
authProvider?: UserAuthProvider;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -111,6 +120,23 @@ export interface UserRoleUpdate {
|
||||
tier?: UserRoleUpdateTier;
|
||||
}
|
||||
|
||||
export interface ChangePasswordInput {
|
||||
/** @minLength 1 */
|
||||
currentPassword: string;
|
||||
/** @minLength 6 */
|
||||
newPassword: string;
|
||||
}
|
||||
|
||||
export interface SetPasswordInput {
|
||||
/** @minLength 6 */
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface PasswordRedirect {
|
||||
/** @nullable */
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
entityType: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ import type {
|
||||
AuthMode,
|
||||
AuthUser,
|
||||
CategoryStats,
|
||||
ChangePasswordInput,
|
||||
EmptyTrash200,
|
||||
ErrorResponse,
|
||||
GetRatingDistributionParams,
|
||||
@@ -35,11 +36,13 @@ import type {
|
||||
ListToolsParams,
|
||||
ListTrashedToolsParams,
|
||||
LocalLoginInput,
|
||||
PasswordRedirect,
|
||||
Rating,
|
||||
RatingDistribution,
|
||||
RatingHistoryItem,
|
||||
RatingInput,
|
||||
RestoreTools200,
|
||||
SetPasswordInput,
|
||||
Tool,
|
||||
ToolInput,
|
||||
ToolUpdate,
|
||||
@@ -2051,6 +2054,154 @@ export function useGetMe<TData = Awaited<ReturnType<typeof getMe>>, TError = Err
|
||||
|
||||
|
||||
|
||||
export const getChangeMyPasswordUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/me/password`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Change own password (local users only)
|
||||
*/
|
||||
export const changeMyPassword = async (changePasswordInput: ChangePasswordInput, options?: RequestInit): Promise<void> => {
|
||||
|
||||
return customFetch<void>(getChangeMyPasswordUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
changePasswordInput,)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getChangeMyPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext> => {
|
||||
|
||||
const mutationKey = ['changeMyPassword'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof changeMyPassword>>, {data: BodyType<ChangePasswordInput>}> = (props) => {
|
||||
const {data} = props ?? {};
|
||||
|
||||
return changeMyPassword(data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type ChangeMyPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof changeMyPassword>>>
|
||||
export type ChangeMyPasswordMutationBody = BodyType<ChangePasswordInput>
|
||||
export type ChangeMyPasswordMutationError = ErrorType<ErrorResponse>
|
||||
|
||||
/**
|
||||
* @summary Change own password (local users only)
|
||||
*/
|
||||
export const useChangeMyPassword = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof changeMyPassword>>, TError,{data: BodyType<ChangePasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof changeMyPassword>>,
|
||||
TError,
|
||||
{data: BodyType<ChangePasswordInput>},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getChangeMyPasswordMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getGetPasswordRedirectUrl = () => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/auth/password-redirect`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get redirect URL for managing credentials in the identity provider
|
||||
*/
|
||||
export const getPasswordRedirect = async ( options?: RequestInit): Promise<PasswordRedirect> => {
|
||||
|
||||
return customFetch<PasswordRedirect>(getGetPasswordRedirectUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'GET'
|
||||
|
||||
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetPasswordRedirectQueryKey = () => {
|
||||
return [
|
||||
`/api/auth/password-redirect`
|
||||
] as const;
|
||||
}
|
||||
|
||||
|
||||
export const getGetPasswordRedirectQueryOptions = <TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>( options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
) => {
|
||||
|
||||
const {query: queryOptions, request: requestOptions} = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetPasswordRedirectQueryKey();
|
||||
|
||||
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getPasswordRedirect>>> = ({ signal }) => getPasswordRedirect({ signal, ...requestOptions });
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData> & { queryKey: QueryKey }
|
||||
}
|
||||
|
||||
export type GetPasswordRedirectQueryResult = NonNullable<Awaited<ReturnType<typeof getPasswordRedirect>>>
|
||||
export type GetPasswordRedirectQueryError = ErrorType<unknown>
|
||||
|
||||
|
||||
/**
|
||||
* @summary Get redirect URL for managing credentials in the identity provider
|
||||
*/
|
||||
|
||||
export function useGetPasswordRedirect<TData = Awaited<ReturnType<typeof getPasswordRedirect>>, TError = ErrorType<unknown>>(
|
||||
options?: { query?:UseQueryOptions<Awaited<ReturnType<typeof getPasswordRedirect>>, TError, TData>, request?: SecondParameter<typeof customFetch>}
|
||||
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
|
||||
const queryOptions = getGetPasswordRedirectQueryOptions(options)
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & { queryKey: QueryKey };
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
export const getGetMePreferencesUrl = () => {
|
||||
|
||||
|
||||
@@ -2566,6 +2717,78 @@ export const useDeleteUser = <TError = ErrorType<unknown>,
|
||||
return useMutation(getDeleteUserMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getSetUserPasswordUrl = (id: number,) => {
|
||||
|
||||
|
||||
|
||||
|
||||
return `/api/users/${id}/password`
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Set/reset a user's password (admin only, local users only)
|
||||
*/
|
||||
export const setUserPassword = async (id: number,
|
||||
setPasswordInput: SetPasswordInput, options?: RequestInit): Promise<void> => {
|
||||
|
||||
return customFetch<void>(getSetUserPasswordUrl(id),
|
||||
{
|
||||
...options,
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
body: JSON.stringify(
|
||||
setPasswordInput,)
|
||||
}
|
||||
);}
|
||||
|
||||
|
||||
|
||||
|
||||
export const getSetUserPasswordMutationOptions = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext> => {
|
||||
|
||||
const mutationKey = ['setUserPassword'];
|
||||
const {mutation: mutationOptions, request: requestOptions} = options ?
|
||||
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
|
||||
options
|
||||
: {...options, mutation: {...options.mutation, mutationKey}}
|
||||
: {mutation: { mutationKey, }, request: undefined};
|
||||
|
||||
|
||||
|
||||
|
||||
const mutationFn: MutationFunction<Awaited<ReturnType<typeof setUserPassword>>, {id: number;data: BodyType<SetPasswordInput>}> = (props) => {
|
||||
const {id,data} = props ?? {};
|
||||
|
||||
return setUserPassword(id,data,requestOptions)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return { mutationFn, ...mutationOptions }}
|
||||
|
||||
export type SetUserPasswordMutationResult = NonNullable<Awaited<ReturnType<typeof setUserPassword>>>
|
||||
export type SetUserPasswordMutationBody = BodyType<SetPasswordInput>
|
||||
export type SetUserPasswordMutationError = ErrorType<ErrorResponse>
|
||||
|
||||
/**
|
||||
* @summary Set/reset a user's password (admin only, local users only)
|
||||
*/
|
||||
export const useSetUserPassword = <TError = ErrorType<ErrorResponse>,
|
||||
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof setUserPassword>>, TError,{id: number;data: BodyType<SetPasswordInput>}, TContext>, request?: SecondParameter<typeof customFetch>}
|
||||
): UseMutationResult<
|
||||
Awaited<ReturnType<typeof setUserPassword>>,
|
||||
TError,
|
||||
{id: number;data: BodyType<SetPasswordInput>},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getSetUserPasswordMutationOptions(options));
|
||||
}
|
||||
|
||||
export const getListAuditLogsUrl = (params?: ListAuditLogsParams,) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
|
||||
@@ -614,6 +614,58 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/auth/me/password:
|
||||
post:
|
||||
operationId: changeMyPassword
|
||||
tags: [auth]
|
||||
summary: Change own password (local users only)
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ChangePasswordInput"
|
||||
responses:
|
||||
"204":
|
||||
description: Password changed
|
||||
"400":
|
||||
description: Invalid input or wrong current password
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"401":
|
||||
description: Not authenticated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: OIDC user - password is managed by the identity provider
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"429":
|
||||
description: Too many attempts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/auth/password-redirect:
|
||||
get:
|
||||
operationId: getPasswordRedirect
|
||||
tags: [auth]
|
||||
summary: Get redirect URL for managing credentials in the identity provider
|
||||
responses:
|
||||
"200":
|
||||
description: Redirect URL (null in local mode)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/PasswordRedirect"
|
||||
|
||||
/auth/me/preferences:
|
||||
get:
|
||||
operationId: getMePreferences
|
||||
@@ -783,6 +835,51 @@ paths:
|
||||
"204":
|
||||
description: Deleted
|
||||
|
||||
/users/{id}/password:
|
||||
patch:
|
||||
operationId: setUserPassword
|
||||
tags: [users]
|
||||
summary: Set/reset a user's password (admin only, local users only)
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SetPasswordInput"
|
||||
responses:
|
||||
"204":
|
||||
description: Password updated
|
||||
"400":
|
||||
description: Validation error
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"404":
|
||||
description: User not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"422":
|
||||
description: OIDC user - password is managed by the identity provider
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
"429":
|
||||
description: Too many attempts
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/audit-logs:
|
||||
get:
|
||||
operationId: listAuditLogs
|
||||
@@ -870,6 +967,10 @@ components:
|
||||
tier:
|
||||
type: string
|
||||
enum: [free, premium, enterprise]
|
||||
authProvider:
|
||||
type: string
|
||||
enum: [local, oidc]
|
||||
default: local
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -903,6 +1004,32 @@ components:
|
||||
type: string
|
||||
enum: [free, premium, enterprise]
|
||||
|
||||
ChangePasswordInput:
|
||||
type: object
|
||||
required: [currentPassword, newPassword]
|
||||
properties:
|
||||
currentPassword:
|
||||
type: string
|
||||
minLength: 1
|
||||
newPassword:
|
||||
type: string
|
||||
minLength: 6
|
||||
|
||||
SetPasswordInput:
|
||||
type: object
|
||||
required: [password]
|
||||
properties:
|
||||
password:
|
||||
type: string
|
||||
minLength: 6
|
||||
|
||||
PasswordRedirect:
|
||||
type: object
|
||||
required: [url]
|
||||
properties:
|
||||
url:
|
||||
type: ["string", "null"]
|
||||
|
||||
AuditLog:
|
||||
type: object
|
||||
required: [id, entityType, action, userId, username, createdAt]
|
||||
|
||||
@@ -480,6 +480,28 @@ 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)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
@@ -532,12 +554,15 @@ export const GetMeWatchlistResponse = zod.array(GetMeWatchlistResponseItem)
|
||||
/**
|
||||
* @summary List all local users (admin only)
|
||||
*/
|
||||
export const listUsersResponseAuthProviderDefault = `local`;
|
||||
|
||||
export const ListUsersResponseItem = zod.object({
|
||||
"id": zod.number(),
|
||||
"username": zod.string(),
|
||||
"email": zod.string().nullish(),
|
||||
"role": zod.enum(['admin', 'user']),
|
||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||
"authProvider": zod.enum(['local', 'oidc']).default(listUsersResponseAuthProviderDefault),
|
||||
"createdAt": zod.coerce.date()
|
||||
})
|
||||
export const ListUsersResponse = zod.array(ListUsersResponseItem)
|
||||
@@ -573,12 +598,15 @@ export const UpdateUserBody = zod.object({
|
||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional()
|
||||
})
|
||||
|
||||
export const updateUserResponseAuthProviderDefault = `local`;
|
||||
|
||||
export const UpdateUserResponse = zod.object({
|
||||
"id": zod.number(),
|
||||
"username": zod.string(),
|
||||
"email": zod.string().nullish(),
|
||||
"role": zod.enum(['admin', 'user']),
|
||||
"tier": zod.enum(['free', 'premium', 'enterprise']).optional(),
|
||||
"authProvider": zod.enum(['local', 'oidc']).default(updateUserResponseAuthProviderDefault),
|
||||
"createdAt": zod.coerce.date()
|
||||
})
|
||||
|
||||
@@ -591,6 +619,22 @@ export const DeleteUserParams = zod.object({
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary Set/reset a user's password (admin only, local users only)
|
||||
*/
|
||||
export const SetUserPasswordParams = zod.object({
|
||||
"id": zod.coerce.number()
|
||||
})
|
||||
|
||||
export const setUserPasswordBodyPasswordMin = 6;
|
||||
|
||||
|
||||
|
||||
export const SetUserPasswordBody = zod.object({
|
||||
"password": zod.string().min(setUserPasswordBodyPasswordMin)
|
||||
})
|
||||
|
||||
|
||||
/**
|
||||
* @summary List audit log entries (admin only)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* 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;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ export * from './authUser';
|
||||
export * from './authUserRole';
|
||||
export * from './authUserTier';
|
||||
export * from './categoryStats';
|
||||
export * from './changePasswordInput';
|
||||
export * from './emptyTrash200';
|
||||
export * from './errorResponse';
|
||||
export * from './getRatingDistributionParams';
|
||||
@@ -26,12 +27,14 @@ export * from './listToolsParams';
|
||||
export * from './listToolsSort';
|
||||
export * from './listTrashedToolsParams';
|
||||
export * from './localLoginInput';
|
||||
export * from './passwordRedirect';
|
||||
export * from './rating';
|
||||
export * from './ratingDistribution';
|
||||
export * from './ratingHistoryItem';
|
||||
export * from './ratingInput';
|
||||
export * from './restoreTools200';
|
||||
export * from './scoreBucket';
|
||||
export * from './setPasswordInput';
|
||||
export * from './tool';
|
||||
export * from './toolInput';
|
||||
export * from './toolUpdate';
|
||||
@@ -40,6 +43,7 @@ export * from './topToolEntry';
|
||||
export * from './trashTools200';
|
||||
export * from './trashToolsInput';
|
||||
export * from './user';
|
||||
export * from './userAuthProvider';
|
||||
export * from './userCreateInput';
|
||||
export * from './userCreateInputRole';
|
||||
export * from './userCreateInputTier';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* 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;
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
* ToolRate API — Tool listing and rating platform
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
import type { UserAuthProvider } from './userAuthProvider';
|
||||
import type { UserRole } from './userRole';
|
||||
import type { UserTier } from './userTier';
|
||||
|
||||
@@ -15,5 +16,6 @@ export interface User {
|
||||
email?: string | null;
|
||||
role: UserRole;
|
||||
tier?: UserTier;
|
||||
authProvider?: UserAuthProvider;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Generated by orval v8.9.1 🍺
|
||||
* 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;
|
||||
Generated
+23
@@ -204,6 +204,9 @@ importers:
|
||||
express:
|
||||
specifier: ^5.2.1
|
||||
version: 5.2.1
|
||||
express-rate-limit:
|
||||
specifier: ^8.6.1
|
||||
version: 8.6.1(express@5.2.1)
|
||||
express-session:
|
||||
specifier: ^1.19.0
|
||||
version: 1.19.0
|
||||
@@ -2246,6 +2249,12 @@ packages:
|
||||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||
engines: {node: ^18.19.0 || >=20.5.0}
|
||||
|
||||
express-rate-limit@8.6.1:
|
||||
resolution: {integrity: sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==}
|
||||
engines: {node: '>= 16'}
|
||||
peerDependencies:
|
||||
express: '>= 4.11'
|
||||
|
||||
express-session@1.19.0:
|
||||
resolution: {integrity: sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -2429,6 +2438,10 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.4.0:
|
||||
resolution: {integrity: sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -4935,6 +4948,14 @@ snapshots:
|
||||
strip-final-newline: 4.0.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
express-rate-limit@8.6.1(express@5.2.1):
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
express: 5.2.1
|
||||
ip-address: 10.4.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
express-session@1.19.0:
|
||||
dependencies:
|
||||
cookie: 0.7.2
|
||||
@@ -5144,6 +5165,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.4.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-extglob@2.1.1: {}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm --filter db push
|
||||
Reference in New Issue
Block a user