31 Commits

Author SHA1 Message Date
5485fd135d new version 2026-06-09 14:45:04 +02:00
5558d42bdb fixed #2 2026-06-09 14:44:28 +02:00
b74df36bda fixed #4 2026-06-09 14:40:49 +02:00
918b9a6b74 removed unnecessary comments 2026-06-09 14:36:03 +02:00
6d32ca13ca added more docs 2026-06-09 14:30:46 +02:00
feffff0898 Updated README/fixed #3 2026-06-09 14:21:49 +02:00
5089f94a21 updated README 2026-06-09 13:50:37 +02:00
f5f5da51c8 embedded frontend to docker image 2026-06-09 13:45:03 +02:00
089998d45b updated deploy 2026-06-08 15:35:43 +02:00
e2d51c6cdf updated dockerfile and deploy 2026-06-08 15:30:49 +02:00
0f8c7f57ac fixed #1 2026-06-08 15:27:06 +02:00
8d2be395b9 added .min.* support 2026-06-08 15:03:24 +02:00
339d5a709c dashboard locations and projects will now show items 2026-06-08 14:46:52 +02:00
405502cb20 added ad page 2026-06-08 00:34:54 +02:00
1a454dd201 Updated README.md 2026-06-08 00:25:40 +02:00
15cc33eb23 added README.md 2026-06-08 00:24:08 +02:00
f367188c08 Under construction page and dynamic profile loading 2026-06-08 00:00:45 +02:00
dcc6dc0b98 item and location names will be shown instead of their ids 2026-06-07 23:28:03 +02:00
d771ebba13 logout is now possible again 2026-06-07 23:04:12 +02:00
92e7ea4667 Added more things to dashboard 2026-06-07 02:29:27 +02:00
b93d9382ac made registration disableable 2026-06-07 02:08:49 +02:00
a31c516e8f Fixed quantity bug 2026-06-07 01:54:09 +02:00
eba273be49 Added stock management 2026-06-07 01:25:07 +02:00
e46b4904e3 Added functionality to all pages 2026-06-07 01:15:30 +02:00
28bf03d1a3 added api endpoints 2026-06-06 21:25:37 +02:00
419e05bb89 Made page more responsive 2026-06-05 22:18:47 +02:00
52d551ab39 Added more frontend and some more login logic 2026-06-05 21:58:10 +02:00
6543149dab Added docker deploy 2026-06-05 19:12:09 +02:00
978ba292a1 Made assets accessible 2026-06-05 19:09:41 +02:00
1a0797ef19 Added HTML 2026-06-05 19:00:48 +02:00
0a44df319d Started with items 2026-06-03 14:24:14 +02:00
45 changed files with 4094 additions and 68 deletions

View File

@@ -11,9 +11,15 @@ COPY . .
ARG TARGETOS
ARG TARGETARCH
LABEL org.opencontainers.image.source="https://git.miaurizius.de/miaurizius/miauinv"
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -ldflags "-s -w" -o MiauInv .
FROM scratch
COPY --from=builder /app/MiauInv /MiauInv
COPY --from=builder /app/frontend /frontend
ENTRYPOINT ["/MiauInv"]

265
README.md Normal file
View File

@@ -0,0 +1,265 @@
# MiauInv - Inventory and Project Management System
MiauInv is a secure, light-weight inventory, stock, and project allocation tracking system written in Go. It utilizes HTMX for a dynamic, reactive single-page experience over traditional server-rendered HTML blocks, backed by an encrypted JWT and dual-token refresh architecture alongside an embedded SQLite instance.
## Table of Contents
* [Technical Specifications](#technical-specifications)
* [Architecture Overview](#architecture-overview)
* [Detailed Documentation](#detailed-documentation)
* [Configuration](#configuration)
* [Configuration File (config.yaml)](#configuration-file-configyaml)
* [Environment Variables](#environment-variables)
* [Route and Endpoint Matrix](#route-and-endpoint-matrix)
* [Frontend Web Routes (HTML Views)](#frontend-web-routes-html-views)
* [Backend API Endpoints (JSON Serialization)](#backend-api-endpoints-json-serialization)
* [Setup and Deployment Tutorial](#setup-and-deployment-tutorial)
* [Prerequisites](#prerequisites)
* [Option 1: Native Local Deployment](#option-1-native-local-deployment)
* [Option 2: Docker Deployment (Recommended)](#option-2-docker-deployment-recommended)
* [Reverse Proxy Integration with Caddy](#reverse-proxy-integration-with-caddy)
* [Images](#images)
## Technical Specifications
* **Backend Language:** Go (Golang 1.22+)
* **Frontend Interactivity:** HTMX (v2.0.4) & Vanilla JavaScript (API Client integration)
* **Database Engine:** SQLite (via standard driver dependencies)
* **Security & Session Layer:** JSON Web Tokens (JWT) with HTTP-Only/Secure dual cookie strategy (Access and Refresh Token Rotation)
* **Styling Architecture:** Modern dark-theme customized native CSS variables (`--bg`, `--card`, `--accent`, `--border`)
* **Transport Security:** Compulsory HTTPS / Native TLS Listener implementation
---
## Architecture Overview
MiauInv splits responsibility cleanly across modularized architecture packages:
* **`main.go`**: Entrypoint initializing components and connecting layers.
* **`server/`**: Configures variables, spins up TLS mechanisms, and exposes route endpoints.
* **`auth/`**: Custom HTTP Middleware interceptors validating JWT signatures and parsing sub-claims.
* **`handlers/`**: Core API Controller actions processing CRUD functions on database entities.
* **`storage/`**: Direct abstraction queries interacting with the underlying SQLite database schema.
* **`frontend/`**: Serving standard static assets and injecting structural data into components.
---
---
## Detailed Documentation
For deep dives into specific subsystems, database layouts, and security mechanisms, please refer to the dedicated documentation files:
* **[Database Schema & Integrity](docs/DATABASE.md):** Comprehensive breakdown of the SQLite table structures, fields, and foreign key relations.
* **[Authentication Architecture](docs/AUTHENTICATION.md):** Detailed explanation of the dual-token rotation flow, JWT lifecycle, and frontend loop protection.
## Configuration
The system uses a combination of a structural JSON configuration file and environment variables for system runtime flags.
### Configuration File (`config.yaml`)
The application automatically creates or reads a configuration file named `config.json` in the working directory on startup.
```yaml
port: "8080"
database_path: ./appdata/database.db
certificate_path: ./appdata/cert.pem
private_key_path: ./appdata/key.pem
allow_registration: true
```
### Environment Variables
For cryptographic functions, a mandatory environment variable must be exported before executing the binary:
| Variable | Type | Description | Minimum Requirement |
| --- | --- | --- | --- |
| `JWT_SECRET` | String | Symmetric secret signature key used to sign access tokens | Minimum 32 characters |
---
## Route and Endpoint Matrix
All communication with the application happens over defined HTTP transport interfaces. The routes are divided into User-Facing Views (HTML renders) and programmatic Data Hooks (JSON APIs).
### Frontend Web Routes (HTML Views)
| Route Path | HTTP Method | Auth Required | Description |
| --- | --- | --- | --- |
| `/` | `GET` | No | Root index landing view. |
| `/login` | `GET` | No | Renders login page component. |
| `/register` | `GET` | No | Registration layout or block alert based on authorization properties. |
| `/dashboard` | `GET` | **Yes** | Aggregated stats layout covering items, projects, and locations. |
| `/inventory` | `GET` | **Yes** | General overview interface managing stock quantities. |
| `/items` | `GET` | **Yes** | Standard component interface targeting primary atomic assets. |
| `/locations` | `GET` | **Yes** | Physical or logical facility structures view. |
| `/projects` | `GET` | **Yes** | Overview interface listing active construction and logistics operations. |
| `/profile/` | `GET` | **Yes** | Component context under development. |
| `/assets/*` | `GET` | No | Serves global minified system design files (`.css`, `.js`). |
### Backend API Endpoints (JSON Serialization)
| Endpoint Path | Method | Auth | Query Parameters | Request/Response Behavior |
| --- | --- | --- | --- | --- |
| `/api/register` | `POST` | No | None | Creates a new user record. Requires plain JSON payload containing raw `username` and `password`. Returns status code `201 Created`. |
| `/api/login` | `POST` | No | None | Performs credential authentication. Sets `access_token` and `refresh_token` as secure, HTTP-Only cookies, and returns user identity metadata. |
| `/api/refresh` | `POST` | No | None | Accepts JSON containing `refresh_token`. Invalidates previous token structures, rotates identities, and hands over a newly active pair. |
| `/api/logout` | `POST` | **Yes** | None | Revokes active database-linked refresh session IDs for the user context and drops current browser state tracking. Returns status `204`. |
| `/api/profile` | `GET` | **Yes** | `id` *(Optional)* | Returns `id`, `username`, and metadata of either the target parameter or active identity mapped from token signatures. |
| `/api/item` | `GET` | **Yes** | `id` *(Optional)* | Empty parameters fetch all available items with aggregated `allocated` and `available` calculations. Providing `id` isolates a specific item. |
| `/api/item` | `POST` | **Yes** | None | Inserts a new tracked inventory item schema definition. |
| `/api/item` | `PUT` | **Yes** | `id` *(Required)* | Modifies values (`name`, `category`, `description`, `total_quantity`) of an active asset by primary key. |
| `/api/item` | `DELETE` | **Yes** | `id` *(Required)* | Removes an entry. SQLite blocks cascade execution if foreign key assignments still exist in stock or project tracking. |
| `/api/location` | `GET` | **Yes** | `id`, `content` | Fetching with `id` and `content=true` extracts an array of items grouped at the facility (`item_id`, `name`, `quantity`). Without `content`, returns details or global catalogs. |
| `/api/location` | `POST` | **Yes** | None | Instantiates a singular distinct location boundary identifier. |
| `/api/location` | `PUT` | **Yes** | `id` *(Required)* | Renames a location while maintaining foreign keys. |
| `/api/location` | `DELETE` | **Yes** | `id` *(Required)* | Destroys location configurations if currently cleared of active items. |
| `/api/project` | `GET` | **Yes** | `id`, `details` | Providing `id` with `details=true` unrolls associated items allocated to that project context. |
| `/api/project` | `POST` | **Yes** | None | Inserts a tracking context entity for targeted hardware allocation. |
| `/api/project` | `PUT` | **Yes** | `id` *(Required)* | Updates a project metadata record definition. |
| `/api/project` | `DELETE` | **Yes** | `id` *(Required)* | Drops an empty project wrapper. |
| `/api/stock` | `GET` | **Yes** | `id` *(Optional)* | Obtains exact relationship matrices between location nodes and items. |
| `/api/stock` | `POST` | **Yes** | None | Links allocations across specific site nodes. |
| `/api/stock` | `PUT` | **Yes** | `id` *(Required)* | Modifies stock metrics directly on specified maps. |
| `/api/stock` | `DELETE` | **Yes** | `id` *(Required)* | Completely severs relationship entry allocations between nodes. |
| `/api/association` | `GET` | **Yes** | `id`, `project_id` | Dumps general configuration links, isolated optionally by operational `project_id`. |
| `/api/association` | `POST` | **Yes** | None | Allocates an inventory batch to a dedicated project infrastructure requirement. |
| `/api/association` | `PUT` | **Yes** | `id` *(Required)* | Alters active quantity indicators inside a specific deployment matrix. |
| `/api/association` | `DELETE` | **Yes** | `id` *(Required)* | Frees up allocations, returning tracking variables back to unassigned states. |
---
## Setup and Deployment Tutorial
### Prerequisites
Before deployment, you must generate SSL/TLS certificates since MiauInv enforces native transport encryption layer communication (or use a bought one).
```bash
# Create directory for certs
mkdir -p appdata
# Generate self-signed certificate and private key
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes
```
### Option 1: Native Local Deployment
1. Make sure your Go environment path variable properties are populated (Go 1.22+).
2. Create your environmental token and execute the initialization routine task:
```bash
export JWT_SECRET="your_minimum_thirty_two_char_secret_key_here"
go build -o miauinv main.go
./miauinv
```
---
### Option 2: Docker Deployment (Recommended)
MiauInv includes container definition orchestrations to package configurations cleanly, binding storage databases outside running containers into persistent volumes.
#### 1. Create the Docker Compose Descriptor
Write the configuration definition mapping layer blocks directly inside a standard file named `docker-compose.yaml`:
```yaml
services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
ports:
- "8080:8080"
environment:
- JWT_SECRET=SECURE_RANDOM_STRING # Must be at least 32 characters long
volumes:
- ./appdata:/appdata # To edit your configuration files
```
#### 2. Execution Commands
To bring up your background container image instance pipelines, execute the compose environment controls:
```bash
# Build and execute the container in background detached mode
docker-compose up --build -d
# Verify container operation statuses
docker-compose ps
# Monitor execution system logs
docker-compose logs -f
```
Once running successfully via Docker orchestration loops, navigate your web browser context safely to `https://localhost:8080` to interact with your MiauInv control panel workspace.
## Reverse Proxy Integration with Caddy
If you deploy MiauInv behind a global Caddy server, Caddy must act as an HTTPS reverse proxy. Since the MiauInv binary enforces native TLS transport, Caddy needs to be configured to establish a secure backend connection and bypass verification for self-signed backend certificates.
### 1. Docker Compose Network Configuration
Ensure your MiauInv container shares an external network with your Caddy container (e.g., a network named `proxy`). The container does not need to expose public ports since Caddy communicates with it internally over port `8080`.
```yaml
services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
networks:
- proxy
environment:
- JWT_SECRET=SECURE_RANDOM_STRING
volumes:
- ./appdata:/appdata
networks:
proxy:
external: true
```
### 2. Caddyfile Configuration
Add the following block to your server's `Caddyfile`. The `https://` prefix forces Caddy to use TLS for the backend connection, and `tls_insecure_skip_verify` allows the proxy to accept the internal self-signed certificate generated during the prerequisites step.
```caddy
inv.yourdomain.com {
encode zstd gzip
reverse_proxy https://miauinv:8080 {
transport http {
tls_insecure_skip_verify
}
}
header {
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
}
```
### 3. Apply Configuration
Reload your Caddy instance to apply the reverse proxy routing rules:
```bash
docker compose exec -w /etc/caddy caddy caddy reload
```
## Images
#### Dashboard
<img src="docs/img/dashboard.png">
#### Inventory
<img src="docs/img/inventory.png">
#### Locations
<img src="docs/img/locations.png">
#### Projects
<img src="docs/img/projects.png">

407
ad.html Normal file
View File

@@ -0,0 +1,407 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiauInv - Modern Inventory & Project Management</title>
<style>
:root {
--bg: #0b0f19;
--card: #111827;
--border: #1f2937;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--code-bg: #1f2937;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
padding: 0;
margin: 0;
}
.navbar {
display: flex;
justify-content: flex-end;
padding: 1rem 2rem;
max-width: 1000px;
margin: 0 auto;
}
.lang-switch {
background: var(--card);
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.lang-switch:hover {
border-color: var(--accent);
color: var(--accent);
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 0 1.5rem 4rem 1.5rem;
}
header {
text-align: center;
padding: 3rem 0 3rem 0;
}
.brand {
font-size: 3.5rem;
font-weight: 800;
letter-spacing: -0.04em;
margin-bottom: 1rem;
}
.brand span {
color: var(--accent);
}
.tagline {
font-size: 1.25rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto 2.5rem auto;
}
.badge-container {
display: flex;
justify-content: center;
gap: 0.75rem;
margin-bottom: 3rem;
flex-wrap: wrap;
}
.badge {
background: rgba(59, 130, 246, 0.1);
color: var(--accent);
border: 1px solid rgba(59, 130, 246, 0.2);
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.85rem;
font-weight: 600;
}
.badge.green {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
border-color: rgba(16, 185, 129, 0.2);
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-bottom: 4rem;
}
@media (min-width: 768px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
}
.card h2 {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 1.25rem;
letter-spacing: -0.02em;
}
.features-list {
list-style: none;
text-align: left;
}
.features-list li {
margin-bottom: 1rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
color: var(--text-muted);
}
.features-list li strong {
color: var(--text);
}
.features-list svg {
flex-shrink: 0;
margin-top: 0.2rem;
color: var(--success);
}
.install-section {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 3rem 2rem;
text-align: left;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
margin-bottom: 4rem;
}
.install-section h2 {
font-size: 1.75rem;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.install-section p {
color: var(--text-muted);
margin-bottom: 2rem;
}
.step {
margin-bottom: 2rem;
}
.step h3 {
font-size: 1.1rem;
margin-bottom: 0.75rem;
color: var(--text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.step-num {
background: var(--border);
width: 24px;
height: 24px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: bold;
}
pre {
background: var(--bg);
border: 1px solid var(--border);
padding: 1rem 1.25rem;
border-radius: 10px;
font-family: monospace;
font-size: 0.9rem;
color: #e5e7eb;
overflow-x: auto;
white-space: pre;
margin-bottom: 1rem;
}
footer {
text-align: center;
padding: 3rem 0;
color: var(--text-muted);
font-size: 0.9rem;
border-top: 1px solid var(--border);
}
/* Sprach-Sichtbarkeitsregeln */
[lang="de"] { display: none; }
html[data-lang="de"] [lang="de"] { display: block; }
html[data-lang="de"] [lang="en"] { display: none; }
html[data-lang="de"] span[lang="de"],
html[data-lang="de"] strong[lang="de"] {
display: inline;
}
html[data-lang="en"] span[lang="en"],
html[data-lang="en"] strong[lang="en"] {
display: inline;
}
</style>
</head>
<body class="lang-en">
<div class="navbar">
<button class="lang-switch" id="langBtn" onclick="toggleLanguage()">DE</button>
</div>
<div class="container">
<header>
<div class="brand">Miau<span>Inv</span></div>
<p class="tagline" lang="en">A secure, light-weight, and minimalistic management system for tracking your inventory stock and project allocations.</p>
<p class="tagline" lang="de">Ein sicheres, pfeilschnelles und minimalistisches System für deine Lagerbestände und Projekt-Zuweisungen.</p>
<div class="badge-container">
<span class="badge">Go Backend</span>
<span class="badge">HTMX Frontend</span>
<span class="badge">SQLite Inside</span>
<span class="badge green">Docker Ready</span>
</div>
</header>
<div class="grid">
<div class="card">
<h2 lang="en">Why MiauInv?</h2>
<h2 lang="de">Warum MiauInv?</h2>
<ul class="features-list">
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Zero JS-Overhead:</strong><strong lang="de">Kein JS-Overhead:</strong>
<span lang="en"> Powered by HTMX for an ultra-reactive, dynamic SPA experience without heavy client-side bundles.</span>
<span lang="de"> Dank HTMX extrem reaktiv und dynamisch wie eine SPA, aber ohne tonnenweise schweres JavaScript.</span>
</span>
</li>
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Live Quantities:</strong><strong lang="de">Live-Verfügbarkeit:</strong>
<span lang="en"> See overall totals, allocated project metrics, and remaining available counts instantly.</span>
<span lang="de"> Siehst sofort den Gesamtbestand, was in Projekten verplant ist und was noch frei zur Verfügung steht.</span>
</span>
</li>
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Unified Dashboards:</strong><strong lang="de">Orte & Projekte im Blick:</strong>
<span lang="en"> Drill straight down into site boundaries or project lists to inspect stock components with one click.</span>
<span lang="de"> Klicke auf Lagerorte oder aktive Projekte im Dashboard, um direkt den aktuellen Inhalt einzusehen.</span>
</span>
</li>
</ul>
</div>
<div class="card">
<h2 lang="en">Security & Tech Stack</h2>
<h2 lang="de">Sicherheit & Tech</h2>
<ul class="features-list">
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Dual-Token Security:</strong><strong lang="de">Dual-Token Security:</strong>
<span lang="en"> Uses robust cryptographically signed JWT cookies combined with seamless backend refresh token rotations.</span>
<span lang="de"> Zugriffsschutz über sichere, verschlüsselte JWT-Cookies inkl. Refresh-Token-Rotation.</span>
</span>
</li>
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Native TLS Listener:</strong><strong lang="de">Native TLS Listener:</strong>
<span lang="en"> Out-of-the-box enforced HTTPS transport layer security powered directly by Go's core networking.</span>
<span lang="de"> Enforcierte HTTPS-Verschlüsselung direkt aus dem Go-Core heraus.</span>
</span>
</li>
<li>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>
<span>
<strong lang="en">Ultra Lightweight:</strong><strong lang="de">Leichtgewicht:</strong>
<span lang="en"> Runs on absolute minimal system footprint resource margins using an embedded optimized SQLite model.</span>
<span lang="de"> Minimaler Ressourcenverbrauch dank kompilierter Go-Binary und eingebetteter SQLite-Datenbank.</span>
</span>
</li>
</ul>
</div>
</div>
<div class="install-section">
<h2 lang="en">Install Your Server</h2>
<h2 lang="de">Server installieren</h2>
<p lang="en">Deploy MiauInv to your server infrastructure or local machine in less than a minute utilizing our streamlined container configurations.</p>
<p lang="de">MiauInv lässt sich dank vorkonfiguriertem Docker-Setup innerhalb weniger Sekunden auf deinem Server oder lokalen System starten.</p>
<div class="step">
<h3>
<span class="step-num">1</span>
<span lang="en">Setup Asset Directories and TLS</span>
<span lang="de">Ordner und Zertifikate vorbereiten</span>
</h3>
<p lang="en">Since MiauInv natively enforces encrypted communication channels, initialize your tracking asset directory and place your TLS files there (or generate a self-signed keypair for testing targets):</p>
<p lang="de">Da MiauInv standardmäßig verschlüsseltes HTTPS erzwingt, erstelle den Appdata-Ordner und lege deine SSL-Zertifikate ab (oder generiere ein Self-Signed Cert für Tests):</p>
<pre>mkdir -p appdata
openssl req -x509 -newkey rsa:4096 -keyout appdata/key.pem -out appdata/cert.pem -sha256 -days 365 -nodes -subj "/CN=localhost"</pre>
</div>
<div class="step">
<h3>
<span class="step-num">2</span>
<span lang="en">Configure Docker Compose</span>
<span lang="de">Docker Compose Datei anlegen</span>
</h3>
<p lang="en">Create a <code>docker-compose.yaml</code> layout file in the parent folder, and inject the orchestration service blocks. Ensure your <code>JWT_SECRET</code> environment variable meets the minimum security threshold length requirements:</p>
<p lang="de">Erstelle eine <code>docker-compose.yaml</code> im selben Verzeichnis und füge folgenden Inhalt ein. Passe den <code>JWT_SECRET</code>-String an:</p>
<pre>services:
miauinv:
image: git.miaurizius.de/miaurizius/miauinv:latest
container_name: MiauInv
restart: unless-stopped
ports:
- "8080:8080"
environment:
- JWT_SECRET=SECURE_RANDOM_STRING_HERE_MIN_32_CHARS
volumes:
- ./appdata:/appdata</pre>
</div>
<div class="step">
<h3>
<span class="step-num">3</span>
<span lang="en">Boot the Environment</span>
<span lang="de">Container starten</span>
</h3>
<p lang="en">Fire up the production background workers by executing your infrastructure controls daemon tasks:</p>
<p lang="de">Führe nun einfach den Start-Befehl aus, um das Image zu ziehen und im Hintergrund zu starten:</p>
<pre>docker-compose up -d</pre>
<p style="margin-top: 0.5rem;" lang="en">Everything is set up! Your instance is running and fully accessible at <strong>https://localhost:8080</strong>.</p>
<p style="margin-top: 0.5rem;" lang="de">Fertig! Dein Server läuft und ist sofort unter <strong>https://localhost:8080</strong> erreichbar.</p>
</div>
</div>
<footer>
<p lang="en">&copy; 2026 Maurice Larivière.</p>
<p lang="de">&copy; 2026 Maurice Larivière.</p>
</footer>
</div>
<script>
function setLanguage(lang) {
document.documentElement.setAttribute('data-lang', lang);
document.getElementById('langBtn').innerText = lang === 'en' ? 'DE' : 'EN';
localStorage.setItem('miauinv-lang', lang);
}
function toggleLanguage() {
const currentLang = document.documentElement.getAttribute('data-lang') || 'en';
const nextLang = currentLang === 'en' ? 'de' : 'en';
setLanguage(nextLang);
}
const savedLang = localStorage.getItem('miauinv-lang') || 'en';
setLanguage(savedLang);
</script>
</body>
</html>

View File

@@ -10,27 +10,58 @@ type contextKey string
const UserContextKey contextKey = contextKey("user")
// middleware.go
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
if r.URL.Path == "/login" || r.URL.Path == "/register" || r.URL.Path == "/" {
next.ServeHTTP(w, r)
return
}
tokenStr := strings.TrimPrefix(authHeader, "Bearer ")
tokenStr := ""
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenStr == "" {
cookie, err := r.Cookie("access_token")
if err == nil {
tokenStr = cookie.Value
}
}
if tokenStr == "" {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "Missing token", http.StatusUnauthorized)
} else {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return
}
claims, err := ValidateJWT(tokenStr, secret)
if err != nil {
if strings.HasPrefix(r.URL.Path, "/api/") {
http.Error(w, "Invalid token", http.StatusUnauthorized)
} else {
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: false,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
return
}
ctx := context.WithValue(r.Context(), UserContextKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

View File

@@ -13,6 +13,7 @@ type Config struct {
DatabasePath string `yaml:"database_path"`
CertificatePath string `yaml:"certificate_path"`
PrivateKeyPath string `yaml:"private_key_path"`
AllowRegistration bool `yaml:"allow_registration"`
}
const configPath = "./appdata/config.yaml"
@@ -38,6 +39,7 @@ func CheckIfExists() error {
DatabasePath: "./appdata/database.db",
CertificatePath: "./appdata/cert.pem",
PrivateKeyPath: "./appdata/key.pem",
AllowRegistration: true,
}
data, err := yaml.Marshal(defaultConfig)

5
deploy.sh Executable file
View File

@@ -0,0 +1,5 @@
sudo docker buildx build \
--platform linux/amd64,linux/arm64 \
-t git.miaurizius.de/miaurizius/miauinv:latest \
-t git.miaurizius.de/miaurizius/miauinv:v1.0.2 \
--push .

51
docs/AUTHENTICATION.md Normal file
View File

@@ -0,0 +1,51 @@
# Authentication Architecture
MiauInv implements a stateless JSON Web Token (JWT) architecture combined with a persistent database-backed Refresh Token mechanism to provide high security alongside seamless session retention.
## Token Lifetime and Properties
| Token Type | Transport Vector | Storage Location | Lifetime | Purpose |
| --- | --- | --- | --- | --- |
| **Access Token** | HTTP-Only Cookie & Auth Header | Memory / Browser Cookies | 15 Minutes | Signed payload validating current session identity for immediate API interaction. |
| **Refresh Token** | Secure Cookie & JSON Payload | LocalStorage / Secure Cookies | 7 Days | Long-lived high-entropy string used to request a new token pair when the Access Token expires. |
---
## Token Rotation and Flow
The application coordinates token validation through cooperative interactions between Go authentication middlewares and the frontend runtime environment.
### 1. Normal Authenticated Requests
During standard interaction loops, the Go server intercepts requests via auth middleware. It checks the incoming context for validity in the following order:
1. `Authorization: Bearer <token>` request header.
2. `access_token` cookie values.
If a valid, unexpired Access Token is recovered, the middleware parses the claims (ID, username, role) and injects them into the request context before execution routes fire.
### 2. Token Refresh Flow
When an Access Token expires mid-session, the following workflow occurs automatically:
1. The backend rejects an API call or routing intent with an HTTP state indicating token expiration.
2. The frontend execution scope identifies the expiration status and reads the `refresh_token` from storage assets.
3. The client submits a POST request containing the token payload to `/api/refresh`.
4. The backend verifies the signature, looks up the hash inside the `refresh_tokens` table, and verifies that `revoked == 0` and `expires_at > now`.
5. If the validation succeeds, a brand-new Access Token and a rotated Refresh Token pair are generated, saved to secure cookies/storage, and the user session continues without explicit re-authentication.
---
## Security Mitigations
### Loop Protection
To prevent broken, expired, or malformed credentials from triggering infinite network refresh loops (which degrade browser performance and strain backend lookup performance), the frontend utilizes an explicit safety lock.
```
Token Expired -> Check 'is_refreshing' flag -> True -> Clear Auth & Force Login
-> False -> Set flag 'true' -> Send Request
```
Before issuing an evaluation request to `/api/refresh`, the application checks a temporary session variable (`is_refreshing` within `sessionStorage`). If the flag is already set to `true`, the loop protection triggers a hard clearance routine via `clearAllAuth()`, drops all token storage records, and routes the user back to the primary login view safely.
### Database Revocation
Refresh sessions can be killed immediately from the server side. When a user requests `/api/logout`, the backend switches the corresponding row state within the `refresh_tokens` database container to `revoked = 1`. Any subsequent rotation requests relying on that token family are automatically dropped, protecting against stolen credential replay attacks.

97
docs/DATABASE.md Normal file
View File

@@ -0,0 +1,97 @@
# Database Documentation
MiauInv utilizes an embedded SQLite database instance for persistent data storage. Foreign key constraints are strictly enforced at the database level.
## Configuration
To ensure data integrity, every database connection initialization explicitly executes the following command before handling queries:
```sql
PRAGMA foreign_keys = ON;
```
---
## Schema Architecture
### Entity-Relationship Summary
The database consists of primary entity tables (`users`, `items`, `locations`, `projects`) and relational junction tables (`stock`, `project_items`, `refresh_tokens`) designed to track stock distribution and access sessions.
```
[users] <--- (1:N) ---> [refresh_tokens]
[items] <--- (1:N) ---> [stock] <--- (N:1) ---> [locations]
[items] <--- (1:N) ---> [project_items] <--- (N:1) ---> [projects]
```
---
## Table Definitions
### 1. users
Stores user credentials and operational roles within the system.
* **id (TEXT, PK):** Unique UUID
* **username (TEXT, Unique):** Unique account identifier.
* **password (TEXT):** Hashed user password.
* **role (TEXT):** Access control flag (e.g., admin, user).
### 2. refresh_tokens
Tracks valid extended sessions linked to specific user accounts.
* **id (TEXT, PK):** Unique identifier.
* **user_id (TEXT, FK):** References `users(id)`.
* **token_hash (TEXT):** Cryptographic hash of the active refresh token.
* **expires_at (INTEGER):** Unix timestamp indicating token expiration.
* **created_at (INTEGER):** Unix timestamp indicating session creation.
* **revoked (INTEGER):** Boolean flag (0 or 1) indicating if the session was manually invalidated.
* **device_info (TEXT, Optional):** Client metadata for auditing.
### 3. items
Represents individual tracked assets.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT):** Asset designation.
* **category (TEXT, Optional):** Grouping classification.
* **description (TEXT, Optional):** Detailed asset context.
* **total_quantity (INTEGER):** Absolute global stock baseline counter.
### 4. locations
Defines logical or physical facilities.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT, Unique):** Unique facility naming constraint.
### 5. projects
Defines distinct tasks or allocation targets.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **name (TEXT, Unique):** Unique operational tracking name.
* **description (TEXT, Optional):** Scope description.
### 6. stock
Junction table mapping physical asset distributions across facilities.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **item_id (INTEGER, FK):** References `items(id)`.
* **location_id (INTEGER, FK):** References `locations(id)`.
* **quantity (INTEGER):** Specific quantity present at this location node.
### 7. project_items
Junction table tracking asset assignments dedicated to specific ongoing project environments.
* **id (INTEGER, PK, Autoincrement):** Primary key.
* **item_id (INTEGER, FK):** References `items(id)`.
* **project_id (INTEGER, FK):** References `projects(id)`.
* **quantity (INTEGER):** Quantity allocated to this project context.
---
## Data Integrity Constraints
* **Foreign Keys:** Because standard `ON DELETE` cascades are not defined explicitly in the schema rules, SQLite blocks parent deletion actions if dependent rows exist in `stock` or `project_items`. You must clear out stock allocations and project associations manually before deleting an item, location, or project.
* **Uniqueness:** String uniqueness constraints protect against duplicate namespace registration on `users(username)`, `locations(name)`, and `projects(name)`.

BIN
docs/img/dashboard.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

BIN
docs/img/inventory.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
docs/img/locations.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

BIN
docs/img/projects.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

View File

@@ -0,0 +1,344 @@
.dashboard-layout {
justify-content: flex-start;
align-items: stretch;
padding: 0;
}
main {
max-width: 1400px;
width: 100%;
margin: 0 auto;
padding: 2rem;
flex: 1;
}
.page-header h1 {
font-size: 2.2rem;
font-weight: 700;
letter-spacing: -0.03em;
margin-bottom: 2rem;
color: var(--text);
}
header {
width: 100%;
border-bottom: 1px solid var(--border);
background: rgba(31, 41, 55, 0.8);
backdrop-filter: blur(12px);
position: sticky;
top: 0;
z-index: 50;
}
.nav-container {
max-width: 1400px;
margin: 0 auto;
padding: 0 2rem;
height: 4.5rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-left {
display: flex;
align-items: center;
gap: 3rem;
height: 100%;
}
.brand {
font-weight: 800;
font-size: 1.4rem;
letter-spacing: -0.03em;
}
.brand span {
color: var(--accent);
}
nav {
display: flex;
gap: 1.5rem;
height: 100%;
}
nav a {
color: var(--text-muted);
text-decoration: none;
font-size: 0.95rem;
font-weight: 500;
display: inline-flex;
align-items: center;
border-bottom: 2px solid transparent;
padding: 0 0.25rem;
transition: color 0.2s ease, border-color 0.2s ease;
}
nav a:hover, nav a.active {
color: var(--text);
border-color: var(--accent);
}
.profile-dropdown {
position: relative;
display: flex;
align-items: center;
height: 100%;
}
.profile-trigger {
background: none;
border: none;
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text);
cursor: pointer;
padding: 0.5rem;
border-radius: 8px;
transition: background-color 0.2s ease;
}
.profile-trigger:hover {
background-color: rgba(255, 255, 255, 0.05);
}
.avatar {
width: 2.2rem;
height: 2.2rem;
background-color: var(--accent);
color: #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.9rem;
}
.username {
font-size: 0.95rem;
font-weight: 500;
display: none;
}
@media (min-width: 640px) {
.username {
display: block;
}
}
.dropdown-menu {
position: absolute;
top: calc(100% - 0.5rem);
right: 0;
width: 220px;
background: #1f2937;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
display: none;
flex-direction: column;
padding: 0.5rem 0;
z-index: 100;
}
.profile-dropdown:hover .dropdown-menu,
.dropdown-menu.show {
display: flex;
}
.dropdown-menu a, .logout-btn {
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.95rem;
color: var(--text-muted);
text-decoration: none;
text-align: left;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
box-sizing: border-box;
display: flex;
align-items: center;
gap: 0.5rem;
}
.dropdown-menu a:hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--text);
}
.dropdown-divider {
border: 0;
border-top: 1px solid var(--border);
margin: 0.5rem 0;
}
.logout-btn {
color: #ef4444;
font-weight: 500;
}
.logout-btn:hover {
background-color: rgba(239, 68, 68, 0.1);
color: #f87171;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 1.75rem;
display: flex;
align-items: center;
gap: 1.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.2);
}
.stat-icon {
width: 3.5rem;
height: 3.5rem;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.stat-info h2 {
font-size: 2rem;
font-weight: 700;
letter-spacing: -0.03em;
margin: 0;
color: var(--text);
line-height: 1.2;
}
.stat-info p {
color: var(--text-muted);
font-size: 0.9rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0 0 0.25rem 0;
}
.action-bar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.search-input {
max-width: 320px;
}
.table-container {
width: 100%;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.95rem;
}
th {
background: #111827;
color: var(--text-muted);
font-weight: 600;
padding: 1.25rem 1.5rem;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
border-bottom: 1px solid var(--border);
}
td {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--border);
color: var(--text);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.02);
}
#modal {
position: fixed;
z-index: 100;
}
@media (max-width: 768px) {
.menu-trigger {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border);
color: var(--text);
padding: 0.5rem;
border-radius: 8px;
cursor: pointer;
}
#main-nav {
display: none;
flex-direction: column;
position: absolute;
top: 4.5rem;
left: 0;
width: 100%;
background: rgba(17, 24, 39, 0.95);
backdrop-filter: blur(12px);
padding: 1rem;
z-index: 1000;
border-bottom: 1px solid var(--border);
}
#main-nav.show {
display: flex;
}
#main-nav a {
padding: 1rem;
border-bottom: 1px solid var(--border);
}
#main-nav a:last-child {
border-bottom: none;
}
}
@media (min-width: 769px) {
.menu-trigger {
display: none;
}
}

View File

@@ -0,0 +1,14 @@
/* error404.css */
.error-code {
font-size: 6rem;
font-weight: 800;
color: var(--accent);
margin: 0;
line-height: 1;
}
@media(max-width:768px){
.error-code {
font-size: 4.5rem;
}
}

View File

@@ -0,0 +1,16 @@
/* home.css */
.brand-title {
font-size: 2.25rem !important;
font-weight: 800 !important;
letter-spacing: -0.04em !important;
}
.brand-title span {
color: var(--accent);
}
.home-actions {
display: flex;
flex-direction: column;
gap: 0.85rem;
}

View File

@@ -0,0 +1,108 @@
:root {
--bg: #111827;
--card: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
}
body:not(.dashboard-layout) {
justify-content: center;
align-items: center;
padding: 1rem;
}
.card {
width: 100%;
max-width: 400px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
text-align: center;
}
.card h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.card .subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin-bottom: 2rem;
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0.85rem;
font-size: 1rem;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn-secondary {
background: #1f2937;
color: white;
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--accent);
border-color: var(--accent);
}
.footer-text {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.message {
margin-top: 1.25rem;
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
display: block;
}

View File

@@ -0,0 +1,285 @@
:root {
--bg: #111827;
--card: #1f2937;
--border: #374151;
--text: #f9fafb;
--text-muted: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #10b981;
--error: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
flex-direction: column;
}
body:not(.dashboard-layout) {
justify-content: center;
align-items: center;
padding: 1rem;
}
.card {
width: 100%;
max-width: 400px;
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2.5rem 2rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
text-align: center;
}
.card h1 {
margin: 0 0 0.5rem 0;
font-size: 1.75rem;
font-weight: 700;
letter-spacing: -0.025em;
}
.card .subtitle {
color: var(--text-muted);
font-size: 0.95rem;
margin-bottom: 2rem;
}
.btn {
display: inline-flex;
justify-content: center;
align-items: center;
width: 100%;
padding: 0.85rem;
font-size: 1rem;
font-weight: 600;
border-radius: 10px;
border: none;
cursor: pointer;
text-decoration: none;
transition: background-color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.btn:active {
transform: scale(0.98);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: #1f2937;
color: white;
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--accent);
border-color: var(--accent);
}
.form-group {
margin-bottom: 1.25rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
input {
width: 100%;
background: #111827;
color: white;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.85rem 1rem;
font-size: 1rem;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.15);
}
input::placeholder {
color: #4b5563;
}
.footer-text {
margin-top: 1.5rem;
font-size: 0.9rem;
color: var(--text-muted);
}
.footer-text a {
color: var(--accent);
text-decoration: none;
}
.footer-text a:hover {
text-decoration: underline;
}
.message {
display: none;
margin-top: 1.25rem;
padding: 0.85rem 1rem;
border-radius: 10px;
font-size: 0.9rem;
line-height: 1.4;
text-align: left;
}
.message.error {
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error);
}
.message.success {
background: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: var(--success);
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(17, 24, 39, 0.7);
backdrop-filter: blur(4px);
display: none;
justify-content: center;
align-items: center;
z-index: 1000;
}
.modal.show {
display: flex;
}
.modal-content {
background: var(--card);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
width: 90%;
max-width: 500px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3);
position: relative;
transform: translateY(20px);
opacity: 0;
animation: modalSlideIn 0.3s forwards ease-out;
}
@keyframes modalSlideIn {
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
font-size: 1.5rem;
}
.button-group {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.danger-btn {
background: rgba(239, 68, 68, 0.1) !important;
color: var(--error) !important;
border: 1px solid rgba(239, 68, 68, 0.2) !important;
}
.danger-btn:hover {
background: var(--error) !important;
color: white !important;
}
.table-loader {
padding: 3rem;
text-align: center;
color: var(--text-muted);
}
.modal-split {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
margin-top: 1.5rem;
}
@media (min-width: 768px) {
.modal-split {
grid-template-columns: 1fr 1fr;
}
}
.modal-content.large {
max-width: 800px;
}
.inner-table {
width: 100%;
margin-top: 1rem;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.inner-table th, .inner-table td {
padding: 0.75rem 1rem;
font-size: 0.85rem;
}
.inner-table th {
background: #111827;
}
.badge {
background: rgba(255, 255, 255, 0.05);
padding: 0.25rem 0.6rem;
border-radius: 999px;
font-size: 0.8rem;
color: var(--text-muted);
border: 1px solid var(--border);
}
.badge.success {
background: rgba(16, 185, 129, 0.1);
color: var(--success);
border-color: rgba(16, 185, 129, 0.2);
}

View File

@@ -0,0 +1,24 @@
.construction-icon-wrapper {
display: inline-flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
background: rgba(59, 130, 246, 0.1); /* Nutzt die var(--accent) Transparenz */
border: 1px solid rgba(59, 130, 246, 0.2);
border-radius: 50%;
margin-bottom: 1.5rem;
}
.gear-icon {
animation: gearRotate 8s linear infinite;
}
@keyframes gearRotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

484
frontend/assets/js/api.js Normal file
View File

@@ -0,0 +1,484 @@
// api.js
document.addEventListener("DOMContentLoaded", () => {
const profileBtn = document.getElementById('profile-btn');
const dropdownMenu = document.getElementById('dropdown-menu');
const menuBtn = document.getElementById('menu-btn');
const mainNav = document.getElementById('main-nav');
const logoutBtn = document.getElementById('logout-btn');
if (profileBtn && dropdownMenu) {
profileBtn.addEventListener('click', (e) => {
e.stopPropagation();
dropdownMenu.classList.toggle('show');
if (mainNav) mainNav.classList.remove('show');
});
}
if (menuBtn && mainNav) {
menuBtn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
mainNav.classList.toggle('show');
if (dropdownMenu) dropdownMenu.classList.remove('show');
});
}
logoutBtn.addEventListener('click', () => {
console.log("Logout")
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC;";
});
document.addEventListener('click', () => {
if (dropdownMenu) dropdownMenu.classList.remove('show');
if (mainNav) mainNav.classList.remove('show');
});
if (document.getElementById('items-table-body')) loadItems();
if (document.getElementById('locations-table-body')) loadLocations();
if (document.getElementById('projects-table-body')) loadProjects();
loadProfile();
});
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
async function apiRequest(endpoint, method = 'GET', body = null) {
const options = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (body) options.body = JSON.stringify(body);
try {
const response = await fetch(endpoint, options);
if (!response.ok && response.status !== 204) {
const errText = await response.text();
throw new Error(errText || `HTTP Error ${response.status}`);
}
if (response.status === 204) return null;
return await response.json();
} catch (err) {
alert("Action failed: " + err.message);
throw err;
}
}
// ---- ITEMS ----
async function loadItems() {
const data = await apiRequest('/api/item');
const tbody = document.getElementById('items-table-body');
tbody.innerHTML = '';
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align: center;">No items found.</td></tr>';
return;
}
data.items.forEach(item => {
tbody.innerHTML += `
<tr>
<td style="font-weight: 600;">${item.name}</td>
<td><span class="badge">${item.category}</span></td>
<td style="font-family: monospace; font-size: 1.05rem;">${item.total_quantity || 0}</td>
<td style="text-align: right;">
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem; border-color: var(--success); color: var(--success);" onclick='openStockModal(${JSON.stringify(item).replace(/'/g, "&#39;")})'>Stock</button>
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick='editItem(${JSON.stringify(item).replace(/'/g, "&#39;")})'>Edit</button>
<button class="btn btn-secondary danger-btn" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick="deleteItem(${item.id})">Del</button>
</td>
</tr>
`;
});
}
function openItemModal() {
document.getElementById('item-form').reset();
document.getElementById('item-id').value = '';
document.getElementById('item-modal-title').innerText = 'New Item';
document.getElementById('item-modal').classList.add('show');
}
function editItem(item) {
document.getElementById('item-id').value = item.id;
document.getElementById('item-name').value = item.name;
document.getElementById('item-category').value = item.category;
document.getElementById('item-desc').value = item.description;
//document.getElementById('item-qty').value = item.total_quantity;
document.getElementById('item-modal-title').innerText = 'Edit Item';
document.getElementById('item-modal').classList.add('show');
}
async function saveItem(event) {
event.preventDefault();
const id = document.getElementById('item-id').value;
const payload = {
name: document.getElementById('item-name').value,
category: document.getElementById('item-category').value,
description: document.getElementById('item-desc').value,
//total_quantity: parseInt(document.getElementById('item-qty').value, 10)
};
const method = id ? 'PUT' : 'POST';
const endpoint = id ? `/api/item?id=${id}` : '/api/item';
await apiRequest(endpoint, method, payload);
closeModal('item-modal');
loadItems();
}
async function deleteItem(id) {
if (confirm("Are you sure you want to delete this item?")) {
await apiRequest(`/api/item?id=${id}`, 'DELETE');
loadItems();
}
}
// ---- LOCATIONS ----
async function loadLocations() {
const data = await apiRequest('/api/location');
const tbody = document.getElementById('locations-table-body');
tbody.innerHTML = '';
if (!data.locations || data.locations.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" style="text-align: center;">No locations found.</td></tr>';
return;
}
data.locations.forEach(loc => {
tbody.innerHTML += `
<tr>
<td style="font-weight: 600;">${loc.name}</td>
<td style="text-align: right;">
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick="editLocation(${loc.id}, '${loc.name}')">Edit</button>
<button class="btn btn-secondary danger-btn" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick="deleteLocation(${loc.id})">Delete</button>
</td>
</tr>
`;
});
}
function openLocationModal() {
document.getElementById('location-form').reset();
document.getElementById('location-id').value = '';
document.getElementById('location-modal-title').innerText = 'New Location';
document.getElementById('location-modal').classList.add('show');
}
function editLocation(id, name) {
document.getElementById('location-id').value = id;
document.getElementById('location-name').value = name;
document.getElementById('location-modal-title').innerText = 'Edit Location';
document.getElementById('location-modal').classList.add('show');
}
async function saveLocation(event) {
event.preventDefault();
const id = document.getElementById('location-id').value;
const payload = { name: document.getElementById('location-name').value };
const method = id ? 'PUT' : 'POST';
const endpoint = id ? `/api/location?id=${id}` : '/api/location';
await apiRequest(endpoint, method, payload);
closeModal('location-modal');
loadLocations();
}
async function deleteLocation(id) {
if (confirm("Are you sure you want to delete this location?")) {
await apiRequest(`/api/location?id=${id}`, 'DELETE');
loadLocations();
}
}
// ---- PROJECTS ----
async function loadProjects() {
const data = await apiRequest('/api/project');
const tbody = document.getElementById('projects-table-body');
tbody.innerHTML = '';
if (!data.projects || data.projects.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">No projects found.</td></tr>';
return;
}
data.projects.forEach(proj => {
tbody.innerHTML += `
<tr>
<td style="font-weight: 600;">${proj.name}</td>
<td style="color: var(--text-muted);">${proj.description}</td>
<td style="text-align: right;">
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem; border-color: var(--accent); color: var(--accent);" onclick='openAssociationModal(${JSON.stringify(proj).replace(/'/g, "&#39;")})'>Items</button>
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick='editProject(${JSON.stringify(proj).replace(/'/g, "&#39;")})'>Edit</button>
<button class="btn btn-secondary danger-btn" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;" onclick="deleteProject(${proj.id})">Del</button>
</td>
</tr>
`;
});
}
function openProjectModal() {
document.getElementById('project-form').reset();
document.getElementById('project-id').value = '';
document.getElementById('project-modal-title').innerText = 'New Project';
document.getElementById('project-modal').classList.add('show');
}
function editProject(proj) {
document.getElementById('project-id').value = proj.id;
document.getElementById('project-name').value = proj.name;
document.getElementById('project-desc').value = proj.description;
document.getElementById('project-modal-title').innerText = 'Edit Project';
document.getElementById('project-modal').classList.add('show');
}
async function saveProject(event) {
event.preventDefault();
const id = document.getElementById('project-id').value;
const payload = {
name: document.getElementById('project-name').value,
description: document.getElementById('project-desc').value
};
const method = id ? 'PUT' : 'POST';
const endpoint = id ? `/api/project?id=${id}` : '/api/project';
await apiRequest(endpoint, method, payload);
closeModal('project-modal');
loadProjects();
}
async function deleteProject(id) {
if (confirm("Are you sure you want to delete this project?")) {
await apiRequest(`/api/project?id=${id}`, 'DELETE');
loadProjects();
}
}
// ---- STOCK ----
async function openStockModal(item) {
document.getElementById('stock-modal-title').innerText = `Stock: ${item.name}`;
document.getElementById('stock-item-id').value = item.id;
document.getElementById('stock-qty').value = '';
document.getElementById('stock-modal').classList.add('show');
await reloadStockTable(item.id);
const locData = await apiRequest('/api/location');
const locSelect = document.getElementById('stock-location');
locSelect.innerHTML = '<option value="">Select Location...</option>';
if (locData.locations) {
locData.locations.forEach(loc => {
locSelect.innerHTML += `<option value="${loc.id}">${loc.name}</option>`;
});
}
}
async function reloadStockTable(itemId) {
const tbody = document.getElementById('stock-table-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Loading...</td></tr>';
try {
const data = await apiRequest(`/api/stock?item_id=${itemId}`);
tbody.innerHTML = '';
if (!data.stock || data.stock.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">No stock entries.</td></tr>';
return;
}
for (const st of data.stock) {
const locData = await apiRequest(`/api/location?id=${st.location_id}`);
tbody.innerHTML += `
<tr>
<td>${locData.name || `Location #${st.location_id}`}</td>
<td><span class="badge success">${st.quantity}</span></td>
<td style="text-align: right;">
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteStock(${st.id}, ${itemId})">Del</button>
</td>
</tr>
`;
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
}
}
async function saveStock(event) {
event.preventDefault();
const itemId = document.getElementById('stock-item-id').value;
const payload = {
item_id: parseInt(itemId, 10),
location_id: parseInt(document.getElementById('stock-location').value, 10),
quantity: parseInt(document.getElementById('stock-qty').value, 10)
};
await apiRequest('/api/stock', 'POST', payload);
document.getElementById('stock-qty').value = '';
await reloadStockTable(itemId);
loadItems();
}
async function deleteStock(stockId, itemId) {
if (confirm("Remove this stock entry?")) {
await apiRequest(`/api/stock?id=${stockId}`, 'DELETE');
await reloadStockTable(itemId);
loadItems();
}
}
// ---- ASSOCIATIONS ----
async function openAssociationModal(project) {
document.getElementById('association-modal-title').innerText = `Items for: ${project.name}`;
document.getElementById('assoc-project-id').value = project.id;
document.getElementById('assoc-qty').value = '';
document.getElementById('association-modal').classList.add('show');
await reloadAssociationTable(project.id);
const itemData = await apiRequest('/api/item');
const itemSelect = document.getElementById('assoc-item');
itemSelect.innerHTML = '<option value="">Select Item...</option>';
if (itemData.items) {
itemData.items.forEach(it => {
itemSelect.innerHTML += `<option value="${it.id}">${it.name} (Avail: ${it.total_quantity})</option>`;
});
}
}
async function reloadAssociationTable(projectId) {
const tbody = document.getElementById('association-table-body');
tbody.innerHTML = '<tr><td colspan="3" style="text-align:center;">Loading...</td></tr>';
try {
const data = await apiRequest(`/api/association?project_id=${projectId}`);
tbody.innerHTML = '';
if (!data.associations || data.associations.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">No allocated items.</td></tr>';
return;
}
for (const asc of data.associations) {
const itemData = await apiRequest(`/api/item?id=${asc.item_id}`);
console.log(itemData)
tbody.innerHTML += `
<tr>
<td>${itemData.name || `Item #${asc.item_id}`}</td>
<td><span class="badge success">${asc.quantity}</span></td>
<td style="text-align: right;">
<button class="btn btn-secondary danger-btn" style="padding: 0.2rem 0.5rem; font-size: 0.75rem;" onclick="deleteAssociation(${asc.id}, ${projectId})">Del</button>
</td>
</tr>
`;
}
} catch (e) {
tbody.innerHTML = '<tr><td colspan="3" style="text-align: center;">Failed to load.</td></tr>';
}
}
async function saveAssociation(event) {
event.preventDefault();
const projectId = document.getElementById('assoc-project-id').value;
const payload = {
project_id: parseInt(projectId, 10),
item_id: parseInt(document.getElementById('assoc-item').value, 10),
quantity: parseInt(document.getElementById('assoc-qty').value, 10)
};
await apiRequest('/api/association', 'POST', payload);
document.getElementById('assoc-qty').value = '';
await reloadAssociationTable(projectId);
}
async function deleteAssociation(assocId, projectId) {
if (confirm("Remove this item from the project?")) {
await apiRequest(`/api/association?id=${assocId}`, 'DELETE');
await reloadAssociationTable(projectId);
}
}
async function handleDashboardView() {
const locTbody = document.getElementById('dash-locations-body');
const projTbody = document.getElementById('dash-projects-body');
if (!locTbody && !projTbody) return;
try {
const locData = await apiRequest('/api/location');
if (locTbody && locData && locData.locations) {
locTbody.innerHTML = '';
if (locData.locations.length === 0) {
locTbody.innerHTML = '<tr><td style="color: var(--text-muted); padding: 1rem;">No locations found.</td></tr>';
} else {
locData.locations.forEach(loc => {
const tr = document.createElement('tr');
tr.innerHTML = `<td style="cursor: pointer; color: var(--accent); font-weight: 500; padding: 0.75rem 1rem;">${loc.name}</td>`;
tr.onclick = () => openDashboardModal(`/api/location?id=${loc.id}&content=true`, `Items in ${loc.name}`, 'contents');
locTbody.appendChild(tr);
});
}
}
const projData = await apiRequest('/api/project');
if (projTbody && projData && projData.projects) {
projTbody.innerHTML = '';
if (projData.projects.length === 0) {
projTbody.innerHTML = '<tr><td style="color: var(--text-muted); padding: 1rem;">No projects found.</td></tr>';
} else {
projData.projects.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `<td style="cursor: pointer; color: var(--accent); font-weight: 500; padding: 0.75rem 1rem;">${p.name}</td>`;
tr.onclick = () => openDashboardModal(`/api/project?id=${p.id}&details=true`, `Items assigned to ${p.name}`, 'items');
projTbody.appendChild(tr);
});
}
}
} catch (err) {
console.error(err);
}
}
async function openDashboardModal(url, title, dataKey) {
document.getElementById('dash-modal-title').innerText = title;
const tbody = document.getElementById('dash-modal-body');
if (!tbody) return;
tbody.innerHTML = '<tr><td colspan="2" style="text-align:center; padding:1.5rem; color:var(--text-muted);">Loading...</td></tr>';
document.getElementById('dash-details-modal').classList.add('show');
try {
const data = await apiRequest(url);
tbody.innerHTML = '';
const list = data[dataKey] || [];
if (list.length === 0) {
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--text-muted); text-align:center; padding:1.5rem;">No active items found.</td></tr>';
return;
}
list.forEach(i => {
tbody.innerHTML += `
<tr>
<td style="color:var(--text); padding:0.75rem 1rem;">${i.item_name}</td>
<td style="text-align:right; padding:0.75rem 1rem;"><span class="badge success">${i.quantity}</span></td>
</tr>
`;
});
} catch (e) {
tbody.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}
// ---- PROFILE ----
async function loadProfile() {
const avatar = document.getElementById("avatar");
const username = document.getElementById('username');
try {
const data = await apiRequest('/api/profile');
avatar.innerText = data.username[0].toLocaleUpperCase();
username.innerText = data.username;
} catch (e) {
username.innerHTML = '<tr><td colspan="2" style="color:var(--error); text-align:center; padding:1.5rem;">Failed to load data.</td></tr>';
}
}

128
frontend/assets/js/auth.js Normal file
View File

@@ -0,0 +1,128 @@
// auth.js
(() => {
const currentPath = window.location.pathname;
if (currentPath !== "/" && currentPath !== "/login" && currentPath !== "/register") {
return;
}
function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
return null;
}
function clearAllAuth() {
console.log("Clearing all auth remnants from everywhere...");
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
sessionStorage.removeItem("is_refreshing");
document.cookie = "access_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
document.cookie = "refresh_token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; Secure; SameSite=Lax;";
}
async function tryTokenRefresh(refreshToken) {
if (!refreshToken) return false;
try {
console.log("Sending refresh token to /api/refresh...");
const response = await fetch("/api/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken })
});
if (response.ok) {
const data = await response.json();
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`;
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
return true;
}
} catch (err) {
console.error("Refresh request failed:", err);
}
return false;
}
async function checkAuth() {
const cookieAccessToken = getCookie("access_token");
const cookieRefreshToken = getCookie("refresh_token");
const localAccessToken = localStorage.getItem("access_token");
const localRefreshToken = localStorage.getItem("refresh_token");
const accessToken = cookieAccessToken || localAccessToken;
const refreshToken = cookieRefreshToken || localRefreshToken;
console.log("Auth check started...");
console.log("AccessToken available (Cookie/Local):", !!cookieAccessToken, "/", !!localAccessToken);
console.log("RefreshToken available (Cookie/Local):", !!cookieRefreshToken, "/", !!localRefreshToken);
if (!accessToken && !refreshToken) {
console.log("No tokens found in cookies or localStorage. User is a guest.");
return;
}
if (accessToken) {
try {
console.log("Validating token against /api/userinfo...");
const response = await fetch("/api/userinfo", {
method: "GET",
headers: { "Authorization": `Bearer ${accessToken}` }
});
if (response.ok) {
console.log("Token is perfectly valid!");
sessionStorage.removeItem("is_refreshing");
if (!cookieAccessToken) {
document.cookie = `access_token=${accessToken}; path=/; max-age=900; SameSite=Lax; Secure`;
}
if (!cookieRefreshToken && localRefreshToken) {
document.cookie = `refresh_token=${localRefreshToken}; path=/; max-age=604800; SameSite=Lax; Secure`;
}
console.log("Redirecting to dashboard...");
window.location.href = "/dashboard";
return;
} else {
console.log("Token rejected by /api/userinfo. Status:", response.status);
}
} catch (err) {
console.error("Network error during token verification:", err);
}
}
if (sessionStorage.getItem("is_refreshing") === "true") {
console.warn("Loop protection triggered! Tokens appear to be corrupt. Clearing storage.");
clearAllAuth();
return;
}
if (refreshToken) {
console.log("Access token has expired. Starting refresh process...");
sessionStorage.setItem("is_refreshing", "true");
const refreshSuccessful = await tryTokenRefresh(refreshToken);
if (refreshSuccessful) {
console.log("Refresh successful! Reloading page to apply cookies...");
window.location.reload();
return;
} else {
console.log("Refresh failed. Refresh token has also expired.");
}
} else {
console.log("No refresh token available for recovery.");
}
clearAllAuth();
console.log("Authentication completely failed. User remains on login page.");
}
setTimeout(checkAuth, 50);
})();

View File

@@ -0,0 +1,42 @@
// login.js
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("login-form");
const errorBox = document.getElementById("error");
if (!form) return;
form.addEventListener("submit", async (e) => {
e.preventDefault();
errorBox.style.display = "none";
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try {
const response = await fetch("/api/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const text = await response.text();
throw new Error(text);
}
const data = await response.json();
localStorage.setItem("access_token", data.access_token);
localStorage.setItem("refresh_token", data.refresh_token);
document.cookie = `access_token=${data.access_token}; path=/; max-age=900; SameSite=Lax; Secure`;
document.cookie = `refresh_token=${data.refresh_token}; path=/; max-age=604800; SameSite=Lax; Secure`;
window.location.href = "/dashboard";
} catch (err) {
errorBox.textContent = err.message || "Login failed.";
errorBox.style.display = "block";
}
});
});

View File

@@ -0,0 +1,43 @@
// register.js
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("register-form");
const msgBox = document.getElementById("message");
form.addEventListener("submit", async (e) => {
e.preventDefault();
msgBox.style.display = "none";
msgBox.className = "message";
const username = document.getElementById("username").value;
const password = document.getElementById("password").value;
try {
const response = await fetch("/api/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password })
});
if (!response.ok) {
const text = await response.text();
throw new Error(text);
}
msgBox.textContent = "Registration successful! Redirecting...";
msgBox.classList.add("success");
msgBox.style.display = "block";
form.querySelector("button").disabled = true;
setTimeout(() => {
window.location.href = "/login";
}, 1500);
} catch (err) {
msgBox.textContent = err.message;
msgBox.classList.add("error");
msgBox.style.display = "block";
}
});
});

View File

@@ -1,19 +1,192 @@
package frontend
import (
"MiauInv/storage"
"html/template"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/tdewolff/minify/v2"
"github.com/tdewolff/minify/v2/css"
"github.com/tdewolff/minify/v2/js"
)
var dashboard = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/dashboard.html"))
var inventory = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/inventory.html"))
var item = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/itemlist.html"))
var locations = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/locations.html"))
var projects = template.Must(template.ParseFiles(
"frontend/htmx/contents/dash/base.html",
"frontend/htmx/contents/dash/projects.html"))
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
func Home(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.ServeFile(w, r, "frontend/htmx/404.html")
return
}
w.Header().Set("Content-Type", "text/html")
var tmpl = template.Must(template.ParseFiles("frontend/htmx/home.html"))
err := tmpl.Execute(w, struct {
err := home.Execute(w, struct {
Name string
}{
Name: "Miau",
Name: "Home",
})
if err != nil {
return
}
}
func Dashboard(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
var itemHive, projectHive, locationHive int
err := storage.DB.QueryRow("SELECT COUNT(*) FROM items").Scan(&itemHive)
if err != nil {
http.Error(w, "Failed to count items", http.StatusInternalServerError)
return
}
err = storage.DB.QueryRow("SELECT COUNT(*) FROM projects").Scan(&projectHive)
if err != nil {
http.Error(w, "Failed to count projects", http.StatusInternalServerError)
return
}
err = storage.DB.QueryRow("SELECT COUNT(*) FROM locations").Scan(&locationHive)
if err != nil {
http.Error(w, "Failed to count locations", http.StatusInternalServerError)
return
}
err = dashboard.ExecuteTemplate(w, "base.html", struct {
Title string
Stats struct {
Items int
Projects int
Locations int
}
}{
Title: "Dashboard",
Stats: struct {
Items int
Projects int
Locations int
}{
Items: itemHive,
Projects: projectHive,
Locations: locationHive,
},
})
if err != nil {
return
}
}
func Inventory(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := inventory.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Inventory",
})
if err != nil {
return
}
}
func Items(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := item.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Items",
})
if err != nil {
return
}
}
func Locations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := locations.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Locations",
})
if err != nil {
return
}
}
func Projects(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
err := projects.ExecuteTemplate(w, "base.html", struct {
Title string
}{
Title: "Projects",
})
if err != nil {
return
}
}
var minifier *minify.M
func init() {
minifier = minify.New()
// Füge die Minifier für CSS und JS hinzu
minifier.AddFunc("text/css", css.Minify)
minifier.AddFunc("text/javascript", js.Minify)
}
func Assets(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/assets/")
fullPath := filepath.Join("frontend/assets", path)
w.Header().Set("Cache-Control", "public, max-age=3600")
var mimeType string
if strings.HasSuffix(path, ".min.js") {
mimeType = "text/javascript"
fullPath = strings.Replace(fullPath, ".min.js", ".js", 1)
} else if strings.HasSuffix(path, ".min.css") {
mimeType = "text/css"
fullPath = strings.Replace(fullPath, ".min.css", ".css", 1)
}
info, err := os.Stat(fullPath)
if err != nil || info.IsDir() {
http.Error(w, "Asset not found", http.StatusNotFound)
return
}
if mimeType != "" {
content, err := os.ReadFile(fullPath)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
minifiedContent, err := minifier.Bytes(mimeType, content)
if err == nil {
w.Header().Set("Content-Type", mimeType)
w.Write(minifiedContent)
return
}
}
http.ServeFile(w, r, fullPath)
}

23
frontend/htmx/404.html Normal file
View File

@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>404 - Page Not Found | MiauInv</title>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/error404.min.css">
</head>
<body>
<main class="card">
<div class="error-code">404</div>
<h1>Page Not Found</h1>
<p class="subtitle" style="margin-bottom: 2rem;">The page you are looking for does not exist or has been moved to another address.</p>
<a href="/dashboard" class="btn btn-secondary">
Back to Dashboard
</a>
</main>
</body>
</html>

View File

@@ -0,0 +1,57 @@
{{ define "base.html" }}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ .Title }} | MiauInv</title>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<script src="/assets/js/api.min.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/dashboard.min.css">
</head>
<body class="dashboard-layout">
<header>
<div class="nav-container">
<div class="nav-left">
<div class="brand">Miau<span>Inv</span></div>
<button class="menu-trigger" id="menu-btn" aria-label="Menu">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
</button>
<nav id="main-nav">
<a href="/dashboard">Dashboard</a>
<a href="/inventory">Inventory</a>
<a href="/projects">Projects</a>
<a href="/locations">Locations</a>
</nav>
</div>
<div class="profile-dropdown">
<button class="profile-trigger" id="profile-btn">
<div id="avatar" class="avatar">M</div>
<span id="username" class="username">Loading...</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>
</button>
<div class="dropdown-menu" id="dropdown-menu">
<a href="/profile/settings">Account Settings</a>
<a href="/profile/activity">Activity Log</a>
<hr class="dropdown-divider">
<button id="logout-btn" class="logout-btn"
hx-post="/api/logout"
hx-on::after-request="window.location.href='/login'">
Log Out
</button>
</div>
</div>
</div>
</header>
<main>
{{ template "content" . }}
</main>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,110 @@
{{ define "content" }}
<div class="page-header">
<h1>Dashboard Overview</h1>
</div>
<div class="stats-grid" style="margin-bottom: 2rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(59, 130, 246, 0.1); color: var(--accent); padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
</div>
<div class="stat-info">
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Total Items</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Items }}{{ else }}0{{ end }}
</h2>
</div>
</div>
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(16, 185, 129, 0.1); color: var(--success); padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>
</div>
<div class="stat-info">
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Active Projects</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Projects }}{{ else }}0{{ end }}
</h2>
</div>
</div>
<div class="stat-card" style="background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<div class="stat-icon" style="background: rgba(245, 158, 11, 0.1); color: #f59e0b; padding: 0.75rem; border-radius: 10px; display: flex; align-items: center; justify-content: center;">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3"></circle></svg>
</div>
<div class="stat-info">
<p style="color: var(--text-muted); font-size: 0.9rem; margin: 0;">Locations</p>
<h2 style="font-size: 1.5rem; font-weight: 700; margin: 0;">
{{ if .Stats }}{{ .Stats.Locations }}{{ else }}0{{ end }}
</h2>
</div>
</div>
</div>
<div class="modal-split">
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 1rem; color: var(--text);">Locations</h2>
<div class="table-container">
<table class="inner-table" style="margin-top: 0; border-radius: 8px; width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem; background: #111827;">Name</th>
</tr>
</thead>
<tbody id="dash-locations-body">
<tr><td class="table-loader" style="padding: 1.5rem; text-align: center; color: var(--text-muted);">Loading locations...</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card" style="max-width: 100%; text-align: left; padding: 1.5rem;">
<h2 style="font-size: 1.25rem; margin-bottom: 1rem; color: var(--text);">Active Projects</h2>
<div class="table-container">
<table class="inner-table" style="margin-top: 0; border-radius: 8px; width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem; background: #111827;">Project Name</th>
</tr>
</thead>
<tbody id="dash-projects-body">
<tr><td class="table-loader" style="padding: 1.5rem; text-align: center; color: var(--text-muted);">Loading projects...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<div id="dash-details-modal" class="modal">
<div class="modal-content">
<h2 id="dash-modal-title">Details</h2>
<div class="table-container">
<table class="inner-table" style="width: 100%;">
<thead>
<tr>
<th style="text-align: left; padding: 0.75rem 1rem;">Item</th>
<th style="text-align: right; width: 100px; padding: 0.75rem 1rem;">Quantity</th>
</tr>
</thead>
<tbody id="dash-modal-body"></tbody>
</table>
</div>
<div class="button-group">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('dash-details-modal').classList.remove('show')">Close</button>
</div>
</div>
</div>
<script>
if (typeof handleDashboardView === "function") {
handleDashboardView();
}
if (window.htmx) {
htmx.on("htmx:afterOnLoad", function() {
if (typeof handleDashboardView === "function") {
handleDashboardView();
}
});
}
</script>
{{ end }}

View File

@@ -0,0 +1,88 @@
{{ define "content" }}
<div class="page-header action-bar">
<h1>Inventory</h1>
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openItemModal()">
Add Item
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Total Quantity</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="items-table-body">
<tr><td colspan="4" class="table-loader">Loading inventory...</td></tr>
</tbody>
</table>
</div>
<div id="item-modal" class="modal">
<div class="modal-content">
<h2 id="item-modal-title">New Item</h2>
<form id="item-form" onsubmit="saveItem(event)">
<input type="hidden" id="item-id">
<div class="form-group">
<input type="text" id="item-name" placeholder="Item Name" required>
</div>
<div class="form-group">
<input type="text" id="item-category" placeholder="Category" required>
</div>
<div class="form-group">
<input type="text" id="item-desc" placeholder="Description">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Save Item</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('item-modal')">Cancel</button>
</div>
</form>
</div>
</div>
<div id="stock-modal" class="modal">
<div class="modal-content large">
<h2 id="stock-modal-title">Manage Stock</h2>
<div class="modal-split">
<div>
<h3>Current Locations</h3>
<div class="inner-table">
<table>
<thead>
<tr>
<th>Location</th>
<th>Quantity</th>
<th></th>
</tr>
</thead>
<tbody id="stock-table-body">
</tbody>
</table>
</div>
</div>
<div>
<h3>Update Stock</h3>
<form id="stock-form" onsubmit="saveStock(event)" style="margin-top: 1rem;">
<input type="hidden" id="stock-item-id">
<div class="form-group">
<select id="stock-location" class="search-input" style="width: 100%; padding: 0.85rem 1rem; background: #111827; color: white; border: 1px solid var(--border); border-radius: 10px;" required>
<option value="">Select Location...</option>
</select>
</div>
<div class="form-group">
<input type="number" id="stock-qty" placeholder="Quantity to add/set" required>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Update Stock</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('stock-modal')">Done</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,28 @@
<table>
<thead>
<tr>
<th>Name</th>
<th>Category</th>
<th>Total</th>
<th>Free</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody>
{{ range . }}
<tr>
<td style="font-weight: 600;">{{ .Name }}</td>
<td>
<span style="background: rgba(255,255,255,0.05); padding: 0.25rem 0.6rem; border-radius: 999px; font-size: 0.8rem; color: var(--text-muted); border: 1px solid var(--border);">
{{ .Category }}
</span>
</td>
<td style="font-family: monospace; font-size: 1.05rem;">{{ .TotalQuantity }}</td>
<td style="font-family: monospace; font-size: 1.05rem; color: var(--success);">{{ .FreeQuantity }}</td>
<td style="text-align: right;">
<button class="btn btn-secondary" style="width: auto; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Edit</button>
</td>
</tr>
{{ end }}
</tbody>
</table>

View File

@@ -0,0 +1,38 @@
{{ define "content" }}
<div class="page-header action-bar">
<h1>Locations</h1>
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openLocationModal()">
Add Location
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="locations-table-body">
<tr><td colspan="2" class="table-loader">Loading locations...</td></tr>
</tbody>
</table>
</div>
<div id="location-modal" class="modal">
<div class="modal-content">
<h2 id="location-modal-title">New Location</h2>
<form id="location-form" onsubmit="saveLocation(event)">
<input type="hidden" id="location-id">
<div class="form-group">
<input type="text" id="location-name" placeholder="Location Name" required>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Save Location</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('location-modal')">Cancel</button>
</div>
</form>
</div>
</div>
{{ end }}

View File

@@ -0,0 +1,84 @@
{{ define "content" }}
<div class="page-header action-bar">
<h1>Projects</h1>
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openProjectModal()">
New Project
</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody id="projects-table-body">
<tr><td colspan="3" class="table-loader">Loading projects...</td></tr>
</tbody>
</table>
</div>
<div id="project-modal" class="modal">
<div class="modal-content">
<h2 id="project-modal-title">New Project</h2>
<form id="project-form" onsubmit="saveProject(event)">
<input type="hidden" id="project-id">
<div class="form-group">
<input type="text" id="project-name" placeholder="Project Name" required>
</div>
<div class="form-group">
<input type="text" id="project-desc" placeholder="Description">
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Save Project</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('project-modal')">Cancel</button>
</div>
</form>
</div>
</div>
<div id="association-modal" class="modal">
<div class="modal-content large">
<h2 id="association-modal-title">Manage Project Items</h2>
<div class="modal-split">
<div>
<h3>Allocated Items</h3>
<div class="inner-table">
<table>
<thead>
<tr>
<th>Item</th>
<th>Quantity</th>
<th></th>
</tr>
</thead>
<tbody id="association-table-body">
</tbody>
</table>
</div>
</div>
<div>
<h3>Allocate New Item</h3>
<form id="association-form" onsubmit="saveAssociation(event)" style="margin-top: 1rem;">
<input type="hidden" id="assoc-project-id">
<div class="form-group">
<select id="assoc-item" class="search-input" style="width: 100%; padding: 0.85rem 1rem; background: #111827; color: white; border: 1px solid var(--border); border-radius: 10px;" required>
<option value="">Select Item...</option>
</select>
</div>
<div class="form-group">
<input type="number" id="assoc-qty" placeholder="Quantity needed" required>
</div>
<div class="button-group">
<button type="submit" class="btn btn-primary">Allocate</button>
<button type="button" class="btn btn-secondary" onclick="closeModal('association-modal')">Done</button>
</div>
</form>
</div>
</div>
</div>
</div>
{{ end }}

View File

@@ -1,6 +1,26 @@
<!DOCTYPE html>
<html>
<body>
<h1>Hallo, {{.Name}}!</h1>
</body>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MiauInv | Private Instance</title>
<script src="/assets/js/auth.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/home.min.css">
</head>
<body>
<main class="card">
<div class="header">
<h1 class="brand-title">Miau<span>Inv</span></h1>
<p class="subtitle">Private Instance</p>
</div>
<div class="home-actions">
<a href="/login" class="btn btn-primary">Sign In</a>
<a href="/register" class="btn btn-secondary">Create Account</a>
</div>
</main>
</body>
</html>

43
frontend/htmx/login.html Normal file
View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign In | MiauInv</title>
<script src="/assets/js/auth.min.js" defer></script>
<script src="/assets/js/login.min.js" defer></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
</head>
<body>
<main class="card">
<div class="header">
<h1>Welcome back</h1>
<p class="subtitle">Sign in to your account</p>
</div>
<form id="login-form">
<div class="form-group">
<label for="username" class="sr-only">Username</label>
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" placeholder="Password" autocomplete="current-password" required>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
<div id="error" class="message error"></div>
<p class="footer-text">
Don't have an account yet? <a href="/register">Create account</a>
</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Registration Disabled - MiauInv</title>
<link rel="stylesheet" href="/assets/css/register-blocked.min.css">
</head>
<body>
<div class="card">
<h1>Registration</h1>
<div class="subtitle">Create a new account</div>
<div class="message error">
<strong>Access Denied:</strong> Public registration is currently disabled for this system. Please contact your system administrator to request an account.
</div>
<div class="footer-text">
<a class="btn btn-secondary" href="/login" style="margin-top: 1.5rem;">Back to Login</a>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register | MiauInv</title>
<script src="/assets/js/auth.min.js"></script>
<script src="/assets/js/register.min.js"></script>
<link rel="stylesheet" href="/assets/css/theme.min.css">
</head>
<body>
<main class="card">
<div class="header">
<h1>Create Account</h1>
<p class="subtitle">Register to continue</p>
</div>
<form id="register-form">
<div class="form-group">
<label for="username" class="sr-only">Username</label>
<input type="text" id="username" placeholder="Username" autocomplete="username" required>
</div>
<div class="form-group">
<label for="password" class="sr-only">Password</label>
<input type="password" id="password" placeholder="Password" autocomplete="new-password" required>
</div>
<button type="submit" class="btn btn-primary">Register</button>
</form>
<div id="message" class="message"></div>
<p class="footer-text">
Already have an account? <a href="/login">Sign in here</a>
</p>
</main>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Feature Under Development | MiauInv</title>
<link rel="stylesheet" href="/assets/css/theme.min.css">
<link rel="stylesheet" href="/assets/css/under-construction.min.css">
</head>
<body>
<div class="card">
<div class="construction-icon-wrapper">
<svg class="gear-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</div>
<h1>Coming Soon</h1>
<div class="subtitle">Feature Under Development</div>
<div class="message success" style="display: block; margin-bottom: 2rem;">
<strong>Development in progress!</strong><br>
Our team is actively working on this feature to bring you the best experience possible.
</div>
<a href="/dashboard" class="btn btn-secondary">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 8px;"><line x1="19" y1="12" x2="5" y2="12"></line><polyline points="12 19 5 12 12 5"></polyline></svg>
Back to Dashboard
</a>
<div class="footer-text">
Need urgent assistance? <a href="mailto:maurice@miaurizius.de">Contact Developer</a>
</div>
</div>
</body>
</html>

4
go.mod
View File

@@ -5,15 +5,17 @@ go 1.26
require (
github.com/glebarez/go-sqlite v1.22.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.5.0
github.com/tdewolff/minify/v2 v2.24.13
golang.org/x/crypto v0.52.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.5.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tdewolff/parse/v2 v2.8.12 // indirect
golang.org/x/sys v0.45.0 // indirect
modernc.org/libc v1.37.6 // indirect
modernc.org/mathutil v1.6.0 // indirect

7
go.sum
View File

@@ -12,6 +12,13 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/tdewolff/minify/v2 v2.24.13 h1:xrcF7gKDnUszseEY9WX9mUlZII2v2Go/QAcAwRASw58=
github.com/tdewolff/minify/v2 v2.24.13/go.mod h1:emvwoYeIl8bfAKqRU5ww95LX9Gpggpqv/naal9a8Yq0=
github.com/tdewolff/parse/v2 v2.8.12 h1:5BBjfaCv482v3nltlS0u6wH1xJaxjR6ofDrWttNvROg=
github.com/tdewolff/parse/v2 v2.8.12/go.mod h1:Hwlni2tiVNKyzR1o6nUs4FOF07URA+JLBLd6dlIXYqo=
github.com/tdewolff/test v1.0.11/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -10,12 +10,13 @@ import (
"log"
"net/http"
"os"
"strings"
"time"
)
var cfg, _ = config.LoadConfig()
func Register(w http.ResponseWriter, r *http.Request) {
func APIRegister(w http.ResponseWriter, r *http.Request) {
var user models.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
@@ -29,6 +30,12 @@ func Register(w http.ResponseWriter, r *http.Request) {
return
}
if len(user.Password) > 72 {
log.Println("POST [api/register] User password too long")
http.Error(w, "Password exceeds the maximum allowed length of 72 characters", http.StatusUnprocessableEntity)
return
}
hashed, err := auth.HashPassword(user.Password)
if err != nil {
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
@@ -48,7 +55,7 @@ func Register(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
}
func Login(w http.ResponseWriter, r *http.Request) {
func APILogin(w http.ResponseWriter, r *http.Request) {
var creds struct {
Username string `json:"username"`
Password string `json:"password"`
@@ -123,6 +130,23 @@ func Login(w http.ResponseWriter, r *http.Request) {
},
}
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: accessToken,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: refreshTokenPlain,
Path: "/",
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(resp)
if err != nil {
@@ -155,7 +179,7 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
log.Println("GET [api/ping] " + r.RemoteAddr + ": " + err.Error())
return
}
log.Println("GET [api/login] " + r.RemoteAddr + ": Successfully tested connection")
log.Println("GET [api/ping] " + r.RemoteAddr + ": Successfully tested connection")
}
func RefreshToken(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -225,6 +249,38 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
}
query := r.URL.Query()
idParam := query.Get("id")
if idParam == "" {
tokenStr := ""
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
tokenStr = strings.TrimPrefix(authHeader, "Bearer ")
}
if tokenStr == "" {
cookie, err := r.Cookie("access_token")
if err == nil {
tokenStr = cookie.Value
}
}
if tokenStr == "" {
http.Error(w, "Missing token", http.StatusUnauthorized)
return
}
claims, err := auth.ValidateJWT(tokenStr, models.JWTSecret)
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Unauthorized: Invalid or expired token", http.StatusUnauthorized)
return
}
idParam = claims.UserID
}
user, err := storage.GetUserById(idParam)
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found")
@@ -234,12 +290,12 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
err = json.NewEncoder(w).Encode(map[string]interface{}{
"id": user.ID,
"name": user.Username,
"username": user.Username,
"avatar_url": "",
})
if err != nil {
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
return
}
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info")
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": Successfully retrieved user info of " + user.Username + " (" + user.ID + ")")
}

676
handlers/api.go Normal file
View File

@@ -0,0 +1,676 @@
package handlers
import (
"MiauInv/models"
"MiauInv/storage"
"database/sql"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
)
func Location(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
contentMode := r.URL.Query().Get("content")
if idStr != "" && contentMode == "true" {
locationID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid location ID", http.StatusBadRequest)
return
}
query := `
SELECT s.item_id, i.name, s.quantity
FROM stock s
JOIN items i ON s.item_id = i.id
WHERE s.location_id = ? AND s.quantity > 0
`
rows, err := storage.DB.Query(query, locationID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var contents []models.LocationContent
for rows.Next() {
var c models.LocationContent
if err := rows.Scan(&c.ItemID, &c.ItemName, &c.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
contents = append(contents, c)
}
json.NewEncoder(w).Encode(map[string]interface{}{"contents": contents})
return
}
if idStr != "" {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
return
}
var loc models.Location
err = storage.DB.QueryRow("SELECT id, name FROM locations WHERE id = ?", id).Scan(&loc.ID, &loc.Name)
if err != nil {
if err == sql.ErrNoRows {
log.Println("GET [api/locations] " + r.RemoteAddr + ": Location not found (ID " + idStr + ")")
http.Error(w, "Location not found", http.StatusNotFound)
return
}
log.Println("GET [api/locations] DB Error: " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(loc)
log.Println("GET [api/locations] " + r.RemoteAddr + ": Successfully retrieved location ID " + idStr)
return
}
rows, err := storage.DB.Query("SELECT id, name FROM locations ORDER BY name ASC")
if err != nil {
log.Println("GET [api/locations] " + r.RemoteAddr + ": " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
var locations []models.Location
for rows.Next() {
var loc models.Location
if err := rows.Scan(&loc.ID, &loc.Name); err != nil {
log.Println("GET [api/locations] Scan Error: " + err.Error())
continue
}
locations = append(locations, loc)
}
if locations == nil {
locations = []models.Location{}
}
json.NewEncoder(w).Encode(map[string]interface{}{
"locations": locations,
})
log.Println("GET [api/locations] " + r.RemoteAddr + ": Successfully retrieved all locations")
case http.MethodPost:
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Println("POST [api/locations] Decode Error: " + err.Error())
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.Name) == "" {
http.Error(w, "Location name cannot be empty", http.StatusBadRequest)
return
}
res, err := storage.DB.Exec("INSERT INTO locations(name) VALUES(?)", body.Name)
if err != nil {
log.Println("POST [api/locations] DB Error: " + err.Error())
http.Error(w, "Location already exists or database error", http.StatusConflict)
return
}
lastID, _ := res.LastInsertId()
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(models.Location{
ID: int(lastID),
Name: body.Name,
})
log.Println("POST [api/locations] " + r.RemoteAddr + ": Successfully created location " + body.Name)
case http.MethodPut:
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "Missing ID parameter for update", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
return
}
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
log.Println("PUT [api/locations] Decode Error: " + err.Error())
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
if strings.TrimSpace(body.Name) == "" {
http.Error(w, "Location name cannot be empty", http.StatusBadRequest)
return
}
res, err := storage.DB.Exec("UPDATE locations SET name = ? WHERE id = ?", body.Name, id)
if err != nil {
log.Println("PUT [api/locations] DB Error: " + err.Error())
http.Error(w, "Location name unique constraint failed or database error", http.StatusConflict)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Location not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(models.Location{
ID: id,
Name: body.Name,
})
log.Println("PUT [api/locations] " + r.RemoteAddr + ": Successfully updated location ID " + idStr)
case http.MethodDelete:
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "Missing ID parameter for deletion", http.StatusBadRequest)
return
}
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
return
}
res, err := storage.DB.Exec("DELETE FROM locations WHERE id = ?", id)
if err != nil {
log.Println("DELETE [api/locations] DB Error: " + err.Error())
http.Error(w, "Cannot delete location. It might still be in use by inventory stock.", http.StatusConflict)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Location not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
log.Println("DELETE [api/locations] " + r.RemoteAddr + ": Successfully deleted location ID " + idStr)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func Item(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
if idStr != "" {
id, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid ID parameter", http.StatusBadRequest)
return
}
var item models.Item
err = storage.DB.QueryRow("SELECT id, name, category, description, total_quantity FROM items WHERE id = ?", id).Scan(&item.ID, &item.Name, &item.Category, &item.Description, &item.TotalQuantity)
if err != nil {
if err == sql.ErrNoRows {
log.Println("GET [api/locations] " + r.RemoteAddr + ": Location not found (ID " + idStr + ")")
http.Error(w, "Location not found", http.StatusNotFound)
return
}
log.Println("GET [api/item] DB Error: " + err.Error())
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(item)
log.Println("GET [api/item] " + r.RemoteAddr + ": Successfully retrieved item ID " + idStr)
return
}
query := `
SELECT
i.id, i.name, i.category, i.description,
COALESCE((SELECT SUM(quantity) FROM stock WHERE item_id = i.id), 0) as total_qty,
COALESCE((SELECT SUM(quantity) FROM project_items WHERE item_id = i.id), 0) as allocated_qty
FROM items i
ORDER BY i.name ASC
`
rows, err := storage.DB.Query(query)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var itemsExtended []models.ItemExtended
for rows.Next() {
var ie models.ItemExtended
err := rows.Scan(&ie.ID, &ie.Name, &ie.Category, &ie.Description, &ie.TotalQuantity, &ie.AllocatedQty)
if err != nil {
continue
}
ie.AvailableQty = ie.TotalQuantity - ie.AllocatedQty
itemsExtended = append(itemsExtended, ie)
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": itemsExtended})
return
case http.MethodPost:
var body models.Item
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
res, err := storage.DB.Exec("INSERT INTO items(name, category, description, total_quantity) VALUES(?, ?, ?, ?)",
body.Name, body.Category, body.Description, body.TotalQuantity)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
lastID, _ := res.LastInsertId()
body.ID = int(lastID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(body)
case http.MethodPut:
idStr := r.URL.Query().Get("id")
if idStr == "" {
http.Error(w, "Missing ID parameter", http.StatusBadRequest)
return
}
id, _ := strconv.Atoi(idStr)
var body models.Item
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("UPDATE items SET name = ?, category = ?, description = ?, total_quantity = ? WHERE id = ?",
body.Name, body.Category, body.Description, body.TotalQuantity, id)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
body.ID = id
json.NewEncoder(w).Encode(body)
case http.MethodDelete:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
// SQLite blockiert dank FK, falls Item noch im Stock oder Projekten ist
res, err := storage.DB.Exec("DELETE FROM items WHERE id = ?", id)
if err != nil {
http.Error(w, "Cannot delete item. Still referenced in stock or projects.", http.StatusConflict)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Item not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func Project(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
detailsMode := r.URL.Query().Get("details")
if idStr != "" && detailsMode == "true" {
projectID, err := strconv.Atoi(idStr)
if err != nil {
http.Error(w, "Invalid project ID", http.StatusBadRequest)
return
}
query := `
SELECT pi.item_id, i.name, pi.quantity
FROM project_items pi
JOIN items i ON pi.item_id = i.id
WHERE pi.project_id = ?
`
rows, err := storage.DB.Query(query, projectID)
if err != nil {
http.Error(w, "Database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var details []models.ProjectDetailItem
for rows.Next() {
var d models.ProjectDetailItem
if err := rows.Scan(&d.ItemID, &d.ItemName, &d.Quantity); err != nil {
http.Error(w, "Row scan error", http.StatusInternalServerError)
return
}
details = append(details, d)
}
json.NewEncoder(w).Encode(map[string]interface{}{"items": details})
return
}
if idStr != "" {
id, _ := strconv.Atoi(idStr)
var p models.Project
err := storage.DB.QueryRow("SELECT id, name, description FROM projects WHERE id = ?", id).Scan(&p.ID, &p.Name, &p.Description)
if err == sql.ErrNoRows {
http.Error(w, "Project not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(p)
return
}
rows, err := storage.DB.Query("SELECT id, name, description FROM projects ORDER BY name ASC")
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
projects := []models.Project{}
for rows.Next() {
var p models.Project
rows.Scan(&p.ID, &p.Name, &p.Description)
projects = append(projects, p)
}
json.NewEncoder(w).Encode(map[string]interface{}{"projects": projects})
case http.MethodPost:
var body models.Project
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("INSERT INTO projects(name, description) VALUES(?, ?)", body.Name, body.Description)
if err != nil {
http.Error(w, "Project name already exists", http.StatusConflict)
return
}
lastID, _ := res.LastInsertId()
body.ID = int(lastID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(body)
case http.MethodPut:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
var body models.Project
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("UPDATE projects SET name = ?, description = ? WHERE id = ?", body.Name, body.Description, id)
if err != nil {
http.Error(w, "Project name constraint violation", http.StatusConflict)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Project not found", http.StatusNotFound)
return
}
body.ID = id
json.NewEncoder(w).Encode(body)
case http.MethodDelete:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
res, err := storage.DB.Exec("DELETE FROM projects WHERE id = ?", id)
if err != nil {
http.Error(w, "Cannot delete project. Clear active item assignments first.", http.StatusConflict)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Project not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func Stock(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
if idStr != "" {
id, _ := strconv.Atoi(idStr)
var s models.Stock
err := storage.DB.QueryRow("SELECT id, item_id, location_id, quantity FROM stock WHERE id = ?", id).
Scan(&s.ID, &s.ItemID, &s.LocationID, &s.Quantity)
if err == sql.ErrNoRows {
http.Error(w, "Stock entry not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(s)
return
}
rows, err := storage.DB.Query("SELECT id, item_id, location_id, quantity FROM stock")
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
stocks := []models.Stock{}
for rows.Next() {
var s models.Stock
rows.Scan(&s.ID, &s.ItemID, &s.LocationID, &s.Quantity)
stocks = append(stocks, s)
}
json.NewEncoder(w).Encode(map[string]interface{}{"stock": stocks})
case http.MethodPost:
var body models.Stock
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("INSERT INTO stock(item_id, location_id, quantity) VALUES(?, ?, ?)",
body.ItemID, body.LocationID, body.Quantity)
if err != nil {
http.Error(w, "Failed to link stock. Ensure Item and Location exist.", http.StatusBadRequest)
return
}
lastID, _ := res.LastInsertId()
body.ID = int(lastID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(body)
case http.MethodPut:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
var body models.Stock
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("UPDATE stock SET item_id = ?, location_id = ?, quantity = ? WHERE id = ?",
body.ItemID, body.LocationID, body.Quantity, id)
if err != nil {
http.Error(w, "Failed to update stock. Verify Foreign Keys.", http.StatusBadRequest)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Stock entry not found", http.StatusNotFound)
return
}
body.ID = id
json.NewEncoder(w).Encode(body)
case http.MethodDelete:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
res, _ := storage.DB.Exec("DELETE FROM stock WHERE id = ?", id)
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Stock entry not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func Associations(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.Method {
case http.MethodGet:
idStr := r.URL.Query().Get("id")
projectIDStr := r.URL.Query().Get("project_id")
if projectIDStr != "" {
pID, _ := strconv.Atoi(projectIDStr)
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items WHERE project_id = ?", pID)
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
list := []models.ProjectItem{}
for rows.Next() {
var pi models.ProjectItem
rows.Scan(&pi.ID, &pi.ItemID, &pi.ProjectID, &pi.Quantity)
list = append(list, pi)
}
json.NewEncoder(w).Encode(map[string]interface{}{"associations": list})
return
}
if idStr != "" {
id, _ := strconv.Atoi(idStr)
var pi models.ProjectItem
err := storage.DB.QueryRow("SELECT id, item_id, project_id, quantity FROM project_items WHERE id = ?", id).
Scan(&pi.ID, &pi.ItemID, &pi.ProjectID, &pi.Quantity)
if err == sql.ErrNoRows {
http.Error(w, "Association not found", http.StatusNotFound)
return
}
json.NewEncoder(w).Encode(pi)
return
}
rows, err := storage.DB.Query("SELECT id, item_id, project_id, quantity FROM project_items")
if err != nil {
http.Error(w, "Internal server error", http.StatusInternalServerError)
return
}
defer rows.Close()
all := []models.ProjectItem{}
for rows.Next() {
var pi models.ProjectItem
rows.Scan(&pi.ID, &pi.ItemID, &pi.ProjectID, &pi.Quantity)
all = append(all, pi)
}
json.NewEncoder(w).Encode(map[string]interface{}{"associations": all})
case http.MethodPost:
var body models.ProjectItem
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("INSERT INTO project_items(item_id, project_id, quantity) VALUES(?, ?, ?)",
body.ItemID, body.ProjectID, body.Quantity)
if err != nil {
http.Error(w, "Link failed. Check item_id and project_id.", http.StatusBadRequest)
return
}
lastID, _ := res.LastInsertId()
body.ID = int(lastID)
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(body)
case http.MethodPut:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
var body models.ProjectItem
json.NewDecoder(r.Body).Decode(&body)
res, err := storage.DB.Exec("UPDATE project_items SET item_id = ?, project_id = ?, quantity = ? WHERE id = ?",
body.ItemID, body.ProjectID, body.Quantity, id)
if err != nil {
http.Error(w, "Update failed. Check Constraints.", http.StatusBadRequest)
return
}
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Association not found", http.StatusNotFound)
return
}
body.ID = id
json.NewEncoder(w).Encode(body)
case http.MethodDelete:
idStr := r.URL.Query().Get("id")
id, _ := strconv.Atoi(idStr)
res, _ := storage.DB.Exec("DELETE FROM project_items WHERE id = ?", id)
rowsAffected, _ := res.RowsAffected()
if rowsAffected == 0 {
http.Error(w, "Association not found", http.StatusNotFound)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}

View File

@@ -1,5 +1,7 @@
package models
var JWTSecret []byte
// Roles
const (
RoleUser = "user"

66
models/inventory.go Normal file
View File

@@ -0,0 +1,66 @@
package models
type Item struct {
ID int `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
TotalQuantity int `json:"total_quantity"` // Berechnet aus der Summe aller Stocks
FreeQuantity int `json:"free_quantity"` // TotalQuantity minus Summe aller Projekt-Zuweisungen
}
type Location struct {
ID int `json:"id"`
Name string `json:"name"`
}
type Project struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
type Stock struct {
ID int `json:"id"`
ItemID int `json:"item_id"`
LocationID int `json:"location_id"`
Quantity int `json:"quantity"`
LocationName string `json:"location_name"` // Used to display the location in the modal table
}
type Association struct {
ID int `json:"id"`
ProjectID int `json:"project_id"`
ItemID int `json:"item_id"`
Quantity int `json:"quantity"`
ItemName string `json:"item_name"` // Used to display the item name in the modal table
}
type ProjectItem struct {
ID int `json:"id"`
ItemID int `json:"item_id"`
ProjectID int `json:"project_id"`
Quantity int `json:"quantity"`
}
type ItemExtended struct {
ID int `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Description string `json:"description"`
TotalQuantity int `json:"total_quantity"` // Summe aus allen Stock-Einträgen
AllocatedQty int `json:"allocated_quantity"` // Summe aus allen Projekt-Zuweisungen
AvailableQty int `json:"available_quantity"` // Total - Allocated
}
type LocationContent struct {
ItemID int `json:"item_id"`
ItemName string `json:"item_name"`
Quantity int `json:"quantity"`
}
type ProjectDetailItem struct {
ItemID int `json:"item_id"`
ItemName string `json:"item_name"`
Quantity int `json:"quantity"`
}

View File

@@ -1,9 +1,12 @@
package server
import (
"MiauInv/auth"
"MiauInv/config"
"MiauInv/frontend"
"MiauInv/handlers"
"MiauInv/models"
utils "MiauInv/util"
"log"
"net/http"
"os"
@@ -15,6 +18,7 @@ type Server struct {
DatabasePath string
CertificatePath string
PrivateKeyPath string
AllowRegistration bool
}
func InitServer() *Server {
@@ -41,12 +45,15 @@ func InitServer() *Server {
return nil
}
models.JWTSecret = []byte(jwtSecret)
return &Server{
Port: cfg.Port,
JWTSecret: []byte(jwtSecret),
DatabasePath: cfg.DatabasePath,
CertificatePath: cfg.CertificatePath,
PrivateKeyPath: cfg.PrivateKeyPath,
AllowRegistration: cfg.AllowRegistration,
}
}
@@ -58,20 +65,39 @@ func (this *Server) Run() {
// FRONTEND
//
mux.HandleFunc("/", frontend.Home)
mux.HandleFunc("/login", utils.RenderFile("frontend/htmx/login.html"))
mux.Handle("/dashboard", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Dashboard)))
mux.Handle("/inventory", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Inventory)))
mux.Handle("/items", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Items)))
mux.Handle("/locations", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Locations)))
mux.Handle("/projects", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(frontend.Projects)))
mux.HandleFunc("/profile/", utils.RenderFile("frontend/htmx/under-construction.html"))
if this.AllowRegistration {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register.html"))
} else {
mux.HandleFunc("/register", utils.RenderFile("frontend/htmx/register-blocked.html"))
}
//
// API
//
// Public
mux.HandleFunc("/api/login", handlers.Login)
mux.HandleFunc("/api/register", handlers.Register)
mux.HandleFunc("/api/login", handlers.APILogin)
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
mux.HandleFunc("/api/logout", handlers.Logout)
mux.Handle("/api/logout", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Logout)))
mux.Handle("/api/profile", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.UserInfo)))
mux.HandleFunc("/api/userinfo", handlers.UserInfo)
if this.AllowRegistration {
mux.HandleFunc("/api/register", handlers.APIRegister)
}
// Login required
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
mux.Handle("/api/location", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Location)))
mux.Handle("/api/project", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Project)))
mux.Handle("/api/stock", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Stock)))
mux.Handle("/api/association", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Associations)))
// Admin-only
// Assets
mux.HandleFunc("/assets/", frontend.Assets)
log.Printf("Listening on port %s", this.Port)
log.Fatal(http.ListenAndServeTLS(":"+this.Port, this.CertificatePath, this.PrivateKeyPath, mux))

52
storage/inventory.go Normal file
View File

@@ -0,0 +1,52 @@
package storage
import "MiauInv/models"
func AddItem(item models.Item) error {
_, err := DB.Exec(
"INSERT INTO items(name, category, description, total_quantity) VALUES(?,?,?,?)",
item.Name,
item.Category,
item.Description,
item.TotalQuantity,
)
return err
}
func GetItems() ([]models.Item, error) {
rows, err := DB.Query(`
SELECT id,name,category,description,total_quantity
FROM items
`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []models.Item
for rows.Next() {
var item models.Item
err = rows.Scan(
&item.ID,
&item.Name,
&item.Category,
&item.Description,
&item.TotalQuantity,
)
if err != nil {
return nil, err
}
items = append(items, item)
}
return items, nil
}

View File

@@ -45,25 +45,37 @@ func InitDB(filepath string) error {
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
category TEXT,
description TEXT,
total_quantity INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
name TEXT NOT NULL UNIQUE,
description TEXT
);
CREATE TABLE IF NOT EXISTS item_allocations (
CREATE TABLE IF NOT EXISTS stock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
location_id INTEGER,
project_id INTEGER,
quantity INTEGER NOT NULL
location_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
FOREIGN KEY(item_id) REFERENCES items(id),
FOREIGN KEY(location_id) REFERENCES locations(id)
);
CREATE TABLE IF NOT EXISTS project_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
quantity INTEGER NOT NULL,
FOREIGN KEY(item_id) REFERENCES items(id),
FOREIGN KEY(project_id) REFERENCES projects(id)
);
`

View File

@@ -47,3 +47,8 @@ func IsLoggedIn(w http.ResponseWriter, r *http.Request) (*auth.Claims, bool) {
}
return claims, true
}
func RenderFile(path string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path)
}
}