This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
README.md
|
||||
*.zip
|
||||
/data
|
||||
pocketwatch.db*
|
||||
87
.gitea/workflows/registry.yml
Normal file
87
.gitea/workflows/registry.yml
Normal file
@@ -0,0 +1,87 @@
|
||||
name: release-tag
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
jobs:
|
||||
release-image:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DOCKER_ORG: sendnrw
|
||||
DOCKER_LATEST: latest
|
||||
RUNNER_TOOL_CACHE: /toolcache
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v2
|
||||
|
||||
- name: Set up Docker BuildX
|
||||
uses: docker/setup-buildx-action@v2
|
||||
with: # replace it with your local IP
|
||||
config-inline: |
|
||||
[registry."git.send.nrw"]
|
||||
http = true
|
||||
insecure = true
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: git.send.nrw # replace it with your local IP
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Get Meta
|
||||
id: meta
|
||||
run: |
|
||||
echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT
|
||||
echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:server_${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.customer-service
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:customer_${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.worker
|
||||
platforms: |
|
||||
linux/amd64
|
||||
push: true
|
||||
tags: | # replace it with your local IP and tags
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ steps.meta.outputs.REPO_VERSION }}
|
||||
git.send.nrw/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:worker_${{ env.DOCKER_LATEST }}
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/data/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
pocketwatch
|
||||
.DS_Store
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
FROM golang:1.26.6-alpine AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/pocketwatch ./cmd/pocketwatch
|
||||
|
||||
FROM alpine:3.24
|
||||
RUN addgroup -S -g 10001 pocketwatch && adduser -S -D -H -u 10001 -G pocketwatch pocketwatch \
|
||||
&& mkdir -p /data && chown -R pocketwatch:pocketwatch /data
|
||||
COPY --from=build /out/pocketwatch /usr/local/bin/pocketwatch
|
||||
USER pocketwatch
|
||||
VOLUME ["/data"]
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD wget -q -O - http://127.0.0.1:8080/healthz | grep -q '"status":"ok"' || exit 1
|
||||
ENV APP_ADDR=:8080 DATA_DIR=/data COOKIE_SECURE=false
|
||||
ENTRYPOINT ["/usr/local/bin/pocketwatch"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Pocketwatch Go contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
16
Makefile
Normal file
16
Makefile
Normal file
@@ -0,0 +1,16 @@
|
||||
.PHONY: run test fmt build docker
|
||||
|
||||
run:
|
||||
DATA_DIR=./data APP_ADDR=:8080 go run ./cmd/pocketwatch
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -o pocketwatch ./cmd/pocketwatch
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w cmd internal
|
||||
|
||||
docker:
|
||||
docker compose up -d --build
|
||||
205
README.md
205
README.md
@@ -1,2 +1,205 @@
|
||||
# flancer
|
||||
# Pocketwatch Go
|
||||
|
||||
Ein schlanker, self-hosted Zeiterfasser in Go – funktional an `winnicodes/pocketwatch` angelehnt, aber ohne React, Node.js, PHP oder Nginx im Anwendungs-Stack.
|
||||
|
||||
Die Anwendung besteht aus einem Go-Binary, eingebettetem HTML/CSS/Vanilla-JavaScript und SQLite über `modernc.org/sqlite` (pure Go, kein CGO notwendig).
|
||||
|
||||
## Enthalten
|
||||
|
||||
- Start-/Stop-Timer mit Live-Anzeige
|
||||
- Laufender Timer überlebt Reloads und Gerätewechsel
|
||||
- Kunde + Tätigkeit, Kunden-Autocomplete nach letzter Nutzung
|
||||
- Tages- und Wochensumme
|
||||
- Verlauf gruppiert nach Tagen
|
||||
- Suche über Kunde und Tätigkeit
|
||||
- Zeitraumfilter: Tag, Woche, Monat, Jahr, alle
|
||||
- Zeitraum vor/zurück schalten
|
||||
- Einträge nachtragen, bearbeiten und löschen
|
||||
- Rundung 1–60 Minuten, normal oder immer aufwärts
|
||||
- CSV-Export (Semikolon + UTF-8 BOM für Excel)
|
||||
- PDF-Export ohne PDF-Framework
|
||||
- Export wahlweise für aktuelle Ansicht oder freien Datumsbereich, auf-/absteigend und kompakt
|
||||
- Responsive Desktop-/Mobile-Oberfläche
|
||||
- Mehrbenutzerbetrieb mit strikt getrennten Daten
|
||||
- Admin-/Benutzerrollen
|
||||
- Benutzer anlegen, deaktivieren und Passwörter zurücksetzen
|
||||
- Eigenes Passwort ändern
|
||||
- Keine öffentliche Registrierung nach der Ersteinrichtung
|
||||
- Sessions in SQLite, HttpOnly-Cookie, CSRF-Token
|
||||
- Login-Rate-Limit
|
||||
- CSP und weitere Security-Header
|
||||
- SQLite WAL, Foreign Keys, Busy Timeout
|
||||
- Healthcheck unter `/healthz`
|
||||
- Docker/Compose, unprivilegierter Runtime-Benutzer, alle Linux-Capabilities entfernt
|
||||
|
||||
## Abhängigkeiten
|
||||
|
||||
Zur Laufzeit gibt es nur eine externe Go-Abhängigkeit:
|
||||
|
||||
```text
|
||||
modernc.org/sqlite
|
||||
```
|
||||
|
||||
Alles andere verwendet die Go-Standardbibliothek. Das Frontend hat **keine** npm-/Node-Abhängigkeiten und keinen separaten Build-Schritt.
|
||||
|
||||
## Schnellstart mit Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Danach:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
Beim ersten Aufruf erscheint die Ersteinrichtung. Der erste Account wird Administrator.
|
||||
|
||||
Die Daten liegen im Docker-Volume `pocketwatch-data` in `/data/pocketwatch.db`. Das Image enthält außerdem einen Docker-Healthcheck gegen `/healthz`.
|
||||
|
||||
Bei einem Host-Bind-Mount statt eines Named Volumes muss das Zielverzeichnis für UID/GID `10001` schreibbar sein.
|
||||
|
||||
## Lokal ohne Docker
|
||||
|
||||
Voraussetzungen:
|
||||
|
||||
- Go 1.26+
|
||||
|
||||
Dann:
|
||||
|
||||
```bash
|
||||
go mod tidy
|
||||
DATA_DIR=./data APP_ADDR=:8080 go run ./cmd/pocketwatch
|
||||
```
|
||||
|
||||
Oder:
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
## Konfiguration
|
||||
|
||||
| Variable | Default | Bedeutung |
|
||||
|---|---:|---|
|
||||
| `APP_ADDR` | `:8080` | Listen-Adresse des HTTP-Servers |
|
||||
| `DATA_DIR` | `/data` | Verzeichnis für `pocketwatch.db` |
|
||||
| `COOKIE_SECURE` | `false` | Auf `true` setzen, wenn die App ausschließlich über HTTPS erreichbar ist |
|
||||
|
||||
### Hinter Reverse Proxy / HTTPS
|
||||
|
||||
Wenn z. B. Caddy, Traefik oder nginx TLS terminiert:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
COOKIE_SECURE: "true"
|
||||
```
|
||||
|
||||
Die App setzt selbst keine CORS-Header. API und UI sind als Same-Origin-Anwendung gedacht.
|
||||
|
||||
## Datenmodell
|
||||
|
||||
SQLite enthält vier Kernbereiche:
|
||||
|
||||
- `users` – Accounts, Rollen, Aktivstatus
|
||||
- `sessions` – gehashte Session-Tokens + CSRF-Token
|
||||
- `entries` – Zeiteinträge, immer mit `user_id`
|
||||
- `user_settings` – persönliche Rundungs-, Export- und Anzeigeeinstellungen
|
||||
|
||||
Ein partieller Unique-Index stellt sicher, dass pro Benutzer höchstens ein laufender Timer existiert.
|
||||
|
||||
Zeitpunkte werden als Unix-Millisekunden gespeichert. Die Web-Oberfläche verwendet die lokale Browser-Zeitzone; Exporte verwenden die persönliche IANA-Zeitzone, z. B. `Europe/Berlin`.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Passwörter
|
||||
|
||||
Passwörter werden mit PBKDF2-HMAC-SHA256, zufälligem Salt und 310.000 Iterationen gespeichert. Die Implementierung nutzt `crypto/pbkdf2`, `crypto/sha256` und `crypto/rand` aus der Go-Standardbibliothek von Go 1.26.
|
||||
|
||||
### Sessions
|
||||
|
||||
- 256-Bit zufällige Session-Tokens
|
||||
- nur SHA-256-Digest des Session-Tokens in SQLite
|
||||
- HttpOnly-Cookie
|
||||
- SameSite=Lax
|
||||
- optional `Secure`
|
||||
- serverseitiges Ablaufdatum
|
||||
- Sessions werden beim Deaktivieren eines Benutzers oder Passwort-Reset invalidiert
|
||||
|
||||
### CSRF
|
||||
|
||||
Schreibende API-Aufrufe benötigen zusätzlich ein zufälliges, sitzungsgebundenes `X-CSRF-Token`.
|
||||
|
||||
### Mandantentrennung
|
||||
|
||||
Jede SQL-Operation auf Zeiten enthält die `user_id` aus der authentifizierten Session. IDs aus einem anderen Benutzerkonto reichen daher nicht aus, um fremde Einträge zu lesen oder zu verändern.
|
||||
|
||||
## Backup
|
||||
|
||||
Wegen WAL sollte die Datenbank nicht blind während Schreibzugriffen als einzelne Datei kopiert werden. Der einfachste konsistente Weg bei Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose stop pocketwatch
|
||||
|
||||
docker run --rm \
|
||||
-v pocketwatch-go_pocketwatch-data:/data:ro \
|
||||
-v "$PWD:/backup" \
|
||||
alpine:3.22 \
|
||||
tar czf /backup/pocketwatch-backup.tgz -C /data .
|
||||
|
||||
docker compose start pocketwatch
|
||||
```
|
||||
|
||||
Der konkrete Volume-Name kann je nach Compose-Projektname abweichen (`docker volume ls`).
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```text
|
||||
pocketwatch-go/
|
||||
├── cmd/pocketwatch/main.go
|
||||
├── internal/app/
|
||||
│ ├── auth.go
|
||||
│ ├── db.go
|
||||
│ ├── export.go
|
||||
│ ├── server.go
|
||||
│ ├── *_test.go
|
||||
│ └── web/
|
||||
│ ├── index.html
|
||||
│ ├── login.html
|
||||
│ ├── app.css
|
||||
│ ├── app.js
|
||||
│ └── login.js
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── Makefile
|
||||
└── go.mod
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Enthalten sind u. a. Tests für PBKDF2, Rundungslogik und den minimalen PDF-Writer.
|
||||
|
||||
## Bewusste Unterschiede zum ursprünglichen Pocketwatch
|
||||
|
||||
Diese Implementierung übernimmt das Produktkonzept und die wichtigsten Bedienabläufe, ist aber technisch ein Neuaufbau:
|
||||
|
||||
- SQLite statt JSON-Dateien
|
||||
- Go statt PHP
|
||||
- Vanilla JS statt React/Vite/Tailwind
|
||||
- kein Node.js im Build oder Betrieb
|
||||
- Login und Mehrbenutzerbetrieb
|
||||
- Adminverwaltung
|
||||
- serverseitige Datenisolation
|
||||
- CSRF- und Session-Schutz
|
||||
- Healthcheck und Security-Header
|
||||
|
||||
Die Oberfläche orientiert sich am dunklen, kompakten Amber-Design des Originals, ist aber kein 1:1 kopierter Frontend-Quellcode. Sie ist derzeit bewusst deutschsprachig; das Datenmodell hält die Spracheinstellung bereits für eine spätere vollständige Lokalisierung vor.
|
||||
|
||||
## Inspiration
|
||||
|
||||
Inspiriert von [`winnicodes/pocketwatch`](https://github.com/winnicodes/pocketwatch), das als minimalistischer self-hosted Zeiterfasser unter MIT veröffentlicht ist.
|
||||
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
||||
services:
|
||||
pocketwatch:
|
||||
build: .
|
||||
image: pocketwatch-go:local
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
APP_ADDR: ":8080"
|
||||
DATA_DIR: "/data"
|
||||
# Hinter HTTPS-Reverse-Proxy auf true setzen.
|
||||
COOKIE_SECURE: "false"
|
||||
volumes:
|
||||
- pocketwatch-data:/data
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
cap_drop:
|
||||
- ALL
|
||||
|
||||
volumes:
|
||||
pocketwatch-data:
|
||||
17
go.mod
Normal file
17
go.mod
Normal file
@@ -0,0 +1,17 @@
|
||||
module pocketwatch-go
|
||||
|
||||
go 1.26
|
||||
|
||||
require modernc.org/sqlite v1.56.0
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
modernc.org/libc v1.74.4 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
50
go.sum
Normal file
50
go.sum
Normal file
@@ -0,0 +1,50 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
204
internal/app/auth.go
Normal file
204
internal/app/auth.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/pbkdf2"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
passwordIterations = 310_000
|
||||
passwordSaltBytes = 16
|
||||
passwordKeyBytes = 32
|
||||
sessionCookie = "pocketwatch_session"
|
||||
)
|
||||
|
||||
// This is deliberately public and never authenticates an account. It only keeps
|
||||
// failed-login password work roughly constant when a username does not exist.
|
||||
const dummyPasswordHash = "pbkdf2-sha256$310000$cG9ja2V0d2F0Y2gtZHVtbXk$y2kfBbJVOW/xpojEt9AAGQfhF+Ul6iGKDKH09PSY7IY"
|
||||
|
||||
type session struct {
|
||||
User User
|
||||
CSRF string
|
||||
}
|
||||
|
||||
type contextKey int
|
||||
|
||||
const sessionKey contextKey = 1
|
||||
|
||||
func hashPassword(password string) (string, error) {
|
||||
if len(password) < 10 {
|
||||
return "", fmt.Errorf("password must have at least 10 characters")
|
||||
}
|
||||
salt := make([]byte, passwordSaltBytes)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := pbkdf2SHA256([]byte(password), salt, passwordIterations, passwordKeyBytes)
|
||||
return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
func verifyPassword(encoded, password string) bool {
|
||||
parts := strings.Split(encoded, "$")
|
||||
if len(parts) != 4 || parts[0] != "pbkdf2-sha256" {
|
||||
return false
|
||||
}
|
||||
iter, err := strconv.Atoi(parts[1])
|
||||
if err != nil || iter < 100_000 || iter > 2_000_000 {
|
||||
return false
|
||||
}
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[3])
|
||||
if err != nil || len(want) == 0 {
|
||||
return false
|
||||
}
|
||||
got := pbkdf2SHA256([]byte(password), salt, iter, len(want))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// pbkdf2SHA256 wraps Go 1.26's standard-library PBKDF2 implementation.
|
||||
func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte {
|
||||
key, err := pbkdf2.Key(sha256.New, string(password), salt, iterations, keyLen)
|
||||
if err != nil {
|
||||
panic(err) // callers use fixed, validated parameters
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func randomToken(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func tokenDigest(token string) []byte {
|
||||
s := sha256.Sum256([]byte(token))
|
||||
return s[:]
|
||||
}
|
||||
|
||||
func newID() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
b[6] = (b[6] & 0x0f) | 0x40
|
||||
b[8] = (b[8] & 0x3f) | 0x80
|
||||
h := hex.EncodeToString(b)
|
||||
return h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
|
||||
}
|
||||
|
||||
func (a *App) createSession(ctx context.Context, userID string) (token, csrf string, expires time.Time, err error) {
|
||||
token, err = randomToken(32)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
csrf, err = randomToken(24)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
expires = now.Add(a.cfg.SessionTTL)
|
||||
_, err = a.store.db.ExecContext(ctx, `INSERT INTO sessions(token_hash,user_id,csrf_token,created_at_ms,expires_at_ms) VALUES(?,?,?,?,?)`, tokenDigest(token), userID, csrf, now.UnixMilli(), expires.UnixMilli())
|
||||
return
|
||||
}
|
||||
|
||||
func (a *App) sessionFromRequest(r *http.Request) (*session, error) {
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
var userID, csrf string
|
||||
var expires int64
|
||||
err = a.store.db.QueryRowContext(r.Context(), `SELECT user_id,csrf_token,expires_at_ms FROM sessions WHERE token_hash=?`, tokenDigest(c.Value)).Scan(&userID, &csrf, &expires)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if expires <= time.Now().UnixMilli() {
|
||||
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
u, err := a.store.userByID(r.Context(), userID)
|
||||
if err != nil || !u.Active {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
return &session{User: u, CSRF: csrf}, nil
|
||||
}
|
||||
|
||||
func (a *App) withSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
s, err := a.sessionFromRequest(r)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
jsonError(w, http.StatusUnauthorized, "not_authenticated", "Bitte anmelden.")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey, s)))
|
||||
})
|
||||
}
|
||||
|
||||
func sessionOf(r *http.Request) *session {
|
||||
x, _ := r.Context().Value(sessionKey).(*session)
|
||||
return x
|
||||
}
|
||||
|
||||
func requireCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions {
|
||||
return true
|
||||
}
|
||||
s := sessionOf(r)
|
||||
if s == nil || subtle.ConstantTimeCompare([]byte(r.Header.Get("X-CSRF-Token")), []byte(s.CSRF)) != 1 {
|
||||
jsonError(w, http.StatusForbidden, "csrf", "Ungültiges CSRF-Token.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
s := sessionOf(r)
|
||||
if s == nil || s.User.Role != "admin" {
|
||||
jsonError(w, http.StatusForbidden, "forbidden", "Admin-Rechte erforderlich.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) deleteCurrentSession(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie(sessionCookie); err == nil {
|
||||
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE token_hash=?`, tokenDigest(c.Value))
|
||||
}
|
||||
a.clearSessionCookie(w)
|
||||
}
|
||||
|
||||
func (a *App) setSessionCookie(w http.ResponseWriter, token string, expires time.Time) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: token, Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())})
|
||||
}
|
||||
|
||||
func (a *App) clearSessionCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: a.cfg.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: -1, Expires: time.Unix(1, 0)})
|
||||
}
|
||||
|
||||
func isUniqueConstraint(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := strings.ToLower(err.Error())
|
||||
return strings.Contains(s, "unique constraint") || strings.Contains(s, "constraint failed")
|
||||
}
|
||||
16
internal/app/auth_test.go
Normal file
16
internal/app/auth_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPBKDF2Deterministic(t *testing.T) {
|
||||
got := pbkdf2SHA256([]byte("password"), []byte("salt"), 2, 32)
|
||||
want := []byte{0xae, 0x4d, 0x0c, 0x95, 0xaf, 0x6b, 0x46, 0xd3, 0x2d, 0x0a, 0xdf, 0xf9, 0x28, 0xf0, 0x6d, 0xd0, 0x2a, 0x30, 0x3f, 0x8e, 0xf3, 0xc2, 0x51, 0xdf, 0xd6, 0xe2, 0xd8, 0x5a, 0x95, 0x47, 0x4c, 0x43}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("length %d", len(got))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("byte %d: got %x want %x", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
531
internal/app/db.go
Normal file
531
internal/app/db.go
Normal file
@@ -0,0 +1,531 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAtMS int64 `json:"created_at_ms"`
|
||||
}
|
||||
|
||||
type Settings struct {
|
||||
Language string `json:"language"`
|
||||
TimeFormat string `json:"time_format"`
|
||||
RoundingMinutes int `json:"rounding_minutes"`
|
||||
RoundUp bool `json:"round_up"`
|
||||
ShowWeekTotal bool `json:"show_week_total"`
|
||||
StickyDays bool `json:"sticky_days"`
|
||||
LongRunReminder bool `json:"long_run_reminder"`
|
||||
ExportName string `json:"export_name"`
|
||||
Timezone string `json:"timezone"`
|
||||
ExportDate bool `json:"export_date"`
|
||||
}
|
||||
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Client string `json:"client"`
|
||||
Activity string `json:"activity"`
|
||||
StartMS int64 `json:"start_ms"`
|
||||
EndMS *int64 `json:"end_ms"`
|
||||
Created int64 `json:"created_at_ms"`
|
||||
Updated int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
type store struct{ db *sql.DB }
|
||||
|
||||
func openStore(path string) (*store, error) {
|
||||
// modernc.org/sqlite supports validated DSN shorthands for common PRAGMAs.
|
||||
u := &url.URL{Scheme: "file", Path: path}
|
||||
q := u.Query()
|
||||
q.Set("_fk", "1")
|
||||
q.Set("_journal", "WAL")
|
||||
q.Set("_timeout", "5000")
|
||||
q.Set("_sync", "NORMAL")
|
||||
q.Set("_dqs", "false")
|
||||
u.RawQuery = q.Encode()
|
||||
dsn := u.String()
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// SQLite has a single writer. A small pool avoids accidental writer stampedes while
|
||||
// still allowing concurrent reads in WAL mode.
|
||||
db.SetMaxOpenConns(8)
|
||||
db.SetMaxIdleConns(4)
|
||||
db.SetConnMaxLifetime(0)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s := &store{db: db}
|
||||
if err := s.migrate(ctx); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *store) migrate(ctx context.Context) error {
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||||
setup_complete INTEGER NOT NULL DEFAULT 0 CHECK(setup_complete IN (0,1))
|
||||
);
|
||||
INSERT OR IGNORE INTO app_state(id, setup_complete) VALUES(1, 0);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
display_name TEXT NOT NULL DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK(role IN ('admin','user')),
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK(active IN (0,1)),
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token_hash BLOB PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
expires_at_ms INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expiry ON sessions(expires_at_ms);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
client TEXT NOT NULL DEFAULT '',
|
||||
activity TEXT NOT NULL DEFAULT '',
|
||||
start_ms INTEGER NOT NULL,
|
||||
end_ms INTEGER,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
CHECK(end_ms IS NULL OR end_ms >= start_ms)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_user_start ON entries(user_id, start_ms DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_user_client ON entries(user_id, client COLLATE NOCASE);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_one_running_entry_per_user ON entries(user_id) WHERE end_ms IS NULL;
|
||||
|
||||
UPDATE app_state SET setup_complete=1 WHERE id=1 AND EXISTS(SELECT 1 FROM users);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
language TEXT NOT NULL DEFAULT 'de' CHECK(language IN ('de','en')),
|
||||
time_format TEXT NOT NULL DEFAULT '24' CHECK(time_format IN ('24','12')),
|
||||
rounding_minutes INTEGER NOT NULL DEFAULT 1 CHECK(rounding_minutes BETWEEN 1 AND 60),
|
||||
round_up INTEGER NOT NULL DEFAULT 0 CHECK(round_up IN (0,1)),
|
||||
show_week_total INTEGER NOT NULL DEFAULT 1 CHECK(show_week_total IN (0,1)),
|
||||
sticky_days INTEGER NOT NULL DEFAULT 1 CHECK(sticky_days IN (0,1)),
|
||||
long_run_reminder INTEGER NOT NULL DEFAULT 1 CHECK(long_run_reminder IN (0,1)),
|
||||
export_name TEXT NOT NULL DEFAULT '',
|
||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
||||
export_date INTEGER NOT NULL DEFAULT 1 CHECK(export_date IN (0,1))
|
||||
);
|
||||
`
|
||||
_, err := s.db.ExecContext(ctx, schema)
|
||||
return err
|
||||
}
|
||||
|
||||
var errAlreadySetup = errors.New("instance already set up")
|
||||
|
||||
func (s *store) needsSetup(ctx context.Context) (bool, error) {
|
||||
var complete int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT setup_complete FROM app_state WHERE id=1`).Scan(&complete)
|
||||
return complete == 0, err
|
||||
}
|
||||
|
||||
func (s *store) bootstrapAdmin(ctx context.Context, username, display, passwordHash string) (User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
display = strings.TrimSpace(display)
|
||||
if display == "" {
|
||||
display = username
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
u := User{ID: newID(), Username: username, DisplayName: display, Role: "admin", Active: true, CreatedAtMS: now}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
res, err := tx.ExecContext(ctx, `UPDATE app_state SET setup_complete=1 WHERE id=1 AND setup_complete=0`)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if n != 1 {
|
||||
return User{}, errAlreadySetup
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,'admin',1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, now); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *store) createUser(ctx context.Context, username, display, passwordHash, role string) (User, error) {
|
||||
username = strings.TrimSpace(username)
|
||||
display = strings.TrimSpace(display)
|
||||
if display == "" {
|
||||
display = username
|
||||
}
|
||||
if role != "admin" {
|
||||
role = "user"
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
u := User{ID: newID(), Username: username, DisplayName: display, Role: role, Active: true, CreatedAtMS: now}
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO users(id,username,display_name,password_hash,role,active,created_at_ms) VALUES(?,?,?,?,?,1,?)`, u.ID, u.Username, u.DisplayName, passwordHash, u.Role, now); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO user_settings(user_id,export_name) VALUES(?,?)`, u.ID, u.DisplayName); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *store) userForLogin(ctx context.Context, username string) (User, string, error) {
|
||||
var u User
|
||||
var hash string
|
||||
var active int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,password_hash,role,active,created_at_ms FROM users WHERE username=?`, strings.TrimSpace(username)).Scan(&u.ID, &u.Username, &u.DisplayName, &hash, &u.Role, &active, &u.CreatedAtMS)
|
||||
u.Active = active == 1
|
||||
return u, hash, err
|
||||
}
|
||||
|
||||
func (s *store) userByID(ctx context.Context, id string) (User, error) {
|
||||
var u User
|
||||
var active int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users WHERE id=?`, id).Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS)
|
||||
u.Active = active == 1
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *store) listUsers(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,username,display_name,role,active,created_at_ms FROM users ORDER BY username COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
var active int
|
||||
if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Role, &active, &u.CreatedAtMS); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Active = active == 1
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *store) setUserActive(ctx context.Context, id string, active bool) error {
|
||||
v := 0
|
||||
if active {
|
||||
v = 1
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET active=? WHERE id=?`, v, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if !active {
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) resetPassword(ctx context.Context, id, hash string) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET password_hash=? WHERE id=?`, hash, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
_, _ = s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id=?`, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *store) settings(ctx context.Context, userID string) (Settings, error) {
|
||||
var x Settings
|
||||
var up, week, sticky, reminder, exportDate int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT language,time_format,rounding_minutes,round_up,show_week_total,sticky_days,long_run_reminder,export_name,timezone,export_date FROM user_settings WHERE user_id=?`, userID).Scan(
|
||||
&x.Language, &x.TimeFormat, &x.RoundingMinutes, &up, &week, &sticky, &reminder, &x.ExportName, &x.Timezone, &exportDate,
|
||||
)
|
||||
x.RoundUp = up == 1
|
||||
x.ShowWeekTotal = week == 1
|
||||
x.StickyDays = sticky == 1
|
||||
x.LongRunReminder = reminder == 1
|
||||
x.ExportDate = exportDate == 1
|
||||
return x, err
|
||||
}
|
||||
|
||||
func (s *store) updateSettings(ctx context.Context, userID string, x Settings) error {
|
||||
boolInt := func(v bool) int {
|
||||
if v {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE user_settings SET language=?,time_format=?,rounding_minutes=?,round_up=?,show_week_total=?,sticky_days=?,long_run_reminder=?,export_name=?,timezone=?,export_date=? WHERE user_id=?`,
|
||||
x.Language, x.TimeFormat, x.RoundingMinutes, boolInt(x.RoundUp), boolInt(x.ShowWeekTotal), boolInt(x.StickyDays), boolInt(x.LongRunReminder), strings.TrimSpace(x.ExportName), x.Timezone, boolInt(x.ExportDate), userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *store) runningEntry(ctx context.Context, userID string) (*Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE user_id=? AND end_ms IS NULL LIMIT 1`, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
return &e, err
|
||||
}
|
||||
|
||||
func (s *store) startEntry(ctx context.Context, userID, client, activity string, startMS int64) (Entry, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
if startMS == 0 {
|
||||
startMS = now
|
||||
}
|
||||
e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, Created: now, Updated: now}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,NULL,?,?)`, e.ID, userID, e.Client, e.Activity, e.StartMS, now, now)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *store) stopEntry(ctx context.Context, userID, id string, endMS int64) (Entry, error) {
|
||||
if endMS == 0 {
|
||||
endMS = time.Now().UnixMilli()
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE entries SET end_ms=?,updated_at_ms=? WHERE id=? AND user_id=? AND end_ms IS NULL AND start_ms<=?`, endMS, now, id, userID, endMS)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return Entry{}, sql.ErrNoRows
|
||||
}
|
||||
return s.entryByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *store) entryByID(ctx context.Context, userID, id string) (Entry, error) {
|
||||
var e Entry
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries WHERE id=? AND user_id=?`, id, userID).Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated)
|
||||
return e, err
|
||||
}
|
||||
|
||||
func (s *store) updateEntry(ctx context.Context, userID, id, client, activity string, startMS int64, endMS *int64) (Entry, error) {
|
||||
if startMS <= 0 || (endMS != nil && *endMS < startMS) {
|
||||
return Entry{}, fmt.Errorf("invalid time range")
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE entries SET client=?,activity=?,start_ms=?,end_ms=?,updated_at_ms=? WHERE id=? AND user_id=?`, strings.TrimSpace(client), strings.TrimSpace(activity), startMS, endMS, now, id, userID)
|
||||
if err != nil {
|
||||
return Entry{}, err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return Entry{}, sql.ErrNoRows
|
||||
}
|
||||
return s.entryByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
func (s *store) deleteEntry(ctx context.Context, userID, id string) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM entries WHERE id=? AND user_id=?`, id, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type entryFilter struct {
|
||||
Query string
|
||||
FromMS int64
|
||||
ToMS int64
|
||||
Limit int
|
||||
Offset int
|
||||
SortAsc bool
|
||||
Compact bool
|
||||
}
|
||||
|
||||
type entryPage struct {
|
||||
Entries []Entry `json:"entries"`
|
||||
TotalCount int `json:"total_count"`
|
||||
TotalDurationMS int64 `json:"total_duration_ms"`
|
||||
}
|
||||
|
||||
func (s *store) listEntries(ctx context.Context, userID string, f entryFilter, cfg Settings) (entryPage, error) {
|
||||
where, args := buildEntryWhere(userID, f)
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM entries `+where+` AND end_ms IS NOT NULL`, args...).Scan(&total); err != nil {
|
||||
return entryPage{}, err
|
||||
}
|
||||
|
||||
// Totals are calculated row-wise to keep the rounding semantics identical to exports.
|
||||
rowsDur, err := s.db.QueryContext(ctx, `SELECT start_ms,end_ms FROM entries `+where+` AND end_ms IS NOT NULL`, args...)
|
||||
if err != nil {
|
||||
return entryPage{}, err
|
||||
}
|
||||
var totalDur int64
|
||||
for rowsDur.Next() {
|
||||
var start, end int64
|
||||
if err := rowsDur.Scan(&start, &end); err != nil {
|
||||
rowsDur.Close()
|
||||
return entryPage{}, err
|
||||
}
|
||||
totalDur += roundedDuration(end-start, cfg.RoundingMinutes, cfg.RoundUp)
|
||||
}
|
||||
rowsDur.Close()
|
||||
if err := rowsDur.Err(); err != nil {
|
||||
return entryPage{}, err
|
||||
}
|
||||
|
||||
qargs := append(append([]any{}, args...), f.Limit, f.Offset)
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms DESC,id DESC LIMIT ? OFFSET ?`, qargs...)
|
||||
if err != nil {
|
||||
return entryPage{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := entryPage{TotalCount: total, TotalDurationMS: totalDur, Entries: []Entry{}}
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil {
|
||||
return entryPage{}, err
|
||||
}
|
||||
out.Entries = append(out.Entries, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *store) allEntries(ctx context.Context, userID string, f entryFilter) ([]Entry, error) {
|
||||
where, args := buildEntryWhere(userID, f)
|
||||
order := "DESC"
|
||||
if f.SortAsc {
|
||||
order = "ASC"
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms FROM entries `+where+` AND end_ms IS NOT NULL ORDER BY start_ms `+order+`,id `+order, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Entry
|
||||
for rows.Next() {
|
||||
var e Entry
|
||||
if err := rows.Scan(&e.ID, &e.Client, &e.Activity, &e.StartMS, &e.EndMS, &e.Created, &e.Updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func buildEntryWhere(userID string, f entryFilter) (string, []any) {
|
||||
where := `WHERE user_id=?`
|
||||
args := []any{userID}
|
||||
if f.FromMS > 0 {
|
||||
where += ` AND start_ms>=?`
|
||||
args = append(args, f.FromMS)
|
||||
}
|
||||
if f.ToMS > 0 {
|
||||
where += ` AND start_ms<?`
|
||||
args = append(args, f.ToMS)
|
||||
}
|
||||
if q := strings.TrimSpace(f.Query); q != "" {
|
||||
where += ` AND (client LIKE ? ESCAPE '\' COLLATE NOCASE OR activity LIKE ? ESCAPE '\' COLLATE NOCASE)`
|
||||
q = "%" + escapeLike(q) + "%"
|
||||
args = append(args, q, q)
|
||||
}
|
||||
return where, args
|
||||
}
|
||||
|
||||
func escapeLike(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *store) recentClients(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT client FROM entries WHERE user_id=? AND TRIM(client)<>'' GROUP BY client COLLATE NOCASE ORDER BY MAX(start_ms) DESC LIMIT 50`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var x string
|
||||
if err := rows.Scan(&x); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, x)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func roundedDuration(ms int64, minutes int, up bool) int64 {
|
||||
if ms <= 0 {
|
||||
return 0
|
||||
}
|
||||
if minutes < 1 {
|
||||
minutes = 1
|
||||
}
|
||||
interval := int64(minutes) * 60_000
|
||||
if interval <= 60_000 {
|
||||
return ms
|
||||
}
|
||||
if up {
|
||||
return ((ms + interval - 1) / interval) * interval
|
||||
}
|
||||
return ((ms + interval/2) / interval) * interval
|
||||
}
|
||||
|
||||
func (s *store) createFinishedEntry(ctx context.Context, userID, client, activity string, startMS, endMS int64) (Entry, error) {
|
||||
if startMS <= 0 || endMS < startMS {
|
||||
return Entry{}, fmt.Errorf("invalid time range")
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
e := Entry{ID: newID(), Client: strings.TrimSpace(client), Activity: strings.TrimSpace(activity), StartMS: startMS, EndMS: &endMS, Created: now, Updated: now}
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO entries(id,user_id,client,activity,start_ms,end_ms,created_at_ms,updated_at_ms) VALUES(?,?,?,?,?,?,?,?)`, e.ID, userID, e.Client, e.Activity, startMS, endMS, now, now)
|
||||
return e, err
|
||||
}
|
||||
253
internal/app/export.go
Normal file
253
internal/app/export.go
Normal file
@@ -0,0 +1,253 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func makeCSV(entries []Entry, cfg Settings, compact bool) ([]byte, error) {
|
||||
loc := location(cfg.Timezone)
|
||||
var b bytes.Buffer
|
||||
b.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM.
|
||||
w := csv.NewWriter(&b)
|
||||
w.Comma = ';'
|
||||
if compact {
|
||||
_ = w.Write([]string{"Datum", "Kunde", "Start", "Ende", "Dauer"})
|
||||
} else {
|
||||
_ = w.Write([]string{"Datum", "Kunde", "Tätigkeit", "Start", "Ende", "Dauer"})
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.EndMS == nil {
|
||||
continue
|
||||
}
|
||||
start := time.UnixMilli(e.StartMS).In(loc)
|
||||
end := time.UnixMilli(*e.EndMS).In(loc)
|
||||
dur := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
|
||||
if compact {
|
||||
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
|
||||
} else {
|
||||
_ = w.Write([]string{start.Format("02.01.2006"), e.Client, e.Activity, formatClock(start, cfg.TimeFormat), formatClock(end, cfg.TimeFormat), formatDuration(dur)})
|
||||
}
|
||||
}
|
||||
w.Flush()
|
||||
return b.Bytes(), w.Error()
|
||||
}
|
||||
|
||||
type pdfPageLine struct {
|
||||
x, y, size float64
|
||||
bold bool
|
||||
text string
|
||||
}
|
||||
|
||||
func makePDF(entries []Entry, cfg Settings, owner User, compact bool) []byte {
|
||||
loc := location(cfg.Timezone)
|
||||
name := strings.TrimSpace(cfg.ExportName)
|
||||
if name == "" {
|
||||
name = owner.DisplayName
|
||||
}
|
||||
|
||||
const pageW, pageH = 595.0, 842.0 // A4 points.
|
||||
var pages [][]pdfPageLine
|
||||
var page []pdfPageLine
|
||||
y := 795.0
|
||||
|
||||
newPage := func() {
|
||||
if len(page) > 0 {
|
||||
pages = append(pages, page)
|
||||
}
|
||||
page = []pdfPageLine{}
|
||||
y = 795
|
||||
page = append(page, pdfPageLine{50, y, 19, true, "Zeiterfassung"})
|
||||
y -= 24
|
||||
page = append(page, pdfPageLine{50, y, 10, false, name})
|
||||
y -= 22
|
||||
if compact {
|
||||
page = append(page,
|
||||
pdfPageLine{50, y, 9, true, "Datum"},
|
||||
pdfPageLine{112, y, 9, true, "Kunde"},
|
||||
pdfPageLine{430, y, 9, true, "Zeit"},
|
||||
pdfPageLine{515, y, 9, true, "Dauer"},
|
||||
)
|
||||
} else {
|
||||
page = append(page,
|
||||
pdfPageLine{50, y, 9, true, "Datum"},
|
||||
pdfPageLine{112, y, 9, true, "Kunde"},
|
||||
pdfPageLine{255, y, 9, true, "Tätigkeit"},
|
||||
pdfPageLine{430, y, 9, true, "Zeit"},
|
||||
pdfPageLine{515, y, 9, true, "Dauer"},
|
||||
)
|
||||
}
|
||||
y -= 16
|
||||
}
|
||||
newPage()
|
||||
|
||||
var total int64
|
||||
for _, e := range entries {
|
||||
if e.EndMS == nil {
|
||||
continue
|
||||
}
|
||||
if y < 65 {
|
||||
newPage()
|
||||
}
|
||||
start := time.UnixMilli(e.StartMS).In(loc)
|
||||
end := time.UnixMilli(*e.EndMS).In(loc)
|
||||
d := roundedDuration(*e.EndMS-e.StartMS, cfg.RoundingMinutes, cfg.RoundUp)
|
||||
total += d
|
||||
page = append(page,
|
||||
pdfPageLine{50, y, 8.5, false, start.Format("02.01.06")},
|
||||
pdfPageLine{112, y, 8.5, false, clipRunes(e.Client, func() int {
|
||||
if compact {
|
||||
return 50
|
||||
}
|
||||
return 25
|
||||
}())},
|
||||
)
|
||||
if !compact {
|
||||
page = append(page, pdfPageLine{255, y, 8.5, false, clipRunes(e.Activity, 29)})
|
||||
}
|
||||
page = append(page,
|
||||
pdfPageLine{430, y, 8.5, false, formatClock(start, cfg.TimeFormat) + "-" + formatClock(end, cfg.TimeFormat)},
|
||||
pdfPageLine{515, y, 8.5, false, formatDuration(d)},
|
||||
)
|
||||
y -= 15
|
||||
}
|
||||
if y < 60 {
|
||||
newPage()
|
||||
}
|
||||
page = append(page, pdfPageLine{430, y - 4, 10, true, "Gesamt"}, pdfPageLine{515, y - 4, 10, true, formatDuration(total)})
|
||||
pages = append(pages, page)
|
||||
|
||||
return buildSimplePDF(pageW, pageH, pages, cfg.ExportDate, loc)
|
||||
}
|
||||
|
||||
// buildSimplePDF writes a small, standards-compliant PDF using only built-in Type 1
|
||||
// fonts. This avoids pulling a PDF framework into the server binary.
|
||||
func buildSimplePDF(pageW, pageH float64, pages [][]pdfPageLine, exportDate bool, loc *time.Location) []byte {
|
||||
objs := make([][]byte, 0)
|
||||
add := func(s string) int {
|
||||
objs = append(objs, []byte(s))
|
||||
return len(objs)
|
||||
}
|
||||
catalogID := add("")
|
||||
pagesID := add("")
|
||||
fontID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>`)
|
||||
boldID := add(`<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>`)
|
||||
|
||||
pageIDs := make([]int, 0, len(pages))
|
||||
for i, lines := range pages {
|
||||
var c strings.Builder
|
||||
for _, ln := range lines {
|
||||
font := "F1"
|
||||
if ln.bold {
|
||||
font = "F2"
|
||||
}
|
||||
fmt.Fprintf(&c, "BT /%s %.1f Tf %.1f %.1f Td (%s) Tj ET\n", font, ln.size, ln.x, ln.y, pdfEscape(ln.text))
|
||||
}
|
||||
footer := "Seite " + strconv.Itoa(i+1) + "/" + strconv.Itoa(len(pages))
|
||||
if exportDate {
|
||||
footer = "Export: " + time.Now().In(loc).Format("02.01.2006") + " - " + footer
|
||||
}
|
||||
fmt.Fprintf(&c, "BT /F1 7.5 Tf 50 28 Td (%s) Tj ET\n", pdfEscape(footer))
|
||||
|
||||
content := c.String()
|
||||
contentID := add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content))
|
||||
pageID := add(fmt.Sprintf(
|
||||
"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %.0f %.0f] /Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>",
|
||||
pagesID, pageW, pageH, fontID, boldID, contentID,
|
||||
))
|
||||
pageIDs = append(pageIDs, pageID)
|
||||
}
|
||||
kids := make([]string, len(pageIDs))
|
||||
for i, id := range pageIDs {
|
||||
kids[i] = fmt.Sprintf("%d 0 R", id)
|
||||
}
|
||||
objs[catalogID-1] = []byte(fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pagesID))
|
||||
objs[pagesID-1] = []byte(fmt.Sprintf("<< /Type /Pages /Count %d /Kids [%s] >>", len(pageIDs), strings.Join(kids, " ")))
|
||||
|
||||
var out bytes.Buffer
|
||||
out.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n")
|
||||
offsets := make([]int, len(objs)+1)
|
||||
for i, obj := range objs {
|
||||
offsets[i+1] = out.Len()
|
||||
fmt.Fprintf(&out, "%d 0 obj\n", i+1)
|
||||
out.Write(obj)
|
||||
out.WriteString("\nendobj\n")
|
||||
}
|
||||
xref := out.Len()
|
||||
fmt.Fprintf(&out, "xref\n0 %d\n", len(objs)+1)
|
||||
out.WriteString("0000000000 65535 f \n")
|
||||
for i := 1; i <= len(objs); i++ {
|
||||
fmt.Fprintf(&out, "%010d 00000 n \n", offsets[i])
|
||||
}
|
||||
fmt.Fprintf(&out, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(objs)+1, catalogID, xref)
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func pdfEscape(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
var c byte
|
||||
switch r {
|
||||
case '–', '—':
|
||||
c = '-'
|
||||
case '€':
|
||||
c = 0x80
|
||||
case '“', '”':
|
||||
c = '"'
|
||||
case '’':
|
||||
c = '\''
|
||||
default:
|
||||
if r >= 32 && r <= 255 {
|
||||
c = byte(r)
|
||||
} else if r == '\n' || r == '\r' {
|
||||
c = ' '
|
||||
} else {
|
||||
c = '?'
|
||||
}
|
||||
}
|
||||
if c == '(' || c == ')' || c == '\\' {
|
||||
b.WriteByte('\\')
|
||||
}
|
||||
b.WriteByte(c)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func formatClock(t time.Time, f string) string {
|
||||
if f == "12" {
|
||||
return t.Format("03:04 PM")
|
||||
}
|
||||
return t.Format("15:04")
|
||||
}
|
||||
|
||||
func formatDuration(ms int64) string {
|
||||
if ms < 0 {
|
||||
ms = 0
|
||||
}
|
||||
mins := (ms + 30_000) / 60_000
|
||||
return fmt.Sprintf("%d:%02d", mins/60, mins%60)
|
||||
}
|
||||
|
||||
func location(name string) *time.Location {
|
||||
if name != "" {
|
||||
if x, err := time.LoadLocation(name); err == nil {
|
||||
return x
|
||||
}
|
||||
}
|
||||
return time.UTC
|
||||
}
|
||||
|
||||
func clipRunes(s string, max int) string {
|
||||
r := []rune(strings.TrimSpace(s))
|
||||
if len(r) <= max {
|
||||
return string(r)
|
||||
}
|
||||
if max < 2 {
|
||||
return string(r[:max])
|
||||
}
|
||||
return string(r[:max-1]) + "…"
|
||||
}
|
||||
28
internal/app/export_test.go
Normal file
28
internal/app/export_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPDFHeader(t *testing.T) {
|
||||
end := int64(3_600_000)
|
||||
pdf := makePDF([]Entry{{ID: "x", Client: "ACME", Activity: "Arbeit", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, User{DisplayName: "Test"}, false)
|
||||
if !bytes.HasPrefix(pdf, []byte("%PDF-1.4")) {
|
||||
t.Fatal("missing PDF header")
|
||||
}
|
||||
if !bytes.Contains(pdf, []byte("xref")) {
|
||||
t.Fatal("missing xref")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVCompactOmitsActivity(t *testing.T) {
|
||||
end := int64(3_600_000)
|
||||
b, err := makeCSV([]Entry{{ID: "x", Client: "ACME", Activity: "Geheim", StartMS: 0, EndMS: &end}}, Settings{RoundingMinutes: 1, TimeFormat: "24", Timezone: "UTC"}, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(b, []byte("Geheim")) {
|
||||
t.Fatal("compact CSV contains activity")
|
||||
}
|
||||
}
|
||||
22
internal/app/rounding_test.go
Normal file
22
internal/app/rounding_test.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRoundedDuration(t *testing.T) {
|
||||
tests := []struct {
|
||||
ms int64
|
||||
min int
|
||||
up bool
|
||||
want int64
|
||||
}{
|
||||
{37 * 60_000, 15, false, 30 * 60_000},
|
||||
{38 * 60_000, 15, false, 45 * 60_000},
|
||||
{37 * 60_000, 15, true, 45 * 60_000},
|
||||
{37 * 60_000, 1, false, 37 * 60_000},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := roundedDuration(tt.ms, tt.min, tt.up); got != tt.want {
|
||||
t.Errorf("roundedDuration(%d,%d,%v)=%d want %d", tt.ms, tt.min, tt.up, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
757
internal/app/server.go
Normal file
757
internal/app/server.go
Normal file
@@ -0,0 +1,757 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"mime"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var webFS embed.FS
|
||||
|
||||
type Config struct {
|
||||
DBPath string
|
||||
CookieSecure bool
|
||||
SessionTTL time.Duration
|
||||
}
|
||||
|
||||
type App struct {
|
||||
cfg Config
|
||||
store *store
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
limiter *loginLimiter
|
||||
static fs.FS
|
||||
}
|
||||
|
||||
func New(cfg Config, logger *slog.Logger) (*App, error) {
|
||||
if cfg.SessionTTL <= 0 {
|
||||
cfg.SessionTTL = 30 * 24 * time.Hour
|
||||
}
|
||||
st, err := openStore(cfg.DBPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
static, err := fs.Sub(webFS, "web")
|
||||
if err != nil {
|
||||
st.db.Close()
|
||||
return nil, err
|
||||
}
|
||||
a := &App{cfg: cfg, store: st, log: logger, mux: http.NewServeMux(), limiter: newLoginLimiter(), static: static}
|
||||
a.routes()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a *App) Close() error { return a.store.db.Close() }
|
||||
|
||||
func (a *App) Handler() http.Handler { return a.securityHeaders(a.recoverer(a.accessLog(a.mux))) }
|
||||
|
||||
func (a *App) routes() {
|
||||
// Public.
|
||||
a.mux.HandleFunc("GET /healthz", a.health)
|
||||
a.mux.HandleFunc("GET /login", a.loginPage)
|
||||
a.mux.HandleFunc("GET /api/bootstrap", a.bootstrap)
|
||||
a.mux.HandleFunc("POST /api/setup", a.setup)
|
||||
a.mux.HandleFunc("POST /api/login", a.login)
|
||||
|
||||
fileServer := http.FileServer(http.FS(a.static))
|
||||
a.mux.Handle("GET /static/", http.StripPrefix("/static/", fileServer))
|
||||
|
||||
// Authenticated HTML.
|
||||
a.mux.Handle("GET /{$}", a.withSession(http.HandlerFunc(a.indexPage)))
|
||||
|
||||
// Authenticated API.
|
||||
api := http.NewServeMux()
|
||||
api.HandleFunc("GET /api/me", a.me)
|
||||
api.HandleFunc("POST /api/logout", a.logout)
|
||||
api.HandleFunc("POST /api/account/password", a.changeOwnPassword)
|
||||
api.HandleFunc("GET /api/settings", a.getSettings)
|
||||
api.HandleFunc("PUT /api/settings", a.putSettings)
|
||||
api.HandleFunc("GET /api/running", a.getRunning)
|
||||
api.HandleFunc("GET /api/clients", a.getClients)
|
||||
api.HandleFunc("GET /api/entries", a.getEntries)
|
||||
api.HandleFunc("POST /api/entries", a.createEntry)
|
||||
api.HandleFunc("POST /api/entries/start", a.startEntry)
|
||||
api.HandleFunc("POST /api/entries/{id}/stop", a.stopEntry)
|
||||
api.HandleFunc("PUT /api/entries/{id}", a.updateEntry)
|
||||
api.HandleFunc("DELETE /api/entries/{id}", a.deleteEntry)
|
||||
api.HandleFunc("GET /api/export.csv", a.exportCSV)
|
||||
api.HandleFunc("GET /api/export.pdf", a.exportPDF)
|
||||
api.HandleFunc("GET /api/admin/users", a.adminUsers)
|
||||
api.HandleFunc("POST /api/admin/users", a.adminCreateUser)
|
||||
api.HandleFunc("PATCH /api/admin/users/{id}", a.adminPatchUser)
|
||||
api.HandleFunc("POST /api/admin/users/{id}/password", a.adminResetPassword)
|
||||
a.mux.Handle("/api/", a.withSession(api))
|
||||
}
|
||||
|
||||
func (a *App) health(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if err := a.store.db.PingContext(ctx); err != nil {
|
||||
jsonError(w, 503, "db_unavailable", "database unavailable")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
func (a *App) loginPage(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := a.sessionFromRequest(r); err == nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
a.serveAsset(w, r, "login.html")
|
||||
}
|
||||
func (a *App) indexPage(w http.ResponseWriter, r *http.Request) { a.serveAsset(w, r, "index.html") }
|
||||
|
||||
func (a *App) serveAsset(w http.ResponseWriter, r *http.Request, name string) {
|
||||
b, err := fs.ReadFile(a.static, name)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
if strings.HasSuffix(name, ".html") {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
}
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func (a *App) bootstrap(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := a.store.needsSetup(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Datenbankfehler.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]bool{"needs_setup": needs})
|
||||
}
|
||||
|
||||
func (a *App) setup(w http.ResponseWriter, r *http.Request) {
|
||||
needs, err := a.store.needsSetup(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Datenbankfehler.")
|
||||
return
|
||||
}
|
||||
if !needs {
|
||||
jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !validUsername(in.Username) {
|
||||
jsonError(w, 400, "username", "Benutzername: 3–64 Zeichen, Buchstaben/Zahlen/._-.")
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.Password)
|
||||
if err != nil {
|
||||
jsonError(w, 400, "password", err.Error())
|
||||
return
|
||||
}
|
||||
u, err := a.store.bootstrapAdmin(r.Context(), in.Username, in.DisplayName, hash)
|
||||
if errors.Is(err, errAlreadySetup) {
|
||||
jsonError(w, 409, "already_setup", "Die Instanz ist bereits eingerichtet.")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
jsonError(w, 409, "create_user", "Benutzer konnte nicht angelegt werden.")
|
||||
return
|
||||
}
|
||||
token, _, exp, err := a.createSession(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "session", "Session konnte nicht erstellt werden.")
|
||||
return
|
||||
}
|
||||
a.setSessionCookie(w, token, exp)
|
||||
writeJSON(w, 201, map[string]any{"user": u})
|
||||
}
|
||||
|
||||
func (a *App) login(w http.ResponseWriter, r *http.Request) {
|
||||
ip := clientIP(r)
|
||||
if !a.limiter.allow(ip) {
|
||||
jsonError(w, 429, "rate_limited", "Zu viele Anmeldeversuche. Bitte später erneut versuchen.")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
u, hash, err := a.store.userForLogin(r.Context(), in.Username)
|
||||
hashToCheck := hash
|
||||
if err != nil {
|
||||
hashToCheck = dummyPasswordHash
|
||||
}
|
||||
passwordOK := verifyPassword(hashToCheck, in.Password)
|
||||
if err != nil || !u.Active || !passwordOK {
|
||||
a.limiter.fail(ip)
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
jsonError(w, 401, "bad_credentials", "Benutzername oder Passwort ist falsch.")
|
||||
return
|
||||
}
|
||||
a.limiter.success(ip)
|
||||
_, _ = a.store.db.ExecContext(r.Context(), `DELETE FROM sessions WHERE expires_at_ms<=?`, time.Now().UnixMilli())
|
||||
token, _, exp, err := a.createSession(r.Context(), u.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "session", "Session konnte nicht erstellt werden.")
|
||||
return
|
||||
}
|
||||
a.setSessionCookie(w, token, exp)
|
||||
writeJSON(w, 200, map[string]any{"user": u})
|
||||
}
|
||||
|
||||
func (a *App) me(w http.ResponseWriter, r *http.Request) {
|
||||
s := sessionOf(r)
|
||||
writeJSON(w, 200, map[string]any{"user": s.User, "csrf_token": s.CSRF})
|
||||
}
|
||||
func (a *App) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
a.deleteCurrentSession(w, r)
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (a *App) changeOwnPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Current string `json:"current_password"`
|
||||
New string `json:"new_password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
s := sessionOf(r)
|
||||
_, currentHash, err := a.store.userForLogin(r.Context(), s.User.Username)
|
||||
if err != nil || !verifyPassword(currentHash, in.Current) {
|
||||
jsonError(w, http.StatusUnauthorized, "bad_password", "Das aktuelle Passwort ist falsch.")
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.New)
|
||||
if err != nil {
|
||||
jsonError(w, 400, "password", err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.store.resetPassword(r.Context(), s.User.ID, hash); err != nil {
|
||||
jsonError(w, 500, "db", "Passwort konnte nicht geändert werden.")
|
||||
return
|
||||
}
|
||||
a.clearSessionCookie(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (a *App) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
x, err := a.store.settings(r.Context(), sessionOf(r).User.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
func (a *App) putSettings(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var x Settings
|
||||
if !decodeJSON(w, r, &x) {
|
||||
return
|
||||
}
|
||||
if x.Language != "de" && x.Language != "en" {
|
||||
x.Language = "de"
|
||||
}
|
||||
if x.TimeFormat != "12" {
|
||||
x.TimeFormat = "24"
|
||||
}
|
||||
if x.RoundingMinutes < 1 || x.RoundingMinutes > 60 {
|
||||
jsonError(w, 400, "rounding", "Rundung muss zwischen 1 und 60 Minuten liegen.")
|
||||
return
|
||||
}
|
||||
if len(x.ExportName) > 120 {
|
||||
jsonError(w, 400, "export_name", "Name ist zu lang.")
|
||||
return
|
||||
}
|
||||
if len(x.Timezone) > 80 {
|
||||
jsonError(w, 400, "timezone", "Zeitzone ist ungültig.")
|
||||
return
|
||||
}
|
||||
if _, err := time.LoadLocation(x.Timezone); err != nil {
|
||||
x.Timezone = "UTC"
|
||||
}
|
||||
if err := a.store.updateSettings(r.Context(), sessionOf(r).User.ID, x); err != nil {
|
||||
jsonError(w, 500, "db", "Einstellungen konnten nicht gespeichert werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, x)
|
||||
}
|
||||
|
||||
func (a *App) getRunning(w http.ResponseWriter, r *http.Request) {
|
||||
e, err := a.store.runningEntry(r.Context(), sessionOf(r).User.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Timer konnte nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"entry": e})
|
||||
}
|
||||
func (a *App) getClients(w http.ResponseWriter, r *http.Request) {
|
||||
x, err := a.store.recentClients(r.Context(), sessionOf(r).User.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Kunden konnten nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"clients": x})
|
||||
}
|
||||
|
||||
func (a *App) getEntries(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := parseFilter(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cfg, err := a.store.settings(r.Context(), sessionOf(r).User.ID)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Einstellungen konnten nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
p, err := a.store.listEntries(r.Context(), sessionOf(r).User.ID, f, cfg)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Einträge konnten nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, p)
|
||||
}
|
||||
func (a *App) startEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Client string `json:"client"`
|
||||
Activity string `json:"activity"`
|
||||
StartMS int64 `json:"start_ms"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if len(in.Client) > 200 || len(in.Activity) > 4000 {
|
||||
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
|
||||
return
|
||||
}
|
||||
if in.StartMS < 0 {
|
||||
jsonError(w, 400, "start_ms", "Ungültiger Startzeitpunkt.")
|
||||
return
|
||||
}
|
||||
e, err := a.store.startEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS)
|
||||
if err != nil {
|
||||
if isUniqueConstraint(err) {
|
||||
jsonError(w, 409, "timer_running", "Es läuft bereits ein Timer.")
|
||||
return
|
||||
}
|
||||
jsonError(w, 500, "db", "Timer konnte nicht gestartet werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, e)
|
||||
}
|
||||
func (a *App) createEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Client string `json:"client"`
|
||||
Activity string `json:"activity"`
|
||||
StartMS int64 `json:"start_ms"`
|
||||
EndMS int64 `json:"end_ms"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if len(in.Client) > 200 || len(in.Activity) > 4000 {
|
||||
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
|
||||
return
|
||||
}
|
||||
e, err := a.store.createFinishedEntry(r.Context(), sessionOf(r).User.ID, in.Client, in.Activity, in.StartMS, in.EndMS)
|
||||
if err != nil {
|
||||
jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, e)
|
||||
}
|
||||
func (a *App) stopEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
EndMS int64 `json:"end_ms"`
|
||||
}
|
||||
if !decodeJSONAllowEmpty(w, r, &in) {
|
||||
return
|
||||
}
|
||||
e, err := a.store.stopEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.EndMS)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
jsonError(w, 404, "not_found", "Laufender Eintrag nicht gefunden.")
|
||||
return
|
||||
}
|
||||
jsonError(w, 500, "db", "Timer konnte nicht gestoppt werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, e)
|
||||
}
|
||||
func (a *App) updateEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Client string `json:"client"`
|
||||
Activity string `json:"activity"`
|
||||
StartMS int64 `json:"start_ms"`
|
||||
EndMS *int64 `json:"end_ms"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if len(in.Client) > 200 || len(in.Activity) > 4000 {
|
||||
jsonError(w, 400, "too_long", "Kunde oder Tätigkeit ist zu lang.")
|
||||
return
|
||||
}
|
||||
e, err := a.store.updateEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id"), in.Client, in.Activity, in.StartMS, in.EndMS)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
jsonError(w, 404, "not_found", "Eintrag nicht gefunden.")
|
||||
return
|
||||
}
|
||||
if isUniqueConstraint(err) {
|
||||
jsonError(w, 409, "timer_running", "Es kann nur einen laufenden Timer geben.")
|
||||
return
|
||||
}
|
||||
jsonError(w, 400, "invalid_entry", "Start und Ende prüfen.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, e)
|
||||
}
|
||||
func (a *App) deleteEntry(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := a.store.deleteEntry(r.Context(), sessionOf(r).User.ID, r.PathValue("id")); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
jsonError(w, 404, "not_found", "Eintrag nicht gefunden.")
|
||||
return
|
||||
}
|
||||
jsonError(w, 500, "db", "Eintrag konnte nicht gelöscht werden.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func (a *App) exportCSV(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := parseFilter(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Export konnte nicht erstellt werden.")
|
||||
return
|
||||
}
|
||||
cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID)
|
||||
b, err := makeCSV(entries, cfg, f.Compact)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "export", "CSV konnte nicht erstellt werden.")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.csv"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
func (a *App) exportPDF(w http.ResponseWriter, r *http.Request) {
|
||||
f, ok := parseFilter(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
entries, err := a.store.allEntries(r.Context(), sessionOf(r).User.ID, f)
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Export konnte nicht erstellt werden.")
|
||||
return
|
||||
}
|
||||
cfg, _ := a.store.settings(r.Context(), sessionOf(r).User.ID)
|
||||
b := makePDF(entries, cfg, sessionOf(r).User, f.Compact)
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="pocketwatch.pdf"`)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write(b)
|
||||
}
|
||||
|
||||
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
users, err := a.store.listUsers(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, 500, "db", "Benutzer konnten nicht geladen werden.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"users": users})
|
||||
}
|
||||
func (a *App) adminCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) || !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Username string `json:"username"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Password string `json:"password"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if !validUsername(in.Username) {
|
||||
jsonError(w, 400, "username", "Ungültiger Benutzername.")
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.Password)
|
||||
if err != nil {
|
||||
jsonError(w, 400, "password", err.Error())
|
||||
return
|
||||
}
|
||||
u, err := a.store.createUser(r.Context(), in.Username, in.DisplayName, hash, in.Role)
|
||||
if err != nil {
|
||||
jsonError(w, 409, "username_exists", "Benutzername ist bereits vergeben.")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, u)
|
||||
}
|
||||
func (a *App) adminPatchUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) || !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if id == sessionOf(r).User.ID {
|
||||
jsonError(w, 400, "self", "Den eigenen Account hier nicht deaktivieren.")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Active *bool `json:"active"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
if in.Active == nil {
|
||||
jsonError(w, 400, "active", "active fehlt.")
|
||||
return
|
||||
}
|
||||
if err := a.store.setUserActive(r.Context(), id, *in.Active); err != nil {
|
||||
jsonError(w, 404, "not_found", "Benutzer nicht gefunden.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
func (a *App) adminResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireAdmin(w, r) || !requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeJSON(w, r, &in) {
|
||||
return
|
||||
}
|
||||
hash, err := hashPassword(in.Password)
|
||||
if err != nil {
|
||||
jsonError(w, 400, "password", err.Error())
|
||||
return
|
||||
}
|
||||
if err := a.store.resetPassword(r.Context(), r.PathValue("id"), hash); err != nil {
|
||||
jsonError(w, 404, "not_found", "Benutzer nicht gefunden.")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(204)
|
||||
}
|
||||
|
||||
func parseFilter(w http.ResponseWriter, r *http.Request) (entryFilter, bool) {
|
||||
q := r.URL.Query()
|
||||
from, ok := parseInt64Param(w, q, "from")
|
||||
if !ok {
|
||||
return entryFilter{}, false
|
||||
}
|
||||
to, ok := parseInt64Param(w, q, "to")
|
||||
if !ok {
|
||||
return entryFilter{}, false
|
||||
}
|
||||
limit := 200
|
||||
if x := q.Get("limit"); x != "" {
|
||||
n, err := strconv.Atoi(x)
|
||||
if err != nil || n < 1 {
|
||||
jsonError(w, 400, "limit", "Ungültiges Limit.")
|
||||
return entryFilter{}, false
|
||||
}
|
||||
if n > 500 {
|
||||
n = 500
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
offset := 0
|
||||
if x := q.Get("offset"); x != "" {
|
||||
n, err := strconv.Atoi(x)
|
||||
if err != nil || n < 0 {
|
||||
jsonError(w, 400, "offset", "Ungültiger Offset.")
|
||||
return entryFilter{}, false
|
||||
}
|
||||
offset = n
|
||||
}
|
||||
return entryFilter{Query: q.Get("q"), FromMS: from, ToMS: to, Limit: limit, Offset: offset, SortAsc: q.Get("sort") == "asc", Compact: q.Get("compact") == "1"}, true
|
||||
}
|
||||
func parseInt64Param(w http.ResponseWriter, q url.Values, key string) (int64, bool) {
|
||||
x := q.Get(key)
|
||||
if x == "" {
|
||||
return 0, true
|
||||
}
|
||||
n, err := strconv.ParseInt(x, 10, 64)
|
||||
if err != nil || n < 0 {
|
||||
jsonError(w, 400, key, "Ungültiger Zeitraum.")
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func decodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if !strings.HasPrefix(strings.ToLower(r.Header.Get("Content-Type")), "application/json") {
|
||||
jsonError(w, 415, "content_type", "Content-Type application/json erforderlich.")
|
||||
return false
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(dst); err != nil {
|
||||
jsonError(w, 400, "json", "Ungültige JSON-Daten.")
|
||||
return false
|
||||
}
|
||||
if err := dec.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
jsonError(w, 400, "json", "Nach dem JSON-Objekt sind weitere Daten enthalten.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func decodeJSONAllowEmpty(w http.ResponseWriter, r *http.Request, dst any) bool {
|
||||
if r.ContentLength == 0 {
|
||||
return true
|
||||
}
|
||||
return decodeJSON(w, r, dst)
|
||||
}
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
func jsonError(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, map[string]any{"error": map[string]string{"code": code, "message": msg}})
|
||||
}
|
||||
|
||||
func validUsername(s string) bool {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 3 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
func clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
func (a *App) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Referrer-Policy", "no-referrer")
|
||||
w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (a *App) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
a.log.Error("panic", "value", fmt.Sprint(v), "path", r.URL.Path)
|
||||
jsonError(w, 500, "internal", "Interner Serverfehler.")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
func (a *App) accessLog(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
if r.URL.Path != "/healthz" {
|
||||
a.log.Info("http", "method", r.Method, "path", r.URL.Path, "duration_ms", time.Since(start).Milliseconds())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type loginAttempt struct {
|
||||
fails int
|
||||
first, blockedUntil time.Time
|
||||
}
|
||||
type loginLimiter struct {
|
||||
mu sync.Mutex
|
||||
m map[string]loginAttempt
|
||||
}
|
||||
|
||||
func newLoginLimiter() *loginLimiter { return &loginLimiter{m: map[string]loginAttempt{}} }
|
||||
func (l *loginLimiter) allow(k string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
x := l.m[k]
|
||||
return x.blockedUntil.IsZero() || time.Now().After(x.blockedUntil)
|
||||
}
|
||||
func (l *loginLimiter) fail(k string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
x := l.m[k]
|
||||
if x.first.IsZero() || now.Sub(x.first) > 10*time.Minute {
|
||||
x = loginAttempt{first: now}
|
||||
}
|
||||
x.fails++
|
||||
if x.fails >= 5 {
|
||||
x.blockedUntil = now.Add(10 * time.Minute)
|
||||
}
|
||||
l.m[k] = x
|
||||
}
|
||||
func (l *loginLimiter) success(k string) { l.mu.Lock(); defer l.mu.Unlock(); delete(l.m, k) }
|
||||
200
internal/app/web/app.css
Normal file
200
internal/app/web/app.css
Normal file
@@ -0,0 +1,200 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0e0f11;
|
||||
--panel: #111316;
|
||||
--card: #16181b;
|
||||
--raised: #1c1f23;
|
||||
--active: #2b3037;
|
||||
--border: #282c31;
|
||||
--divider: #202328;
|
||||
--text: #eceef1;
|
||||
--muted: #969ca4;
|
||||
--dim: #6c727a;
|
||||
--amber: #f5c065;
|
||||
--amber-hover: #ffd98a;
|
||||
--on-amber: #16191c;
|
||||
--danger: #e85b61;
|
||||
--shadow: 0 24px 70px rgba(0,0,0,.42);
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
|
||||
body { min-height: 100dvh; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
input, textarea, select {
|
||||
width: 100%; border: 1px solid var(--border); background: var(--card); color: var(--text);
|
||||
border-radius: 12px; padding: 11px 12px; outline: none; transition: border-color .15s, background .15s;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--amber); background: #191c20; }
|
||||
textarea { resize: vertical; min-height: 90px; }
|
||||
label { display: grid; gap: 7px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
.app-shell { height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.topbar { height: 68px; flex: 0 0 68px; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; background: var(--panel); border-bottom: 1px solid var(--divider); }
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-weight: 720; letter-spacing: -.025em; font-size: 18px; }
|
||||
.brand-large { font-size: 22px; margin-bottom: 32px; }
|
||||
.brand-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 4px rgba(245,192,101,.08); }
|
||||
.topbar-actions { display: flex; align-items: center; gap: 9px; }
|
||||
.metric-inline { display: grid; text-align: right; margin-right: 12px; }
|
||||
.metric-inline strong { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 18px; }
|
||||
.metric-inline span { font-size: 10px; color: var(--muted); }
|
||||
.icon-btn, .avatar-btn { width: 38px; height: 38px; border-radius: 11px; display: grid; place-items: center; background: transparent; border: 1px solid transparent; }
|
||||
.icon-btn:hover, .avatar-btn:hover { background: var(--raised); border-color: var(--border); }
|
||||
.avatar-btn { background: var(--amber); color: var(--on-amber); font-weight: 800; border-radius: 50%; width: 34px; height: 34px; margin-left: 4px; }
|
||||
|
||||
.main-grid { min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(320px, 400px) 1fr; }
|
||||
.tracker-pane { min-height: 0; overflow-y: auto; background: var(--panel); padding: 26px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.tracker-card { padding: 22px; border: 1px solid var(--border); border-radius: 22px; background: var(--card); display: grid; gap: 16px; box-shadow: inset 0 1px rgba(255,255,255,.02); }
|
||||
.tracker-date { display: flex; align-items: center; justify-content: space-between; font-size: 13px; color: var(--muted); }
|
||||
.status-pill { color: var(--amber); background: rgba(245,192,101,.08); border: 1px solid rgba(245,192,101,.25); border-radius: 999px; padding: 4px 8px; font-size: 11px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; }
|
||||
.timer-display { padding: 7px 0 0; text-align: center; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: clamp(34px, 5vw, 48px); font-variant-numeric: tabular-nums; font-weight: 700; letter-spacing: -.05em; }
|
||||
.timer-display.running { color: var(--amber); }
|
||||
.btn { min-height: 40px; padding: 9px 14px; border-radius: 12px; font-weight: 700; border: 1px solid transparent; }
|
||||
.btn.primary { background: var(--amber); color: var(--on-amber); }
|
||||
.btn.primary:hover { background: var(--amber-hover); }
|
||||
.btn.subtle { background: var(--raised); border-color: var(--border); color: var(--text); }
|
||||
.btn.subtle:hover { background: var(--active); }
|
||||
.btn.danger { background: rgba(232,91,97,.11); color: #ff9296; border-color: rgba(232,91,97,.28); }
|
||||
.btn.wide { width: 100%; }
|
||||
.timer-btn { min-height: 52px; font-size: 16px; }
|
||||
.link-btn { background: transparent; color: var(--muted); padding: 3px; font-size: 13px; }
|
||||
.link-btn:hover { color: var(--text); }
|
||||
.centered { justify-self: center; }
|
||||
.metric-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.metric-card { background: var(--card); border: 1px solid var(--border); border-radius: 17px; padding: 15px 16px; display: grid; gap: 3px; }
|
||||
.metric-card span { font-size: 11px; color: var(--muted); }
|
||||
.metric-card strong { color: var(--amber); font: 700 21px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.warning-box, .error-box { border-radius: 12px; padding: 11px 12px; font-size: 13px; }
|
||||
.warning-box { background: rgba(245,192,101,.08); color: #f6cf8c; border: 1px solid rgba(245,192,101,.22); }
|
||||
.error-box { background: rgba(232,91,97,.09); color: #ff9aa0; border: 1px solid rgba(232,91,97,.22); margin: 0; }
|
||||
.sidebar-footer { margin-top: auto; padding-top: 10px; display: flex; justify-content: space-between; color: var(--dim); font-size: 11px; }
|
||||
.mobile-pane-head { display: none; }
|
||||
|
||||
.history-pane { min-width: 0; min-height: 0; display: flex; flex-direction: column; border-left: 1px solid var(--divider); }
|
||||
.history-head { padding: 25px 28px 15px; display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; }
|
||||
.history-head h1, .mobile-pane-head h1 { margin: 0; font-size: 31px; line-height: 1; letter-spacing: -.035em; }
|
||||
.eyebrow { margin: 0 0 7px; color: var(--dim); letter-spacing: .13em; font-size: 10px; font-weight: 800; }
|
||||
.history-actions { display: flex; gap: 8px; }
|
||||
.filter-bar { padding: 0 28px 15px; display: grid; grid-template-columns: minmax(180px, 1fr) auto auto; gap: 10px; align-items: center; border-bottom: 1px solid var(--divider); }
|
||||
.filter-bar input { min-width: 0; }
|
||||
.period-tabs { display: flex; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 3px; }
|
||||
.period-tabs button, .period-step button { background: transparent; color: var(--muted); padding: 7px 9px; border-radius: 8px; font-size: 12px; font-weight: 700; }
|
||||
.period-tabs button.active { background: var(--active); color: var(--text); }
|
||||
.period-step { display: flex; align-items: center; justify-content: center; gap: 2px; color: var(--muted); white-space: nowrap; }
|
||||
.period-step span { min-width: 112px; text-align: center; font-size: 12px; }
|
||||
.period-step button:hover { color: var(--text); background: var(--raised); }
|
||||
.history-scroll { min-height: 0; flex: 1; overflow-y: auto; padding: 0 28px 20px; }
|
||||
.entries { display: grid; }
|
||||
.day-group { min-width: 0; }
|
||||
.day-heading { position: sticky; top: 0; z-index: 2; padding: 15px 0 8px; display: flex; justify-content: space-between; align-items: baseline; background: linear-gradient(var(--bg) 80%, transparent); }
|
||||
.day-heading strong { font-size: 13px; }
|
||||
.day-heading span { color: var(--dim); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.entry-row { width: 100%; display: grid; grid-template-columns: minmax(120px, .8fr) minmax(180px, 1.3fr) 120px 72px 28px; gap: 14px; align-items: center; padding: 12px 14px; margin-bottom: 6px; background: var(--card); border: 1px solid transparent; border-radius: 13px; text-align: left; }
|
||||
.entry-row:hover { border-color: var(--border); background: var(--raised); }
|
||||
.entry-client { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.entry-activity { min-width: 0; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.entry-time { color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.entry-duration { text-align: right; color: var(--amber); font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.entry-chevron { color: var(--dim); font-size: 19px; }
|
||||
.history-footer { flex: 0 0 50px; border-top: 1px solid var(--divider); display: flex; align-items: center; justify-content: space-between; padding: 0 28px; color: var(--muted); font-size: 12px; }
|
||||
.history-footer strong { color: var(--text); }
|
||||
.history-footer span:last-child strong { color: var(--amber); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 15px; margin-left: 8px; }
|
||||
.empty-state { min-height: 55vh; display: grid; place-items: center; align-content: center; text-align: center; color: var(--muted); }
|
||||
.empty-state h2 { color: var(--text); margin: 10px 0 4px; font-size: 18px; }
|
||||
.empty-state p { margin: 0; font-size: 13px; }
|
||||
.empty-icon { font-size: 40px; color: var(--dim); }
|
||||
.load-more { display: block; margin: 18px auto 0; }
|
||||
|
||||
.modal { width: min(620px, calc(100vw - 28px)); max-height: calc(100dvh - 28px); padding: 0; border: 1px solid var(--border); border-radius: 20px; background: var(--card); color: var(--text); box-shadow: var(--shadow); }
|
||||
.modal::backdrop { background: rgba(0,0,0,.68); backdrop-filter: blur(5px); }
|
||||
.modal-wide { width: min(860px, calc(100vw - 28px)); }
|
||||
.modal-card { padding: 22px; display: grid; gap: 16px; max-height: calc(100dvh - 30px); overflow-y: auto; }
|
||||
.modal-card.compact { max-width: 520px; }
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; }
|
||||
.modal h2, .modal h3 { margin: 0; letter-spacing: -.025em; }
|
||||
.modal h2 { font-size: 24px; }
|
||||
.modal h3 { font-size: 15px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; padding-top: 5px; }
|
||||
.modal-actions.split { justify-content: initial; }
|
||||
.spacer { flex: 1; }
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 28px; }
|
||||
.settings-grid section { display: grid; gap: 13px; align-content: start; }
|
||||
.settings-grid h3 { padding-bottom: 4px; }
|
||||
.check-row { display: flex; align-items: center; gap: 10px; color: var(--text); font-weight: 500; }
|
||||
.check-row input { width: 17px; height: 17px; accent-color: var(--amber); }
|
||||
.admin-section { border-top: 1px solid var(--divider); padding-top: 18px; display: grid; gap: 10px; }
|
||||
.section-title { display: flex; justify-content: space-between; align-items: center; }
|
||||
.user-list { display: grid; gap: 7px; }
|
||||
.user-row { display: grid; grid-template-columns: minmax(140px,1fr) auto auto auto; gap: 9px; align-items: center; padding: 10px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.user-row .user-meta { min-width: 0; display: grid; }
|
||||
.user-row .user-meta strong, .user-row .user-meta span { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.user-row .user-meta span { color: var(--muted); font-size: 11px; }
|
||||
.role-badge { color: var(--muted); font-size: 11px; background: var(--raised); padding: 4px 7px; border-radius: 999px; }
|
||||
.export-scope { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; padding: 11px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.export-compact { align-self: end; min-height: 42px; padding-bottom: 9px; }
|
||||
.export-preview { margin: -2px 0 0; padding: 10px 12px; border-radius: 11px; background: var(--bg); border: 1px solid var(--border); color: var(--muted); font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.export-buttons { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.toast { position: fixed; z-index: 30; bottom: 24px; left: 50%; transform: translateX(-50%); background: #25292e; border: 1px solid var(--border); border-radius: 12px; padding: 10px 14px; box-shadow: var(--shadow); font-size: 13px; }
|
||||
.mobile-only { display: none; }
|
||||
|
||||
.auth-body { display: grid; min-height: 100dvh; place-items: center; background: radial-gradient(circle at 50% 0, #1c1a16 0, var(--bg) 38%); padding: 20px; }
|
||||
.auth-shell { width: min(440px, 100%); }
|
||||
.auth-card { background: rgba(22,24,27,.96); border: 1px solid var(--border); border-radius: 24px; padding: 30px; box-shadow: var(--shadow); }
|
||||
.auth-card h1 { margin: 0 0 7px; font-size: 30px; letter-spacing: -.035em; }
|
||||
.auth-card .muted { margin: 0 0 22px; line-height: 1.5; }
|
||||
.form-stack { display: grid; gap: 15px; }
|
||||
.form-stack .btn { margin-top: 5px; }
|
||||
.auth-card .error-box { margin-top: 15px; }
|
||||
|
||||
@media (max-width: 1050px) {
|
||||
.filter-bar { grid-template-columns: 1fr auto; }
|
||||
.period-step { grid-column: 1 / -1; justify-self: end; }
|
||||
.entry-row { grid-template-columns: minmax(110px,.8fr) minmax(140px,1.2fr) 110px 68px 20px; gap: 9px; }
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.app-shell { overflow: hidden; }
|
||||
.topbar { height: 58px; flex-basis: 58px; padding: 0 15px; }
|
||||
.metric-inline, #export-open { display: none; }
|
||||
.main-grid { display: block; overflow: hidden; }
|
||||
.tracker-pane, .history-pane { height: calc(100dvh - 58px); border-left: 0; }
|
||||
.tracker-pane { padding: 15px; }
|
||||
.history-pane { display: none; }
|
||||
body.mobile-history .tracker-pane { display: none; }
|
||||
body.mobile-history .history-pane { display: flex; }
|
||||
.mobile-pane-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.mobile-only { display: inline-block; }
|
||||
.history-head { padding: 16px 15px 12px; align-items: center; }
|
||||
.history-title .eyebrow { display: none; }
|
||||
.history-title h1 { font-size: 25px; margin-top: 4px; }
|
||||
.history-actions #history-export { display: none; }
|
||||
.filter-bar { padding: 0 15px 12px; display: flex; flex-wrap: wrap; }
|
||||
.filter-bar > input { flex: 1 1 100%; }
|
||||
.period-tabs { flex: 1 1 auto; overflow-x: auto; }
|
||||
.period-step { flex: 1 1 100%; justify-content: space-between; }
|
||||
.history-scroll { padding: 0 15px 16px; }
|
||||
.entry-row { grid-template-columns: 1fr auto 18px; grid-template-areas: "client dur chevron" "activity time chevron"; gap: 4px 10px; padding: 12px; }
|
||||
.entry-client { grid-area: client; }
|
||||
.entry-activity { grid-area: activity; }
|
||||
.entry-time { grid-area: time; }
|
||||
.entry-duration { grid-area: dur; }
|
||||
.entry-chevron { grid-area: chevron; align-self: center; }
|
||||
.history-footer { padding: 0 15px; }
|
||||
.settings-grid { grid-template-columns: 1fr; gap: 20px; }
|
||||
.user-row { grid-template-columns: 1fr auto; }
|
||||
.user-row .role-badge { justify-self: end; }
|
||||
.two-col, .export-scope, .export-buttons { grid-template-columns: 1fr; }
|
||||
.modal { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); border-radius: 17px; }
|
||||
.modal-card { max-height: calc(100dvh - 14px); padding: 18px; }
|
||||
}
|
||||
@media (max-width: 430px) {
|
||||
.tracker-card { padding: 17px; }
|
||||
.metric-grid { grid-template-columns: 1fr 1fr; }
|
||||
.history-actions .btn { padding-inline: 10px; }
|
||||
.auth-card { padding: 23px; }
|
||||
}
|
||||
299
internal/app/web/app.js
Normal file
299
internal/app/web/app.js
Normal file
@@ -0,0 +1,299 @@
|
||||
const state = {
|
||||
me: null,
|
||||
csrf: '',
|
||||
settings: null,
|
||||
running: null,
|
||||
entries: [],
|
||||
clients: [],
|
||||
totalCount: 0,
|
||||
totalDuration: 0,
|
||||
filter: { q: '', period: 'all', anchor: new Date(), offset: 0, limit: 120 },
|
||||
timerTick: null,
|
||||
};
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
const $$ = (s) => [...document.querySelectorAll(s)];
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const headers = { ...(options.headers || {}) };
|
||||
if (options.body !== undefined && !(options.body instanceof FormData)) headers['Content-Type'] = 'application/json';
|
||||
if (state.csrf && options.method && !['GET','HEAD'].includes(options.method.toUpperCase())) headers['X-CSRF-Token'] = state.csrf;
|
||||
const res = await fetch(url, { ...options, headers });
|
||||
if (res.status === 401) { location.assign('/login'); throw new Error('Nicht angemeldet.'); }
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
const body = res.status === 204 ? null : (ct.includes('application/json') ? await res.json().catch(() => ({})) : await res.text());
|
||||
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
function toast(msg) {
|
||||
const el = $('#toast'); el.textContent = msg; el.hidden = false;
|
||||
clearTimeout(toast._t); toast._t = setTimeout(() => { el.hidden = true; }, 2600);
|
||||
}
|
||||
function showError(sel, err) { const el = $(sel); el.textContent = err?.message || String(err); el.hidden = false; }
|
||||
function hideError(sel) { $(sel).hidden = true; }
|
||||
function pad(n) { return String(n).padStart(2, '0'); }
|
||||
function duration(ms, withSeconds = false) {
|
||||
ms = Math.max(0, ms || 0);
|
||||
const sec = Math.floor(ms / 1000), h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s = sec % 60;
|
||||
return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${h}:${pad(m)}`;
|
||||
}
|
||||
function rounded(ms) {
|
||||
const mins = Math.max(1, Number(state.settings?.rounding_minutes || 1));
|
||||
if (mins <= 1) return Math.max(0, ms);
|
||||
const step = mins * 60000;
|
||||
return state.settings?.round_up ? Math.ceil(ms / step) * step : Math.round(ms / step) * step;
|
||||
}
|
||||
function clock(ms) {
|
||||
const d = new Date(ms);
|
||||
return new Intl.DateTimeFormat(state.settings?.language === 'en' ? 'en' : 'de-DE', { hour: '2-digit', minute: '2-digit', hour12: state.settings?.time_format === '12' }).format(d);
|
||||
}
|
||||
function localDateTimeValue(ms) {
|
||||
const d = new Date(ms); const off = d.getTimezoneOffset();
|
||||
return new Date(d.getTime() - off * 60000).toISOString().slice(0,16);
|
||||
}
|
||||
function msFromLocalValue(v) { return v ? new Date(v).getTime() : null; }
|
||||
function dayKey(ms) { const d = new Date(ms); return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||
function dayTitle(ms) {
|
||||
const d = new Date(ms), now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||||
const target = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
if (target === today) return 'Heute';
|
||||
const yd = new Date(now.getFullYear(), now.getMonth(), now.getDate()); yd.setDate(yd.getDate()-1);
|
||||
if (target === yd.getTime()) return 'Gestern';
|
||||
return new Intl.DateTimeFormat('de-DE', { weekday:'long', day:'2-digit', month:'long', year:'numeric' }).format(d);
|
||||
}
|
||||
function initials(s) { return (s || '?').trim().split(/\s+/).slice(0,2).map(x=>x[0]?.toUpperCase()||'').join('') || '?'; }
|
||||
function escapeText(s) { const x=document.createElement('span'); x.textContent=s??''; return x.innerHTML; }
|
||||
|
||||
function rangeForFilter() {
|
||||
const { period, anchor } = state.filter;
|
||||
if (period === 'all') return { from:0, to:0, label:'Alle Zeiten' };
|
||||
let from, to;
|
||||
const d = new Date(anchor); d.setHours(0,0,0,0);
|
||||
if (period === 'day') { from = d; to = new Date(d); to.setDate(to.getDate()+1); }
|
||||
if (period === 'week') { const wd = (d.getDay()+6)%7; d.setDate(d.getDate()-wd); from = new Date(d); to = new Date(d); to.setDate(to.getDate()+7); }
|
||||
if (period === 'month') { from = new Date(d.getFullYear(),d.getMonth(),1); to = new Date(d.getFullYear(),d.getMonth()+1,1); }
|
||||
if (period === 'year') { from = new Date(d.getFullYear(),0,1); to = new Date(d.getFullYear()+1,0,1); }
|
||||
let label='';
|
||||
if (period === 'day') label = new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(from);
|
||||
if (period === 'week') label = `${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit'}).format(from)} – ${new Intl.DateTimeFormat('de-DE',{day:'2-digit',month:'2-digit',year:'numeric'}).format(new Date(to.getTime()-1))}`;
|
||||
if (period === 'month') label = new Intl.DateTimeFormat('de-DE',{month:'long',year:'numeric'}).format(from);
|
||||
if (period === 'year') label = String(from.getFullYear());
|
||||
return { from: from.getTime(), to: to.getTime(), label };
|
||||
}
|
||||
function stepPeriod(delta) {
|
||||
const d = new Date(state.filter.anchor), p = state.filter.period;
|
||||
if (p === 'day') d.setDate(d.getDate()+delta);
|
||||
if (p === 'week') d.setDate(d.getDate()+7*delta);
|
||||
if (p === 'month') d.setMonth(d.getMonth()+delta);
|
||||
if (p === 'year') d.setFullYear(d.getFullYear()+delta);
|
||||
state.filter.anchor = d; state.filter.offset = 0; refreshEntries();
|
||||
}
|
||||
function queryString(includePaging = true) {
|
||||
const r = rangeForFilter(); const p = new URLSearchParams();
|
||||
if (state.filter.q) p.set('q', state.filter.q);
|
||||
if (r.from) p.set('from', r.from); if (r.to) p.set('to', r.to);
|
||||
if (includePaging) { p.set('limit', state.filter.limit); p.set('offset', state.filter.offset); }
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const me = await api('/api/me'); state.me = me.user; state.csrf = me.csrf_token;
|
||||
const [settings, running, clients] = await Promise.all([api('/api/settings'), api('/api/running'), api('/api/clients')]);
|
||||
state.settings = settings; state.running = running.entry; state.clients = clients.clients || [];
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
if ((!state.settings.timezone || state.settings.timezone === 'UTC') && tz && tz !== 'UTC') {
|
||||
state.settings.timezone = tz;
|
||||
try { state.settings = await api('/api/settings', { method:'PUT', body:JSON.stringify(state.settings) }); } catch (_) {}
|
||||
}
|
||||
renderIdentity(); renderClients(); renderRunning(); fillSettings();
|
||||
await Promise.all([refreshEntries(), refreshTotals()]);
|
||||
startTicker();
|
||||
} catch (e) { toast(e.message); }
|
||||
}
|
||||
|
||||
function renderIdentity() {
|
||||
$('#user-label').textContent = state.me.display_name || state.me.username;
|
||||
$('#avatar-text').textContent = initials(state.me.display_name || state.me.username);
|
||||
}
|
||||
function renderClients() { $('#client-list').innerHTML = state.clients.map(x => `<option value="${escapeText(x)}"></option>`).join(''); }
|
||||
function renderRunning() {
|
||||
const e = state.running;
|
||||
$('#running-hint').hidden = !e; $('#edit-running').hidden = !e;
|
||||
$('#timer-button').textContent = e ? '■ Stop' : '▶ Start';
|
||||
$('#timer-display').classList.toggle('running', !!e);
|
||||
$('#client-input').disabled = !!e;
|
||||
if (e) { $('#client-input').value=e.client||''; $('#activity-input').value=e.activity||''; }
|
||||
updateTicker();
|
||||
}
|
||||
function startTicker() { clearInterval(state.timerTick); state.timerTick = setInterval(updateTicker, 1000); updateTicker(); }
|
||||
function updateTicker() {
|
||||
const e=state.running, elapsed=e ? Date.now()-e.start_ms : 0;
|
||||
$('#timer-display').textContent=duration(elapsed,true);
|
||||
const warn=!!(e && state.settings?.long_run_reminder && elapsed>8*3600000); $('#long-running-warning').hidden=!warn;
|
||||
}
|
||||
|
||||
async function timerToggle() {
|
||||
try {
|
||||
if (!state.running) {
|
||||
const x = await api('/api/entries/start', { method:'POST', body:JSON.stringify({ client:$('#client-input').value, activity:$('#activity-input').value, start_ms:Date.now() }) });
|
||||
state.running=x; renderRunning(); toast('Timer gestartet.');
|
||||
} else {
|
||||
await saveRunningActivity();
|
||||
await api(`/api/entries/${state.running.id}/stop`, { method:'POST', body:JSON.stringify({ end_ms:Date.now() }) });
|
||||
state.running=null; $('#client-input').disabled=false; $('#client-input').value=''; $('#activity-input').value=''; renderRunning(); toast('Timer gestoppt.');
|
||||
await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);
|
||||
}
|
||||
} catch(e) { toast(e.message); }
|
||||
}
|
||||
async function saveRunningActivity() {
|
||||
if (!state.running) return;
|
||||
const activity=$('#activity-input').value;
|
||||
if (activity===state.running.activity) return;
|
||||
const x=await api(`/api/entries/${state.running.id}`,{method:'PUT',body:JSON.stringify({client:state.running.client,activity,start_ms:state.running.start_ms,end_ms:null})});
|
||||
state.running=x;
|
||||
}
|
||||
async function refreshClients(){const x=await api('/api/clients');state.clients=x.clients||[];renderClients();}
|
||||
|
||||
async function refreshTotals() {
|
||||
const now=new Date(); const today=new Date(now.getFullYear(),now.getMonth(),now.getDate());
|
||||
const week=new Date(today); week.setDate(week.getDate()-((week.getDay()+6)%7));
|
||||
try {
|
||||
const [t,w]=await Promise.all([
|
||||
api(`/api/entries?from=${today.getTime()}&to=${new Date(today.getFullYear(),today.getMonth(),today.getDate()+1).getTime()}&limit=1&offset=0`),
|
||||
api(`/api/entries?from=${week.getTime()}&limit=1&offset=0`)
|
||||
]);
|
||||
$('#today-total').textContent=duration(t.total_duration_ms);
|
||||
$('#week-total').textContent=duration(w.total_duration_ms);
|
||||
$('#header-week').textContent=duration(w.total_duration_ms);
|
||||
$('#week-badge').hidden=!state.settings.show_week_total;
|
||||
} catch(e){ console.warn(e); }
|
||||
}
|
||||
|
||||
async function refreshEntries(reset=true) {
|
||||
if(reset){state.filter.offset=0;state.entries=[];}
|
||||
const range=rangeForFilter(); $('#period-label').textContent=range.label;
|
||||
try {
|
||||
const p=await api('/api/entries?'+queryString(true));
|
||||
state.totalCount=p.total_count;state.totalDuration=p.total_duration_ms;
|
||||
state.entries=reset?p.entries:[...state.entries,...p.entries];
|
||||
renderEntries();
|
||||
} catch(e){toast(e.message);}
|
||||
}
|
||||
function renderEntries() {
|
||||
$('#result-count').textContent=state.totalCount; $('#result-total').textContent=duration(state.totalDuration);
|
||||
$('#empty-state').hidden=state.totalCount!==0;
|
||||
$('#load-more').hidden=state.entries.length>=state.totalCount;
|
||||
const groups=[]; let current=null;
|
||||
for(const e of state.entries){const k=dayKey(e.start_ms);if(!current||current.key!==k){current={key:k,start:e.start_ms,items:[]};groups.push(current)}current.items.push(e)}
|
||||
$('#entries').innerHTML=groups.map(g=>{
|
||||
const dayTotal=g.items.reduce((n,e)=>n+rounded((e.end_ms||e.start_ms)-e.start_ms),0);
|
||||
return `<section class="day-group"><div class="day-heading"><strong>${escapeText(dayTitle(g.start))}</strong><span>${duration(dayTotal)}</span></div>${g.items.map(entryHTML).join('')}</section>`;
|
||||
}).join('');
|
||||
$$('.entry-row').forEach(el=>el.addEventListener('click',()=>openEntry(el.dataset.id)));
|
||||
$$('.day-heading').forEach(el=>el.style.position=state.settings.sticky_days?'sticky':'static');
|
||||
}
|
||||
function entryHTML(e){return `<button class="entry-row" data-id="${e.id}"><span class="entry-client">${escapeText(e.client||'Kein Kunde')}</span><span class="entry-activity">${escapeText(e.activity||'—')}</span><span class="entry-time">${clock(e.start_ms)}–${clock(e.end_ms)}</span><span class="entry-duration">${duration(rounded(e.end_ms-e.start_ms))}</span><span class="entry-chevron">›</span></button>`}
|
||||
|
||||
function openEntry(id=null, running=false) {
|
||||
hideError('#entry-error');
|
||||
let e = null;
|
||||
if (running) e=state.running; else if(id) e=state.entries.find(x=>x.id===id);
|
||||
$('#entry-id').value=e?.id||''; $('#edit-client').value=e?.client||''; $('#edit-activity').value=e?.activity||'';
|
||||
const now=Date.now(); $('#edit-start').value=localDateTimeValue(e?.start_ms||now); $('#edit-end').value=e?.end_ms?localDateTimeValue(e.end_ms):(running?'':localDateTimeValue(now+3600000));
|
||||
$('#entry-dialog-title').textContent=e?'Eintrag bearbeiten':'Zeit nachtragen'; $('#delete-entry').hidden=!e;
|
||||
updateEditDuration(); $('#entry-dialog').showModal();
|
||||
}
|
||||
function updateEditDuration(){const s=msFromLocalValue($('#edit-start').value),e=msFromLocalValue($('#edit-end').value);$('#edit-duration').textContent=s&&e&&e>=s?duration(rounded(e-s)):'–'}
|
||||
async function saveEntry(ev){ev.preventDefault();hideError('#entry-error');const id=$('#entry-id').value;const start=msFromLocalValue($('#edit-start').value),end=msFromLocalValue($('#edit-end').value);if(!start||!end||end<start){showError('#entry-error',new Error('Bitte einen gültigen Start- und Endzeitpunkt wählen.'));return}const body={client:$('#edit-client').value,activity:$('#edit-activity').value,start_ms:start,end_ms:end};try{if(id)await api(`/api/entries/${id}`,{method:'PUT',body:JSON.stringify(body)});else await api('/api/entries',{method:'POST',body:JSON.stringify(body)});$('#entry-dialog').close();if(state.running?.id===id){state.running=null;renderRunning()}await Promise.all([refreshEntries(true),refreshTotals(),refreshClients()]);toast('Eintrag gespeichert.')}catch(e){showError('#entry-error',e)}}
|
||||
async function deleteEntry(){const id=$('#entry-id').value;if(!id||!confirm('Diesen Eintrag wirklich löschen?'))return;try{await api(`/api/entries/${id}`,{method:'DELETE'});if(state.running?.id===id){state.running=null;renderRunning()}$('#entry-dialog').close();await Promise.all([refreshEntries(true),refreshTotals()]);toast('Eintrag gelöscht.')}catch(e){showError('#entry-error',e)}}
|
||||
|
||||
function fillSettings(){const s=state.settings;$('#setting-time-format').value=s.time_format;$('#setting-rounding').value=s.rounding_minutes;$('#setting-round-up').checked=s.round_up;$('#setting-week').checked=s.show_week_total;$('#setting-sticky').checked=s.sticky_days;$('#setting-reminder').checked=s.long_run_reminder;$('#setting-export-name').value=s.export_name||'';$('#setting-timezone').value=s.timezone||Intl.DateTimeFormat().resolvedOptions().timeZone||'UTC';$('#setting-export-date').checked=s.export_date;$('#admin-section').hidden=state.me.role!=='admin'}
|
||||
async function openSettings(){fillSettings();hideError('#settings-error');$('#settings-dialog').showModal();if(state.me.role==='admin')await loadUsers()}
|
||||
async function saveSettings(ev){ev.preventDefault();hideError('#settings-error');const s={language:state.settings.language||'de',time_format:$('#setting-time-format').value,rounding_minutes:Number($('#setting-rounding').value),round_up:$('#setting-round-up').checked,show_week_total:$('#setting-week').checked,sticky_days:$('#setting-sticky').checked,long_run_reminder:$('#setting-reminder').checked,export_name:$('#setting-export-name').value,timezone:$('#setting-timezone').value,export_date:$('#setting-export-date').checked};try{state.settings=await api('/api/settings',{method:'PUT',body:JSON.stringify(s)});$('#settings-dialog').close();renderEntries();await refreshTotals();toast('Einstellungen gespeichert.')}catch(e){showError('#settings-error',e)}}
|
||||
|
||||
|
||||
async function changeOwnPassword(ev){
|
||||
ev.preventDefault(); hideError('#account-password-error');
|
||||
try {
|
||||
await api('/api/account/password',{method:'POST',body:JSON.stringify({current_password:$('#current-password').value,new_password:$('#own-new-password').value})});
|
||||
location.assign('/login');
|
||||
} catch(e){ showError('#account-password-error',e); }
|
||||
}
|
||||
|
||||
async function loadUsers(){try{const x=await api('/api/admin/users');$('#user-list').innerHTML=x.users.map(u=>`<div class="user-row" data-user-id="${u.id}"><div class="user-meta"><strong>${escapeText(u.display_name||u.username)}</strong><span>${escapeText(u.username)}</span></div><span class="role-badge">${u.role==='admin'?'Admin':'Benutzer'}</span><button type="button" class="btn subtle user-password" ${u.active?'':'disabled'}>Passwort</button><button type="button" class="btn ${u.active?'danger':'subtle'} user-toggle" ${u.id===state.me.id?'disabled':''}>${u.active?'Deaktivieren':'Aktivieren'}</button></div>`).join('');$$('.user-password').forEach(b=>b.addEventListener('click',()=>openPassword(b.closest('.user-row').dataset.userId)));$$('.user-toggle').forEach(b=>b.addEventListener('click',()=>toggleUser(b.closest('.user-row').dataset.userId,b.textContent==='Aktivieren')))}catch(e){showError('#settings-error',e)}}
|
||||
function openPassword(id){$('#password-user-id').value=id;$('#reset-password').value='';hideError('#password-error');$('#password-dialog').showModal()}
|
||||
async function toggleUser(id,active){try{await api(`/api/admin/users/${id}`,{method:'PATCH',body:JSON.stringify({active})});await loadUsers();toast(active?'Benutzer aktiviert.':'Benutzer deaktiviert.')}catch(e){showError('#settings-error',e)}}
|
||||
async function createUser(ev){ev.preventDefault();hideError('#user-error');const body={username:$('#new-username').value,displayName:$('#new-display-name').value,password:$('#new-password').value,role:$('#new-role').value};try{await api('/api/admin/users',{method:'POST',body:JSON.stringify(body)});$('#user-dialog').close();ev.currentTarget.reset();await loadUsers();toast('Benutzer angelegt.')}catch(e){showError('#user-error',e)}}
|
||||
async function resetPassword(ev){ev.preventDefault();hideError('#password-error');try{await api(`/api/admin/users/${$('#password-user-id').value}/password`,{method:'POST',body:JSON.stringify({password:$('#reset-password').value})});$('#password-dialog').close();toast('Passwort zurückgesetzt.')}catch(e){showError('#password-error',e)}}
|
||||
|
||||
function dateInputValue(d) { return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}`; }
|
||||
function localDayStartMS(value) {
|
||||
if (!value) return 0;
|
||||
const [y,m,d]=value.split('-').map(Number);
|
||||
return new Date(y,m-1,d,0,0,0,0).getTime();
|
||||
}
|
||||
function exportParams(includePresentation=true) {
|
||||
const p=new URLSearchParams();
|
||||
const scope=$('input[name="export-scope"]:checked')?.value||'view';
|
||||
if(scope==='view') {
|
||||
const r=rangeForFilter();
|
||||
if(state.filter.q) p.set('q',state.filter.q);
|
||||
if(r.from) p.set('from',r.from);
|
||||
if(r.to) p.set('to',r.to);
|
||||
} else {
|
||||
const from=localDayStartMS($('#export-from').value);
|
||||
const toStart=localDayStartMS($('#export-to').value);
|
||||
if(from) p.set('from',from);
|
||||
if(toStart) { const end=new Date(toStart); end.setDate(end.getDate()+1); p.set('to',end.getTime()); }
|
||||
}
|
||||
if(includePresentation) {
|
||||
if($('#export-sort').value==='asc') p.set('sort','asc');
|
||||
if($('#export-compact').checked) p.set('compact','1');
|
||||
}
|
||||
return p;
|
||||
}
|
||||
let exportPreviewSeq=0;
|
||||
async function updateExportPreview() {
|
||||
const seq=++exportPreviewSeq;
|
||||
const scope=$('input[name="export-scope"]:checked')?.value||'view';
|
||||
$('#export-custom').hidden=scope!=='custom';
|
||||
if(scope==='view') { $('#export-preview').textContent=`${state.totalCount} Einträge · ${duration(state.totalDuration)}`; return; }
|
||||
const from=localDayStartMS($('#export-from').value), to=localDayStartMS($('#export-to').value);
|
||||
if(!from||!to||to<from) { $('#export-preview').textContent='Bitte einen gültigen Zeitraum wählen.'; return; }
|
||||
try {
|
||||
const p=exportParams(false); p.set('limit','1'); p.set('offset','0');
|
||||
const result=await api('/api/entries?'+p.toString());
|
||||
if(seq===exportPreviewSeq) $('#export-preview').textContent=`${result.total_count} Einträge · ${duration(result.total_duration_ms)}`;
|
||||
} catch(e) { if(seq===exportPreviewSeq) $('#export-preview').textContent=e.message; }
|
||||
}
|
||||
function openExport(){
|
||||
const now=new Date(), first=new Date(now.getFullYear(),now.getMonth(),1);
|
||||
if(!$('#export-from').value) $('#export-from').value=dateInputValue(first);
|
||||
if(!$('#export-to').value) $('#export-to').value=dateInputValue(now);
|
||||
updateExportPreview(); $('#export-dialog').showModal();
|
||||
}
|
||||
function downloadExport(type){
|
||||
const p=exportParams(true), a=document.createElement('a');
|
||||
a.href=`/api/export.${type}${p.toString()?'?'+p.toString():''}`; a.click();
|
||||
}
|
||||
|
||||
function wireEvents(){
|
||||
$('#timer-button').addEventListener('click',timerToggle); $('#activity-input').addEventListener('blur',()=>saveRunningActivity().catch(e=>toast(e.message)));
|
||||
$('#edit-running').addEventListener('click',()=>openEntry(null,true)); $('#add-entry').addEventListener('click',()=>openEntry());
|
||||
$('#entry-form').addEventListener('submit',saveEntry); $('#delete-entry').addEventListener('click',deleteEntry); $('#edit-start').addEventListener('input',updateEditDuration); $('#edit-end').addEventListener('input',updateEditDuration);
|
||||
$('#settings-open').addEventListener('click',openSettings); $('#settings-form').addEventListener('submit',saveSettings); $('#change-own-password').addEventListener('click',()=>{hideError('#account-password-error');$('#account-password-form').reset();$('#account-password-dialog').showModal()}); $('#account-password-form').addEventListener('submit',changeOwnPassword); $('#add-user').addEventListener('click',()=>{hideError('#user-error');$('#user-dialog').showModal()}); $('#user-form').addEventListener('submit',createUser); $('#password-form').addEventListener('submit',resetPassword);
|
||||
$('#export-open').addEventListener('click',openExport); $('#history-export').addEventListener('click',openExport); $('#export-pdf').addEventListener('click',()=>downloadExport('pdf')); $('#export-csv').addEventListener('click',()=>downloadExport('csv')); $$('input[name="export-scope"]').forEach(x=>x.addEventListener('change',updateExportPreview)); $('#export-from').addEventListener('change',updateExportPreview); $('#export-to').addEventListener('change',updateExportPreview);
|
||||
$('#account-menu').addEventListener('click',async()=>{if(!confirm('Abmelden?'))return;try{await api('/api/logout',{method:'POST',body:'{}'});}finally{location.assign('/login')}});
|
||||
let searchTimer; $('#search-input').addEventListener('input',e=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{state.filter.q=e.target.value.trim();state.filter.offset=0;refreshEntries(true)},220)});
|
||||
$$('.period-tabs button').forEach(b=>b.addEventListener('click',()=>{$$('.period-tabs button').forEach(x=>x.classList.remove('active'));b.classList.add('active');state.filter.period=b.dataset.period;state.filter.anchor=new Date();state.filter.offset=0;refreshEntries(true)}));
|
||||
$('#period-prev').addEventListener('click',()=>stepPeriod(-1)); $('#period-next').addEventListener('click',()=>stepPeriod(1));
|
||||
$('#load-more').addEventListener('click',()=>{state.filter.offset=state.entries.length;refreshEntries(false)});
|
||||
$$('[data-close]').forEach(b=>b.addEventListener('click',()=>document.getElementById(b.dataset.close).close()));
|
||||
$$('dialog').forEach(d=>d.addEventListener('click',e=>{const r=d.getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)d.close()}));
|
||||
$('#mobile-history').addEventListener('click',()=>document.body.classList.add('mobile-history')); $('#mobile-track').addEventListener('click',()=>document.body.classList.remove('mobile-history'));
|
||||
}
|
||||
|
||||
wireEvents(); init();
|
||||
174
internal/app/web/index.html
Normal file
174
internal/app/web/index.html
Normal file
@@ -0,0 +1,174 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#0e0f11">
|
||||
<title>Pocketwatch</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand"><span class="brand-dot"></span><span>pocketwatch</span></div>
|
||||
<div class="topbar-actions">
|
||||
<div id="week-badge" class="metric-inline"><strong id="header-week">0:00</strong><span>Diese Woche</span></div>
|
||||
<button class="icon-btn" id="export-open" title="Exportieren" aria-label="Exportieren">⇩</button>
|
||||
<button class="icon-btn" id="settings-open" title="Einstellungen" aria-label="Einstellungen">⚙</button>
|
||||
<button class="avatar-btn" id="account-menu" title="Abmelden"><span id="avatar-text">U</span></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-grid">
|
||||
<aside class="tracker-pane">
|
||||
<div class="mobile-pane-head"><h1>Erfassen</h1><button class="link-btn" id="mobile-history">Verlauf →</button></div>
|
||||
<section class="tracker-card">
|
||||
<div class="tracker-date"><span id="today-label">Heute</span><span id="running-hint" class="status-pill" hidden>läuft</span></div>
|
||||
<label class="field-label">Kunde
|
||||
<input id="client-input" list="client-list" maxlength="200" placeholder="Kunde / Projekt">
|
||||
<datalist id="client-list"></datalist>
|
||||
</label>
|
||||
<label class="field-label">Tätigkeit
|
||||
<textarea id="activity-input" rows="4" maxlength="4000" placeholder="Woran arbeitest du?"></textarea>
|
||||
</label>
|
||||
<div class="timer-display" id="timer-display">00:00:00</div>
|
||||
<button id="timer-button" class="btn primary timer-btn">▶ Start</button>
|
||||
<button id="edit-running" class="link-btn centered" hidden>Laufenden Eintrag bearbeiten</button>
|
||||
</section>
|
||||
|
||||
<div class="metric-grid">
|
||||
<article class="metric-card"><span>Heute</span><strong id="today-total">0:00</strong></article>
|
||||
<article class="metric-card"><span>Diese Woche</span><strong id="week-total">0:00</strong></article>
|
||||
</div>
|
||||
<div id="long-running-warning" class="warning-box" hidden>Der Timer läuft seit mehr als 8 Stunden. Vielleicht vergessen zu stoppen?</div>
|
||||
<footer class="sidebar-footer"><span id="user-label"></span><span>SQLite · Go</span></footer>
|
||||
</aside>
|
||||
|
||||
<section class="history-pane" id="history-pane">
|
||||
<div class="history-head">
|
||||
<div class="history-title"><button class="link-btn mobile-only" id="mobile-track">← Erfassen</button><p class="eyebrow">ZEITEN</p><h1>Verlauf</h1></div>
|
||||
<div class="history-actions">
|
||||
<button class="btn subtle" id="add-entry">+ Nachtragen</button>
|
||||
<button class="btn subtle" id="history-export">Export</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<input id="search-input" type="search" placeholder="Kunde oder Tätigkeit suchen…" aria-label="Verlauf durchsuchen">
|
||||
<div class="period-tabs" role="group" aria-label="Zeitraum">
|
||||
<button data-period="day">Tag</button><button data-period="week">Woche</button><button data-period="month">Monat</button><button data-period="year">Jahr</button><button data-period="all" class="active">Alle</button>
|
||||
</div>
|
||||
<div class="period-step"><button id="period-prev" aria-label="Vorheriger Zeitraum">‹</button><span id="period-label">Alle Zeiten</span><button id="period-next" aria-label="Nächster Zeitraum">›</button></div>
|
||||
</div>
|
||||
|
||||
<div class="history-scroll">
|
||||
<div id="empty-state" class="empty-state" hidden><div class="empty-icon">◷</div><h2>Noch keine Einträge</h2><p>Starte links einen Timer oder trage eine Zeit nach.</p></div>
|
||||
<div id="entries" class="entries"></div>
|
||||
<button id="load-more" class="btn subtle load-more" hidden>Weitere laden</button>
|
||||
</div>
|
||||
<footer class="history-footer"><span><strong id="result-count">0</strong> Einträge</span><span>Gesamt <strong id="result-total">0:00</strong></span></footer>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="entry-dialog" class="modal">
|
||||
<form id="entry-form" method="dialog" class="modal-card">
|
||||
<div class="modal-head"><div><p class="eyebrow">ZEITEINTRAG</p><h2 id="entry-dialog-title">Eintrag bearbeiten</h2></div><button type="button" class="icon-btn" data-close="entry-dialog">×</button></div>
|
||||
<input type="hidden" id="entry-id">
|
||||
<label>Kunde<input id="edit-client" maxlength="200" list="client-list"></label>
|
||||
<label>Tätigkeit<textarea id="edit-activity" rows="4" maxlength="4000"></textarea></label>
|
||||
<div class="two-col"><label>Start<input id="edit-start" type="datetime-local" required></label><label>Ende<input id="edit-end" type="datetime-local"></label></div>
|
||||
<p class="muted small">Dauer: <strong id="edit-duration">–</strong></p>
|
||||
<p id="entry-error" class="error-box" hidden></p>
|
||||
<div class="modal-actions split"><button type="button" id="delete-entry" class="btn danger" hidden>Löschen</button><div class="spacer"></div><button type="button" class="btn subtle" data-close="entry-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="settings-dialog" class="modal modal-wide">
|
||||
<form id="settings-form" method="dialog" class="modal-card">
|
||||
<div class="modal-head"><div><p class="eyebrow">POCKETWATCH</p><h2>Einstellungen</h2></div><button type="button" class="icon-btn" data-close="settings-dialog">×</button></div>
|
||||
<div class="settings-grid">
|
||||
<section>
|
||||
<h3>Anzeige & Auswertung</h3>
|
||||
<label>Zeitformat<select id="setting-time-format"><option value="24">24 Stunden</option><option value="12">12 Stunden</option></select></label>
|
||||
<label>Rundung in Minuten<input id="setting-rounding" type="number" min="1" max="60"></label>
|
||||
<label class="check-row"><input id="setting-round-up" type="checkbox"><span>Immer aufrunden</span></label>
|
||||
<label class="check-row"><input id="setting-week" type="checkbox"><span>Wochensumme anzeigen</span></label>
|
||||
<label class="check-row"><input id="setting-sticky" type="checkbox"><span>Tagesüberschriften fixieren</span></label>
|
||||
<label class="check-row"><input id="setting-reminder" type="checkbox"><span>Warnung bei langem Timer</span></label>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Export</h3>
|
||||
<label>Name im PDF<input id="setting-export-name" maxlength="120"></label>
|
||||
<label>Zeitzone für Exporte<input id="setting-timezone" maxlength="80" placeholder="Europe/Berlin"></label>
|
||||
<label class="check-row"><input id="setting-export-date" type="checkbox"><span>Exportdatum im PDF</span></label>
|
||||
<p class="muted small">Tipp: Die Zeitzone wird beim ersten Öffnen automatisch aus dem Browser übernommen.</p>
|
||||
<button type="button" class="btn subtle" id="change-own-password">Eigenes Passwort ändern</button>
|
||||
</section>
|
||||
</div>
|
||||
<section id="admin-section" class="admin-section" hidden>
|
||||
<div class="section-title"><div><p class="eyebrow">ADMIN</p><h3>Benutzer</h3></div><button type="button" id="add-user" class="btn subtle">+ Benutzer</button></div>
|
||||
<div id="user-list" class="user-list"></div>
|
||||
</section>
|
||||
<p id="settings-error" class="error-box" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="btn subtle" data-close="settings-dialog">Abbrechen</button><button type="submit" class="btn primary">Speichern</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="user-dialog" class="modal">
|
||||
<form id="user-form" method="dialog" class="modal-card compact">
|
||||
<div class="modal-head"><h2>Benutzer anlegen</h2><button type="button" class="icon-btn" data-close="user-dialog">×</button></div>
|
||||
<label>Benutzername<input id="new-username" minlength="3" maxlength="64" required></label>
|
||||
<label>Anzeigename<input id="new-display-name" maxlength="120"></label>
|
||||
<label>Passwort<input id="new-password" type="password" minlength="10" required></label>
|
||||
<label>Rolle<select id="new-role"><option value="user">Benutzer</option><option value="admin">Administrator</option></select></label>
|
||||
<p id="user-error" class="error-box" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="btn subtle" data-close="user-dialog">Abbrechen</button><button type="submit" class="btn primary">Anlegen</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="account-password-dialog" class="modal">
|
||||
<form id="account-password-form" method="dialog" class="modal-card compact">
|
||||
<div class="modal-head"><h2>Eigenes Passwort ändern</h2><button type="button" class="icon-btn" data-close="account-password-dialog">×</button></div>
|
||||
<label>Aktuelles Passwort<input id="current-password" type="password" autocomplete="current-password" required></label>
|
||||
<label>Neues Passwort<input id="own-new-password" type="password" minlength="10" autocomplete="new-password" required></label>
|
||||
<p id="account-password-error" class="error-box" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="btn subtle" data-close="account-password-dialog">Abbrechen</button><button type="submit" class="btn primary">Ändern</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="password-dialog" class="modal">
|
||||
<form id="password-form" method="dialog" class="modal-card compact">
|
||||
<div class="modal-head"><h2>Passwort zurücksetzen</h2><button type="button" class="icon-btn" data-close="password-dialog">×</button></div>
|
||||
<input type="hidden" id="password-user-id">
|
||||
<label>Neues Passwort<input id="reset-password" type="password" minlength="10" required></label>
|
||||
<p id="password-error" class="error-box" hidden></p>
|
||||
<div class="modal-actions"><button type="button" class="btn subtle" data-close="password-dialog">Abbrechen</button><button type="submit" class="btn primary">Zurücksetzen</button></div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="export-dialog" class="modal">
|
||||
<div class="modal-card compact">
|
||||
<div class="modal-head"><div><p class="eyebrow">EXPORT</p><h2>Zeiten exportieren</h2></div><button type="button" class="icon-btn" data-close="export-dialog">×</button></div>
|
||||
<p class="muted">Rundung und Zeitzone werden aus deinen Einstellungen übernommen.</p>
|
||||
<div class="export-scope" role="radiogroup" aria-label="Exportumfang">
|
||||
<label class="check-row"><input type="radio" name="export-scope" value="view" checked><span>Aktuelle Ansicht</span></label>
|
||||
<label class="check-row"><input type="radio" name="export-scope" value="custom"><span>Eigener Zeitraum</span></label>
|
||||
</div>
|
||||
<div id="export-custom" class="two-col" hidden>
|
||||
<label>Von<input id="export-from" type="date"></label>
|
||||
<label>Bis<input id="export-to" type="date"></label>
|
||||
</div>
|
||||
<div class="two-col">
|
||||
<label>Sortierung<select id="export-sort"><option value="desc">Neueste zuerst</option><option value="asc">Älteste zuerst</option></select></label>
|
||||
<label class="check-row export-compact"><input id="export-compact" type="checkbox"><span>Nur Zeiten (ohne Tätigkeit)</span></label>
|
||||
</div>
|
||||
<p id="export-preview" class="export-preview">0 Einträge · 0:00</p>
|
||||
<div class="export-buttons"><button id="export-pdf" class="btn primary">PDF herunterladen</button><button id="export-csv" class="btn subtle">CSV herunterladen</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
|
||||
<script src="/static/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
41
internal/app/web/login.html
Normal file
41
internal/app/web/login.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<meta name="theme-color" content="#0e0f11">
|
||||
<title>Pocketwatch</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body class="auth-body">
|
||||
<main class="auth-shell">
|
||||
<section class="auth-card" aria-labelledby="auth-title">
|
||||
<div class="brand brand-large"><span class="brand-dot"></span><span>pocketwatch</span></div>
|
||||
<div id="login-panel">
|
||||
<p class="eyebrow">SELF-HOSTED TIME TRACKING</p>
|
||||
<h1 id="auth-title">Anmelden</h1>
|
||||
<p class="muted">Deine Zeiten bleiben auf deinem Server.</p>
|
||||
<form id="login-form" class="form-stack">
|
||||
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
|
||||
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary wide" type="submit">Anmelden</button>
|
||||
</form>
|
||||
</div>
|
||||
<div id="setup-panel" hidden>
|
||||
<p class="eyebrow">ERSTEINRICHTUNG</p>
|
||||
<h1>Admin anlegen</h1>
|
||||
<p class="muted">Der erste Benutzer wird Administrator. Weitere Konten legst du später in den Einstellungen an.</p>
|
||||
<form id="setup-form" class="form-stack">
|
||||
<label>Benutzername<input name="username" minlength="3" maxlength="64" autocomplete="username" required autofocus></label>
|
||||
<label>Anzeigename<input name="displayName" maxlength="120" autocomplete="name"></label>
|
||||
<label>Passwort<input name="password" type="password" minlength="10" autocomplete="new-password" required></label>
|
||||
<button class="btn primary wide" type="submit">Instanz einrichten</button>
|
||||
</form>
|
||||
</div>
|
||||
<p id="auth-error" class="error-box" role="alert" hidden></p>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/static/login.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
40
internal/app/web/login.js
Normal file
40
internal/app/web/login.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const errorBox = document.querySelector('#auth-error');
|
||||
const loginPanel = document.querySelector('#login-panel');
|
||||
const setupPanel = document.querySelector('#setup-panel');
|
||||
|
||||
function showError(message) {
|
||||
errorBox.textContent = message;
|
||||
errorBox.hidden = !message;
|
||||
}
|
||||
async function request(url, options = {}) {
|
||||
const res = await fetch(url, { ...options, headers: { 'Content-Type': 'application/json', ...(options.headers || {}) } });
|
||||
const body = res.status === 204 ? null : await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(body?.error?.message || `HTTP ${res.status}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const x = await request('/api/bootstrap');
|
||||
loginPanel.hidden = x.needs_setup;
|
||||
setupPanel.hidden = !x.needs_setup;
|
||||
} catch (e) { showError(e.message); }
|
||||
})();
|
||||
|
||||
document.querySelector('#login-form').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault(); showError('');
|
||||
const fd = new FormData(ev.currentTarget);
|
||||
try {
|
||||
await request('/api/login', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), password: fd.get('password') }) });
|
||||
location.assign('/');
|
||||
} catch (e) { showError(e.message); }
|
||||
});
|
||||
|
||||
document.querySelector('#setup-form').addEventListener('submit', async (ev) => {
|
||||
ev.preventDefault(); showError('');
|
||||
const fd = new FormData(ev.currentTarget);
|
||||
try {
|
||||
await request('/api/setup', { method: 'POST', body: JSON.stringify({ username: fd.get('username'), displayName: fd.get('displayName'), password: fd.get('password') }) });
|
||||
location.assign('/');
|
||||
} catch (e) { showError(e.message); }
|
||||
});
|
||||
Reference in New Issue
Block a user