Scaffold Homelab Manager foundation
Monorepo (Express+TS+Drizzle/libSQL server, React+Vite+Tabler web) matching the stack used by ScheduleTaskManager and Sloth Manager. Includes Authentik OIDC login with local admin/operator/viewer roles (first user becomes admin), a generalized audit log, encrypted-at-rest storage for future integration API tokens, the DB schema for all planned modules, and the Tabler-styled app shell/nav. Also ports the Secrets (expiry tracker) and IP Addresses (IPAM) modules from Sloth Manager onto the new stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "homelab-manager",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "start"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Public URL the app is reachable at (used for OIDC redirect_uri and cookie behavior).
|
||||
APP_BASE_URL=https://homelab.example.lan
|
||||
|
||||
# Random long string used to sign session cookies. Generate with:
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
SESSION_SECRET=change-me-to-a-random-64-char-hex-string
|
||||
|
||||
# 32-byte (64 hex char) key used to encrypt stored integration API tokens at
|
||||
# rest (AES-256-GCM). Generate the same way as SESSION_SECRET. Losing/changing
|
||||
# this key makes previously-stored integration credentials unreadable.
|
||||
CREDENTIALS_ENCRYPTION_KEY=change-me-to-a-random-64-char-hex-string
|
||||
|
||||
# Port docker-compose publishes on the host (container always listens on 3000).
|
||||
HOST_PORT=3000
|
||||
|
||||
# --- Authentik OIDC application/provider ---
|
||||
# Create an OAuth2/OIDC "Provider" in Authentik with:
|
||||
# Redirect URI: <APP_BASE_URL>/auth/callback
|
||||
# Scopes: openid, email, profile
|
||||
# then create an "Application" using that provider, and assign the users/groups
|
||||
# who should be able to sign in. Copy the provider's values below.
|
||||
AUTHENTIK_ISSUER_URL=https://authentik.example.lan/application/o/homelab-manager/
|
||||
AUTHENTIK_CLIENT_ID=
|
||||
AUTHENTIK_CLIENT_SECRET=
|
||||
|
||||
# --- Optional: Gotify notifications (secret expiry, integration offline, etc.) ---
|
||||
GOTIFY_URL=
|
||||
GOTIFY_TOKEN=
|
||||
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
build/
|
||||
.env
|
||||
data/*.sqlite
|
||||
data/*.sqlite-*
|
||||
data/sessions/
|
||||
*.log
|
||||
.DS_Store
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
FROM node:22-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY server/package.json server/package.json
|
||||
COPY web/package.json web/package.json
|
||||
RUN npm ci
|
||||
|
||||
FROM deps AS build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package.json package-lock.json ./
|
||||
COPY server/package.json server/package.json
|
||||
RUN npm ci --omit=dev --workspace server
|
||||
|
||||
COPY --from=build /app/server/dist ./server/dist
|
||||
COPY --from=build /app/web/dist ./web/dist
|
||||
COPY server/drizzle ./server/drizzle
|
||||
COPY agent ./agent
|
||||
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
|
||||
CMD node -e "fetch('http://localhost:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server/dist/index.js"]
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
homelab-manager:
|
||||
build: .
|
||||
container_name: homelab-manager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
@@ -0,0 +1,14 @@
|
||||
services:
|
||||
homelab-manager:
|
||||
image: gitea.labsconnect.se/bobban/homelabmanager-homelab-manager:latest
|
||||
container_name: homelab-manager
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HOST_PORT:-3000}:3000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
PORT: 3000
|
||||
NODE_ENV: production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
Generated
+4776
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "homelab-manager",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"workspaces": [
|
||||
"server",
|
||||
"web"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:server": "npm run dev --workspace server",
|
||||
"dev:web": "npm run dev --workspace web",
|
||||
"build": "npm run build --workspace web && npm run build --workspace server",
|
||||
"db:generate": "npm run db:generate --workspace server",
|
||||
"db:migrate": "npm run db:migrate --workspace server",
|
||||
"start": "npm run start --workspace server"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "sqlite",
|
||||
schema: "./src/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dbCredentials: {
|
||||
url: `file:${process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite"}`,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
CREATE TABLE `audit_log` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`actor_user_id` integer,
|
||||
`actor_label` text,
|
||||
`category` text NOT NULL,
|
||||
`action` text NOT NULL,
|
||||
`target_type` text,
|
||||
`target_id` text,
|
||||
`detail` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`actor_user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `dns_providers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`provider_type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`credential_id` integer,
|
||||
`config` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`credential_id`) REFERENCES `integration_credentials`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `dns_records_cache` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`provider_id` integer NOT NULL,
|
||||
`zone` text NOT NULL,
|
||||
`record_type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`ttl` integer,
|
||||
`raw` text,
|
||||
`synced_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`provider_id`) REFERENCES `dns_providers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `integration_credentials` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`encrypted_secret` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `integrations` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`type` text NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`base_url` text NOT NULL,
|
||||
`credential_id` integer,
|
||||
`config` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`credential_id`) REFERENCES `integration_credentials`(`id`) ON UPDATE no action ON DELETE set null
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `ipam_entries` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`ip_address` text NOT NULL,
|
||||
`label` text,
|
||||
`vendor` text,
|
||||
`location` text,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `ipam_entries_ip_address_unique` ON `ipam_entries` (`ip_address`);--> statement-breakpoint
|
||||
CREATE TABLE `scheduled_tasks` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`server_id` integer NOT NULL,
|
||||
`schedule_type` text NOT NULL,
|
||||
`origin` text DEFAULT 'agent' NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`command` text,
|
||||
`schedule_expression` text,
|
||||
`source` text,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`next_run_at` text,
|
||||
`raw_metadata` text,
|
||||
`is_stale` integer DEFAULT false NOT NULL,
|
||||
`first_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`last_seen_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
FOREIGN KEY (`server_id`) REFERENCES `servers`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `secrets` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`type` text DEFAULT 'generic' NOT NULL,
|
||||
`description` text,
|
||||
`expiry_date` text NOT NULL,
|
||||
`warn_days` integer DEFAULT 30 NOT NULL,
|
||||
`notes` text,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `servers` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`hostname` text,
|
||||
`os_type` text DEFAULT 'linux' NOT NULL,
|
||||
`description` text,
|
||||
`api_token_hash` text NOT NULL,
|
||||
`api_token_prefix` text NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`last_seen_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `settings` (
|
||||
`key` text PRIMARY KEY NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`updated_at` text DEFAULT (current_timestamp) NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `users` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`oidc_sub` text NOT NULL,
|
||||
`email` text,
|
||||
`name` text,
|
||||
`role` text DEFAULT 'viewer' NOT NULL,
|
||||
`created_at` text DEFAULT (current_timestamp) NOT NULL,
|
||||
`last_login_at` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `users_oidc_sub_unique` ON `users` (`oidc_sub`);
|
||||
@@ -0,0 +1,846 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "1480fe39-d215-4e92-aee3-4de44905a67d",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"audit_log": {
|
||||
"name": "audit_log",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"actor_user_id": {
|
||||
"name": "actor_user_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"actor_label": {
|
||||
"name": "actor_label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"category": {
|
||||
"name": "category",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"action": {
|
||||
"name": "action",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"target_type": {
|
||||
"name": "target_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"target_id": {
|
||||
"name": "target_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"detail": {
|
||||
"name": "detail",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"audit_log_actor_user_id_users_id_fk": {
|
||||
"name": "audit_log_actor_user_id_users_id_fk",
|
||||
"tableFrom": "audit_log",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"actor_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"dns_providers": {
|
||||
"name": "dns_providers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"provider_type": {
|
||||
"name": "provider_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"dns_providers_credential_id_integration_credentials_id_fk": {
|
||||
"name": "dns_providers_credential_id_integration_credentials_id_fk",
|
||||
"tableFrom": "dns_providers",
|
||||
"tableTo": "integration_credentials",
|
||||
"columnsFrom": [
|
||||
"credential_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"dns_records_cache": {
|
||||
"name": "dns_records_cache",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"zone": {
|
||||
"name": "zone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"record_type": {
|
||||
"name": "record_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ttl": {
|
||||
"name": "ttl",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw": {
|
||||
"name": "raw",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"synced_at": {
|
||||
"name": "synced_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"dns_records_cache_provider_id_dns_providers_id_fk": {
|
||||
"name": "dns_records_cache_provider_id_dns_providers_id_fk",
|
||||
"tableFrom": "dns_records_cache",
|
||||
"tableTo": "dns_providers",
|
||||
"columnsFrom": [
|
||||
"provider_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"integration_credentials": {
|
||||
"name": "integration_credentials",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"encrypted_secret": {
|
||||
"name": "encrypted_secret",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"integrations": {
|
||||
"name": "integrations",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"base_url": {
|
||||
"name": "base_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"credential_id": {
|
||||
"name": "credential_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"config": {
|
||||
"name": "config",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"integrations_credential_id_integration_credentials_id_fk": {
|
||||
"name": "integrations_credential_id_integration_credentials_id_fk",
|
||||
"tableFrom": "integrations",
|
||||
"tableTo": "integration_credentials",
|
||||
"columnsFrom": [
|
||||
"credential_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"ipam_entries": {
|
||||
"name": "ipam_entries",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"vendor": {
|
||||
"name": "vendor",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"location": {
|
||||
"name": "location",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"ipam_entries_ip_address_unique": {
|
||||
"name": "ipam_entries_ip_address_unique",
|
||||
"columns": [
|
||||
"ip_address"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"scheduled_tasks": {
|
||||
"name": "scheduled_tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"server_id": {
|
||||
"name": "server_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_type": {
|
||||
"name": "schedule_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"origin": {
|
||||
"name": "origin",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'agent'"
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"command": {
|
||||
"name": "command",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"schedule_expression": {
|
||||
"name": "schedule_expression",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source": {
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"enabled": {
|
||||
"name": "enabled",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": true
|
||||
},
|
||||
"next_run_at": {
|
||||
"name": "next_run_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"raw_metadata": {
|
||||
"name": "raw_metadata",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_stale": {
|
||||
"name": "is_stale",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"scheduled_tasks_server_id_servers_id_fk": {
|
||||
"name": "scheduled_tasks_server_id_servers_id_fk",
|
||||
"tableFrom": "scheduled_tasks",
|
||||
"tableTo": "servers",
|
||||
"columnsFrom": [
|
||||
"server_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"secrets": {
|
||||
"name": "secrets",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'generic'"
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"expiry_date": {
|
||||
"name": "expiry_date",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"warn_days": {
|
||||
"name": "warn_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 30
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"servers": {
|
||||
"name": "servers",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"hostname": {
|
||||
"name": "hostname",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"os_type": {
|
||||
"name": "os_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'linux'"
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_hash": {
|
||||
"name": "api_token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"api_token_prefix": {
|
||||
"name": "api_token_prefix",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"settings": {
|
||||
"name": "settings",
|
||||
"columns": {
|
||||
"key": {
|
||||
"name": "key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"users": {
|
||||
"name": "users",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": true
|
||||
},
|
||||
"oidc_sub": {
|
||||
"name": "oidc_sub",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'viewer'"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "(current_timestamp)"
|
||||
},
|
||||
"last_login_at": {
|
||||
"name": "last_login_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_oidc_sub_unique": {
|
||||
"name": "users_oidc_sub_unique",
|
||||
"columns": [
|
||||
"oidc_sub"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1789415495200,
|
||||
"tag": "0000_tricky_nocturne",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@libsql/client": "^0.14.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^4.21.2",
|
||||
"express-session": "^1.18.1",
|
||||
"openid-client": "^6.1.7",
|
||||
"session-file-store": "^1.5.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/express-session": "^1.18.1",
|
||||
"@types/node": "^22.10.5",
|
||||
"@types/session-file-store": "^1.2.5",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Request, Response, NextFunction } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, type UserRole } from "../db/schema.js";
|
||||
|
||||
type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Express {
|
||||
interface Request {
|
||||
currentUser?: CurrentUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires a valid session AND a matching local user row; attaches req.currentUser. */
|
||||
export async function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionUser = req.session.user;
|
||||
if (!sessionUser) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.oidcSub, sessionUser.sub)).limit(1);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
|
||||
req.currentUser = user;
|
||||
next();
|
||||
}
|
||||
|
||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
|
||||
/** Must run after requireAuth. Rejects unless the current user's role is >= minRole. */
|
||||
export function requireRole(minRole: UserRole) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const user = req.currentUser;
|
||||
if (!user || roleRank[user.role] < roleRank[minRole]) {
|
||||
return res.status(403).json({ error: "forbidden" });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as client from "openid-client";
|
||||
import { env } from "../env.js";
|
||||
|
||||
let configPromise: Promise<client.Configuration> | null = null;
|
||||
|
||||
export function getOidcConfig(): Promise<client.Configuration> {
|
||||
if (!configPromise) {
|
||||
configPromise = client.discovery(
|
||||
new URL(env.authentik.issuerUrl),
|
||||
env.authentik.clientId,
|
||||
env.authentik.clientSecret,
|
||||
);
|
||||
}
|
||||
return configPromise;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Router } from "express";
|
||||
import * as client from "openid-client";
|
||||
import { getOidcConfig } from "./oidc.js";
|
||||
import { upsertUserFromLogin } from "./users.js";
|
||||
import { env } from "../env.js";
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.get("/login", async (req, res, next) => {
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
const state = client.randomState();
|
||||
|
||||
req.session.pendingAuth = { codeVerifier, state };
|
||||
|
||||
const redirectUri = new URL("/auth/callback", env.appBaseUrl).toString();
|
||||
const authUrl = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: redirectUri,
|
||||
scope: "openid email profile",
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: "S256",
|
||||
state,
|
||||
});
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(authUrl.href);
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/callback", async (req, res, next) => {
|
||||
try {
|
||||
const pending = req.session.pendingAuth;
|
||||
if (!pending) {
|
||||
return res.status(400).send("Login session expired. Please try signing in again.");
|
||||
}
|
||||
|
||||
const config = await getOidcConfig();
|
||||
const currentUrl = new URL(req.originalUrl, env.appBaseUrl);
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (!claims?.sub) {
|
||||
return res.status(400).send("Identity provider did not return a valid identity.");
|
||||
}
|
||||
|
||||
let email: string | undefined = typeof claims.email === "string" ? claims.email : undefined;
|
||||
let name: string | undefined = typeof claims.name === "string" ? claims.name : undefined;
|
||||
try {
|
||||
const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub);
|
||||
email = userinfo.email ?? email;
|
||||
name = userinfo.name ?? userinfo.preferred_username ?? name;
|
||||
} catch {
|
||||
// fall back to ID token claims already captured above
|
||||
}
|
||||
|
||||
await upsertUserFromLogin({ sub: claims.sub, email, name });
|
||||
|
||||
delete req.session.pendingAuth;
|
||||
req.session.user = { sub: claims.sub, email, name, idToken: tokens.id_token };
|
||||
req.session.save((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect("/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
authRouter.get("/logout", async (req, res, next) => {
|
||||
const idToken = req.session.user?.idToken;
|
||||
try {
|
||||
const config = await getOidcConfig();
|
||||
let endSessionUrl: URL | undefined;
|
||||
try {
|
||||
endSessionUrl = client.buildEndSessionUrl(config, {
|
||||
post_logout_redirect_uri: env.appBaseUrl,
|
||||
...(idToken ? { id_token_hint: idToken } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Provider doesn't advertise RP-Initiated Logout; just clear our own session.
|
||||
}
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) return next(err);
|
||||
res.redirect(endSessionUrl ? endSessionUrl.href : "/");
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { users } from "../db/schema.js";
|
||||
|
||||
/**
|
||||
* Called on every successful OIDC login. The very first user ever to sign in
|
||||
* becomes admin; everyone after defaults to viewer until an admin promotes
|
||||
* them from the Users page.
|
||||
*/
|
||||
export async function upsertUserFromLogin(params: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
}) {
|
||||
const [existing] = await db.select().from(users).where(eq(users.oidcSub, params.sub)).limit(1);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
if (existing) {
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
email: params.email ?? existing.email,
|
||||
name: params.name ?? existing.name,
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.where(eq(users.id, existing.id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
const anyUser = await db.select({ id: users.id }).from(users).limit(1);
|
||||
const role = anyUser.length === 0 ? ("admin" as const) : ("viewer" as const);
|
||||
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcSub: params.sub,
|
||||
email: params.email,
|
||||
name: params.name,
|
||||
role,
|
||||
lastLoginAt: now,
|
||||
})
|
||||
.returning();
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
||||
import { env } from "./env.js";
|
||||
|
||||
const ALGO = "aes-256-gcm";
|
||||
|
||||
function getKey(): Buffer {
|
||||
if (!env.credentialsEncryptionEnabled) {
|
||||
throw new Error(
|
||||
"CREDENTIALS_ENCRYPTION_KEY is not configured (must be a 64-character hex string)",
|
||||
);
|
||||
}
|
||||
return Buffer.from(env.credentialsEncryptionKey, "hex");
|
||||
}
|
||||
|
||||
/** Encrypts a plaintext secret for storage. Returns "iv:authTag:ciphertext" as hex. */
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv(ALGO, getKey(), iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return [iv.toString("hex"), authTag.toString("hex"), ciphertext.toString("hex")].join(":");
|
||||
}
|
||||
|
||||
/** Reverses encryptSecret(). Throws if the key changed or the data was tampered with. */
|
||||
export function decryptSecret(stored: string): string {
|
||||
const [ivHex, authTagHex, ciphertextHex] = stored.split(":");
|
||||
if (!ivHex || !authTagHex || !ciphertextHex) {
|
||||
throw new Error("Malformed encrypted credential");
|
||||
}
|
||||
const decipher = createDecipheriv(ALGO, getKey(), Buffer.from(ivHex, "hex"));
|
||||
decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
|
||||
const plaintext = Buffer.concat([
|
||||
decipher.update(Buffer.from(ciphertextHex, "hex")),
|
||||
decipher.final(),
|
||||
]);
|
||||
return plaintext.toString("utf8");
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createClient } from "@libsql/client";
|
||||
import { drizzle } from "drizzle-orm/libsql";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveDataPath } from "../paths.js";
|
||||
import * as schema from "./schema.js";
|
||||
|
||||
const dbPath = resolveDataPath(process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite");
|
||||
mkdirSync(dirname(dbPath), { recursive: true });
|
||||
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
await client.execute("PRAGMA foreign_keys = ON;");
|
||||
|
||||
export const db = drizzle(client, { schema });
|
||||
export { client };
|
||||
@@ -0,0 +1,17 @@
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { db, client } from "./client.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export async function runMigrations() {
|
||||
await migrate(db, { migrationsFolder: join(__dirname, "..", "..", "drizzle") });
|
||||
}
|
||||
|
||||
// Allow running directly via `npm run db:migrate`
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await runMigrations();
|
||||
client.close();
|
||||
console.log("Migrations applied.");
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { sql } from "drizzle-orm";
|
||||
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
||||
|
||||
// ─── Users & roles ──────────────────────────────────────────────────────────
|
||||
|
||||
export const userRoles = ["admin", "operator", "viewer"] as const;
|
||||
export type UserRole = (typeof userRoles)[number];
|
||||
|
||||
export const users = sqliteTable("users", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
oidcSub: text("oidc_sub").notNull().unique(),
|
||||
email: text("email"),
|
||||
name: text("name"),
|
||||
role: text("role").$type<UserRole>().notNull().default("viewer"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
lastLoginAt: text("last_login_at"),
|
||||
});
|
||||
|
||||
// ─── Audit log ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const auditLog = sqliteTable("audit_log", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
actorUserId: integer("actor_user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
actorLabel: text("actor_label"), // denormalized name/email snapshot, survives user deletion
|
||||
category: text("category").notNull(), // 'secret' | 'ipam' | 'dns' | 'user' | 'integration' | 'task' | ...
|
||||
action: text("action").notNull(), // 'create' | 'update' | 'delete' | 'start' | 'stop' | ...
|
||||
targetType: text("target_type"),
|
||||
targetId: text("target_id"),
|
||||
detail: text("detail"), // JSON-encoded free-form context
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Settings (key/value) ───────────────────────────────────────────────────
|
||||
|
||||
export const settings = sqliteTable("settings", {
|
||||
key: text("key").primaryKey(),
|
||||
value: text("value").notNull(),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Secrets expiry tracker (ported from Sloth Manager) ────────────────────
|
||||
|
||||
export const secretTypes = ["api_token", "ssl_certificate", "password", "generic"] as const;
|
||||
export type SecretType = (typeof secretTypes)[number];
|
||||
|
||||
export const secrets = sqliteTable("secrets", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
type: text("type").$type<SecretType>().notNull().default("generic"),
|
||||
description: text("description"),
|
||||
expiryDate: text("expiry_date").notNull(), // ISO date, e.g. 2026-03-01
|
||||
warnDays: integer("warn_days").notNull().default(30),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── IPAM (ported from Sloth Manager) ───────────────────────────────────────
|
||||
|
||||
export const ipamEntries = sqliteTable("ipam_entries", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
ipAddress: text("ip_address").notNull().unique(),
|
||||
label: text("label"),
|
||||
vendor: text("vendor"),
|
||||
location: text("location"),
|
||||
notes: text("notes"),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Integration credentials (encrypted API tokens) ─────────────────────────
|
||||
|
||||
export const integrationCredentials = sqliteTable("integration_credentials", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
encryptedSecret: text("encrypted_secret").notNull(), // AES-256-GCM, see src/crypto.ts
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── DNS providers + record cache (ported from Sloth Manager) ──────────────
|
||||
|
||||
export const dnsProviderTypes = [
|
||||
"cloudflare",
|
||||
"loopia",
|
||||
"pihole",
|
||||
"azure",
|
||||
"cpanel",
|
||||
"technitium",
|
||||
] as const;
|
||||
export type DnsProviderType = (typeof dnsProviderTypes)[number];
|
||||
|
||||
export const dnsProviders = sqliteTable("dns_providers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
providerType: text("provider_type").$type<DnsProviderType>().notNull(),
|
||||
name: text("name").notNull(),
|
||||
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
config: text("config"), // JSON: non-secret provider config (base URL, zone list, etc.)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
export const dnsRecordsCache = sqliteTable("dns_records_cache", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
providerId: integer("provider_id")
|
||||
.notNull()
|
||||
.references(() => dnsProviders.id, { onDelete: "cascade" }),
|
||||
zone: text("zone").notNull(),
|
||||
recordType: text("record_type").notNull(), // A, AAAA, CNAME, TXT, MX, ...
|
||||
name: text("name").notNull(),
|
||||
value: text("value").notNull(),
|
||||
ttl: integer("ttl"),
|
||||
raw: text("raw"), // JSON: original provider payload for this record
|
||||
syncedAt: text("synced_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Servers & scheduled tasks (ported from Schedule Task Manager) ─────────
|
||||
|
||||
export const servers = sqliteTable("servers", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
name: text("name").notNull(),
|
||||
hostname: text("hostname"),
|
||||
osType: text("os_type").notNull().default("linux"),
|
||||
description: text("description"),
|
||||
apiTokenHash: text("api_token_hash").notNull(),
|
||||
apiTokenPrefix: text("api_token_prefix").notNull(),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
lastSeenAt: text("last_seen_at"),
|
||||
});
|
||||
|
||||
export const scheduledTasks = sqliteTable("scheduled_tasks", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
serverId: integer("server_id")
|
||||
.notNull()
|
||||
.references(() => servers.id, { onDelete: "cascade" }),
|
||||
scheduleType: text("schedule_type").notNull(), // 'cron' | 'systemd_timer' | 'docker' | 'backup' | 'update' | 'n8n_workflow' | 'manual'
|
||||
origin: text("origin").notNull().default("agent"), // 'agent' | 'manual' — manual rows are never touched by agent sync
|
||||
name: text("name").notNull(),
|
||||
command: text("command"),
|
||||
scheduleExpression: text("schedule_expression"),
|
||||
source: text("source"),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
nextRunAt: text("next_run_at"),
|
||||
rawMetadata: text("raw_metadata"),
|
||||
isStale: integer("is_stale", { mode: "boolean" }).notNull().default(false),
|
||||
firstSeenAt: text("first_seen_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
lastSeenAt: text("last_seen_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
|
||||
// ─── Live integrations (Proxmox, Synology, Semaphore, Tailscale, Gitea, Dockhand) ─
|
||||
|
||||
export const integrationTypes = [
|
||||
"proxmox",
|
||||
"synology",
|
||||
"semaphore",
|
||||
"tailscale",
|
||||
"gitea",
|
||||
"dockhand",
|
||||
] as const;
|
||||
export type IntegrationType = (typeof integrationTypes)[number];
|
||||
|
||||
export const integrations = sqliteTable("integrations", {
|
||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||
type: text("type").$type<IntegrationType>().notNull(),
|
||||
name: text("name").notNull(),
|
||||
baseUrl: text("base_url").notNull(),
|
||||
credentialId: integer("credential_id").references(() => integrationCredentials.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
config: text("config"), // JSON: per-type non-secret config (tailnet name, node name, etc.)
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
createdAt: text("created_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
export const env = {
|
||||
port: Number(process.env.PORT ?? 3000),
|
||||
nodeEnv: process.env.NODE_ENV ?? "development",
|
||||
appBaseUrl: process.env.APP_BASE_URL ?? "http://localhost:3000",
|
||||
sessionSecret: process.env.SESSION_SECRET ?? "dev-insecure-session-secret-change-me",
|
||||
sessionDir: process.env.SESSION_DIR ?? "../data/sessions",
|
||||
databasePath: process.env.DATABASE_PATH ?? "../data/homelab-manager.sqlite",
|
||||
credentialsEncryptionKey: process.env.CREDENTIALS_ENCRYPTION_KEY ?? "",
|
||||
authentik: {
|
||||
issuerUrl: process.env.AUTHENTIK_ISSUER_URL ?? "",
|
||||
clientId: process.env.AUTHENTIK_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET ?? "",
|
||||
},
|
||||
gotify: {
|
||||
url: process.env.GOTIFY_URL ?? "",
|
||||
token: process.env.GOTIFY_TOKEN ?? "",
|
||||
},
|
||||
get authEnabled() {
|
||||
return Boolean(this.authentik.issuerUrl && this.authentik.clientId && this.authentik.clientSecret);
|
||||
},
|
||||
get credentialsEncryptionEnabled() {
|
||||
return this.credentialsEncryptionKey.length === 64;
|
||||
},
|
||||
};
|
||||
|
||||
export function warnIfAuthNotConfigured() {
|
||||
if (!env.authEnabled) {
|
||||
console.warn(
|
||||
"AUTHENTIK_ISSUER_URL / AUTHENTIK_CLIENT_ID / AUTHENTIK_CLIENT_SECRET are not fully set. " +
|
||||
"The app will boot, but /auth/login and /auth/logout will fail until they are configured.",
|
||||
);
|
||||
}
|
||||
if (!env.credentialsEncryptionEnabled) {
|
||||
console.warn(
|
||||
"CREDENTIALS_ENCRYPTION_KEY is not set to a 64-character hex string. " +
|
||||
"Saving integration credentials (Proxmox/Synology/etc API tokens) will fail until it is configured.",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import express from "express";
|
||||
import session from "express-session";
|
||||
import FileStoreFactory from "session-file-store";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { env, warnIfAuthNotConfigured } from "./env.js";
|
||||
import { resolveDataPath } from "./paths.js";
|
||||
import { runMigrations } from "./db/migrate.js";
|
||||
import { authRouter } from "./auth/router.js";
|
||||
import { meRouter } from "./routes/me.js";
|
||||
import { usersRouter } from "./routes/users.js";
|
||||
import { auditLogRouter } from "./routes/auditLog.js";
|
||||
import { secretsRouter } from "./routes/secrets.js";
|
||||
import { ipamRouter } from "./routes/ipam.js";
|
||||
|
||||
warnIfAuthNotConfigured();
|
||||
await runMigrations();
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const webDist = join(__dirname, "..", "..", "web", "dist");
|
||||
const agentDir = join(__dirname, "..", "..", "agent");
|
||||
|
||||
const FileStore = FileStoreFactory(session);
|
||||
const sessionDir = resolveDataPath(env.sessionDir);
|
||||
mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
const app = express();
|
||||
app.set("trust proxy", 1);
|
||||
app.use(express.json());
|
||||
app.use(
|
||||
session({
|
||||
store: new FileStore({ path: sessionDir, logFn: () => {} }),
|
||||
secret: env.sessionSecret,
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.nodeEnv === "production",
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.get("/health", (_req, res) => res.json({ ok: true }));
|
||||
|
||||
// Publicly readable so `curl .../agent/linux/install.sh | bash` works from a
|
||||
// freshly provisioned server with no prior session. Contains no secrets.
|
||||
if (existsSync(agentDir)) {
|
||||
app.use("/agent", express.static(agentDir));
|
||||
}
|
||||
|
||||
app.use("/auth", authRouter);
|
||||
app.use("/api/me", meRouter);
|
||||
app.use("/api/users", usersRouter);
|
||||
app.use("/api/audit-log", auditLogRouter);
|
||||
app.use("/api/secrets", secretsRouter);
|
||||
app.use("/api/ipam", ipamRouter);
|
||||
|
||||
if (existsSync(webDist)) {
|
||||
app.use(express.static(webDist));
|
||||
app.get("*", (_req, res) => res.sendFile(join(webDist, "index.html")));
|
||||
}
|
||||
|
||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: "internal_error" });
|
||||
});
|
||||
|
||||
app.listen(env.port, () => {
|
||||
console.log(`homelab-manager listening on port ${env.port}`);
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve, isAbsolute } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const serverRoot = resolve(__dirname, ".."); // server/
|
||||
|
||||
/** Resolves a config path relative to the server package root, regardless of process.cwd(). */
|
||||
export function resolveDataPath(path: string): string {
|
||||
return isAbsolute(path) ? path : resolve(serverRoot, path);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Router } from "express";
|
||||
import { desc } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { auditLog } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
|
||||
export const auditLogRouter = Router();
|
||||
|
||||
auditLogRouter.use(requireAuth, requireRole("operator"));
|
||||
|
||||
auditLogRouter.get("/", async (req, res) => {
|
||||
const limit = Math.min(Number(req.query.limit ?? 200), 500);
|
||||
const rows = await db.select().from(auditLog).orderBy(desc(auditLog.createdAt)).limit(limit);
|
||||
res.json({ entries: rows });
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Router } from "express";
|
||||
import { isIP } from "node:net";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { ipamEntries } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const ipamRouter = Router();
|
||||
|
||||
ipamRouter.use(requireAuth);
|
||||
|
||||
ipamRouter.get("/", async (_req, res) => {
|
||||
const rows = await db.select().from(ipamEntries).orderBy(ipamEntries.ipAddress);
|
||||
// matchingDnsRecords will be populated once the DNS module (phase 3) has cached records for cross-reference.
|
||||
res.json({ entries: rows.map((r) => ({ ...r, matchingDnsRecords: [] as string[] })) });
|
||||
});
|
||||
|
||||
const createInput = z.object({
|
||||
ipAddress: z.string().refine((v) => isIP(v) !== 0, "Must be a valid IPv4 or IPv6 address"),
|
||||
label: z.string().max(200).optional(),
|
||||
vendor: z.string().max(200).optional(),
|
||||
location: z.string().max(200).optional(),
|
||||
notes: z.string().max(4000).optional(),
|
||||
});
|
||||
|
||||
const updateInput = createInput.omit({ ipAddress: true }).partial();
|
||||
|
||||
ipamRouter.post("/", requireRole("operator"), async (req, res) => {
|
||||
const parsed = createInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select({ id: ipamEntries.id })
|
||||
.from(ipamEntries)
|
||||
.where(eq(ipamEntries.ipAddress, parsed.data.ipAddress))
|
||||
.limit(1);
|
||||
if (existing) {
|
||||
return res.status(409).json({ error: "duplicate_ip" });
|
||||
}
|
||||
|
||||
const [created] = await db.insert(ipamEntries).values(parsed.data).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "create",
|
||||
targetType: "ipam_entry",
|
||||
targetId: created.id,
|
||||
detail: { ipAddress: created.ipAddress },
|
||||
});
|
||||
|
||||
res.status(201).json({ entry: { ...created, matchingDnsRecords: [] } });
|
||||
});
|
||||
|
||||
ipamRouter.patch("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = updateInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(ipamEntries)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(ipamEntries.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "update",
|
||||
targetType: "ipam_entry",
|
||||
targetId: id,
|
||||
detail: { ipAddress: updated.ipAddress },
|
||||
});
|
||||
|
||||
res.json({ entry: { ...updated, matchingDnsRecords: [] } });
|
||||
});
|
||||
|
||||
ipamRouter.delete("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(ipamEntries).where(eq(ipamEntries.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
await db.delete(ipamEntries).where(eq(ipamEntries.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "ipam",
|
||||
action: "delete",
|
||||
targetType: "ipam_entry",
|
||||
targetId: id,
|
||||
detail: { ipAddress: existing.ipAddress },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Router } from "express";
|
||||
import { requireAuth } from "../auth/middleware.js";
|
||||
|
||||
export const meRouter = Router();
|
||||
|
||||
meRouter.get("/", requireAuth, (req, res) => {
|
||||
const { id, email, name, role, oidcSub } = req.currentUser!;
|
||||
res.json({ user: { id, sub: oidcSub, email, name, role } });
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Router } from "express";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { secrets, secretTypes } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
import { computeSecretStatus } from "../services/secretStatus.js";
|
||||
|
||||
export const secretsRouter = Router();
|
||||
|
||||
secretsRouter.use(requireAuth);
|
||||
|
||||
secretsRouter.get("/", async (_req, res) => {
|
||||
const rows = await db.select().from(secrets).orderBy(secrets.expiryDate);
|
||||
res.json({
|
||||
secrets: rows.map((s) => ({ ...s, ...computeSecretStatus(s.expiryDate, s.warnDays) })),
|
||||
});
|
||||
});
|
||||
|
||||
const secretInput = z.object({
|
||||
name: z.string().min(1).max(200),
|
||||
type: z.enum(secretTypes),
|
||||
description: z.string().max(2000).optional(),
|
||||
expiryDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Expected YYYY-MM-DD"),
|
||||
warnDays: z.number().int().min(0).max(3650).default(30),
|
||||
notes: z.string().max(4000).optional(),
|
||||
});
|
||||
|
||||
secretsRouter.post("/", requireRole("operator"), async (req, res) => {
|
||||
const parsed = secretInput.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [created] = await db.insert(secrets).values(parsed.data).returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "secret",
|
||||
action: "create",
|
||||
targetType: "secret",
|
||||
targetId: created.id,
|
||||
detail: { name: created.name },
|
||||
});
|
||||
|
||||
res.status(201).json({ secret: { ...created, ...computeSecretStatus(created.expiryDate, created.warnDays) } });
|
||||
});
|
||||
|
||||
secretsRouter.patch("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = secretInput.partial().safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(secrets)
|
||||
.set({ ...parsed.data, updatedAt: new Date().toISOString() })
|
||||
.where(eq(secrets.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "secret",
|
||||
action: "update",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
detail: { name: updated.name },
|
||||
});
|
||||
|
||||
res.json({ secret: { ...updated, ...computeSecretStatus(updated.expiryDate, updated.warnDays) } });
|
||||
});
|
||||
|
||||
secretsRouter.delete("/:id", requireRole("operator"), async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const [existing] = await db.select().from(secrets).where(eq(secrets.id, id)).limit(1);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
await db.delete(secrets).where(eq(secrets.id, id));
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "secret",
|
||||
action: "delete",
|
||||
targetType: "secret",
|
||||
targetId: id,
|
||||
detail: { name: existing.name },
|
||||
});
|
||||
|
||||
res.status(204).end();
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Router } from "express";
|
||||
import { eq, ne, and } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { users, userRoles } from "../db/schema.js";
|
||||
import { requireAuth, requireRole } from "../auth/middleware.js";
|
||||
import { recordAudit } from "../services/audit.js";
|
||||
|
||||
export const usersRouter = Router();
|
||||
|
||||
usersRouter.use(requireAuth, requireRole("admin"));
|
||||
|
||||
usersRouter.get("/", async (_req, res) => {
|
||||
const rows = await db.select().from(users).orderBy(users.createdAt);
|
||||
res.json({ users: rows });
|
||||
});
|
||||
|
||||
const updateRoleSchema = z.object({
|
||||
role: z.enum(userRoles),
|
||||
});
|
||||
|
||||
usersRouter.patch("/:id/role", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const parsed = updateRoleSchema.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
|
||||
}
|
||||
|
||||
const [target] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
||||
if (!target) {
|
||||
return res.status(404).json({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (target.role === "admin" && parsed.data.role !== "admin") {
|
||||
const otherAdmins = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, "admin"), ne(users.id, id)))
|
||||
.limit(1);
|
||||
if (otherAdmins.length === 0) {
|
||||
return res.status(400).json({ error: "last_admin", message: "Cannot remove the only admin." });
|
||||
}
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ role: parsed.data.role })
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
await recordAudit({
|
||||
actor: req.currentUser!,
|
||||
category: "user",
|
||||
action: "update_role",
|
||||
targetType: "user",
|
||||
targetId: id,
|
||||
detail: { from: target.role, to: parsed.data.role, targetLabel: target.name ?? target.email },
|
||||
});
|
||||
|
||||
res.json({ user: updated });
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { db } from "../db/client.js";
|
||||
import { auditLog, users } from "../db/schema.js";
|
||||
|
||||
type CurrentUser = typeof users.$inferSelect;
|
||||
|
||||
/** Records one audit-log entry. Call this from any route that mutates state or takes an action. */
|
||||
export async function recordAudit(params: {
|
||||
actor: CurrentUser;
|
||||
category: string;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string | number;
|
||||
detail?: unknown;
|
||||
}) {
|
||||
await db.insert(auditLog).values({
|
||||
actorUserId: params.actor.id,
|
||||
actorLabel: params.actor.name ?? params.actor.email ?? params.actor.oidcSub,
|
||||
category: params.category,
|
||||
action: params.action,
|
||||
targetType: params.targetType,
|
||||
targetId: params.targetId !== undefined ? String(params.targetId) : undefined,
|
||||
detail: params.detail !== undefined ? JSON.stringify(params.detail) : undefined,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type SecretStatus = "ok" | "expiring" | "expired";
|
||||
|
||||
/** Computes status + days-left for a secret, matching Sloth Manager's original semantics. */
|
||||
export function computeSecretStatus(
|
||||
expiryDate: string,
|
||||
warnDays: number,
|
||||
now: Date = new Date(),
|
||||
): { status: SecretStatus; daysLeft: number } {
|
||||
const expiry = new Date(`${expiryDate}T00:00:00Z`);
|
||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const daysLeft = Math.round((expiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
let status: SecretStatus;
|
||||
if (daysLeft < 0) status = "expired";
|
||||
else if (daysLeft <= warnDays) status = "expiring";
|
||||
else status = "ok";
|
||||
|
||||
return { status, daysLeft };
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import "express-session";
|
||||
|
||||
declare module "express-session" {
|
||||
interface SessionData {
|
||||
user?: {
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
idToken?: string;
|
||||
};
|
||||
pendingAuth?: {
|
||||
codeVerifier: string;
|
||||
state: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Homelab Manager</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tabler/core": "^1.5.1",
|
||||
"@tabler/icons-react": "^3.46.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import { api, type CurrentUser, type UserRole, UnauthorizedError } from "./api/client";
|
||||
import Login from "./pages/Login";
|
||||
import Dashboard from "./pages/Dashboard";
|
||||
import Users from "./pages/Users";
|
||||
import AuditLog from "./pages/AuditLog";
|
||||
import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import ComingSoon from "./pages/ComingSoon";
|
||||
import AppShell from "./layout/AppShell";
|
||||
|
||||
const roleRank: Record<UserRole, number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
|
||||
function RequireRole({
|
||||
user,
|
||||
minRole,
|
||||
children,
|
||||
}: {
|
||||
user: CurrentUser;
|
||||
minRole: UserRole;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
if (roleRank[user.role] < roleRank[minRole]) {
|
||||
return (
|
||||
<div className="alert alert-danger">You don't have permission to view this page.</div>
|
||||
);
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
api.me().then(
|
||||
(res) => setUser(res.user),
|
||||
(err) => {
|
||||
if (!(err instanceof UnauthorizedError)) {
|
||||
console.error(err);
|
||||
}
|
||||
setUser(null);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (user === undefined) {
|
||||
return <div className="loading-screen">Loading…</div>;
|
||||
}
|
||||
|
||||
if (user === null) {
|
||||
return <Login />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell user={user}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard user={user} />} />
|
||||
<Route path="/servers" element={<ComingSoon title="Servers & Tasks" />} />
|
||||
<Route path="/dns" element={<ComingSoon title="DNS" />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
<Route path="/integrations" element={<ComingSoon title="Integrations" />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<RequireRole user={user} minRole="admin">
|
||||
<Users />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-log"
|
||||
element={
|
||||
<RequireRole user={user} minRole="operator">
|
||||
<AuditLog />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<RequireRole user={user} minRole="admin">
|
||||
<ComingSoon title="Settings" />
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
export type UserRole = "admin" | "operator" | "viewer";
|
||||
|
||||
export interface CurrentUser {
|
||||
id: number;
|
||||
sub: string;
|
||||
email?: string;
|
||||
name?: string;
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
export interface UserRecord {
|
||||
id: number;
|
||||
oidcSub: string;
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
role: UserRole;
|
||||
createdAt: string;
|
||||
lastLoginAt: string | null;
|
||||
}
|
||||
|
||||
export interface AuditLogEntry {
|
||||
id: number;
|
||||
actorUserId: number | null;
|
||||
actorLabel: string | null;
|
||||
category: string;
|
||||
action: string;
|
||||
targetType: string | null;
|
||||
targetId: string | null;
|
||||
detail: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SecretType = "api_token" | "ssl_certificate" | "password" | "generic";
|
||||
export type SecretStatus = "ok" | "expiring" | "expired";
|
||||
|
||||
export interface SecretRecord {
|
||||
id: number;
|
||||
name: string;
|
||||
type: SecretType;
|
||||
description: string | null;
|
||||
expiryDate: string;
|
||||
warnDays: number;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
status: SecretStatus;
|
||||
daysLeft: number;
|
||||
}
|
||||
|
||||
export interface SecretInput {
|
||||
name: string;
|
||||
type: SecretType;
|
||||
description?: string;
|
||||
expiryDate: string;
|
||||
warnDays: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface IpamEntry {
|
||||
id: number;
|
||||
ipAddress: string;
|
||||
label: string | null;
|
||||
vendor: string | null;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
matchingDnsRecords: string[];
|
||||
}
|
||||
|
||||
export interface IpamInput {
|
||||
ipAddress: string;
|
||||
label?: string;
|
||||
vendor?: string;
|
||||
location?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ForbiddenError extends Error {}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (res.status === 401) {
|
||||
throw new UnauthorizedError("unauthorized");
|
||||
}
|
||||
if (res.status === 403) {
|
||||
throw new ForbiddenError("forbidden");
|
||||
}
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(`Request failed (${res.status}): ${body}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<{ user: CurrentUser }>("/api/me"),
|
||||
users: {
|
||||
list: () => request<{ users: UserRecord[] }>("/api/users"),
|
||||
updateRole: (id: number, role: UserRole) =>
|
||||
request<{ user: UserRecord }>(`/api/users/${id}/role`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ role }),
|
||||
}),
|
||||
},
|
||||
auditLog: {
|
||||
list: (limit = 200) => request<{ entries: AuditLogEntry[] }>(`/api/audit-log?limit=${limit}`),
|
||||
},
|
||||
secrets: {
|
||||
list: () => request<{ secrets: SecretRecord[] }>("/api/secrets"),
|
||||
create: (data: SecretInput) =>
|
||||
request<{ secret: SecretRecord }>("/api/secrets", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<SecretInput>) =>
|
||||
request<{ secret: SecretRecord }>(`/api/secrets/${id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
remove: (id: number) => request<void>(`/api/secrets/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
ipam: {
|
||||
list: () => request<{ entries: IpamEntry[] }>("/api/ipam"),
|
||||
create: (data: IpamInput) =>
|
||||
request<{ entry: IpamEntry }>("/api/ipam", { method: "POST", body: JSON.stringify(data) }),
|
||||
update: (id: number, data: Partial<Omit<IpamInput, "ipAddress">>) =>
|
||||
request<{ entry: IpamEntry }>(`/api/ipam/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
remove: (id: number) => request<void>(`/api/ipam/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import {
|
||||
IconLayoutDashboard,
|
||||
IconServer2,
|
||||
IconWorld,
|
||||
IconNetwork,
|
||||
IconKey,
|
||||
IconPlugConnected,
|
||||
IconUsers,
|
||||
IconHistory,
|
||||
IconSettings,
|
||||
IconMenu2,
|
||||
} from "@tabler/icons-react";
|
||||
import type { CurrentUser } from "../api/client";
|
||||
|
||||
interface NavItem {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
minRole?: "operator" | "admin";
|
||||
}
|
||||
|
||||
const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/", label: "Dashboard", icon: <IconLayoutDashboard size={20} /> },
|
||||
{ to: "/servers", label: "Servers & Tasks", icon: <IconServer2 size={20} /> },
|
||||
{ to: "/dns", label: "DNS", icon: <IconWorld size={20} /> },
|
||||
{ to: "/ipam", label: "IP Addresses", icon: <IconNetwork size={20} /> },
|
||||
{ to: "/secrets", label: "Secrets", icon: <IconKey size={20} /> },
|
||||
{ to: "/integrations", label: "Integrations", icon: <IconPlugConnected size={20} /> },
|
||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||
];
|
||||
|
||||
const roleRank: Record<CurrentUser["role"], number> = { viewer: 0, operator: 1, admin: 2 };
|
||||
|
||||
export default function AppShell({ user, children }: { user: CurrentUser; children: ReactNode }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const visibleItems = NAV_ITEMS.filter(
|
||||
(item) => !item.minRole || roleRank[user.role] >= roleRank[item.minRole],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<aside className="navbar navbar-vertical navbar-expand-lg" data-bs-theme="dark">
|
||||
<div className="container-fluid">
|
||||
<button
|
||||
type="button"
|
||||
className="navbar-toggler"
|
||||
onClick={() => setSidebarOpen((open) => !open)}
|
||||
aria-label="Toggle navigation"
|
||||
>
|
||||
<IconMenu2 size={20} />
|
||||
</button>
|
||||
<h1 className="navbar-brand navbar-brand-autodark">
|
||||
<NavLink to="/">Homelab Manager</NavLink>
|
||||
</h1>
|
||||
<div className={`navbar-collapse collapse ${sidebarOpen ? "show" : ""}`}>
|
||||
<ul className="navbar-nav pt-lg-3">
|
||||
{visibleItems.map((item) => (
|
||||
<li className="nav-item" key={item.to}>
|
||||
<NavLink
|
||||
to={item.to}
|
||||
end={item.to === "/"}
|
||||
className="nav-link"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<span className="nav-link-icon d-md-none d-lg-inline-block">{item.icon}</span>
|
||||
<span className="nav-link-title">{item.label}</span>
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="page-wrapper">
|
||||
<div className="page-header d-print-none">
|
||||
<div className="container-xl">
|
||||
<div className="row g-2 align-items-center">
|
||||
<div className="col">
|
||||
<span className="text-secondary">
|
||||
Signed in as {user.name ?? user.email ?? user.sub} · {user.role}
|
||||
</span>
|
||||
</div>
|
||||
<div className="col-auto ms-auto d-print-none">
|
||||
<a className="btn btn-outline-secondary btn-sm" href="/auth/logout">
|
||||
Sign out
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-body">
|
||||
<div className="container-xl">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "@tabler/core/dist/css/tabler.min.css";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type AuditLogEntry } from "../api/client";
|
||||
|
||||
export default function AuditLog() {
|
||||
const [entries, setEntries] = useState<AuditLogEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.auditLog
|
||||
.list()
|
||||
.then((res) => setEntries(res.entries))
|
||||
.catch((err) => setError(String(err)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Audit Log</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>When</th>
|
||||
<th>Actor</th>
|
||||
<th>Category</th>
|
||||
<th>Action</th>
|
||||
<th>Target</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries?.map((e) => (
|
||||
<tr key={e.id}>
|
||||
<td>{new Date(e.createdAt).toLocaleString()}</td>
|
||||
<td>{e.actorLabel ?? "—"}</td>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{e.category}</span>
|
||||
</td>
|
||||
<td>{e.action}</td>
|
||||
<td>
|
||||
{e.targetType ? `${e.targetType}${e.targetId ? ` #${e.targetId}` : ""}` : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{entries?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
No activity recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export default function ComingSoon({ title }: { title: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">{title}</h2>
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
This module hasn't been built yet — it's on the roadmap for a follow-up pass.
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CurrentUser } from "../api/client";
|
||||
|
||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
return (
|
||||
<>
|
||||
<div className="row row-cards mb-3">
|
||||
<div className="col-12">
|
||||
<h2 className="page-title mb-3">Dashboard</h2>
|
||||
<p className="text-secondary">
|
||||
Welcome back, {user.name ?? user.email ?? user.sub}. Live status widgets for Proxmox,
|
||||
Synology, Semaphore, Tailscale, Gitea, and Dockhand will appear here as each
|
||||
integration is connected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row row-cards">
|
||||
{[
|
||||
"Proxmox",
|
||||
"Synology NAS",
|
||||
"Semaphore",
|
||||
"Tailscale",
|
||||
"Gitea",
|
||||
"Dockhand / Docker",
|
||||
].map((name) => (
|
||||
<div className="col-sm-6 col-lg-4" key={name}>
|
||||
<div className="card">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="subheader">{name}</div>
|
||||
<div className="ms-auto">
|
||||
<span className="badge bg-secondary-lt text-secondary">Not connected</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-secondary mt-2">
|
||||
Configure this integration from the Integrations page.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type CurrentUser, type IpamEntry, type IpamInput } from "../api/client";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
|
||||
const emptyForm: IpamInput = { ipAddress: "", label: "", vendor: "", location: "", notes: "" };
|
||||
|
||||
function isIpv6(ip: string) {
|
||||
return ip.includes(":");
|
||||
}
|
||||
|
||||
export default function Ipam({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
const [entries, setEntries] = useState<IpamEntry[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [editing, setEditing] = useState<IpamEntry | null>(null);
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [form, setForm] = useState<IpamInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
api.ipam
|
||||
.list()
|
||||
.then((res) => setEntries(res.entries))
|
||||
.catch((err) => setError(String(err)));
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!entries) return [];
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return entries;
|
||||
return entries.filter((e) =>
|
||||
[e.ipAddress, e.label, e.vendor, e.location].some((v) => (v ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
}, [entries, search]);
|
||||
|
||||
function startAdd() {
|
||||
setAdding(true);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
function startEdit(entry: IpamEntry) {
|
||||
setEditing(entry);
|
||||
setAdding(false);
|
||||
setForm({
|
||||
ipAddress: entry.ipAddress,
|
||||
label: entry.label ?? "",
|
||||
vendor: entry.vendor ?? "",
|
||||
location: entry.location ?? "",
|
||||
notes: entry.notes ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelForm() {
|
||||
setAdding(false);
|
||||
setEditing(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
const { ipAddress: _ip, ...rest } = form;
|
||||
await api.ipam.update(editing.id, rest);
|
||||
} else {
|
||||
await api.ipam.create(form);
|
||||
}
|
||||
cancelForm();
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(entry: IpamEntry) {
|
||||
if (!confirm(`Delete IP address "${entry.ipAddress}"?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.ipam.remove(entry.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(
|
||||
"ip-addresses.csv",
|
||||
["IP Address", "Label", "Vendor", "Location", "Notes", "DNS Records"],
|
||||
filtered.map((e) => [
|
||||
e.ipAddress,
|
||||
e.label ?? "",
|
||||
e.vendor ?? "",
|
||||
e.location ?? "",
|
||||
e.notes ?? "",
|
||||
e.matchingDnsRecords.join("; "),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
const showForm = adding || editing;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">IP Addresses</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{canEdit && showForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editing ? `Edit ${editing.ipAddress}` : "Add an IP address"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">IP address</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
disabled={!!editing}
|
||||
placeholder="203.0.113.10 or 2001:db8::1"
|
||||
value={form.ipAddress}
|
||||
onChange={(e) => setForm({ ...form, ipAddress: e.target.value })}
|
||||
/>
|
||||
{!editing && <div className="form-hint">Cannot be changed after creation.</div>}
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Label</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Web Server 1"
|
||||
value={form.label}
|
||||
onChange={(e) => setForm({ ...form, label: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Vendor / provider</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Hetzner"
|
||||
value={form.vendor}
|
||||
onChange={(e) => setForm({ ...form, vendor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Location</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="Frankfurt"
|
||||
value={form.location}
|
||||
onChange={(e) => setForm({ ...form, location: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Notes</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{editing ? "Save changes" : "Add IP address"}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={cancelForm}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ maxWidth: 280 }}
|
||||
placeholder="Search IP, label, vendor, location…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{canEdit && !showForm && (
|
||||
<button className="btn btn-primary" onClick={startAdd}>
|
||||
Add IP address
|
||||
</button>
|
||||
)}
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP address</th>
|
||||
<th>Label</th>
|
||||
<th>Vendor</th>
|
||||
<th>Location</th>
|
||||
<th>DNS records</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>
|
||||
<span className={`badge ${isIpv6(entry.ipAddress) ? "bg-purple-lt" : "bg-blue-lt"} me-2`}>
|
||||
{isIpv6(entry.ipAddress) ? "IPv6" : "IPv4"}
|
||||
</span>
|
||||
{entry.ipAddress}
|
||||
</td>
|
||||
<td>{entry.label ?? "—"}</td>
|
||||
<td>{entry.vendor ?? "—"}</td>
|
||||
<td>{entry.location ?? "—"}</td>
|
||||
<td className="text-secondary">
|
||||
{entry.matchingDnsRecords.length > 0 ? entry.matchingDnsRecords.join(", ") : "—"}
|
||||
</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEdit(entry)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(entry)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No IP addresses tracked yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function Login() {
|
||||
return (
|
||||
<div className="login-screen">
|
||||
<div className="login-card">
|
||||
<h1>Homelab Manager</h1>
|
||||
<p>Sign in with your homelab identity provider to continue.</p>
|
||||
<a className="btn btn-primary w-100" href="/auth/login">
|
||||
Sign in with Authentik
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api, type CurrentUser, type SecretInput, type SecretRecord, type SecretStatus } from "../api/client";
|
||||
import { downloadCsv } from "../utils/csv";
|
||||
|
||||
const TYPE_LABELS: Record<SecretRecord["type"], string> = {
|
||||
api_token: "API Token",
|
||||
ssl_certificate: "SSL Certificate",
|
||||
password: "Password",
|
||||
generic: "Generic",
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<SecretStatus, string> = {
|
||||
ok: "bg-green-lt text-green",
|
||||
expiring: "bg-yellow-lt text-yellow",
|
||||
expired: "bg-red-lt text-red",
|
||||
};
|
||||
|
||||
const emptyForm: SecretInput = {
|
||||
name: "",
|
||||
type: "generic",
|
||||
description: "",
|
||||
expiryDate: "",
|
||||
warnDays: 30,
|
||||
notes: "",
|
||||
};
|
||||
|
||||
export default function Secrets({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
const [secrets, setSecrets] = useState<SecretRecord[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | SecretStatus>("all");
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<SecretInput>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
function load() {
|
||||
api.secrets
|
||||
.list()
|
||||
.then((res) => setSecrets(res.secrets))
|
||||
.catch((err) => setError(String(err)));
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!secrets) return [];
|
||||
return secrets.filter((s) => {
|
||||
if (statusFilter !== "all" && s.status !== statusFilter) return false;
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return s.name.toLowerCase().includes(q) || (s.description ?? "").toLowerCase().includes(q);
|
||||
});
|
||||
}, [secrets, search, statusFilter]);
|
||||
|
||||
function startEdit(s: SecretRecord) {
|
||||
setEditingId(s.id);
|
||||
setForm({
|
||||
name: s.name,
|
||||
type: s.type,
|
||||
description: s.description ?? "",
|
||||
expiryDate: s.expiryDate,
|
||||
warnDays: s.warnDays,
|
||||
notes: s.notes ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setForm(emptyForm);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.secrets.update(editingId, form);
|
||||
} else {
|
||||
await api.secrets.create(form);
|
||||
}
|
||||
cancelEdit();
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(s: SecretRecord) {
|
||||
if (!confirm(`Delete secret "${s.name}"?`)) return;
|
||||
setError(null);
|
||||
try {
|
||||
await api.secrets.remove(s.id);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function exportCsv() {
|
||||
downloadCsv(
|
||||
"secrets.csv",
|
||||
["Name", "Type", "Description", "Expiry Date", "Warn Days", "Status", "Days Left", "Notes"],
|
||||
filtered.map((s) => [
|
||||
s.name,
|
||||
TYPE_LABELS[s.type],
|
||||
s.description ?? "",
|
||||
s.expiryDate,
|
||||
s.warnDays,
|
||||
s.status,
|
||||
s.daysLeft,
|
||||
s.notes ?? "",
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Secrets</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{canEdit && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editingId ? "Edit secret" : "Add a secret"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submit}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={form.type}
|
||||
onChange={(e) => setForm({ ...form, type: e.target.value as SecretInput["type"] })}
|
||||
>
|
||||
{Object.entries(TYPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-2">
|
||||
<label className="form-label">Expiry date</label>
|
||||
<input
|
||||
type="date"
|
||||
className="form-control"
|
||||
required
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Warn (days before)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="form-control"
|
||||
value={form.warnDays}
|
||||
onChange={(e) => setForm({ ...form, warnDays: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Description</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Notes</label>
|
||||
<input
|
||||
className="form-control"
|
||||
value={form.notes}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={saving}>
|
||||
{editingId ? "Save changes" : "Add secret"}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn" onClick={cancelEdit}>
|
||||
Cancel
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<div className="d-flex flex-wrap gap-2 align-items-center w-100">
|
||||
<input
|
||||
className="form-control"
|
||||
style={{ maxWidth: 260 }}
|
||||
placeholder="Search name or description…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
<select
|
||||
className="form-select"
|
||||
style={{ maxWidth: 160 }}
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="ok">OK</option>
|
||||
<option value="expiring">Expiring</option>
|
||||
<option value="expired">Expired</option>
|
||||
</select>
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={exportCsv}>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Expiry</th>
|
||||
<th>Days left</th>
|
||||
<th>Status</th>
|
||||
<th>Description</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>{TYPE_LABELS[s.type]}</td>
|
||||
<td>{s.expiryDate}</td>
|
||||
<td>{s.daysLeft < 0 ? `${Math.abs(s.daysLeft)}d ago` : `${s.daysLeft}d`}</td>
|
||||
<td>
|
||||
<span className={`badge ${STATUS_BADGE[s.status]}`}>{s.status}</span>
|
||||
</td>
|
||||
<td className="text-secondary">{s.description ?? "—"}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
<button className="btn btn-sm" onClick={() => startEdit(s)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => remove(s)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 7 : 6} className="text-secondary text-center">
|
||||
No secrets tracked yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type UserRecord, type UserRole } from "../api/client";
|
||||
|
||||
const ROLES: UserRole[] = ["viewer", "operator", "admin"];
|
||||
|
||||
export default function Users() {
|
||||
const [users, setUsers] = useState<UserRecord[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savingId, setSavingId] = useState<number | null>(null);
|
||||
|
||||
function load() {
|
||||
api.users
|
||||
.list()
|
||||
.then((res) => setUsers(res.users))
|
||||
.catch((err) => setError(String(err)));
|
||||
}
|
||||
|
||||
useEffect(load, []);
|
||||
|
||||
async function changeRole(id: number, role: UserRole) {
|
||||
setError(null);
|
||||
setSavingId(id);
|
||||
try {
|
||||
await api.users.updateRole(id, role);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Users</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
<div className="card">
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Last login</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.name ?? "—"}</td>
|
||||
<td>{u.email ?? "—"}</td>
|
||||
<td style={{ maxWidth: 160 }}>
|
||||
<select
|
||||
className="form-select form-select-sm"
|
||||
value={u.role}
|
||||
disabled={savingId === u.id}
|
||||
onChange={(e) => changeRole(u.id, e.target.value as UserRole)}
|
||||
>
|
||||
{ROLES.map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : "never"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No users yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.loading-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
color: var(--tblr-secondary);
|
||||
}
|
||||
|
||||
.login-screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--tblr-bg-surface-secondary, #f4f6fb);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 22rem;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
background: var(--tblr-bg-surface, #fff);
|
||||
border-radius: var(--tblr-border-radius, 4px);
|
||||
box-shadow: var(--tblr-box-shadow-card, 0 1px 3px rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
.login-card h1 {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-card p {
|
||||
color: var(--tblr-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
function csvCell(value: unknown): string {
|
||||
const s = value === null || value === undefined ? "" : String(value);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
|
||||
export function downloadCsv(filename: string, headers: string[], rows: unknown[][]) {
|
||||
const lines = [headers, ...rows].map((row) => row.map(csvCell).join(","));
|
||||
const blob = new Blob([lines.join("\n")], { type: "text/csv;charset=utf-8;" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/layout/appshell.tsx","./src/pages/auditlog.tsx","./src/pages/comingsoon.tsx","./src/pages/dashboard.tsx","./src/pages/ipam.tsx","./src/pages/login.tsx","./src/pages/secrets.tsx","./src/pages/users.tsx","./src/utils/csv.ts"],"version":"5.9.3"}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/auth": "http://localhost:3000",
|
||||
"/health": "http://localhost:3000",
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user