Compare commits
29 Commits
1a0797ef19
...
v1.0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
5485fd135d
|
|||
|
5558d42bdb
|
|||
|
b74df36bda
|
|||
|
918b9a6b74
|
|||
|
6d32ca13ca
|
|||
|
feffff0898
|
|||
|
5089f94a21
|
|||
|
f5f5da51c8
|
|||
|
089998d45b
|
|||
|
e2d51c6cdf
|
|||
|
0f8c7f57ac
|
|||
|
8d2be395b9
|
|||
|
339d5a709c
|
|||
|
405502cb20
|
|||
|
1a454dd201
|
|||
|
15cc33eb23
|
|||
|
f367188c08
|
|||
|
dcc6dc0b98
|
|||
|
d771ebba13
|
|||
|
92e7ea4667
|
|||
|
b93d9382ac
|
|||
|
a31c516e8f
|
|||
|
eba273be49
|
|||
|
e46b4904e3
|
|||
|
28bf03d1a3
|
|||
|
419e05bb89
|
|||
|
52d551ab39
|
|||
|
6543149dab
|
|||
|
978ba292a1
|
@@ -11,9 +11,15 @@ COPY . .
|
|||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.source="https://git.miaurizius.de/miaurizius/miauinv"
|
||||||
|
|
||||||
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||||
go build -ldflags "-s -w" -o MiauInv .
|
go build -ldflags "-s -w" -o MiauInv .
|
||||||
|
|
||||||
FROM scratch
|
FROM scratch
|
||||||
|
|
||||||
COPY --from=builder /app/MiauInv /MiauInv
|
COPY --from=builder /app/MiauInv /MiauInv
|
||||||
|
|
||||||
|
COPY --from=builder /app/frontend /frontend
|
||||||
|
|
||||||
ENTRYPOINT ["/MiauInv"]
|
ENTRYPOINT ["/MiauInv"]
|
||||||
265
README.md
Normal file
265
README.md
Normal 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
407
ad.html
Normal 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">© 2026 Maurice Larivière.</p>
|
||||||
|
<p lang="de">© 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>
|
||||||
@@ -10,27 +10,58 @@ type contextKey string
|
|||||||
|
|
||||||
const UserContextKey contextKey = contextKey("user")
|
const UserContextKey contextKey = contextKey("user")
|
||||||
|
|
||||||
|
// middleware.go
|
||||||
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
|
func AuthMiddleware(secret []byte) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
authHeader := r.Header.Get("Authorization")
|
if r.URL.Path == "/login" || r.URL.Path == "/register" || r.URL.Path == "/" {
|
||||||
if authHeader == "" {
|
next.ServeHTTP(w, r)
|
||||||
http.Error(w, "Missing token", http.StatusUnauthorized)
|
|
||||||
return
|
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)
|
claims, err := ValidateJWT(tokenStr, secret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
ctx := context.WithValue(r.Context(), UserContextKey, claims)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
DatabasePath string `yaml:"database_path"`
|
DatabasePath string `yaml:"database_path"`
|
||||||
CertificatePath string `yaml:"certificate_path"`
|
CertificatePath string `yaml:"certificate_path"`
|
||||||
PrivateKeyPath string `yaml:"private_key_path"`
|
PrivateKeyPath string `yaml:"private_key_path"`
|
||||||
|
AllowRegistration bool `yaml:"allow_registration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
const configPath = "./appdata/config.yaml"
|
const configPath = "./appdata/config.yaml"
|
||||||
@@ -34,10 +35,11 @@ func CheckIfExists() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig := Config{
|
defaultConfig := Config{
|
||||||
Port: "8080",
|
Port: "8080",
|
||||||
DatabasePath: "./appdata/database.db",
|
DatabasePath: "./appdata/database.db",
|
||||||
CertificatePath: "./appdata/cert.pem",
|
CertificatePath: "./appdata/cert.pem",
|
||||||
PrivateKeyPath: "./appdata/key.pem",
|
PrivateKeyPath: "./appdata/key.pem",
|
||||||
|
AllowRegistration: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := yaml.Marshal(defaultConfig)
|
data, err := yaml.Marshal(defaultConfig)
|
||||||
|
|||||||
5
deploy.sh
Executable file
5
deploy.sh
Executable 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
51
docs/AUTHENTICATION.md
Normal 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
97
docs/DATABASE.md
Normal 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
BIN
docs/img/dashboard.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/img/inventory.png
Normal file
BIN
docs/img/inventory.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
docs/img/locations.png
Normal file
BIN
docs/img/locations.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/img/projects.png
Normal file
BIN
docs/img/projects.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
344
frontend/assets/css/dashboard.css
Normal file
344
frontend/assets/css/dashboard.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
frontend/assets/css/error404.css
Normal file
14
frontend/assets/css/error404.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
frontend/assets/css/home.css
Normal file
16
frontend/assets/css/home.css
Normal 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;
|
||||||
|
}
|
||||||
108
frontend/assets/css/register-blocked.css
Normal file
108
frontend/assets/css/register-blocked.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
:root {
|
|
||||||
--bg: #111827;
|
|
||||||
--card: #1f2937;
|
|
||||||
--border: #374151;
|
|
||||||
--text: #f9fafb;
|
|
||||||
--accent: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: system-ui;
|
|
||||||
}
|
|
||||||
|
|
||||||
nav {
|
|
||||||
display: flex;
|
|
||||||
gap: 1rem;
|
|
||||||
|
|
||||||
padding: 1rem;
|
|
||||||
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
nav a {
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
max-width: 1400px;
|
|
||||||
|
|
||||||
margin: auto;
|
|
||||||
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.cards {
|
|
||||||
display: grid;
|
|
||||||
|
|
||||||
grid-template-columns:
|
|
||||||
repeat(auto-fit,minmax(250px,1fr));
|
|
||||||
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background: var(--card);
|
|
||||||
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
th,
|
|
||||||
td {
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
tr {
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
select,
|
|
||||||
button {
|
|
||||||
|
|
||||||
background: #1f2937;
|
|
||||||
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
|
|
||||||
border-radius: 8px;
|
|
||||||
|
|
||||||
padding: .8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:hover {
|
|
||||||
background: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media(max-width:768px){
|
|
||||||
|
|
||||||
nav{
|
|
||||||
flex-wrap:wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
table{
|
|
||||||
display:block;
|
|
||||||
overflow:auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
285
frontend/assets/css/theme.css
Normal file
285
frontend/assets/css/theme.css
Normal 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);
|
||||||
|
}
|
||||||
24
frontend/assets/css/under-construction.css
Normal file
24
frontend/assets/css/under-construction.css
Normal 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
484
frontend/assets/js/api.js
Normal 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, "'")})'>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, "'")})'>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, "'")})'>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, "'")})'>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
128
frontend/assets/js/auth.js
Normal 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);
|
||||||
|
})();
|
||||||
42
frontend/assets/js/login.js
Normal file
42
frontend/assets/js/login.js
Normal 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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
43
frontend/assets/js/register.js
Normal file
43
frontend/assets/js/register.js
Normal 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";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,21 +1,51 @@
|
|||||||
package frontend
|
package frontend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"MiauInv/storage"
|
||||||
"html/template"
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/tdewolff/minify/v2"
|
||||||
|
"github.com/tdewolff/minify/v2/css"
|
||||||
|
"github.com/tdewolff/minify/v2/js"
|
||||||
)
|
)
|
||||||
|
|
||||||
var dashbaord = template.Must(template.ParseFiles(
|
var dashboard = template.Must(template.ParseFiles(
|
||||||
"frontend/htmx/contents/dash/base.html",
|
"frontend/htmx/contents/dash/base.html",
|
||||||
"frontend/htmx/contents/dash/dashboard.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"))
|
var home = template.Must(template.ParseFiles("frontend/htmx/home.html"))
|
||||||
|
|
||||||
func Home(w http.ResponseWriter, r *http.Request) {
|
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")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := home.Execute(w, struct {
|
err := home.Execute(w, struct {
|
||||||
Name string
|
Name string
|
||||||
}{
|
}{
|
||||||
Name: "Miau",
|
Name: "Home",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -24,12 +54,139 @@ func Home(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
func Dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html")
|
w.Header().Set("Content-Type", "text/html")
|
||||||
err := dashbaord.Execute(w, struct {
|
|
||||||
|
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
|
Title string
|
||||||
|
Stats struct {
|
||||||
|
Items int
|
||||||
|
Projects int
|
||||||
|
Locations int
|
||||||
|
}
|
||||||
}{
|
}{
|
||||||
Title: "Miau",
|
Title: "Dashboard",
|
||||||
|
Stats: struct {
|
||||||
|
Items int
|
||||||
|
Projects int
|
||||||
|
Locations int
|
||||||
|
}{
|
||||||
|
Items: itemHive,
|
||||||
|
Projects: projectHive,
|
||||||
|
Locations: locationHive,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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
23
frontend/htmx/404.html
Normal 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>
|
||||||
@@ -1,27 +1,57 @@
|
|||||||
<!DOCTYPE html>
|
{{ define "base.html" }}
|
||||||
<html lang="de">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{{ .Title }}</title>
|
<title>{{ .Title }} | MiauInv</title>
|
||||||
|
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script src="/assets/js/api.min.js"></script>
|
||||||
<link rel="stylesheet" href="/static/css/app.css">
|
<link rel="stylesheet" href="/assets/css/theme.min.css">
|
||||||
|
<link rel="stylesheet" href="/assets/css/dashboard.min.css">
|
||||||
</head>
|
</head>
|
||||||
|
<body class="dashboard-layout">
|
||||||
|
|
||||||
<body>
|
<header>
|
||||||
|
<div class="nav-container">
|
||||||
|
<div class="nav-left">
|
||||||
|
<div class="brand">Miau<span>Inv</span></div>
|
||||||
|
|
||||||
<nav>
|
<button class="menu-trigger" id="menu-btn" aria-label="Menu">
|
||||||
<a href="/dashboard">Dashboard</a>
|
<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>
|
||||||
<a href="/items">Inventar</a>
|
</button>
|
||||||
<a href="/projects">Projekte</a>
|
|
||||||
<a href="/locations">Orte</a>
|
<nav id="main-nav">
|
||||||
</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>
|
<main>
|
||||||
{{ template "content" . }}
|
{{ template "content" . }}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
{{ end }}
|
||||||
@@ -1,24 +1,110 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
<div class="page-header">
|
||||||
<h1>Dashboard</h1>
|
<h1>Dashboard Overview</h1>
|
||||||
|
|
||||||
<div class="cards">
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>{{ .Stats.Items }}</h2>
|
|
||||||
<p>Items</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>{{ .Stats.Projects }}</h2>
|
|
||||||
<p>Projekte</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>{{ .Stats.Locations }}</h2>
|
|
||||||
<p>Orte</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
@@ -1,21 +1,88 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
<div class="page-header action-bar">
|
||||||
<h1>Inventar</h1>
|
<h1>Inventory</h1>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openItemModal()">
|
||||||
<input
|
Add Item
|
||||||
type="search"
|
</button>
|
||||||
name="q"
|
|
||||||
placeholder="Suchen..."
|
|
||||||
hx-get="/api/items/search"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#items"
|
|
||||||
>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id="items"
|
|
||||||
hx-get="/api/items"
|
|
||||||
hx-trigger="load"
|
|
||||||
>
|
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
@@ -1,32 +1,28 @@
|
|||||||
<table>
|
<table>
|
||||||
|
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Kategorie</th>
|
<th>Category</th>
|
||||||
<th>Gesamt</th>
|
<th>Total</th>
|
||||||
<th>Frei</th>
|
<th>Free</th>
|
||||||
|
<th style="text-align: right;">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
||||||
{{ range . }}
|
{{ range . }}
|
||||||
|
|
||||||
<tr>
|
<tr>
|
||||||
|
<td style="font-weight: 600;">{{ .Name }}</td>
|
||||||
<td>{{ .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);">
|
||||||
<td>{{ .Category }}</td>
|
{{ .Category }}
|
||||||
|
</span>
|
||||||
<td>{{ .TotalQuantity }}</td>
|
</td>
|
||||||
|
<td style="font-family: monospace; font-size: 1.05rem;">{{ .TotalQuantity }}</td>
|
||||||
<td>{{ .FreeQuantity }}</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>
|
</tr>
|
||||||
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
||||||
</table>
|
</table>
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
<div class="page-header action-bar">
|
||||||
<h1>Lagerorte</h1>
|
<h1>Locations</h1>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openLocationModal()">
|
||||||
<div
|
Add Location
|
||||||
hx-get="/api/locations"
|
</button>
|
||||||
hx-trigger="load"
|
|
||||||
>
|
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
@@ -1,21 +1,84 @@
|
|||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
|
<div class="page-header action-bar">
|
||||||
<h1>Projekte</h1>
|
<h1>Projects</h1>
|
||||||
|
<button class="btn btn-primary" style="width: auto; padding: 0.6rem 1.2rem; font-size: 0.95rem;" onclick="openProjectModal()">
|
||||||
<button
|
New Project
|
||||||
hx-get="/projects/new"
|
</button>
|
||||||
hx-target="#modal"
|
|
||||||
>
|
|
||||||
Neues Projekt
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
hx-get="/api/projects"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-target="this"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="modal"></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 }}
|
{{ end }}
|
||||||
@@ -1,6 +1,26 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html lang="en">
|
||||||
<body>
|
<head>
|
||||||
<h1>Hallo, {{.Name}}!</h1>
|
<meta charset="UTF-8">
|
||||||
</body>
|
<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>
|
</html>
|
||||||
43
frontend/htmx/login.html
Normal file
43
frontend/htmx/login.html
Normal 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>
|
||||||
26
frontend/htmx/register-blocked.html
Normal file
26
frontend/htmx/register-blocked.html
Normal 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>
|
||||||
41
frontend/htmx/register.html
Normal file
41
frontend/htmx/register.html
Normal 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>
|
||||||
39
frontend/htmx/under-construction.html
Normal file
39
frontend/htmx/under-construction.html
Normal 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
4
go.mod
@@ -5,15 +5,17 @@ go 1.26
|
|||||||
require (
|
require (
|
||||||
github.com/glebarez/go-sqlite v1.22.0
|
github.com/glebarez/go-sqlite v1.22.0
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
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
|
golang.org/x/crypto v0.52.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
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/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
golang.org/x/sys v0.45.0 // indirect
|
||||||
modernc.org/libc v1.37.6 // indirect
|
modernc.org/libc v1.37.6 // indirect
|
||||||
modernc.org/mathutil v1.6.0 // indirect
|
modernc.org/mathutil v1.6.0 // indirect
|
||||||
|
|||||||
7
go.sum
7
go.sum
@@ -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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
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=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var cfg, _ = config.LoadConfig()
|
var cfg, _ = config.LoadConfig()
|
||||||
|
|
||||||
func Register(w http.ResponseWriter, r *http.Request) {
|
func APIRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
var user models.User
|
var user models.User
|
||||||
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
||||||
@@ -29,6 +30,12 @@ func Register(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
hashed, err := auth.HashPassword(user.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": " + err.Error())
|
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)
|
w.WriteHeader(http.StatusCreated)
|
||||||
log.Println("POST [api/register] " + r.RemoteAddr + ": Successfully created user")
|
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 {
|
var creds struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(resp)
|
err = json.NewEncoder(w).Encode(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -155,7 +179,7 @@ func TestHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
log.Println("GET [api/ping] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("GET [api/ping] " + r.RemoteAddr + ": " + err.Error())
|
||||||
return
|
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) {
|
func RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
@@ -225,6 +249,38 @@ func UserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
query := r.URL.Query()
|
query := r.URL.Query()
|
||||||
idParam := query.Get("id")
|
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)
|
user, err := storage.GetUserById(idParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": User " + idParam + " not found")
|
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")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
err = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
"id": user.ID,
|
"id": user.ID,
|
||||||
"name": user.Username,
|
"username": user.Username,
|
||||||
"avatar_url": "",
|
"avatar_url": "",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
log.Println("GET [api/userinfo] " + r.RemoteAddr + ": " + err.Error())
|
||||||
return
|
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
676
handlers/api.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"MiauInv/models"
|
|
||||||
"MiauInv/storage"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GetItems(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
items, err := storage.GetItems()
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
json.NewEncoder(w).Encode(items)
|
|
||||||
}
|
|
||||||
|
|
||||||
func CreateItem(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
var item models.Item
|
|
||||||
|
|
||||||
err := json.NewDecoder(r.Body).Decode(&item)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 400)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = storage.AddItem(item)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusCreated)
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"MiauInv/storage"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateLocation(w http.ResponseWriter, r *http.Request) {
|
|
||||||
name := r.FormValue("name")
|
|
||||||
|
|
||||||
_, err := storage.DB.Exec(
|
|
||||||
"INSERT INTO locations(name) VALUES(?)",
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"MiauInv/storage"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProjectItemRequest struct {
|
|
||||||
ItemID int `json:"item_id"`
|
|
||||||
ProjectID int `json:"project_id"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AllocateToProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
var req ProjectItemRequest
|
|
||||||
|
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
|
|
||||||
_, err := storage.DB.Exec(`
|
|
||||||
INSERT INTO project_items(item_id,project_id,quantity)
|
|
||||||
VALUES(?,?,?)
|
|
||||||
`,
|
|
||||||
req.ItemID,
|
|
||||||
req.ProjectID,
|
|
||||||
req.Quantity,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"MiauInv/storage"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
func CreateProject(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
name := r.FormValue("name")
|
|
||||||
|
|
||||||
_, err := storage.DB.Exec(
|
|
||||||
"INSERT INTO projects(name) VALUES(?)",
|
|
||||||
name,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"MiauInv/storage"
|
|
||||||
"encoding/json"
|
|
||||||
"net/http"
|
|
||||||
)
|
|
||||||
|
|
||||||
type StockRequest struct {
|
|
||||||
ItemID int `json:"item_id"`
|
|
||||||
LocationID int `json:"location_id"`
|
|
||||||
Quantity int `json:"quantity"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func AddStock(w http.ResponseWriter, r *http.Request) {
|
|
||||||
|
|
||||||
var req StockRequest
|
|
||||||
|
|
||||||
json.NewDecoder(r.Body).Decode(&req)
|
|
||||||
|
|
||||||
_, err := storage.DB.Exec(`
|
|
||||||
INSERT INTO stock(item_id,location_id,quantity)
|
|
||||||
VALUES(?,?,?)
|
|
||||||
`,
|
|
||||||
req.ItemID,
|
|
||||||
req.LocationID,
|
|
||||||
req.Quantity,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, err.Error(), 500)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
|
var JWTSecret []byte
|
||||||
|
|
||||||
// Roles
|
// Roles
|
||||||
const (
|
const (
|
||||||
RoleUser = "user"
|
RoleUser = "user"
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ type Item struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
TotalQuantity int `json:"total_quantity"`
|
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 {
|
type Location struct {
|
||||||
@@ -20,10 +21,19 @@ type Project struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Stock struct {
|
type Stock struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
ItemID int `json:"item_id"`
|
ItemID int `json:"item_id"`
|
||||||
LocationID int `json:"location_id"`
|
LocationID int `json:"location_id"`
|
||||||
Quantity int `json:"quantity"`
|
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 {
|
type ProjectItem struct {
|
||||||
@@ -32,3 +42,25 @@ type ProjectItem struct {
|
|||||||
ProjectID int `json:"project_id"`
|
ProjectID int `json:"project_id"`
|
||||||
Quantity int `json:"quantity"`
|
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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"MiauInv/auth"
|
||||||
"MiauInv/config"
|
"MiauInv/config"
|
||||||
"MiauInv/frontend"
|
"MiauInv/frontend"
|
||||||
"MiauInv/handlers"
|
"MiauInv/handlers"
|
||||||
|
"MiauInv/models"
|
||||||
|
utils "MiauInv/util"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Port string
|
Port string
|
||||||
JWTSecret []byte
|
JWTSecret []byte
|
||||||
DatabasePath string
|
DatabasePath string
|
||||||
CertificatePath string
|
CertificatePath string
|
||||||
PrivateKeyPath string
|
PrivateKeyPath string
|
||||||
|
AllowRegistration bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitServer() *Server {
|
func InitServer() *Server {
|
||||||
@@ -41,12 +45,15 @@ func InitServer() *Server {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
models.JWTSecret = []byte(jwtSecret)
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
Port: cfg.Port,
|
Port: cfg.Port,
|
||||||
JWTSecret: []byte(jwtSecret),
|
JWTSecret: []byte(jwtSecret),
|
||||||
DatabasePath: cfg.DatabasePath,
|
DatabasePath: cfg.DatabasePath,
|
||||||
CertificatePath: cfg.CertificatePath,
|
CertificatePath: cfg.CertificatePath,
|
||||||
PrivateKeyPath: cfg.PrivateKeyPath,
|
PrivateKeyPath: cfg.PrivateKeyPath,
|
||||||
|
AllowRegistration: cfg.AllowRegistration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,28 +65,39 @@ func (this *Server) Run() {
|
|||||||
// FRONTEND
|
// FRONTEND
|
||||||
//
|
//
|
||||||
mux.HandleFunc("/", frontend.Home)
|
mux.HandleFunc("/", frontend.Home)
|
||||||
mux.HandleFunc("/dashboard", frontend.Dashboard)
|
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
|
// API
|
||||||
//
|
//
|
||||||
|
mux.HandleFunc("/api/login", handlers.APILogin)
|
||||||
// Public
|
|
||||||
mux.HandleFunc("/api/login", handlers.Login)
|
|
||||||
mux.HandleFunc("/api/register", handlers.Register)
|
|
||||||
mux.HandleFunc("/api/refresh", handlers.RefreshToken)
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
mux.HandleFunc("/api/items", handlers.GetItems)
|
mux.Handle("/api/item", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Item)))
|
||||||
mux.HandleFunc("/api/items/create", handlers.CreateItem)
|
mux.Handle("/api/location", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Location)))
|
||||||
mux.HandleFunc("/api/locations/create", handlers.CreateLocation)
|
mux.Handle("/api/project", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Project)))
|
||||||
mux.HandleFunc("/api/projects/create", handlers.CreateProject)
|
mux.Handle("/api/stock", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Stock)))
|
||||||
mux.HandleFunc("/api/stock/add", handlers.AddStock)
|
mux.Handle("/api/association", auth.AuthMiddleware(this.JWTSecret)(http.HandlerFunc(handlers.Associations)))
|
||||||
mux.HandleFunc("/api/project-items/add", handlers.AllocateToProject)
|
|
||||||
|
|
||||||
// Login required
|
// Assets
|
||||||
|
mux.HandleFunc("/assets/", frontend.Assets)
|
||||||
// Admin-only
|
|
||||||
|
|
||||||
log.Printf("Listening on port %s", this.Port)
|
log.Printf("Listening on port %s", this.Port)
|
||||||
log.Fatal(http.ListenAndServeTLS(":"+this.Port, this.CertificatePath, this.PrivateKeyPath, mux))
|
log.Fatal(http.ListenAndServeTLS(":"+this.Port, this.CertificatePath, this.PrivateKeyPath, mux))
|
||||||
|
|||||||
@@ -47,3 +47,8 @@ func IsLoggedIn(w http.ResponseWriter, r *http.Request) (*auth.Claims, bool) {
|
|||||||
}
|
}
|
||||||
return claims, true
|
return claims, true
|
||||||
}
|
}
|
||||||
|
func RenderFile(path string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.ServeFile(w, r, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user