Compare commits
3 commits
a6edf804b4
...
11b16ac3cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 11b16ac3cf | |||
| 8ab9a9a497 | |||
| c42b8e2904 |
32 changed files with 1162 additions and 59 deletions
17
.dockerignore
Normal file
17
.dockerignore
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
.git
|
||||||
|
.jj
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
.turbo
|
||||||
|
.cache
|
||||||
|
coverage
|
||||||
|
**/dist
|
||||||
|
**/build
|
||||||
|
**/*.tsbuildinfo
|
||||||
|
.env*
|
||||||
|
**/.env*
|
||||||
|
!.env.example
|
||||||
|
!**/.env.example
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
docs
|
||||||
15
.env.example
Normal file
15
.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
POSTGRES_DB=minmon
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@postgres:5432/minmon
|
||||||
|
|
||||||
|
BETTER_AUTH_SECRET=minmon-compose-secret-change-me-123456
|
||||||
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
|
CORS_ORIGIN=http://localhost:3001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
SERVER_PORT=3000
|
||||||
|
WEB_PORT=3001
|
||||||
|
VITE_SERVER_URL=http://localhost:3000
|
||||||
21
README.md
21
README.md
|
|
@ -159,6 +159,25 @@ bun run dev
|
||||||
- Web: `http://localhost:5173`
|
- Web: `http://localhost:5173`
|
||||||
- API: `http://localhost:3000`
|
- API: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Docker Compose Deployment
|
||||||
|
|
||||||
|
Run the full stack with PostgreSQL, database initialization, API, and web UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Default endpoints:
|
||||||
|
|
||||||
|
- Web: `http://localhost:3001`
|
||||||
|
- API: `http://localhost:3000`
|
||||||
|
|
||||||
|
On first startup, the database initializer applies the schema and seeds the sample admin only when the users table is empty.
|
||||||
|
|
||||||
|
Docker Compose reads the root `.env` file automatically. Edit it before startup if you need different ports, public URLs, credentials, or secrets.
|
||||||
|
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|
@ -186,5 +205,5 @@ See:
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- DNS checking is limited to A record / IPv4 as required.
|
- DNS checking is limited to A record / IPv4 as required.
|
||||||
- This is not a deployment platform and does not include SSH, Docker orchestration, or reverse proxy automation.
|
- Docker Compose is available for running the app stack; the app itself does not include SSH, reverse proxy, or host orchestration automation.
|
||||||
- Full workspace typecheck is still blocked by a pre-existing desktop typing issue for `three`, but the web app and server app are verified.
|
- Full workspace typecheck is still blocked by a pre-existing desktop typing issue for `three`, but the web app and server app are verified.
|
||||||
|
|
|
||||||
33
apps/server/Dockerfile
Normal file
33
apps/server/Dockerfile
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.12 AS base
|
||||||
|
WORKDIR /app
|
||||||
|
ENV CI=1
|
||||||
|
|
||||||
|
COPY package.json bun.lock turbo.json tsconfig.json bts.jsonc ./
|
||||||
|
COPY apps ./apps
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
FROM base AS deps
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
|
||||||
|
FROM deps AS tools
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
RUN touch apps/server/.env
|
||||||
|
|
||||||
|
FROM deps AS build
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN bun run --filter server build
|
||||||
|
|
||||||
|
FROM base AS prod-deps
|
||||||
|
RUN bun install --frozen-lockfile --production
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.12 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
COPY --from=prod-deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=build /app/apps/server/dist ./apps/server/dist
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["bun", "apps/server/dist/index.mjs"]
|
||||||
21
apps/web/Dockerfile
Normal file
21
apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM oven/bun:1.3.12 AS build
|
||||||
|
WORKDIR /app
|
||||||
|
ENV CI=1
|
||||||
|
|
||||||
|
ARG VITE_SERVER_URL=http://localhost:3000
|
||||||
|
ENV VITE_SERVER_URL=${VITE_SERVER_URL}
|
||||||
|
|
||||||
|
COPY package.json bun.lock turbo.json tsconfig.json bts.jsonc ./
|
||||||
|
COPY apps ./apps
|
||||||
|
COPY packages ./packages
|
||||||
|
|
||||||
|
RUN bun install --frozen-lockfile
|
||||||
|
RUN bun run --filter web build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
11
apps/web/nginx.conf
Normal file
11
apps/web/nginx.conf
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
MenuIcon,
|
MenuIcon,
|
||||||
Server,
|
Server,
|
||||||
|
Users,
|
||||||
Wrench,
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
@ -25,30 +26,42 @@ const dashboardLinks = [
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
description: "Health, alerts, and recent checks",
|
description: "Health, alerts, and recent checks",
|
||||||
icon: LayoutGrid,
|
icon: LayoutGrid,
|
||||||
|
permission: "dashboard.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/dashboard/servers",
|
to: "/dashboard/servers",
|
||||||
label: "Servers",
|
label: "Servers",
|
||||||
description: "Inventory and host details",
|
description: "Inventory and host details",
|
||||||
icon: Server,
|
icon: Server,
|
||||||
|
permission: "servers.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/dashboard/services",
|
to: "/dashboard/services",
|
||||||
label: "Services",
|
label: "Services",
|
||||||
description: "Processes and uptime targets",
|
description: "Processes and uptime targets",
|
||||||
icon: Wrench,
|
icon: Wrench,
|
||||||
|
permission: "services.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/dashboard/domains",
|
to: "/dashboard/domains",
|
||||||
label: "Domains",
|
label: "Domains",
|
||||||
description: "DNS, SSL, and expiry status",
|
description: "DNS, SSL, and expiry status",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
|
permission: "domains.read",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/dashboard/notes",
|
to: "/dashboard/notes",
|
||||||
label: "Notes",
|
label: "Notes",
|
||||||
description: "Runbooks and operational handover",
|
description: "Runbooks and operational handover",
|
||||||
icon: BookText,
|
icon: BookText,
|
||||||
|
permission: "notes.read",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: "/dashboard/users",
|
||||||
|
label: "Users",
|
||||||
|
description: "Roles and permission assignments",
|
||||||
|
icon: Users,
|
||||||
|
permission: "users.read",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -60,14 +73,18 @@ function isActive(pathname: string, to: string): boolean {
|
||||||
|
|
||||||
function NavItems({
|
function NavItems({
|
||||||
pathname,
|
pathname,
|
||||||
|
permissions,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
pathname: string;
|
pathname: string;
|
||||||
|
permissions: string[];
|
||||||
onNavigate?: () => void;
|
onNavigate?: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const allowedPermissions = new Set(permissions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="grid gap-1 p-2" aria-label="Dashboard sections">
|
<nav className="grid gap-1 p-2" aria-label="Dashboard sections">
|
||||||
{dashboardLinks.map(({ to, label, description, icon: Icon }) => {
|
{dashboardLinks.filter((link) => allowedPermissions.has(link.permission)).map(({ to, label, description, icon: Icon }) => {
|
||||||
const active = isActive(pathname, to);
|
const active = isActive(pathname, to);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -97,15 +114,18 @@ function NavItems({
|
||||||
export function DashboardShell({
|
export function DashboardShell({
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
|
permissions,
|
||||||
}: {
|
}: {
|
||||||
userName?: string | null;
|
userName?: string | null;
|
||||||
userEmail?: string | null;
|
userEmail?: string | null;
|
||||||
|
permissions: string[];
|
||||||
}) {
|
}) {
|
||||||
const pathname = useRouterState({
|
const pathname = useRouterState({
|
||||||
select: (state) => state.location.pathname,
|
select: (state) => state.location.pathname,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mobileOpen, setMobileOpen] = useState(false);
|
const [mobileOpen, setMobileOpen] = useState(false);
|
||||||
|
const allowedPermissions = new Set(permissions);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileOpen(false);
|
setMobileOpen(false);
|
||||||
|
|
@ -139,6 +159,7 @@ export function DashboardShell({
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<NavItems
|
<NavItems
|
||||||
pathname={pathname}
|
pathname={pathname}
|
||||||
|
permissions={permissions}
|
||||||
onNavigate={() => setMobileOpen(false)}
|
onNavigate={() => setMobileOpen(false)}
|
||||||
/>
|
/>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|
@ -171,14 +192,18 @@ export function DashboardShell({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Link to="/dashboard/servers/new">
|
{allowedPermissions.has("servers.create") ? (
|
||||||
<Button size="sm">Add server</Button>
|
<Link to="/dashboard/servers/new">
|
||||||
</Link>
|
<Button size="sm">Add server</Button>
|
||||||
<Link to="/dashboard/notes/new">
|
</Link>
|
||||||
<Button size="sm" variant="outline">
|
) : null}
|
||||||
New note
|
{allowedPermissions.has("notes.create") ? (
|
||||||
</Button>
|
<Link to="/dashboard/notes/new">
|
||||||
</Link>
|
<Button size="sm" variant="outline">
|
||||||
|
New note
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,7 +217,7 @@ export function DashboardShell({
|
||||||
Navigation
|
Navigation
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NavItems pathname={pathname} />
|
<NavItems pathname={pathname} permissions={permissions} />
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="min-w-0">
|
<main className="min-w-0">
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as DashboardRouteImport } from './routes/dashboard'
|
import { Route as DashboardRouteImport } from './routes/dashboard'
|
||||||
import { Route as IndexRouteImport } from './routes/index'
|
import { Route as IndexRouteImport } from './routes/index'
|
||||||
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
|
import { Route as DashboardIndexRouteImport } from './routes/dashboard.index'
|
||||||
|
import { Route as DashboardUsersRouteImport } from './routes/dashboard.users'
|
||||||
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
|
import { Route as DashboardServicesRouteImport } from './routes/dashboard.services'
|
||||||
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
|
import { Route as DashboardServersRouteImport } from './routes/dashboard.servers'
|
||||||
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
|
import { Route as DashboardNotesRouteImport } from './routes/dashboard.notes'
|
||||||
|
|
@ -46,6 +47,11 @@ const DashboardIndexRoute = DashboardIndexRouteImport.update({
|
||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => DashboardRoute,
|
getParentRoute: () => DashboardRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const DashboardUsersRoute = DashboardUsersRouteImport.update({
|
||||||
|
id: '/users',
|
||||||
|
path: '/users',
|
||||||
|
getParentRoute: () => DashboardRoute,
|
||||||
|
} as any)
|
||||||
const DashboardServicesRoute = DashboardServicesRouteImport.update({
|
const DashboardServicesRoute = DashboardServicesRouteImport.update({
|
||||||
id: '/services',
|
id: '/services',
|
||||||
path: '/services',
|
path: '/services',
|
||||||
|
|
@ -118,6 +124,7 @@ export interface FileRoutesByFullPath {
|
||||||
'/dashboard/notes': typeof DashboardNotesRoute
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRoute
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRoute
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
|
|
@ -135,6 +142,7 @@ export interface FileRoutesByTo {
|
||||||
'/dashboard/notes': typeof DashboardNotesRoute
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRoute
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRoute
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
'/dashboard': typeof DashboardIndexRoute
|
'/dashboard': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
'/dashboard/domains/new': typeof DashboardDomainsNewRoute
|
||||||
|
|
@ -154,6 +162,7 @@ export interface FileRoutesById {
|
||||||
'/dashboard/notes': typeof DashboardNotesRoute
|
'/dashboard/notes': typeof DashboardNotesRoute
|
||||||
'/dashboard/servers': typeof DashboardServersRoute
|
'/dashboard/servers': typeof DashboardServersRoute
|
||||||
'/dashboard/services': typeof DashboardServicesRoute
|
'/dashboard/services': typeof DashboardServicesRoute
|
||||||
|
'/dashboard/users': typeof DashboardUsersRoute
|
||||||
'/dashboard/': typeof DashboardIndexRoute
|
'/dashboard/': typeof DashboardIndexRoute
|
||||||
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
|
'/dashboard/domains_/$domainId': typeof DashboardDomainsDomainIdRoute
|
||||||
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
|
'/dashboard/domains_/new': typeof DashboardDomainsNewRoute
|
||||||
|
|
@ -174,6 +183,7 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/notes'
|
| '/dashboard/notes'
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
|
| '/dashboard/users'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/domains/$domainId'
|
| '/dashboard/domains/$domainId'
|
||||||
| '/dashboard/domains/new'
|
| '/dashboard/domains/new'
|
||||||
|
|
@ -191,6 +201,7 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/notes'
|
| '/dashboard/notes'
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
|
| '/dashboard/users'
|
||||||
| '/dashboard'
|
| '/dashboard'
|
||||||
| '/dashboard/domains/$domainId'
|
| '/dashboard/domains/$domainId'
|
||||||
| '/dashboard/domains/new'
|
| '/dashboard/domains/new'
|
||||||
|
|
@ -209,6 +220,7 @@ export interface FileRouteTypes {
|
||||||
| '/dashboard/notes'
|
| '/dashboard/notes'
|
||||||
| '/dashboard/servers'
|
| '/dashboard/servers'
|
||||||
| '/dashboard/services'
|
| '/dashboard/services'
|
||||||
|
| '/dashboard/users'
|
||||||
| '/dashboard/'
|
| '/dashboard/'
|
||||||
| '/dashboard/domains_/$domainId'
|
| '/dashboard/domains_/$domainId'
|
||||||
| '/dashboard/domains_/new'
|
| '/dashboard/domains_/new'
|
||||||
|
|
@ -256,6 +268,13 @@ declare module '@tanstack/react-router' {
|
||||||
preLoaderRoute: typeof DashboardIndexRouteImport
|
preLoaderRoute: typeof DashboardIndexRouteImport
|
||||||
parentRoute: typeof DashboardRoute
|
parentRoute: typeof DashboardRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/users': {
|
||||||
|
id: '/dashboard/users'
|
||||||
|
path: '/users'
|
||||||
|
fullPath: '/dashboard/users'
|
||||||
|
preLoaderRoute: typeof DashboardUsersRouteImport
|
||||||
|
parentRoute: typeof DashboardRoute
|
||||||
|
}
|
||||||
'/dashboard/services': {
|
'/dashboard/services': {
|
||||||
id: '/dashboard/services'
|
id: '/dashboard/services'
|
||||||
path: '/services'
|
path: '/services'
|
||||||
|
|
@ -348,6 +367,7 @@ interface DashboardRouteChildren {
|
||||||
DashboardNotesRoute: typeof DashboardNotesRoute
|
DashboardNotesRoute: typeof DashboardNotesRoute
|
||||||
DashboardServersRoute: typeof DashboardServersRoute
|
DashboardServersRoute: typeof DashboardServersRoute
|
||||||
DashboardServicesRoute: typeof DashboardServicesRoute
|
DashboardServicesRoute: typeof DashboardServicesRoute
|
||||||
|
DashboardUsersRoute: typeof DashboardUsersRoute
|
||||||
DashboardIndexRoute: typeof DashboardIndexRoute
|
DashboardIndexRoute: typeof DashboardIndexRoute
|
||||||
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
DashboardDomainsDomainIdRoute: typeof DashboardDomainsDomainIdRoute
|
||||||
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
DashboardDomainsNewRoute: typeof DashboardDomainsNewRoute
|
||||||
|
|
@ -364,6 +384,7 @@ const DashboardRouteChildren: DashboardRouteChildren = {
|
||||||
DashboardNotesRoute: DashboardNotesRoute,
|
DashboardNotesRoute: DashboardNotesRoute,
|
||||||
DashboardServersRoute: DashboardServersRoute,
|
DashboardServersRoute: DashboardServersRoute,
|
||||||
DashboardServicesRoute: DashboardServicesRoute,
|
DashboardServicesRoute: DashboardServicesRoute,
|
||||||
|
DashboardUsersRoute: DashboardUsersRoute,
|
||||||
DashboardIndexRoute: DashboardIndexRoute,
|
DashboardIndexRoute: DashboardIndexRoute,
|
||||||
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
DashboardDomainsDomainIdRoute: DashboardDomainsDomainIdRoute,
|
||||||
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
DashboardDomainsNewRoute: DashboardDomainsNewRoute,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { DashboardShell } from "@/components/dashboard-shell";
|
import { DashboardShell } from "@/components/dashboard-shell";
|
||||||
import { requireSession } from "@/lib/protected-route";
|
import { requireSession } from "@/lib/protected-route";
|
||||||
|
import { trpc } from "@/utils/trpc";
|
||||||
|
|
||||||
export const Route = createFileRoute("/dashboard")({
|
export const Route = createFileRoute("/dashboard")({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
|
|
@ -10,8 +12,13 @@ export const Route = createFileRoute("/dashboard")({
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { session } = Route.useRouteContext();
|
const { session } = Route.useRouteContext();
|
||||||
|
const me = useQuery(trpc.users.me.queryOptions());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardShell userName={session.data?.user.name} userEmail={session.data?.user.email} />
|
<DashboardShell
|
||||||
|
userName={session.data?.user.name}
|
||||||
|
userEmail={session.data?.user.email}
|
||||||
|
permissions={me.data?.permissions ?? []}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
329
apps/web/src/routes/dashboard.users.tsx
Normal file
329
apps/web/src/routes/dashboard.users.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
||||||
|
import { Button } from "@minmon/ui/components/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@minmon/ui/components/card";
|
||||||
|
import { Checkbox } from "@minmon/ui/components/checkbox";
|
||||||
|
import { StatusBadge } from "@minmon/ui/components/status-badge";
|
||||||
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import z from "zod";
|
||||||
|
|
||||||
|
import { ConfirmDeleteIconButton, FormCard, PageSection, QueryStateCard, ResourceListCard, TextField } from "@/components/dashboard-ui";
|
||||||
|
import { queryClient, trpc, trpcClient } from "@/utils/trpc";
|
||||||
|
|
||||||
|
export const Route = createFileRoute("/dashboard/users")({
|
||||||
|
component: RouteComponent,
|
||||||
|
});
|
||||||
|
|
||||||
|
type RoleOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UserCreateFormValues = {
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
roleIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const userCreateSchema = z.object({
|
||||||
|
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||||
|
email: z.email("Invalid email address"),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||||
|
roleIds: z.array(z.string()).min(1, "Select at least one role"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function RoleBadges({ roles }: { roles: { id: string; name: string }[] }) {
|
||||||
|
if (!roles.length) {
|
||||||
|
return <span className="text-muted-foreground">No roles</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<StatusBadge key={role.id} variant={role.name === "Admin" ? "info" : role.name === "Editor" ? "warning" : "neutral"}>
|
||||||
|
{role.name}
|
||||||
|
</StatusBadge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RoleAssignmentCard({ roles, permissions }: { roles: RoleOption[]; permissions: string[] }) {
|
||||||
|
const users = useQuery(trpc.users.list.queryOptions());
|
||||||
|
const allowedPermissions = new Set(permissions);
|
||||||
|
const updateRoles = useMutation({
|
||||||
|
mutationFn: async ({ userId, roleIds }: { userId: string; roleIds: string[] }) =>
|
||||||
|
trpcClient.users.updateRoles.mutate({ userId, roleIds }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("User roles updated");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
const removeUser = useMutation({
|
||||||
|
mutationFn: async (id: string) => trpcClient.users.delete.mutate({ id }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("User deleted");
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (users.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading users" description="Fetching users and their current role assignments." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (users.isError || !users.data) {
|
||||||
|
return <QueryStateCard title="Users unavailable" description="User role assignments could not be loaded." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ResourceListCard
|
||||||
|
title="Users"
|
||||||
|
description={
|
||||||
|
allowedPermissions.has("users.assignRoles")
|
||||||
|
? "Grant one or more roles to each user. Changes are saved immediately."
|
||||||
|
: "Review application users and their current roles."
|
||||||
|
}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: "user",
|
||||||
|
header: "User",
|
||||||
|
render: (item: (typeof users.data)[number]) => (
|
||||||
|
<div className="grid gap-1">
|
||||||
|
<span className="font-medium">{item.name}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{item.email}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "roles",
|
||||||
|
header: "Current roles",
|
||||||
|
render: (item: (typeof users.data)[number]) => <RoleBadges roles={item.roles} />,
|
||||||
|
},
|
||||||
|
...(allowedPermissions.has("users.assignRoles")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "assign",
|
||||||
|
header: "Assign roles",
|
||||||
|
render: (item: (typeof users.data)[number]) => {
|
||||||
|
const assignedRoleIds = new Set(item.roles.map((role) => role.id));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{roles.map((role) => {
|
||||||
|
const checked = assignedRoleIds.has(role.id);
|
||||||
|
const nextRoleIds = checked
|
||||||
|
? item.roles.map((assignedRole) => assignedRole.id).filter((roleId) => roleId !== role.id)
|
||||||
|
: [...item.roles.map((assignedRole) => assignedRole.id), role.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
disabled={updateRoles.isPending || nextRoleIds.length === 0}
|
||||||
|
onCheckedChange={() => updateRoles.mutate({ userId: item.id, roleIds: nextRoleIds })}
|
||||||
|
/>
|
||||||
|
<span>{role.name}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
...(allowedPermissions.has("users.delete")
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "Actions",
|
||||||
|
className: "w-20",
|
||||||
|
render: (item: (typeof users.data)[number]) => (
|
||||||
|
<ConfirmDeleteIconButton
|
||||||
|
itemLabel={`user ${item.email}`}
|
||||||
|
disabled={removeUser.isPending}
|
||||||
|
onConfirm={() => removeUser.mutate(item.id)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
items={users.data}
|
||||||
|
emptyTitle="No users found"
|
||||||
|
emptyDescription="Users appear here after they sign up or are seeded."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateUserCard({ roles }: { roles: RoleOption[] }) {
|
||||||
|
const createUser = useMutation({
|
||||||
|
mutationFn: async (values: UserCreateFormValues) => trpcClient.users.create.mutate(values),
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("User created");
|
||||||
|
form.reset();
|
||||||
|
await queryClient.invalidateQueries();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(error.message),
|
||||||
|
});
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
roleIds: [roles.find((role) => role.name === "Viewer")?.id ?? roles[0]?.id ?? ""].filter(Boolean),
|
||||||
|
} satisfies UserCreateFormValues,
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
await createUser.mutateAsync(value);
|
||||||
|
},
|
||||||
|
validators: {
|
||||||
|
onSubmit: userCreateSchema,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
className="grid gap-4"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
form.handleSubmit();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FormCard
|
||||||
|
title="Create user"
|
||||||
|
description="Add a password user and assign an initial role. The account can sign in immediately."
|
||||||
|
footer={
|
||||||
|
<form.Subscribe selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}>
|
||||||
|
{({ canSubmit, isSubmitting }) => (
|
||||||
|
<Button type="submit" disabled={!canSubmit} loading={isSubmitting}>
|
||||||
|
Create user
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</form.Subscribe>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<form.Field name="name">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Name"
|
||||||
|
name={field.name}
|
||||||
|
autoComplete="name"
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
|
errors={field.state.meta.errors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="email">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Email"
|
||||||
|
name={field.name}
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
|
errors={field.state.meta.errors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
<form.Field name="password">
|
||||||
|
{(field) => (
|
||||||
|
<TextField
|
||||||
|
label="Password"
|
||||||
|
name={field.name}
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={field.state.value}
|
||||||
|
onBlur={field.handleBlur}
|
||||||
|
onChange={(event) => field.handleChange(event.target.value)}
|
||||||
|
errors={field.state.meta.errors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</div>
|
||||||
|
<form.Field name="roleIds">
|
||||||
|
{(field) => (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<p className="text-sm font-medium">Initial roles</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-3">
|
||||||
|
{roles.map((role) => {
|
||||||
|
const checked = field.state.value.includes(role.id);
|
||||||
|
const nextRoleIds = checked
|
||||||
|
? field.state.value.filter((roleId) => roleId !== role.id)
|
||||||
|
: [...field.state.value, role.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label key={role.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={checked}
|
||||||
|
disabled={checked && field.state.value.length === 1}
|
||||||
|
onCheckedChange={() => field.handleChange(nextRoleIds)}
|
||||||
|
/>
|
||||||
|
<span>{role.name}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{field.state.meta.errors.length ? (
|
||||||
|
<p className="text-xs text-destructive" role="alert">
|
||||||
|
{field.state.meta.errors.map((error) => (typeof error === "string" ? error : error?.message)).filter(Boolean).join(", ")}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form.Field>
|
||||||
|
</FormCard>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteComponent() {
|
||||||
|
const roles = useQuery(trpc.users.roles.queryOptions());
|
||||||
|
const me = useQuery(trpc.users.me.queryOptions());
|
||||||
|
|
||||||
|
if (roles.isLoading || me.isLoading) {
|
||||||
|
return <QueryStateCard title="Loading roles" description="Fetching available Admin, Editor, and Viewer roles." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roles.isError || !roles.data || me.isError || !me.data) {
|
||||||
|
return <QueryStateCard title="Roles unavailable" description="Role options could not be loaded right now." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedPermissions = new Set(me.data.permissions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageSection title="Users and roles" description="Assign Admin, Editor, or Viewer access to application users.">
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{allowedPermissions.has("users.create") ? <CreateUserCard roles={roles.data} /> : null}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Role guide</CardTitle>
|
||||||
|
<CardDescription>Default RBAC roles seeded by the application.</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 md:grid-cols-3">
|
||||||
|
{roles.data.map((role) => (
|
||||||
|
<div key={role.id} className="grid gap-1 border p-3">
|
||||||
|
<StatusBadge variant={role.name === "Admin" ? "info" : role.name === "Editor" ? "warning" : "neutral"}>
|
||||||
|
{role.name}
|
||||||
|
</StatusBadge>
|
||||||
|
<p className="text-sm text-muted-foreground">{role.description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<RoleAssignmentCard roles={roles.data} permissions={me.data.permissions} />
|
||||||
|
</div>
|
||||||
|
</PageSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
docker-compose.yml
Normal file
64
docker-compose.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
name: minmon
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set in root .env}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER must be set in root .env}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in root .env}
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:?POSTGRES_PORT must be set in root .env}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
start_period: 10s
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db-init:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/server/Dockerfile
|
||||||
|
target: tools
|
||||||
|
environment:
|
||||||
|
<<: *server-environment
|
||||||
|
command: ["sh", "-c", "bun run db:push && bun run --filter @minmon/db db:seed:if-empty"]
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
|
server:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/server/Dockerfile
|
||||||
|
environment:
|
||||||
|
<<: *server-environment
|
||||||
|
ports:
|
||||||
|
- "${SERVER_PORT:?SERVER_PORT must be set in root .env}:3000"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
db-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/web/Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_SERVER_URL: ${VITE_SERVER_URL:?VITE_SERVER_URL must be set in root .env}
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:?WEB_PORT must be set in root .env}:80"
|
||||||
|
depends_on:
|
||||||
|
server:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
|
@ -114,6 +114,25 @@ bun run dev
|
||||||
- Web: `http://localhost:5173`
|
- Web: `http://localhost:5173`
|
||||||
- API: `http://localhost:3000`
|
- API: `http://localhost:3000`
|
||||||
|
|
||||||
|
## Docker Compose Deployment
|
||||||
|
|
||||||
|
Run the deployable stack with one command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Default Docker endpoints:
|
||||||
|
|
||||||
|
- Web: `http://localhost:3001`
|
||||||
|
- API: `http://localhost:3000`
|
||||||
|
|
||||||
|
The Compose stack starts PostgreSQL, applies the schema, and seeds the sample admin only if the users table is empty.
|
||||||
|
|
||||||
|
Docker Compose reads the root `.env` file automatically. Edit it before startup if you need different ports, public URLs, credentials, or secrets.
|
||||||
|
|
||||||
|
|
||||||
## Current Behavior Notes
|
## Current Behavior Notes
|
||||||
|
|
||||||
### List Ordering
|
### List Ordering
|
||||||
|
|
@ -157,4 +176,4 @@ bun x tsc --noEmit -p packages/api/tsconfig.json
|
||||||
- Full workspace typecheck may still fail because of a pre-existing `apps/desktop` typing issue for `three`.
|
- Full workspace typecheck may still fail because of a pre-existing `apps/desktop` typing issue for `three`.
|
||||||
- The verified implementation path is the web app + server app + database push + database seed.
|
- The verified implementation path is the web app + server app + database push + database seed.
|
||||||
- DNS checking is limited to A record / IPv4.
|
- DNS checking is limited to A record / IPv4.
|
||||||
- This project is not a deployment platform and does not include SSH, Docker orchestration, or reverse proxy automation.
|
- Docker Compose is available for running the app stack; the app itself does not include SSH, reverse proxy, or host orchestration automation.
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,42 @@
|
||||||
import { auth } from "@minmon/auth";
|
import { auth } from "@minmon/auth";
|
||||||
|
import { db, permission, role, rolePermission, userRole } from "@minmon/db";
|
||||||
import type { Context as ElysiaContext } from "elysia";
|
import type { Context as ElysiaContext } from "elysia";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
export type CreateContextOptions = {
|
export type CreateContextOptions = {
|
||||||
context: ElysiaContext;
|
context: ElysiaContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
async function getAuthorization(userId: string) {
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
roleId: role.id,
|
||||||
|
roleName: role.name,
|
||||||
|
permissionName: permission.name,
|
||||||
|
})
|
||||||
|
.from(userRole)
|
||||||
|
.innerJoin(role, eq(role.id, userRole.roleId))
|
||||||
|
.innerJoin(rolePermission, eq(rolePermission.roleId, role.id))
|
||||||
|
.innerJoin(permission, eq(permission.id, rolePermission.permissionId))
|
||||||
|
.where(eq(userRole.userId, userId));
|
||||||
|
|
||||||
|
return {
|
||||||
|
roles: Array.from(new Map(rows.map((row) => [row.roleId, { id: row.roleId, name: row.roleName }])).values()),
|
||||||
|
permissions: Array.from(new Set(rows.map((row) => row.permissionName))),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function createContext({ context }: CreateContextOptions) {
|
export async function createContext({ context }: CreateContextOptions) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers: context.request.headers,
|
headers: context.request.headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const authorization = session ? await getAuthorization(session.user.id) : { roles: [], permissions: [] };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
auth: null,
|
auth: null,
|
||||||
session,
|
session,
|
||||||
|
authorization,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { initTRPC, TRPCError } from "@trpc/server";
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
import type { Context } from "./context";
|
import type { Context } from "./context";
|
||||||
|
import type { Permission } from "./permissions";
|
||||||
|
|
||||||
export const t = initTRPC.context<Context>().create();
|
export const t = initTRPC.context<Context>().create();
|
||||||
|
|
||||||
|
|
@ -23,3 +24,33 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function requirePermission(permission: Permission) {
|
||||||
|
return t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.session) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "UNAUTHORIZED",
|
||||||
|
message: "Authentication required",
|
||||||
|
cause: "No session",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ctx.authorization.permissions.includes(permission)) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "FORBIDDEN",
|
||||||
|
message: `Missing permission: ${permission}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return next({
|
||||||
|
ctx: {
|
||||||
|
...ctx,
|
||||||
|
session: ctx.session,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function permissionedProcedure(permission: Permission) {
|
||||||
|
return t.procedure.use(requirePermission(permission));
|
||||||
|
}
|
||||||
|
|
|
||||||
1
packages/api/src/permissions.ts
Normal file
1
packages/api/src/permissions.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { permissions, roleDefinitions, type Permission, type RoleName } from "@minmon/db/rbac";
|
||||||
|
|
@ -2,10 +2,10 @@ import { db } from "@minmon/db";
|
||||||
import { domain, note, server, service } from "@minmon/db";
|
import { domain, note, server, service } from "@minmon/db";
|
||||||
import { count, desc, eq, inArray } from "drizzle-orm";
|
import { count, desc, eq, inArray } from "drizzle-orm";
|
||||||
|
|
||||||
import { protectedProcedure, router } from "../index";
|
import { permissionedProcedure, router } from "../index";
|
||||||
|
|
||||||
export const dashboardRouter = router({
|
export const dashboardRouter = router({
|
||||||
summary: protectedProcedure.query(async () => {
|
summary: permissionedProcedure("dashboard.read").query(async () => {
|
||||||
const [
|
const [
|
||||||
serverCount,
|
serverCount,
|
||||||
serviceCount,
|
serviceCount,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { domain, service } from "@minmon/db";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { runAndPersistDomainCheck } from "../lib/domain-check";
|
import { runAndPersistDomainCheck } from "../lib/domain-check";
|
||||||
import { protectedProcedure, router } from "../index";
|
import { permissionedProcedure, router } from "../index";
|
||||||
import {
|
import {
|
||||||
byIdSchema,
|
byIdSchema,
|
||||||
domainCreateSchema,
|
domainCreateSchema,
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export const domainsRouter = router({
|
export const domainsRouter = router({
|
||||||
list: protectedProcedure.input(domainListSchema).query(async ({ input }) => {
|
list: permissionedProcedure("domains.read").input(domainListSchema).query(async ({ input }) => {
|
||||||
if (!input?.serviceId) {
|
if (!input?.serviceId) {
|
||||||
return db.select().from(domain).orderBy(desc(domain.createdAt), desc(domain.id));
|
return db.select().from(domain).orderBy(desc(domain.createdAt), desc(domain.id));
|
||||||
}
|
}
|
||||||
|
|
@ -33,11 +33,11 @@ export const domainsRouter = router({
|
||||||
.orderBy(desc(domain.createdAt), desc(domain.id));
|
.orderBy(desc(domain.createdAt), desc(domain.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: permissionedProcedure("domains.read").input(byIdSchema).query(async ({ input }) => {
|
||||||
return requireDomain(input.id);
|
return requireDomain(input.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure.input(domainCreateSchema).mutation(async ({ input }) => {
|
create: permissionedProcedure("domains.create").input(domainCreateSchema).mutation(async ({ input }) => {
|
||||||
const { service: parentService, server: parentServer } = await requireServiceWithServer(input.serviceId);
|
const { service: parentService, server: parentServer } = await requireServiceWithServer(input.serviceId);
|
||||||
|
|
||||||
const expectedServerIp = input.expectedServerIp ?? parentServer.primaryIpAddress;
|
const expectedServerIp = input.expectedServerIp ?? parentServer.primaryIpAddress;
|
||||||
|
|
@ -56,7 +56,7 @@ export const domainsRouter = router({
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure.input(domainUpdateSchema).mutation(async ({ input }) => {
|
update: permissionedProcedure("domains.update").input(domainUpdateSchema).mutation(async ({ input }) => {
|
||||||
const { id, serviceId, ...changes } = input;
|
const { id, serviceId, ...changes } = input;
|
||||||
const existing = await requireDomain(id);
|
const existing = await requireDomain(id);
|
||||||
|
|
||||||
|
|
@ -78,13 +78,13 @@ export const domainsRouter = router({
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
delete: permissionedProcedure("domains.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||||
await requireDomain(input.id);
|
await requireDomain(input.id);
|
||||||
await db.delete(domain).where(eq(domain.id, input.id));
|
await db.delete(domain).where(eq(domain.id, input.id));
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
check: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
check: permissionedProcedure("domains.check").input(byIdSchema).mutation(async ({ input }) => {
|
||||||
const existing = await requireDomain(input.id);
|
const existing = await requireDomain(input.id);
|
||||||
|
|
||||||
const result = await runAndPersistDomainCheck(db, {
|
const result = await runAndPersistDomainCheck(db, {
|
||||||
|
|
@ -99,7 +99,7 @@ export const domainsRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
serviceOptions: protectedProcedure.query(async () => {
|
serviceOptions: permissionedProcedure("services.read").query(async () => {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
id: service.id,
|
id: service.id,
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { domainsRouter } from "./domains";
|
||||||
import { notesRouter } from "./notes";
|
import { notesRouter } from "./notes";
|
||||||
import { serversRouter } from "./servers";
|
import { serversRouter } from "./servers";
|
||||||
import { servicesRouter } from "./services";
|
import { servicesRouter } from "./services";
|
||||||
|
import { usersRouter } from "./users";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
healthCheck: publicProcedure.query(() => {
|
healthCheck: publicProcedure.query(() => {
|
||||||
|
|
@ -15,5 +16,6 @@ export const appRouter = router({
|
||||||
services: servicesRouter,
|
services: servicesRouter,
|
||||||
domains: domainsRouter,
|
domains: domainsRouter,
|
||||||
notes: notesRouter,
|
notes: notesRouter,
|
||||||
|
users: usersRouter,
|
||||||
});
|
});
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { db } from "@minmon/db";
|
||||||
import { note, server } from "@minmon/db";
|
import { note, server } from "@minmon/db";
|
||||||
import { desc, eq } from "drizzle-orm";
|
import { desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { protectedProcedure, router } from "../index";
|
import { permissionedProcedure, router } from "../index";
|
||||||
import { byIdSchema, noteCreateSchema, noteListSchema, noteUpdateSchema } from "./schemas";
|
import { byIdSchema, noteCreateSchema, noteListSchema, noteUpdateSchema } from "./schemas";
|
||||||
import {
|
import {
|
||||||
createEntityId,
|
createEntityId,
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export const notesRouter = router({
|
export const notesRouter = router({
|
||||||
list: protectedProcedure.input(noteListSchema).query(async ({ input }) => {
|
list: permissionedProcedure("notes.read").input(noteListSchema).query(async ({ input }) => {
|
||||||
if (!input?.serverId) {
|
if (!input?.serverId) {
|
||||||
return db.select().from(note).orderBy(desc(note.createdAt), desc(note.id));
|
return db.select().from(note).orderBy(desc(note.createdAt), desc(note.id));
|
||||||
}
|
}
|
||||||
|
|
@ -26,11 +26,11 @@ export const notesRouter = router({
|
||||||
.orderBy(desc(note.createdAt), desc(note.id));
|
.orderBy(desc(note.createdAt), desc(note.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: permissionedProcedure("notes.read").input(byIdSchema).query(async ({ input }) => {
|
||||||
return requireNote(input.id);
|
return requireNote(input.id);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure.input(noteCreateSchema).mutation(async ({ input }) => {
|
create: permissionedProcedure("notes.create").input(noteCreateSchema).mutation(async ({ input }) => {
|
||||||
await requireServer(input.serverId);
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
|
|
@ -44,7 +44,7 @@ export const notesRouter = router({
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure.input(noteUpdateSchema).mutation(async ({ input }) => {
|
update: permissionedProcedure("notes.update").input(noteUpdateSchema).mutation(async ({ input }) => {
|
||||||
const { id, serverId, ...changes } = input;
|
const { id, serverId, ...changes } = input;
|
||||||
await requireNote(id);
|
await requireNote(id);
|
||||||
|
|
||||||
|
|
@ -61,13 +61,13 @@ export const notesRouter = router({
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
delete: permissionedProcedure("notes.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||||
await requireNote(input.id);
|
await requireNote(input.id);
|
||||||
await db.delete(note).where(eq(note.id, input.id));
|
await db.delete(note).where(eq(note.id, input.id));
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
serverOptions: protectedProcedure.query(async () => {
|
serverOptions: permissionedProcedure("servers.read").query(async () => {
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
id: server.id,
|
id: server.id,
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,15 @@ export const noteCreateSchema = z.object({
|
||||||
export const noteUpdateSchema = noteCreateSchema.partial().extend({
|
export const noteUpdateSchema = noteCreateSchema.partial().extend({
|
||||||
id: entityIdSchema,
|
id: entityIdSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const updateUserRolesSchema = z.object({
|
||||||
|
userId: entityIdSchema,
|
||||||
|
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const userCreateSchema = z.object({
|
||||||
|
name: z.string().trim().min(2, "Name must be at least 2 characters").max(120),
|
||||||
|
email: z.email("Invalid email address").max(255),
|
||||||
|
password: z.string().min(8, "Password must be at least 8 characters").max(255),
|
||||||
|
roleIds: z.array(entityIdSchema).min(1, "Select at least one role"),
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,16 @@ import { db } from "@minmon/db";
|
||||||
import { note, server, service } from "@minmon/db";
|
import { note, server, service } from "@minmon/db";
|
||||||
import { count, desc, eq } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { protectedProcedure, router } from "../index";
|
import { permissionedProcedure, router } from "../index";
|
||||||
import { byIdSchema, serverCreateSchema, serverUpdateSchema } from "./schemas";
|
import { byIdSchema, serverCreateSchema, serverUpdateSchema } from "./schemas";
|
||||||
import { createEntityId, requireServer, stripUndefined } from "./utils";
|
import { createEntityId, requireServer, stripUndefined } from "./utils";
|
||||||
|
|
||||||
export const serversRouter = router({
|
export const serversRouter = router({
|
||||||
list: protectedProcedure.query(async () => {
|
list: permissionedProcedure("servers.read").query(async () => {
|
||||||
return db.select().from(server).orderBy(desc(server.createdAt), desc(server.id));
|
return db.select().from(server).orderBy(desc(server.createdAt), desc(server.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: permissionedProcedure("servers.read").input(byIdSchema).query(async ({ input }) => {
|
||||||
const item = await requireServer(input.id);
|
const item = await requireServer(input.id);
|
||||||
|
|
||||||
const [serviceCount, noteCount] = await Promise.all([
|
const [serviceCount, noteCount] = await Promise.all([
|
||||||
|
|
@ -28,7 +28,7 @@ export const serversRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure.input(serverCreateSchema).mutation(async ({ input }) => {
|
create: permissionedProcedure("servers.create").input(serverCreateSchema).mutation(async ({ input }) => {
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
.insert(server)
|
.insert(server)
|
||||||
.values({
|
.values({
|
||||||
|
|
@ -40,7 +40,7 @@ export const serversRouter = router({
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure.input(serverUpdateSchema).mutation(async ({ input }) => {
|
update: permissionedProcedure("servers.update").input(serverUpdateSchema).mutation(async ({ input }) => {
|
||||||
const { id, ...changes } = input;
|
const { id, ...changes } = input;
|
||||||
await requireServer(id);
|
await requireServer(id);
|
||||||
|
|
||||||
|
|
@ -53,7 +53,7 @@ export const serversRouter = router({
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
delete: permissionedProcedure("servers.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||||
await requireServer(input.id);
|
await requireServer(input.id);
|
||||||
await db.delete(server).where(eq(server.id, input.id));
|
await db.delete(server).where(eq(server.id, input.id));
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { db } from "@minmon/db";
|
||||||
import { domain, service } from "@minmon/db";
|
import { domain, service } from "@minmon/db";
|
||||||
import { count, desc, eq } from "drizzle-orm";
|
import { count, desc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
import { protectedProcedure, router } from "../index";
|
import { permissionedProcedure, router } from "../index";
|
||||||
import {
|
import {
|
||||||
byIdSchema,
|
byIdSchema,
|
||||||
serviceCreateSchema,
|
serviceCreateSchema,
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
|
|
||||||
export const servicesRouter = router({
|
export const servicesRouter = router({
|
||||||
list: protectedProcedure.input(serviceListSchema).query(async ({ input }) => {
|
list: permissionedProcedure("services.read").input(serviceListSchema).query(async ({ input }) => {
|
||||||
if (!input?.serverId) {
|
if (!input?.serverId) {
|
||||||
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
|
return db.select().from(service).orderBy(desc(service.createdAt), desc(service.id));
|
||||||
}
|
}
|
||||||
|
|
@ -31,7 +31,7 @@ export const servicesRouter = router({
|
||||||
.orderBy(desc(service.createdAt), desc(service.id));
|
.orderBy(desc(service.createdAt), desc(service.id));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
byId: protectedProcedure.input(byIdSchema).query(async ({ input }) => {
|
byId: permissionedProcedure("services.read").input(byIdSchema).query(async ({ input }) => {
|
||||||
const item = await requireService(input.id);
|
const item = await requireService(input.id);
|
||||||
const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
|
const [domainCount] = await db.select({ count: count() }).from(domain).where(eq(domain.serviceId, input.id));
|
||||||
|
|
||||||
|
|
@ -43,7 +43,7 @@ export const servicesRouter = router({
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|
||||||
create: protectedProcedure.input(serviceCreateSchema).mutation(async ({ input }) => {
|
create: permissionedProcedure("services.create").input(serviceCreateSchema).mutation(async ({ input }) => {
|
||||||
await requireServer(input.serverId);
|
await requireServer(input.serverId);
|
||||||
|
|
||||||
const [created] = await db
|
const [created] = await db
|
||||||
|
|
@ -57,7 +57,7 @@ export const servicesRouter = router({
|
||||||
return created;
|
return created;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
update: protectedProcedure.input(serviceUpdateSchema).mutation(async ({ input }) => {
|
update: permissionedProcedure("services.update").input(serviceUpdateSchema).mutation(async ({ input }) => {
|
||||||
const { id, serverId, ...changes } = input;
|
const { id, serverId, ...changes } = input;
|
||||||
await requireService(id);
|
await requireService(id);
|
||||||
|
|
||||||
|
|
@ -74,7 +74,7 @@ export const servicesRouter = router({
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
delete: protectedProcedure.input(byIdSchema).mutation(async ({ input }) => {
|
delete: permissionedProcedure("services.delete").input(byIdSchema).mutation(async ({ input }) => {
|
||||||
await requireService(input.id);
|
await requireService(input.id);
|
||||||
await db.delete(service).where(eq(service.id, input.id));
|
await db.delete(service).where(eq(service.id, input.id));
|
||||||
return { id: input.id };
|
return { id: input.id };
|
||||||
|
|
|
||||||
156
packages/api/src/routers/users.ts
Normal file
156
packages/api/src/routers/users.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { db, account, role, user, userRole } from "@minmon/db";
|
||||||
|
import { hashCredentialPassword } from "@minmon/db/auth-utils";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
|
||||||
|
import { permissionedProcedure, protectedProcedure, router } from "../index";
|
||||||
|
import { byIdSchema, updateUserRolesSchema, userCreateSchema } from "./schemas";
|
||||||
|
import { badRequest, createEntityId, notFound } from "./utils";
|
||||||
|
|
||||||
|
async function getUsersWithRoles() {
|
||||||
|
const [users, assignments] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: user.id,
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
image: user.image,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
updatedAt: user.updatedAt,
|
||||||
|
})
|
||||||
|
.from(user)
|
||||||
|
.orderBy(asc(user.name), asc(user.email)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
userId: userRole.userId,
|
||||||
|
roleId: role.id,
|
||||||
|
roleName: role.name,
|
||||||
|
})
|
||||||
|
.from(userRole)
|
||||||
|
.innerJoin(role, eq(role.id, userRole.roleId))
|
||||||
|
.orderBy(asc(role.name)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return users.map((item) => ({
|
||||||
|
...item,
|
||||||
|
roles: assignments
|
||||||
|
.filter((assignment) => assignment.userId === item.id)
|
||||||
|
.map((assignment) => ({ id: assignment.roleId, name: assignment.roleName })),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateRoleIds(roleIds: string[]) {
|
||||||
|
const roles = await db.select({ id: role.id }).from(role);
|
||||||
|
const validRoleIds = new Set(roles.map((item) => item.id));
|
||||||
|
const uniqueRoleIds = Array.from(new Set(roleIds));
|
||||||
|
|
||||||
|
const invalidRoleId = uniqueRoleIds.find((roleId) => !validRoleIds.has(roleId));
|
||||||
|
if (invalidRoleId) {
|
||||||
|
notFound(`Role not found: ${invalidRoleId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueRoleIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usersRouter = router({
|
||||||
|
me: protectedProcedure.query(({ ctx }) => ({
|
||||||
|
user: ctx.session.user,
|
||||||
|
roles: ctx.authorization.roles,
|
||||||
|
permissions: ctx.authorization.permissions,
|
||||||
|
})),
|
||||||
|
|
||||||
|
list: permissionedProcedure("users.read").query(async () => getUsersWithRoles()),
|
||||||
|
|
||||||
|
roles: permissionedProcedure("users.read").query(async () =>
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: role.id,
|
||||||
|
name: role.name,
|
||||||
|
description: role.description,
|
||||||
|
})
|
||||||
|
.from(role)
|
||||||
|
.orderBy(asc(role.name)),
|
||||||
|
),
|
||||||
|
|
||||||
|
create: permissionedProcedure("users.create")
|
||||||
|
.input(userCreateSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.email, input.email)).limit(1);
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
badRequest("A user with this email already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRoleIds = await validateRoleIds(input.roleIds);
|
||||||
|
const userId = createEntityId();
|
||||||
|
const now = new Date();
|
||||||
|
const passwordHash = await hashCredentialPassword(input.password);
|
||||||
|
|
||||||
|
await db.insert(user).values({
|
||||||
|
id: userId,
|
||||||
|
name: input.name,
|
||||||
|
email: input.email,
|
||||||
|
emailVerified: true,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(account).values({
|
||||||
|
id: createEntityId(),
|
||||||
|
accountId: userId,
|
||||||
|
providerId: "credential",
|
||||||
|
userId,
|
||||||
|
password: passwordHash,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
await db.insert(userRole).values(
|
||||||
|
uniqueRoleIds.map((roleId) => ({
|
||||||
|
userId,
|
||||||
|
roleId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const users = await getUsersWithRoles();
|
||||||
|
return users.find((item) => item.id === userId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
updateRoles: permissionedProcedure("users.assignRoles")
|
||||||
|
.input(updateUserRolesSchema)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.userId)).limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
notFound("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueRoleIds = await validateRoleIds(input.roleIds);
|
||||||
|
|
||||||
|
await db.delete(userRole).where(eq(userRole.userId, input.userId));
|
||||||
|
await db.insert(userRole).values(
|
||||||
|
uniqueRoleIds.map((roleId) => ({
|
||||||
|
userId: input.userId,
|
||||||
|
roleId,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await getUsersWithRoles();
|
||||||
|
return updated.find((item) => item.id === input.userId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: permissionedProcedure("users.delete").input(byIdSchema).mutation(async ({ ctx, input }) => {
|
||||||
|
if (ctx.session.user.id === input.id) {
|
||||||
|
badRequest("You cannot delete your own user account while signed in");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUser] = await db.select({ id: user.id }).from(user).where(eq(user.id, input.id)).limit(1);
|
||||||
|
|
||||||
|
if (!existingUser) {
|
||||||
|
notFound("User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(user).where(eq(user.id, input.id));
|
||||||
|
return { id: input.id };
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:migrate": "drizzle-kit migrate",
|
"db:migrate": "drizzle-kit migrate",
|
||||||
"db:seed": "bun --env-file=../../apps/server/.env run ./src/seed.ts",
|
"db:seed": "bun --env-file=../../apps/server/.env run ./src/seed.ts",
|
||||||
|
"db:seed:if-empty": "bun --env-file=../../apps/server/.env run ./src/seed-if-empty.ts",
|
||||||
"db:start": "docker compose up -d",
|
"db:start": "docker compose up -d",
|
||||||
"db:watch": "docker compose up",
|
"db:watch": "docker compose up",
|
||||||
"db:stop": "docker compose stop",
|
"db:stop": "docker compose stop",
|
||||||
|
|
|
||||||
5
packages/db/src/auth-utils.ts
Normal file
5
packages/db/src/auth-utils.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { hashPassword } from "better-auth/crypto";
|
||||||
|
|
||||||
|
export async function hashCredentialPassword(password: string) {
|
||||||
|
return hashPassword(password);
|
||||||
|
}
|
||||||
40
packages/db/src/migrations/0001_add_rbac.sql
Normal file
40
packages/db/src/migrations/0001_add_rbac.sql
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
CREATE TABLE IF NOT EXISTS "permission" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "permission_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "role" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "role_name_unique" UNIQUE("name")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "role_permission" (
|
||||||
|
"role_id" text NOT NULL,
|
||||||
|
"permission_id" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "role_permission_role_id_permission_id_pk" PRIMARY KEY("role_id","permission_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE IF NOT EXISTS "user_role" (
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"role_id" text NOT NULL,
|
||||||
|
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "user_role_user_id_role_id_pk" PRIMARY KEY("user_id","role_id")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "role_permission" ADD CONSTRAINT "role_permission_permission_id_permission_id_fk" FOREIGN KEY ("permission_id") REFERENCES "public"."permission"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_role" ADD CONSTRAINT "user_role_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_role" ADD CONSTRAINT "user_role_role_id_role_id_fk" FOREIGN KEY ("role_id") REFERENCES "public"."role"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "role_permission_role_id_idx" ON "role_permission" USING btree ("role_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "role_permission_permission_id_idx" ON "role_permission" USING btree ("permission_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_role_user_id_idx" ON "user_role" USING btree ("user_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX IF NOT EXISTS "user_role_role_id_idx" ON "user_role" USING btree ("role_id");
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
INSERT INTO "permission" ("id", "name", "description")
|
||||||
|
VALUES
|
||||||
|
('users.create', 'users.create', 'Allows users create'),
|
||||||
|
('users.delete', 'users.delete', 'Allows users delete')
|
||||||
|
ON CONFLICT ("id") DO UPDATE SET
|
||||||
|
"name" = EXCLUDED."name",
|
||||||
|
"description" = EXCLUDED."description",
|
||||||
|
"updated_at" = now();
|
||||||
|
--> statement-breakpoint
|
||||||
|
INSERT INTO "role_permission" ("role_id", "permission_id")
|
||||||
|
SELECT 'role_admin', "permission"."id"
|
||||||
|
FROM "permission"
|
||||||
|
WHERE "permission"."id" IN ('users.create', 'users.delete')
|
||||||
|
AND EXISTS (SELECT 1 FROM "role" WHERE "role"."id" = 'role_admin')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
@ -8,6 +8,20 @@
|
||||||
"when": 1777458791180,
|
"when": 1777458791180,
|
||||||
"tag": "0000_thick_molecule_man",
|
"tag": "0000_thick_molecule_man",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782694800000,
|
||||||
|
"tag": "0001_add_rbac",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1782698400000,
|
||||||
|
"tag": "0002_user_lifecycle_permissions",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
51
packages/db/src/rbac.ts
Normal file
51
packages/db/src/rbac.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
export const permissions = [
|
||||||
|
"dashboard.read",
|
||||||
|
"servers.read",
|
||||||
|
"servers.create",
|
||||||
|
"servers.update",
|
||||||
|
"servers.delete",
|
||||||
|
"services.read",
|
||||||
|
"services.create",
|
||||||
|
"services.update",
|
||||||
|
"services.delete",
|
||||||
|
"domains.read",
|
||||||
|
"domains.create",
|
||||||
|
"domains.update",
|
||||||
|
"domains.delete",
|
||||||
|
"domains.check",
|
||||||
|
"notes.read",
|
||||||
|
"notes.create",
|
||||||
|
"notes.update",
|
||||||
|
"notes.delete",
|
||||||
|
"users.read",
|
||||||
|
"users.create",
|
||||||
|
"users.delete",
|
||||||
|
"users.assignRoles",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Permission = (typeof permissions)[number];
|
||||||
|
|
||||||
|
export const roleDefinitions = [
|
||||||
|
{
|
||||||
|
id: "role_admin",
|
||||||
|
name: "Admin",
|
||||||
|
description: "Full access to monitoring data, users, roles, and permissions.",
|
||||||
|
permissions: [...permissions],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role_editor",
|
||||||
|
name: "Editor",
|
||||||
|
description: "Can create and update monitoring resources, run checks, and read users.",
|
||||||
|
permissions: permissions.filter(
|
||||||
|
(permission) => !["users.create", "users.delete", "users.assignRoles"].includes(permission),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "role_viewer",
|
||||||
|
name: "Viewer",
|
||||||
|
description: "Read-only access to monitoring resources and users.",
|
||||||
|
permissions: permissions.filter((permission) => permission.endsWith(".read")),
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type RoleName = (typeof roleDefinitions)[number]["name"];
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { relations } from "drizzle-orm";
|
import { relations } from "drizzle-orm";
|
||||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
import { pgTable, text, timestamp, boolean, index, primaryKey } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
|
|
@ -73,9 +73,68 @@ export const verification = pgTable(
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const role = pgTable("role", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull().unique(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const permission = pgTable("permission", {
|
||||||
|
id: text("id").primaryKey(),
|
||||||
|
name: text("name").notNull().unique(),
|
||||||
|
description: text("description"),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updated_at")
|
||||||
|
.defaultNow()
|
||||||
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
|
.notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rolePermission = pgTable(
|
||||||
|
"role_permission",
|
||||||
|
{
|
||||||
|
roleId: text("role_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => role.id, { onDelete: "cascade" }),
|
||||||
|
permissionId: text("permission_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => permission.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.roleId, table.permissionId] }),
|
||||||
|
index("role_permission_role_id_idx").on(table.roleId),
|
||||||
|
index("role_permission_permission_id_idx").on(table.permissionId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const userRole = pgTable(
|
||||||
|
"user_role",
|
||||||
|
{
|
||||||
|
userId: text("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
|
roleId: text("role_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => role.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
primaryKey({ columns: [table.userId, table.roleId] }),
|
||||||
|
index("user_role_user_id_idx").on(table.userId),
|
||||||
|
index("user_role_role_id_idx").on(table.roleId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
|
roles: many(userRole),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
|
|
@ -91,3 +150,34 @@ export const accountRelations = relations(account, ({ one }) => ({
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const roleRelations = relations(role, ({ many }) => ({
|
||||||
|
permissions: many(rolePermission),
|
||||||
|
users: many(userRole),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const permissionRelations = relations(permission, ({ many }) => ({
|
||||||
|
roles: many(rolePermission),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const rolePermissionRelations = relations(rolePermission, ({ one }) => ({
|
||||||
|
role: one(role, {
|
||||||
|
fields: [rolePermission.roleId],
|
||||||
|
references: [role.id],
|
||||||
|
}),
|
||||||
|
permission: one(permission, {
|
||||||
|
fields: [rolePermission.permissionId],
|
||||||
|
references: [permission.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const userRoleRelations = relations(userRole, ({ one }) => ({
|
||||||
|
user: one(user, {
|
||||||
|
fields: [userRole.userId],
|
||||||
|
references: [user.id],
|
||||||
|
}),
|
||||||
|
role: one(role, {
|
||||||
|
fields: [userRole.roleId],
|
||||||
|
references: [role.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
35
packages/db/src/seed-if-empty.ts
Normal file
35
packages/db/src/seed-if-empty.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import dotenv from "dotenv";
|
||||||
|
|
||||||
|
dotenv.config({
|
||||||
|
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
||||||
|
});
|
||||||
|
|
||||||
|
import { db } from "./index";
|
||||||
|
import { seed, seedRolesAndPermissions } from "./seed";
|
||||||
|
|
||||||
|
async function seedIfEmpty() {
|
||||||
|
const existingUser = await db.query.user.findFirst({
|
||||||
|
columns: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existingUser) {
|
||||||
|
await seedRolesAndPermissions(existingUser.id);
|
||||||
|
console.info("Database already has users; ensured default roles and permissions.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await seed();
|
||||||
|
console.info("Seeded auth admin and monitoring dashboard sample data.");
|
||||||
|
console.info("Admin login: admin@minmon.local / admin12345");
|
||||||
|
}
|
||||||
|
|
||||||
|
seedIfEmpty()
|
||||||
|
.then(() => {
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to initialize seed data.", error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
import { hashPassword } from "better-auth/crypto";
|
import { roleDefinitions, permissions } from "./rbac";
|
||||||
|
import { hashCredentialPassword } from "./auth-utils";
|
||||||
|
|
||||||
dotenv.config({
|
dotenv.config({
|
||||||
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
path: new URL("../../../apps/server/.env", import.meta.url).pathname,
|
||||||
});
|
});
|
||||||
|
|
||||||
import { db } from "./index";
|
import { db } from "./index";
|
||||||
import { account, user } from "./schema/auth";
|
import { account, permission, role, rolePermission, user, userRole } from "./schema/auth";
|
||||||
import { domain, note, server, service } from "./schema/monitoring";
|
import { domain, note, server, service } from "./schema/monitoring";
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
@ -195,7 +196,7 @@ async function seedAuthAdmin() {
|
||||||
return existingUser.id;
|
return existingUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await hashPassword(adminUser.password);
|
const passwordHash = await hashCredentialPassword(adminUser.password);
|
||||||
|
|
||||||
await db.insert(user).values({
|
await db.insert(user).values({
|
||||||
id: adminUser.id,
|
id: adminUser.id,
|
||||||
|
|
@ -219,6 +220,46 @@ async function seedAuthAdmin() {
|
||||||
return adminUser.id;
|
return adminUser.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function seedRolesAndPermissions(adminUserId?: string) {
|
||||||
|
await db.insert(permission).values(
|
||||||
|
permissions.map((name) => ({
|
||||||
|
id: name,
|
||||||
|
name,
|
||||||
|
description: `Allows ${name.replace(".", " ")}`,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
).onConflictDoNothing();
|
||||||
|
|
||||||
|
await db.insert(role).values(
|
||||||
|
roleDefinitions.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
name: item.name,
|
||||||
|
description: item.description,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})),
|
||||||
|
).onConflictDoNothing();
|
||||||
|
|
||||||
|
await db.insert(rolePermission).values(
|
||||||
|
roleDefinitions.flatMap((item) =>
|
||||||
|
item.permissions.map((permissionName) => ({
|
||||||
|
roleId: item.id,
|
||||||
|
permissionId: permissionName,
|
||||||
|
createdAt: now,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
).onConflictDoNothing();
|
||||||
|
|
||||||
|
if (adminUserId) {
|
||||||
|
await db.insert(userRole).values({
|
||||||
|
userId: adminUserId,
|
||||||
|
roleId: "role_admin",
|
||||||
|
createdAt: now,
|
||||||
|
}).onConflictDoNothing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function seedMonitoringData() {
|
async function seedMonitoringData() {
|
||||||
await db.delete(note);
|
await db.delete(note);
|
||||||
await db.delete(domain);
|
await db.delete(domain);
|
||||||
|
|
@ -261,18 +302,21 @@ async function seedMonitoringData() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seed() {
|
export async function seed() {
|
||||||
await seedAuthAdmin();
|
const adminUserId = await seedAuthAdmin();
|
||||||
|
await seedRolesAndPermissions(adminUserId);
|
||||||
await seedMonitoringData();
|
await seedMonitoringData();
|
||||||
}
|
}
|
||||||
|
|
||||||
seed()
|
if (import.meta.main) {
|
||||||
.then(() => {
|
seed()
|
||||||
console.info("Seeded auth admin and monitoring dashboard sample data.");
|
.then(() => {
|
||||||
console.info("Admin login: admin@minmon.local / admin12345");
|
console.info("Seeded auth admin and monitoring dashboard sample data.");
|
||||||
process.exit(0);
|
console.info("Admin login: admin@minmon.local / admin12345");
|
||||||
})
|
process.exit(0);
|
||||||
.catch((error) => {
|
})
|
||||||
console.error("Failed to seed dashboard sample data.", error);
|
.catch((error) => {
|
||||||
process.exit(1);
|
console.error("Failed to seed dashboard sample data.", error);
|
||||||
});
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue