From a5aed05b989cf85a9870f573de4a74cf781c0fa4 Mon Sep 17 00:00:00 2001 From: nfel Date: Tue, 28 Jul 2026 02:04:36 +0330 Subject: [PATCH] init --- .dockerignore | 11 + .env.example | 11 + .gitea/actions/docker-build-push/action.yml | 44 ++ .gitea/actions/docker-login/action.yml | 27 + .gitea/actions/setup-mirrors/action.yml | 52 ++ .gitea/workflows/ci.yaml | 38 + .gitignore | 6 + Dockerfile | 22 + Makefile | 10 + README.md | 113 +++ cmd/server/main.go | 23 + go.mod | 18 + go.sum | 49 ++ internal/app/server.go | 811 ++++++++++++++++++++ internal/app/server_test.go | 318 ++++++++ internal/app/static/jalali-picker.js | 281 +++++++ internal/app/static/style.css | 400 ++++++++++ internal/app/static/theme.js | 64 ++ internal/app/store.go | 597 ++++++++++++++ internal/app/templates/admin.html | 33 + internal/app/templates/base.html | 84 ++ internal/app/templates/calendar.html | 35 + internal/app/templates/dashboard.html | 63 ++ internal/app/templates/day.html | 56 ++ internal/app/templates/login.html | 44 ++ internal/app/templates/register.html | 51 ++ internal/app/templates/reports.html | 14 + internal/app/templates/requests.html | 42 + internal/app/templates/users.html | 38 + internal/jalali/jalali.go | 109 +++ internal/jalali/jalali_test.go | 36 + 31 files changed, 3500 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitea/actions/docker-build-push/action.yml create mode 100644 .gitea/actions/docker-login/action.yml create mode 100644 .gitea/actions/setup-mirrors/action.yml create mode 100644 .gitea/workflows/ci.yaml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/server/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/server.go create mode 100644 internal/app/server_test.go create mode 100644 internal/app/static/jalali-picker.js create mode 100644 internal/app/static/style.css create mode 100644 internal/app/static/theme.js create mode 100644 internal/app/store.go create mode 100644 internal/app/templates/admin.html create mode 100644 internal/app/templates/base.html create mode 100644 internal/app/templates/calendar.html create mode 100644 internal/app/templates/dashboard.html create mode 100644 internal/app/templates/day.html create mode 100644 internal/app/templates/login.html create mode 100644 internal/app/templates/register.html create mode 100644 internal/app/templates/reports.html create mode 100644 internal/app/templates/requests.html create mode 100644 internal/app/templates/users.html create mode 100644 internal/jalali/jalali.go create mode 100644 internal/jalali/jalali_test.go diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab53cb4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitea +.agents +.codex +.env +data +teammate +*.db +*.db-shm +*.db-wal +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2e8e474 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +APP_ADDR=:8080 +APP_BASE_URL=http://localhost:8080 +DATABASE_PATH=./data/teammate.db +SESSION_SECURE=false +INITIAL_ADMIN_PASSWORD=admin123 + +# Optional OAuth providers. Local username/password works without these. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= diff --git a/.gitea/actions/docker-build-push/action.yml b/.gitea/actions/docker-build-push/action.yml new file mode 100644 index 0000000..53e0544 --- /dev/null +++ b/.gitea/actions/docker-build-push/action.yml @@ -0,0 +1,44 @@ +name: Docker Build and Push +description: Build, test, tag, and optionally push the Hamkar image +inputs: + image: + description: Full OCI image name without a tag + required: true + branch: + description: Human-readable image tag + required: true + commit_sha: + description: Immutable commit image tag + required: true + push: + description: Push both tags to the registry + required: false + default: "true" +runs: + using: composite + steps: + - name: Build image + shell: bash + env: + IMAGE: ${{ inputs.image }} + BRANCH: ${{ inputs.branch }} + COMMIT_SHA: ${{ inputs.commit_sha }} + PUSH_IMAGE: ${{ inputs.push }} + run: | + set -euo pipefail + + go_proxy="${GOPROXY:-https://proxy.golang.org/}" + output=(--load) + if [[ "$PUSH_IMAGE" == "true" ]]; then + output=(--push) + fi + + docker buildx build \ + -f Dockerfile \ + --build-arg "GO_PROXY=$go_proxy" \ + --label "org.opencontainers.image.revision=$COMMIT_SHA" \ + --label "org.opencontainers.image.source=${GITHUB_SERVER_URL:-}" \ + "${output[@]}" \ + -t "$IMAGE:$BRANCH" \ + -t "$IMAGE:$COMMIT_SHA" \ + . diff --git a/.gitea/actions/docker-login/action.yml b/.gitea/actions/docker-login/action.yml new file mode 100644 index 0000000..1870e0f --- /dev/null +++ b/.gitea/actions/docker-login/action.yml @@ -0,0 +1,27 @@ +name: Docker Login +description: Log in to the OCI registry without exposing the password in process arguments +inputs: + registry: + description: OCI registry hostname + required: false + default: oci.reg.darano.ir + username: + description: OCI registry username + required: false + default: admin + password: + description: OCI registry password + required: true +runs: + using: composite + steps: + - name: Log in + shell: bash + env: + REGISTRY: ${{ inputs.registry }} + REGISTRY_USERNAME: ${{ inputs.username }} + REGISTRY_PASSWORD: ${{ inputs.password }} + run: | + set -euo pipefail + printf '%s' "$REGISTRY_PASSWORD" | + docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin diff --git a/.gitea/actions/setup-mirrors/action.yml b/.gitea/actions/setup-mirrors/action.yml new file mode 100644 index 0000000..bdc5fd8 --- /dev/null +++ b/.gitea/actions/setup-mirrors/action.yml @@ -0,0 +1,52 @@ +name: Select Go module proxy +description: Select the fastest reachable Go module proxy for Docker builds +inputs: + go_mirrors: + description: Newline-separated Go module proxies + required: false + default: | + https://go.devneeds.ir/ + https://package-mirror.liara.ir/repository/go/ + https://go.reg.darano.ir/ + https://proxy.golang.org/ +outputs: + go_proxy: + description: Selected Go module proxy + value: ${{ steps.select.outputs.go_proxy }} +runs: + using: composite + steps: + - name: Select fastest reachable proxy + id: select + shell: bash + env: + GO_MIRRORS: ${{ inputs.go_mirrors }} + run: | + set -euo pipefail + scores="$(mktemp)" + fallback="" + + while IFS= read -r mirror; do + mirror="$(printf '%s' "$mirror" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [[ -z "$mirror" || "$mirror" == \#* ]] && continue + [[ -z "$fallback" ]] && fallback="$mirror" + result="$(curl --location --silent --output /dev/null \ + --connect-timeout 3 --max-time 10 \ + --write-out '%{http_code} %{time_total}' "$mirror" 2>/dev/null || true)" + read -r status duration <<< "$result" + if [[ "$status" =~ ^(2|3)[0-9]{2}$ ]] && [[ "$duration" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf '%s\t%s\n' "$duration" "$mirror" >> "$scores" + fi + done <<< "$GO_MIRRORS" + + if [[ -s "$scores" ]]; then + go_proxy="$(sort -n -k1,1 "$scores" | head -n 1 | cut -f2-)" + else + go_proxy="$fallback" + fi + rm -f "$scores" + + test -n "$go_proxy" + echo "GOPROXY=$go_proxy" >> "$GITHUB_ENV" + echo "go_proxy=$go_proxy" >> "$GITHUB_OUTPUT" + echo "Selected Go proxy: $go_proxy" diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..da3d1c2 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,38 @@ +--- +name: Test and publish + +on: + pull_request: + push: + branches: + - main + +env: + IMAGE: oci.reg.darano.ir/personal/tracking-personel + +jobs: + verify: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: https://git.darano.ir/actions/checkout@v5 + with: + token: ${{ gitea.token }} + path: ./ + + - name: Select package mirror + uses: ./.gitea/actions/setup-mirrors + + - name: Log in to registry + if: gitea.event_name == 'push' + uses: ./.gitea/actions/docker-login + with: + password: ${{ secrets.REG_PASS }} + + - name: Build and test image + uses: ./.gitea/actions/docker-build-push + with: + image: ${{ env.IMAGE }} + branch: main + commit_sha: ${{ gitea.sha }} + push: ${{ gitea.event_name == 'push' }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cedd4b5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/data/ +/teammate +/.env +*.db +*.db-shm +*.db-wal diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ce1a65 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +ARG GO_PROXY=https://proxy.golang.org/ +ENV GOPROXY=${GO_PROXY} +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go test ./... && \ + CGO_ENABLED=0 go build -trimpath -buildvcs=false -ldflags="-s -w" -o /out/teammate ./cmd/server + +FROM alpine:3.22 +RUN addgroup -S app && adduser -S -G app app +WORKDIR /app +COPY --from=build /out/teammate /usr/local/bin/teammate +RUN mkdir -p /data && chown app:app /data +USER app +ENV APP_ADDR=:8080 DATABASE_PATH=/data/teammate.db SESSION_SECURE=true +EXPOSE 8080 +VOLUME ["/data"] +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q -O - http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["teammate"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c197d49 --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: run test build + +run: + go run ./cmd/server + +test: + go test ./... + +build: + go build -o teammate ./cmd/server diff --git a/README.md b/README.md new file mode 100644 index 0000000..328be23 --- /dev/null +++ b/README.md @@ -0,0 +1,113 @@ +# Hamkar + +Hamkar is a small team presence tracker built for startups that work with the Persian (Solar Hijri/Shamsi) calendar. It ships as one Go binary with embedded HTMX templates and uses SQLite by default. + +## Included + +- Self-service account creation with email or username and a salted PBKDF2 password +- Login with either email address or username +- Optional Google and GitHub OAuth login +- Admin-created username/password teammate accounts +- Persistent light and dark modes with automatic system-theme detection +- Vazirmatn as the default interface font +- Office and remote check-in/check-out +- Persian month calendar with attendance, approved leave, remote days, Fridays, and fixed Solar Hijri public holidays +- Team day detail showing who is present, remote, absent, or expected at the office +- Time-off and remote-day requests with a dependency-free Jalali date picker and typed Persian-date fallback +- Admin approval/rejection with review notes +- Excel-compatible UTF-8 CSV reports with both Persian and Gregorian dates +- CSRF protection, secure session cookies, security headers, and role checks +- Responsive UI with no JavaScript build step + +## Start locally + +Requirements: Go 1.26 or later. + +```bash +cp .env.example .env +go run ./cmd/server +``` + +The app listens at . On a fresh database, use: + +```text +username: admin +password: admin123 +``` + +Set `INITIAL_ADMIN_PASSWORD` before the first start to replace the demo password. It is only read when creating an empty database. The database is created at `./data/teammate.db`. + +Environment files are not loaded automatically. Export values in your shell, use a process manager, or run: + +```bash +set -a +source .env +set +a +go run ./cmd/server +``` + +## OAuth + +Create OAuth applications with these callback URLs: + +```text +http://localhost:8080/auth/github/callback +http://localhost:8080/auth/google/callback +``` + +Set the matching client ID and secret in the environment. Provider buttons only appear when configured. For production, set `APP_BASE_URL` to the public HTTPS origin and `SESSION_SECURE=true`. + +## Production + +```bash +go build -o teammate ./cmd/server +APP_ADDR=:8080 DATABASE_PATH=/var/lib/teammate/teammate.db SESSION_SECURE=true ./teammate +``` + +Place a TLS reverse proxy such as Caddy or nginx in front of the binary. Back up the SQLite database and its WAL files together, or use SQLite's online backup command. The store is intentionally serialized to one connection, which is a good fit for a small startup deployment. + +## Docker + +The multi-stage image runs tests during the build, compiles a static Go binary, uses a non-root runtime user, persists SQLite under `/data`, and exposes a `/healthz` container health check. + +```bash +docker build -t hamkar:local . +docker run --rm -p 8080:8080 \ + -e SESSION_SECURE=false \ + -e INITIAL_ADMIN_PASSWORD='replace-this-password' \ + -v hamkar-data:/data \ + hamkar:local +``` + +## Gitea Actions + +The workflow under `.gitea/workflows/ci.yaml` is adapted from the Sana API pipeline. Pull requests build and test the container without publishing it. Pushes to `main` build and publish both `main` and immutable commit-SHA tags. + +Configure this repository secret: + +```text +REG_PASS Password for oci.reg.darano.ir +``` + +The registry username defaults to `admin`, and the image defaults to `oci.reg.darano.ir/personal/tracking-personel`. Change the workflow environment if the repository uses a different registry path. + +## Persian calendar behavior + +The application stores dates in ISO Gregorian form internally and converts at the UI boundary. This keeps SQL comparisons and exports straightforward while making the primary calendar and request input Solar Hijri. Request dates use `YYYY-MM-DD`, for example `1405-05-06`. + +Fixed national holidays are included. Lunar Islamic holidays move each year and should be added from an authoritative annual calendar before production use. + +## Development + +```bash +make test +make build +``` + +The main packages are: + +- `internal/jalali`: dependency-free Persian/Gregorian date conversion +- `internal/app/store.go`: SQLite schema and data access +- `internal/app/server.go`: HTTP routes, auth, workflows, and CSV export +- `internal/app/templates`: embedded server-rendered views +- `internal/app/static`: responsive application styling diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..083d804 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,23 @@ +package main + +import ( + "log" + "os" + + "teammate/internal/app" +) + +func main() { + cfg := app.ConfigFromEnv() + server, err := app.New(cfg) + if err != nil { + log.Fatal(err) + } + defer server.Close() + + log.Printf("Teammate is running on %s", cfg.Addr) + if err := server.ListenAndServe(); err != nil { + log.Println(err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5f96673 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module teammate + +go 1.26 + +require modernc.org/sqlite v1.39.1 + +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.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.36.0 // indirect + modernc.org/libc v1.66.10 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4ffbac8 --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +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-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/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/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= +modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= +modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A= +modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I= +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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/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.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4= +modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE= +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= diff --git a/internal/app/server.go b/internal/app/server.go new file mode 100644 index 0000000..3769a09 --- /dev/null +++ b/internal/app/server.go @@ -0,0 +1,811 @@ +package app + +import ( + "context" + "crypto/subtle" + "database/sql" + "embed" + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "log" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" + + "teammate/internal/jalali" +) + +//go:embed templates/*.html static/* +var assets embed.FS + +type Config struct { + Addr string + BaseURL string + DatabasePath string + SessionSecure bool + GitHubClientID string + GitHubClientSecret string + GoogleClientID string + GoogleClientSecret string +} + +func ConfigFromEnv() Config { + return Config{ + Addr: env("APP_ADDR", ":8080"), + BaseURL: strings.TrimRight(env("APP_BASE_URL", "http://localhost:8080"), "/"), + DatabasePath: env("DATABASE_PATH", "./data/teammate.db"), + SessionSecure: env("SESSION_SECURE", "false") == "true", + GitHubClientID: os.Getenv("GITHUB_CLIENT_ID"), + GitHubClientSecret: os.Getenv("GITHUB_CLIENT_SECRET"), + GoogleClientID: os.Getenv("GOOGLE_CLIENT_ID"), + GoogleClientSecret: os.Getenv("GOOGLE_CLIENT_SECRET"), + } +} + +func env(name, fallback string) string { + if v := os.Getenv(name); v != "" { + return v + } + return fallback +} + +type Server struct { + cfg Config + store *Store + templates *template.Template + http *http.Server +} + +type PageData struct { + Title string + User *User + CSRF string + Flash string + Error string + Today string + TodayJalali string + Attendance *Attendance + Calendar Calendar + Requests []Request + PendingCount int + Stats Stats + OAuthGitHub bool + OAuthGoogle bool + ReportStart string + ReportEnd string + SelectedSection string + Users []User + Day DayDetail +} + +type Stats struct{ Present, Remote, Leave int } + +type DayDetail struct { + Gregorian string + Jalali string + Weekday string + DayNote string + Prev string + Next string + Present int + Remote int + Absent int + Members []RosterMember +} + +type RosterMember struct { + User User + Status string + Label string + Detail string + CheckIn string + CheckOut string +} + +type Calendar struct { + Year, Month int + MonthName string + MonthNameFA string + Prev, Next string + Cells []CalendarCell +} + +type CalendarCell struct { + Day, Weekday int + Gregorian string + InMonth bool + IsToday bool + IsFriday bool + Holiday string + Status string + StartRequest bool +} + +func New(cfg Config) (*Server, error) { + if err := os.MkdirAll(filepath.Dir(cfg.DatabasePath), 0o750); err != nil { + return nil, err + } + store, err := OpenStore(cfg.DatabasePath) + if err != nil { + return nil, err + } + funcs := template.FuncMap{ + "timeHM": func(t sql.NullTime) string { + if !t.Valid { + return "—" + } + return t.Time.Local().Format("15:04") + }, + "dateFA": func(raw string) string { + t, err := time.Parse("2006-01-02", raw) + if err != nil { + return raw + } + return jalali.FromTime(t).String() + }, + "kindLabel": func(v string) string { + if v == "remote" { + return "Remote day" + } + return "Time off" + }, + "statusLabel": func(v string) string { + return strings.ToUpper(v[:1]) + v[1:] + }, + "initial": func(v string) string { + r, _ := utf8.DecodeRuneInString(v) + return string(r) + }, + } + tmpl, err := template.New("root").Funcs(funcs).ParseFS(assets, "templates/*.html") + if err != nil { + store.Close() + return nil, err + } + s := &Server{cfg: cfg, store: store, templates: tmpl} + mux := http.NewServeMux() + s.routes(mux) + s.http = &http.Server{ + Addr: cfg.Addr, + Handler: s.securityHeaders(s.withUser(mux)), + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + return s, nil +} + +func (s *Server) ListenAndServe() error { + err := s.http.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) Close() error { return s.store.Close() } + +func (s *Server) routes(mux *http.ServeMux) { + mux.Handle("GET /static/", http.FileServer(http.FS(assets))) + mux.HandleFunc("GET /healthz", s.health) + mux.HandleFunc("GET /login", s.loginPage) + mux.HandleFunc("POST /login", s.login) + mux.HandleFunc("GET /register", s.registerPage) + mux.HandleFunc("POST /register", s.register) + mux.HandleFunc("POST /logout", s.requireAuth(s.csrf(s.logout))) + mux.HandleFunc("GET /auth/{provider}", s.oauthStart) + mux.HandleFunc("GET /auth/{provider}/callback", s.oauthCallback) + mux.HandleFunc("GET /", s.requireAuth(s.dashboard)) + mux.HandleFunc("GET /day", s.requireAuth(s.dayPage)) + mux.HandleFunc("GET /calendar", s.requireAuth(s.calendarPartial)) + mux.HandleFunc("POST /attendance/check-in", s.requireAuth(s.csrf(s.checkIn))) + mux.HandleFunc("POST /attendance/check-out", s.requireAuth(s.csrf(s.checkOut))) + mux.HandleFunc("GET /requests", s.requireAuth(s.requestsPage)) + mux.HandleFunc("POST /requests", s.requireAuth(s.csrf(s.createRequest))) + mux.HandleFunc("POST /requests/{id}/cancel", s.requireAuth(s.csrf(s.cancelRequest))) + mux.HandleFunc("GET /admin/requests", s.requireAdmin(s.adminPage)) + mux.HandleFunc("POST /admin/requests/{id}/review", s.requireAdmin(s.csrf(s.reviewRequest))) + mux.HandleFunc("GET /admin/users", s.requireAdmin(s.usersPage)) + mux.HandleFunc("POST /admin/users", s.requireAdmin(s.csrf(s.createUser))) + mux.HandleFunc("GET /reports", s.requireAdmin(s.reportPage)) + mux.HandleFunc("GET /reports/attendance.csv", s.requireAdmin(s.reportCSV)) +} + +func (s *Server) dayPage(w http.ResponseWriter, r *http.Request) { + rawDate := r.URL.Query().Get("date") + if rawDate == "" { + rawDate = time.Now().Format("2006-01-02") + } + day, ok := parseUserDate(rawDate) + errorMessage := r.URL.Query().Get("error") + if !ok { + day = time.Now().Format("2006-01-02") + errorMessage = "Choose a valid Persian date." + } + selected, _ := time.Parse("2006-01-02", day) + rows, err := s.store.DayRoster(day) + if err != nil { + http.Error(w, "could not load the team day view", http.StatusInternalServerError) + return + } + jalaliDate := jalali.FromTime(selected) + detail := DayDetail{ + Gregorian: day, + Jalali: jalaliDate.String(), + Weekday: selected.Format("Monday"), + Prev: jalali.FromTime(selected.AddDate(0, 0, -1)).String(), + Next: jalali.FromTime(selected.AddDate(0, 0, 1)).String(), + } + if holiday := persianHoliday(jalaliDate.Month, jalaliDate.Day); holiday != "" { + detail.DayNote = holiday + } else if selected.Weekday() == time.Friday { + detail.DayNote = "Friday weekend" + } + today := time.Now().Format("2006-01-02") + for _, row := range rows { + member := RosterMember{User: row.User, CheckIn: row.CheckIn, CheckOut: row.CheckOut} + switch { + case row.Mode == "office": + member.Status, member.Label = "present", "Present" + member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Office") + detail.Present++ + case row.Mode == "remote": + member.Status, member.Label = "remote", "Remote" + member.Detail = attendanceDetail(row.CheckIn, row.CheckOut, "Remote") + detail.Remote++ + case row.RequestKind == "leave": + member.Status, member.Label, member.Detail = "absent", "Absent", "Approved time off" + detail.Absent++ + case row.RequestKind == "remote": + member.Status, member.Label, member.Detail = "remote", "Remote", "Approved remote day" + detail.Remote++ + case detail.DayNote != "": + member.Status, member.Label, member.Detail = "absent", "Absent", detail.DayNote + detail.Absent++ + case day >= today: + member.Status, member.Label = "present", "Present" + if day == today { + member.Detail = "Expected at the office today" + } else { + member.Detail = "Expected at the office" + } + detail.Present++ + default: + member.Status, member.Label, member.Detail = "absent", "Absent", "No attendance recorded" + detail.Absent++ + } + detail.Members = append(detail.Members, member) + } + s.render(w, "day.html", PageData{ + Title: "Team day", User: currentUser(r), CSRF: csrfToken(r), Day: detail, + Error: errorMessage, SelectedSection: "day", + }) +} + +func attendanceDetail(checkIn, checkOut, location string) string { + if checkOut != "" { + return fmt.Sprintf("%s · %s–%s", location, checkIn, checkOut) + } + if checkIn != "" { + return fmt.Sprintf("%s · checked in %s", location, checkIn) + } + return location +} + +func (s *Server) health(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), time.Second) + defer cancel() + if err := s.store.Ping(ctx); err != nil { + http.Error(w, `{"status":"unhealthy"}`, http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"status":"ok"}`) +} + +func (s *Server) render(w http.ResponseWriter, name string, data PageData) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + log.Printf("render %s: %v", name, err) + } +} + +func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.render(w, "login.html", PageData{ + Title: "Sign in", Error: r.URL.Query().Get("error"), + OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "", + }) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + identifier := r.FormValue("identifier") + if identifier == "" { + identifier = r.FormValue("username") + } + u, err := s.store.Authenticate(identifier, r.FormValue("password")) + if err != nil { + http.Redirect(w, r, "/login?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + s.startSession(w, u.ID) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) registerPage(w http.ResponseWriter, r *http.Request) { + if currentUser(r) != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.render(w, "register.html", PageData{ + Title: "Create account", Error: r.URL.Query().Get("error"), + OAuthGitHub: s.cfg.GitHubClientID != "", OAuthGoogle: s.cfg.GoogleClientID != "", + }) +} + +func (s *Server) register(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + if r.FormValue("password") != r.FormValue("password_confirm") { + http.Redirect(w, r, "/register?error=Passwords+do+not+match", http.StatusSeeOther) + return + } + userID, err := s.store.CreateUser( + r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), + r.FormValue("email"), "member", + ) + if err != nil { + http.Redirect(w, r, "/register?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + s.startSession(w, userID) + http.Redirect(w, r, "/?flash=Welcome+to+Hamkar", http.StatusSeeOther) +} + +func (s *Server) startSession(w http.ResponseWriter, userID int64) { + token, _, err := s.store.CreateSession(userID) + if err != nil { + http.Error(w, "could not create session", http.StatusInternalServerError) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "teammate_session", Value: token, Path: "/", HttpOnly: true, + Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 30 * 24 * 60 * 60, + }) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + if c, err := r.Cookie("teammate_session"); err == nil { + s.store.DeleteSession(c.Value) + } + http.SetCookie(w, &http.Cookie{Name: "teammate_session", Path: "/", MaxAge: -1, HttpOnly: true}) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + now := time.Now() + today := now.Format("2006-01-02") + a, _ := s.store.TodayAttendance(u.ID, today) + cal := s.buildCalendar(u.ID, r.URL.Query().Get("month")) + start := jalali.ToGregorian(cal.Year, cal.Month, 1).Format("2006-01-02") + end := jalali.ToGregorian(cal.Year, cal.Month, jalali.DaysInMonth(cal.Year, cal.Month)).Format("2006-01-02") + present, remote, leave, _ := s.store.Stats(u.ID, start, end) + requests, _ := s.store.Requests(u.ID, false, "") + if len(requests) > 4 { + requests = requests[:4] + } + pending, _ := s.store.Requests(u.ID, u.Role == "admin", "pending") + s.render(w, "dashboard.html", PageData{ + Title: "Dashboard", User: u, CSRF: csrfToken(r), Flash: r.URL.Query().Get("flash"), + Error: r.URL.Query().Get("error"), + Today: today, TodayJalali: jalali.FromTime(now).String(), Attendance: a, + Calendar: cal, Requests: requests, PendingCount: len(pending), + Stats: Stats{present, remote, leave}, SelectedSection: "dashboard", + }) +} + +func (s *Server) calendarPartial(w http.ResponseWriter, r *http.Request) { + s.render(w, "calendar.html", PageData{User: currentUser(r), Calendar: s.buildCalendar(currentUser(r).ID, r.URL.Query().Get("month"))}) +} + +func (s *Server) buildCalendar(userID int64, selected string) Calendar { + today := jalali.FromTime(time.Now()) + year, month := today.Year, today.Month + if selected != "" { + if _, err := fmt.Sscanf(selected, "%d-%d", &year, &month); err != nil || month < 1 || month > 12 { + year, month = today.Year, today.Month + } + } + first := jalali.ToGregorian(year, month, 1) + last := jalali.ToGregorian(year, month, jalali.DaysInMonth(year, month)) + attendance, _ := s.store.AttendanceBetween(userID, first.Format("2006-01-02"), last.Format("2006-01-02")) + requests, _ := s.store.Requests(userID, false, "approved") + // Persian weeks begin Saturday. Go's Sunday=0, so Saturday maps to zero. + offset := (int(first.Weekday()) + 1) % 7 + cells := make([]CalendarCell, offset, offset+jalali.DaysInMonth(year, month)) + for d := 1; d <= jalali.DaysInMonth(year, month); d++ { + g := jalali.ToGregorian(year, month, d) + raw := g.Format("2006-01-02") + cell := CalendarCell{Day: d, Weekday: (offset + d - 1) % 7, Gregorian: raw, InMonth: true, IsToday: raw == time.Now().Format("2006-01-02")} + cell.IsFriday = g.Weekday() == time.Friday + cell.Holiday = persianHoliday(month, d) + if a, ok := attendance[raw]; ok { + cell.Status = a.Mode + } + for _, req := range requests { + if raw >= req.StartDate && raw <= req.EndDate { + cell.Status = req.Kind + } + } + cells = append(cells, cell) + } + prevY, prevM, nextY, nextM := year, month-1, year, month+1 + if prevM == 0 { + prevY, prevM = year-1, 12 + } + if nextM == 13 { + nextY, nextM = year+1, 1 + } + return Calendar{ + Year: year, Month: month, MonthName: jalali.MonthNames[month], MonthNameFA: jalali.MonthNamesFA[month], + Prev: fmt.Sprintf("%04d-%02d", prevY, prevM), Next: fmt.Sprintf("%04d-%02d", nextY, nextM), Cells: cells, + } +} + +func persianHoliday(month, day int) string { + holidays := map[string]string{ + "1-1": "Nowruz", "1-2": "Nowruz", "1-3": "Nowruz", "1-4": "Nowruz", + "1-12": "Islamic Republic Day", "1-13": "Nature Day", + "3-14": "Demise of Imam Khomeini", "3-15": "Khordad Uprising", + "11-22": "Revolution Day", "12-29": "Oil Nationalization Day", + } + return holidays[fmt.Sprintf("%d-%d", month, day)] +} + +func (s *Server) checkIn(w http.ResponseWriter, r *http.Request) { + u := currentUser(r) + if err := s.store.CheckIn(u.ID, time.Now().Format("2006-01-02"), r.FormValue("mode")); err != nil { + s.redirectError(w, r, err) + return + } + http.Redirect(w, r, "/?flash=Checked+in+successfully", http.StatusSeeOther) +} + +func (s *Server) checkOut(w http.ResponseWriter, r *http.Request) { + if err := s.store.CheckOut(currentUser(r).ID, time.Now().Format("2006-01-02")); err != nil { + s.redirectError(w, r, err) + return + } + http.Redirect(w, r, "/?flash=Checked+out+successfully", http.StatusSeeOther) +} + +func (s *Server) requestsPage(w http.ResponseWriter, r *http.Request) { + requests, err := s.store.Requests(currentUser(r).ID, false, "") + if err != nil { + http.Error(w, "could not load requests", http.StatusInternalServerError) + return + } + s.render(w, "requests.html", PageData{ + Title: "My requests", User: currentUser(r), CSRF: csrfToken(r), Requests: requests, + Today: time.Now().Format("2006-01-02"), Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), + SelectedSection: "requests", + }) +} + +func (s *Server) createRequest(w http.ResponseWriter, r *http.Request) { + start, startOK := parseUserDate(r.FormValue("start_date")) + end, endOK := parseUserDate(r.FormValue("end_date")) + if !startOK || !endOK || end < start { + s.redirectRequestError(w, r, errors.New("choose a valid date range")) + return + } + if err := s.store.CreateRequest(currentUser(r).ID, r.FormValue("kind"), start, end, r.FormValue("reason")); err != nil { + s.redirectRequestError(w, r, err) + return + } + http.Redirect(w, r, "/requests?flash=Request+sent+for+approval", http.StatusSeeOther) +} + +func parseUserDate(raw string) (string, bool) { + var y, m, d int + if _, err := fmt.Sscanf(strings.TrimSpace(raw), "%d-%d-%d", &y, &m, &d); err != nil { + return "", false + } + if y >= 1700 { + t, err := time.Parse("2006-01-02", fmt.Sprintf("%04d-%02d-%02d", y, m, d)) + return t.Format("2006-01-02"), err == nil + } + if y < 1200 || m < 1 || m > 12 || d < 1 || d > jalali.DaysInMonth(y, m) { + return "", false + } + return jalali.ToGregorian(y, m, d).Format("2006-01-02"), true +} + +func validDate(raw string) bool { + _, err := time.Parse("2006-01-02", raw) + return err == nil +} + +func (s *Server) cancelRequest(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + _ = s.store.CancelRequest(id, currentUser(r).ID) + http.Redirect(w, r, "/requests?flash=Request+cancelled", http.StatusSeeOther) +} + +func (s *Server) adminPage(w http.ResponseWriter, r *http.Request) { + requests, err := s.store.Requests(currentUser(r).ID, true, r.URL.Query().Get("status")) + if err != nil { + http.Error(w, "could not load requests", http.StatusInternalServerError) + return + } + s.render(w, "admin.html", PageData{ + Title: "Approvals", User: currentUser(r), CSRF: csrfToken(r), Requests: requests, + Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "admin", + }) +} + +func (s *Server) reviewRequest(w http.ResponseWriter, r *http.Request) { + id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) + err := s.store.ReviewRequest(r.Context(), id, currentUser(r).ID, r.FormValue("decision"), r.FormValue("note")) + if err != nil { + http.Redirect(w, r, "/admin/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/requests?flash=Request+reviewed", http.StatusSeeOther) +} + +func (s *Server) usersPage(w http.ResponseWriter, r *http.Request) { + users, err := s.store.Users() + if err != nil { + http.Error(w, "could not load teammates", http.StatusInternalServerError) + return + } + s.render(w, "users.html", PageData{ + Title: "Teammates", User: currentUser(r), CSRF: csrfToken(r), Users: users, + Error: r.URL.Query().Get("error"), Flash: r.URL.Query().Get("flash"), SelectedSection: "users", + }) +} + +func (s *Server) createUser(w http.ResponseWriter, r *http.Request) { + _, err := s.store.CreateUser(r.FormValue("username"), r.FormValue("password"), r.FormValue("display_name"), r.FormValue("email"), r.FormValue("role")) + if err != nil { + http.Redirect(w, r, "/admin/users?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) + return + } + http.Redirect(w, r, "/admin/users?flash=Teammate+account+created", http.StatusSeeOther) +} + +func (s *Server) reportPage(w http.ResponseWriter, r *http.Request) { + now := time.Now() + start := now.AddDate(0, -1, 0).Format("2006-01-02") + s.render(w, "reports.html", PageData{ + Title: "Reports", User: currentUser(r), CSRF: csrfToken(r), ReportStart: start, + ReportEnd: now.Format("2006-01-02"), SelectedSection: "reports", + }) +} + +func (s *Server) reportCSV(w http.ResponseWriter, r *http.Request) { + start, end := r.URL.Query().Get("start"), r.URL.Query().Get("end") + if !validDate(start) || !validDate(end) || end < start { + http.Error(w, "invalid report date range", http.StatusBadRequest) + return + } + rows, err := s.store.ReportRows(start, end) + if err != nil { + http.Error(w, "could not generate report", http.StatusInternalServerError) + return + } + defer rows.Close() + w.Header().Set("Content-Type", "text/csv; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="attendance-%s-to-%s.csv"`, start, end)) + _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) // Excel-friendly UTF-8 BOM. + cw := csv.NewWriter(w) + _ = cw.Write([]string{"Name", "Username", "Date", "Persian date", "Check in", "Check out", "Work mode", "Approved request"}) + for rows.Next() { + var name, username, day, in, out, mode, request string + if err := rows.Scan(&name, &username, &day, &in, &out, &mode, &request); err != nil { + continue + } + t, _ := time.Parse("2006-01-02", day) + _ = cw.Write([]string{name, username, day, jalali.FromTime(t).String(), in, out, mode, request}) + } + cw.Flush() +} + +func (s *Server) redirectError(w http.ResponseWriter, r *http.Request, err error) { + http.Redirect(w, r, "/?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) +} + +func (s *Server) redirectRequestError(w http.ResponseWriter, r *http.Request, err error) { + http.Redirect(w, r, "/requests?error="+url.QueryEscape(err.Error()), http.StatusSeeOther) +} + +type contextKey string + +const userKey contextKey = "user" +const csrfKey contextKey = "csrf" + +func currentUser(r *http.Request) *User { + u, _ := r.Context().Value(userKey).(*User) + return u +} + +func csrfToken(r *http.Request) string { + v, _ := r.Context().Value(csrfKey).(string) + return v +} + +func (s *Server) withUser(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := r.Cookie("teammate_session") + if err == nil { + if u, csrf, err := s.store.Session(c.Value); err == nil { + ctx := r.Context() + ctx = context.WithValue(ctx, userKey, u) + ctx = context.WithValue(ctx, csrfKey, csrf) + r = r.WithContext(ctx) + } + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if currentUser(r) == nil { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return + } + next(w, r) + } +} + +func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { + return s.requireAuth(func(w http.ResponseWriter, r *http.Request) { + if currentUser(r).Role != "admin" { + http.Error(w, "admin access required", http.StatusForbidden) + return + } + next(w, r) + }) +} + +func (s *Server) csrf(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil || subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(csrfToken(r))) != 1 { + http.Error(w, "invalid security token; reload the page and try again", http.StatusForbidden) + return + } + next(w, r) + } +} + +func (s *Server) 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", "strict-origin-when-cross-origin") + w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://unpkg.com; style-src 'self' https://cdn.jsdelivr.net; font-src 'self' https://cdn.jsdelivr.net data:; img-src 'self' data: https:; connect-src 'self'") + next.ServeHTTP(w, r) + }) +} + +type oauthProvider struct { + authorize, token, profile, clientID, secret, scope string +} + +func (s *Server) provider(name string) (oauthProvider, bool) { + switch name { + case "github": + return oauthProvider{"https://github.com/login/oauth/authorize", "https://github.com/login/oauth/access_token", "https://api.github.com/user", s.cfg.GitHubClientID, s.cfg.GitHubClientSecret, "read:user user:email"}, s.cfg.GitHubClientID != "" + case "google": + return oauthProvider{"https://accounts.google.com/o/oauth2/v2/auth", "https://oauth2.googleapis.com/token", "https://openidconnect.googleapis.com/v1/userinfo", s.cfg.GoogleClientID, s.cfg.GoogleClientSecret, "openid email profile"}, s.cfg.GoogleClientID != "" + default: + return oauthProvider{}, false + } +} + +func (s *Server) oauthStart(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("provider") + p, ok := s.provider(name) + if !ok { + http.Redirect(w, r, "/login?error=OAuth+provider+is+not+configured", http.StatusSeeOther) + return + } + state, _ := randomToken(24) + http.SetCookie(w, &http.Cookie{Name: "oauth_state", Value: state, Path: "/auth/", HttpOnly: true, Secure: s.cfg.SessionSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600}) + q := url.Values{ + "client_id": {p.clientID}, "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, + "response_type": {"code"}, "scope": {p.scope}, "state": {state}, + } + http.Redirect(w, r, p.authorize+"?"+q.Encode(), http.StatusTemporaryRedirect) +} + +func (s *Server) oauthCallback(w http.ResponseWriter, r *http.Request) { + name := r.PathValue("provider") + p, ok := s.provider(name) + state, err := r.Cookie("oauth_state") + if !ok || err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 { + http.Redirect(w, r, "/login?error=Invalid+OAuth+state", http.StatusSeeOther) + return + } + form := url.Values{ + "client_id": {p.clientID}, "client_secret": {p.secret}, "code": {r.URL.Query().Get("code")}, + "redirect_uri": {s.cfg.BaseURL + "/auth/" + name + "/callback"}, "grant_type": {"authorization_code"}, + } + req, _ := http.NewRequestWithContext(r.Context(), http.MethodPost, p.token, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.oauthFail(w, r) + return + } + defer resp.Body.Close() + var token struct { + AccessToken string `json:"access_token"` + } + if json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&token) != nil || token.AccessToken == "" { + s.oauthFail(w, r) + return + } + profileReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, p.profile, nil) + profileReq.Header.Set("Authorization", "Bearer "+token.AccessToken) + profileReq.Header.Set("Accept", "application/json") + profileResp, err := http.DefaultClient.Do(profileReq) + if err != nil { + s.oauthFail(w, r) + return + } + defer profileResp.Body.Close() + var profile map[string]any + decoder := json.NewDecoder(io.LimitReader(profileResp.Body, 1<<20)) + decoder.UseNumber() + if decoder.Decode(&profile) != nil { + s.oauthFail(w, r) + return + } + id := fmt.Sprint(profile["id"]) + if name == "google" { + id = fmt.Sprint(profile["sub"]) + } + login, _ := profile["login"].(string) + email, _ := profile["email"].(string) + display, _ := profile["name"].(string) + avatar, _ := profile["avatar_url"].(string) + if name == "google" { + avatar, _ = profile["picture"].(string) + login = strings.Split(email, "@")[0] + } + userID, err := s.store.UpsertOAuthUser(name, id, login, email, display, avatar) + if err != nil { + s.oauthFail(w, r) + return + } + s.startSession(w, userID) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) oauthFail(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/login?error=Could+not+sign+in+with+OAuth", http.StatusSeeOther) +} diff --git a/internal/app/server_test.go b/internal/app/server_test.go new file mode 100644 index 0000000..37887dd --- /dev/null +++ b/internal/app/server_test.go @@ -0,0 +1,318 @@ +package app + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "regexp" + "strings" + "testing" +) + +func TestLoginAttendanceAndReportFlow(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "test.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + login := formRequest(t, s.http.Handler, "/login", url.Values{ + "username": {"admin"}, + "password": {"admin123"}, + }, nil) + if login.Code != http.StatusSeeOther { + t.Fatalf("login status: got %d, body %s", login.Code, login.Body.String()) + } + cookies := login.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("login did not set a session cookie") + } + session := cookies[0] + + dashboardReq := httptest.NewRequest(http.MethodGet, "/", nil) + dashboardReq.AddCookie(session) + dashboard := httptest.NewRecorder() + s.http.Handler.ServeHTTP(dashboard, dashboardReq) + if dashboard.Code != http.StatusOK || !strings.Contains(dashboard.Body.String(), "PERSIAN CALENDAR") { + t.Fatalf("dashboard status/body: %d %s", dashboard.Code, dashboard.Body.String()) + } + if !strings.Contains(dashboard.Body.String(), "/day?date=") { + t.Fatal("dashboard calendar days do not link to the team day view") + } + csrf := extractCSRF(t, dashboard.Body.String()) + + createUser := formRequest(t, s.http.Handler, "/admin/users", url.Values{ + "csrf": {csrf}, + "username": {"sara"}, + "password": {"temporary-password"}, + "display_name": {"Sara Ahmadi"}, + "email": {"sara@example.test"}, + "role": {"member"}, + }, session) + if createUser.Code != http.StatusSeeOther { + t.Fatalf("create user status: got %d, body %s", createUser.Code, createUser.Body.String()) + } + if _, err := s.store.Authenticate("sara", "temporary-password"); err != nil { + t.Fatalf("created teammate could not authenticate: %v", err) + } + + checkIn := formRequest(t, s.http.Handler, "/attendance/check-in", url.Values{ + "csrf": {csrf}, + "mode": {"office"}, + }, session) + if checkIn.Code != http.StatusSeeOther { + t.Fatalf("check-in status: got %d, body %s", checkIn.Code, checkIn.Body.String()) + } + + reportReq := httptest.NewRequest(http.MethodGet, "/reports/attendance.csv?start=2020-01-01&end=2030-01-01", nil) + reportReq.AddCookie(session) + report := httptest.NewRecorder() + s.http.Handler.ServeHTTP(report, reportReq) + if report.Code != http.StatusOK { + t.Fatalf("report status: got %d, body %s", report.Code, report.Body.String()) + } + if got := report.Header().Get("Content-Type"); !strings.Contains(got, "text/csv") { + t.Fatalf("report content type: %s", got) + } + if !strings.Contains(report.Body.String(), "Workspace Admin") { + t.Fatalf("report did not contain attendance row: %s", report.Body.String()) + } +} + +func TestPersianRequestDateParsing(t *testing.T) { + got, ok := parseUserDate("1405-05-06") + if !ok || got != "2026-07-28" { + t.Fatalf("got %q, %v; want 2026-07-28, true", got, ok) + } + if _, ok := parseUserDate("1400-12-30"); ok { + t.Fatal("accepted an invalid non-leap Esfand date") + } +} + +func TestReviewedRequestCanBeListed(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "review.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + if err := s.store.CreateRequest(1, "leave", "2026-07-28", "2026-07-29", "Personal"); err != nil { + t.Fatal(err) + } + pending, err := s.store.Requests(1, true, "pending") + if err != nil || len(pending) != 1 { + t.Fatalf("pending requests: %#v, %v", pending, err) + } + if err := s.store.ReviewRequest(t.Context(), pending[0].ID, 1, "approved", "Approved"); err != nil { + t.Fatal(err) + } + reviewed, err := s.store.Requests(1, true, "") + if err != nil { + t.Fatalf("listing reviewed request failed: %v", err) + } + if len(reviewed) != 1 || reviewed[0].Status != "approved" { + t.Fatalf("unexpected reviewed requests: %#v", reviewed) + } + + token, _, err := s.store.CreateSession(1) + if err != nil { + t.Fatal(err) + } + adminRequest := httptest.NewRequest(http.MethodGet, "/admin/requests?flash=Request+reviewed", nil) + adminRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) + adminResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(adminResponse, adminRequest) + if adminResponse.Code != http.StatusOK || !strings.Contains(adminResponse.Body.String(), "Request reviewed") { + t.Fatalf("review redirect page: got %d, body %s", adminResponse.Code, adminResponse.Body.String()) + } +} + +func TestPublicRegistrationAndIdentifierLogin(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "registration.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + healthRequest := httptest.NewRequest(http.MethodGet, "/healthz", nil) + healthResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(healthResponse, healthRequest) + if healthResponse.Code != http.StatusOK || healthResponse.Body.String() != `{"status":"ok"}` { + t.Fatalf("health endpoint: got %d, body %s", healthResponse.Code, healthResponse.Body.String()) + } + + registerPage := httptest.NewRequest(http.MethodGet, "/register", nil) + registerView := httptest.NewRecorder() + s.http.Handler.ServeHTTP(registerView, registerPage) + if registerView.Code != http.StatusOK || !strings.Contains(registerView.Body.String(), "Create your account") { + t.Fatalf("register page: got %d, body %s", registerView.Code, registerView.Body.String()) + } + for _, expected := range []string{"Vazirmatn-font-face.css", "/static/theme.js", "data-theme-toggle"} { + if !strings.Contains(registerView.Body.String(), expected) { + t.Fatalf("register page does not include %q", expected) + } + } + + themeRequest := httptest.NewRequest(http.MethodGet, "/static/theme.js", nil) + themeResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(themeResponse, themeRequest) + if themeResponse.Code != http.StatusOK || !strings.Contains(themeResponse.Body.String(), "hamkar-theme") { + t.Fatalf("theme asset: got %d, body %s", themeResponse.Code, themeResponse.Body.String()) + } + + register := formRequest(t, s.http.Handler, "/register", url.Values{ + "display_name": {"Neda Karimi"}, + "email": {"neda@example.test"}, + "password": {"secure-password"}, + "password_confirm": {"secure-password"}, + }, nil) + if register.Code != http.StatusSeeOther || register.Header().Get("Location") != "/?flash=Welcome+to+Hamkar" { + t.Fatalf("registration: got %d location %q body %s", register.Code, register.Header().Get("Location"), register.Body.String()) + } + if len(register.Result().Cookies()) == 0 { + t.Fatal("registration did not sign the new member in") + } + + user, err := s.store.Authenticate("neda@example.test", "secure-password") + if err != nil { + t.Fatalf("email login failed: %v", err) + } + if user.Username != "neda" || user.Role != "member" { + t.Fatalf("unexpected registered user: %#v", user) + } + if _, err := s.store.Authenticate("neda", "secure-password"); err != nil { + t.Fatalf("generated username login failed: %v", err) + } +} + +func TestRequestPageIncludesJalaliPicker(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "picker.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + token, _, err := s.store.CreateSession(1) + if err != nil { + t.Fatal(err) + } + pageRequest := httptest.NewRequest(http.MethodGet, "/requests", nil) + pageRequest.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) + pageResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(pageResponse, pageRequest) + if pageResponse.Code != http.StatusOK { + t.Fatalf("request page status: %d", pageResponse.Code) + } + for _, expected := range []string{"/static/jalali-picker.js", "data-jalali-picker", "jalali-trigger"} { + if !strings.Contains(pageResponse.Body.String(), expected) { + t.Fatalf("request page does not include %q", expected) + } + } + + assetRequest := httptest.NewRequest(http.MethodGet, "/static/jalali-picker.js", nil) + assetResponse := httptest.NewRecorder() + s.http.Handler.ServeHTTP(assetResponse, assetRequest) + if assetResponse.Code != http.StatusOK || !strings.Contains(assetResponse.Body.String(), "gregorianToJalali") { + t.Fatalf("picker asset: got %d, body %s", assetResponse.Code, assetResponse.Body.String()) + } +} + +func TestTeamDayShowsPresentRemoteAndAbsent(t *testing.T) { + s, err := New(Config{ + Addr: ":0", + BaseURL: "http://example.test", + DatabasePath: filepath.Join(t.TempDir(), "day.db"), + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + remoteID, err := s.store.CreateUser("remote-user", "secure-password", "Remote Teammate", "remote@example.test", "member") + if err != nil { + t.Fatal(err) + } + leaveID, err := s.store.CreateUser("leave-user", "secure-password", "Absent Teammate", "leave@example.test", "member") + if err != nil { + t.Fatal(err) + } + const selectedDay = "2030-01-02" + if err := s.store.CheckIn(1, selectedDay, "office"); err != nil { + t.Fatal(err) + } + if err := s.store.CreateRequest(remoteID, "remote", selectedDay, selectedDay, "Working from home"); err != nil { + t.Fatal(err) + } + if err := s.store.CreateRequest(leaveID, "leave", selectedDay, selectedDay, "Holiday"); err != nil { + t.Fatal(err) + } + for _, userID := range []int64{remoteID, leaveID} { + requests, err := s.store.Requests(userID, false, "pending") + if err != nil || len(requests) != 1 { + t.Fatalf("pending request for %d: %#v, %v", userID, requests, err) + } + if err := s.store.ReviewRequest(t.Context(), requests[0].ID, 1, "approved", "Approved"); err != nil { + t.Fatal(err) + } + } + + token, _, err := s.store.CreateSession(1) + if err != nil { + t.Fatal(err) + } + request := httptest.NewRequest(http.MethodGet, "/day?date="+selectedDay, nil) + request.AddCookie(&http.Cookie{Name: "teammate_session", Value: token}) + response := httptest.NewRecorder() + s.http.Handler.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("day page status: %d, body %s", response.Code, response.Body.String()) + } + for _, expected := range []string{ + "Workspace Admin", "Remote Teammate", "Absent Teammate", + "Office · checked in", "Approved remote day", "Approved time off", + `class="presence-badge present"`, `class="presence-badge remote"`, `class="presence-badge absent"`, + } { + if !strings.Contains(response.Body.String(), expected) { + t.Fatalf("day page does not include %q", expected) + } + } +} + +func formRequest(t *testing.T, handler http.Handler, path string, values url.Values, cookie *http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(values.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if cookie != nil { + req.AddCookie(cookie) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec +} + +func extractCSRF(t *testing.T, body string) string { + t.Helper() + re := regexp.MustCompile(`name="csrf" value="([^"]+)"`) + match := re.FindStringSubmatch(body) + if len(match) != 2 { + t.Fatal("page did not contain a CSRF token") + } + return match[1] +} diff --git a/internal/app/static/jalali-picker.js b/internal/app/static/jalali-picker.js new file mode 100644 index 0000000..c4e9863 --- /dev/null +++ b/internal/app/static/jalali-picker.js @@ -0,0 +1,281 @@ +(function () { + "use strict"; + + var monthNames = [ + "", + "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند" + ]; + var weekNames = ["ش", "ی", "د", "س", "چ", "پ", "ج"]; + var openPicker = null; + + function div(a, b) { + return Math.floor(a / b); + } + + function gregorianToJalali(gy, gm, gd) { + var gdm = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334]; + var gy2 = gy + (gm > 2 ? 1 : 0); + var days = 355666 + 365 * gy + div(gy2 + 3, 4) - div(gy2 + 99, 100) + + div(gy2 + 399, 400) + gd + gdm[gm - 1]; + var jy = -1595 + 33 * div(days, 12053); + days %= 12053; + jy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + jy += div(days - 1, 365); + days = (days - 1) % 365; + } + if (days < 186) { + return { year: jy, month: 1 + div(days, 31), day: 1 + (days % 31) }; + } + return { year: jy, month: 7 + div(days - 186, 30), day: 1 + ((days - 186) % 30) }; + } + + function jalaliToGregorian(jy, jm, jd) { + jy += 1595; + var days = -355668 + 365 * jy + div(jy, 33) * 8 + div((jy % 33) + 3, 4) + jd; + days += jm < 7 ? (jm - 1) * 31 : (jm - 7) * 30 + 186; + var gy = 400 * div(days, 146097); + days %= 146097; + if (days > 36524) { + gy += 100 * div(days - 1, 36524); + days = (days - 1) % 36524; + if (days >= 365) { + days += 1; + } + } + gy += 4 * div(days, 1461); + days %= 1461; + if (days > 365) { + gy += div(days - 1, 365); + days = (days - 1) % 365; + } + var gd = days + 1; + var lengths = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if ((gy % 4 === 0 && gy % 100 !== 0) || gy % 400 === 0) { + lengths[2] = 29; + } + var gm = 1; + while (gm <= 12 && gd > lengths[gm]) { + gd -= lengths[gm]; + gm += 1; + } + return { year: gy, month: gm, day: gd }; + } + + function todayJalali() { + var now = new Date(); + return gregorianToJalali(now.getFullYear(), now.getMonth() + 1, now.getDate()); + } + + function daysInMonth(year, month) { + if (month <= 6) { + return 31; + } + if (month <= 11) { + return 30; + } + var current = jalaliToGregorian(year, 1, 1); + var next = jalaliToGregorian(year + 1, 1, 1); + var currentUTC = Date.UTC(current.year, current.month - 1, current.day); + var nextUTC = Date.UTC(next.year, next.month - 1, next.day); + return (nextUTC - currentUTC) / 86400000 === 366 ? 30 : 29; + } + + function parseDate(value) { + var match = /^\s*(\d{4})-(\d{2})-(\d{2})\s*$/.exec(value); + if (!match) { + return null; + } + var date = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }; + if (date.year < 1200 || date.month < 1 || date.month > 12 || + date.day < 1 || date.day > daysInMonth(date.year, date.month)) { + return null; + } + return date; + } + + function formatDate(date) { + return String(date.year).padStart(4, "0") + "-" + + String(date.month).padStart(2, "0") + "-" + + String(date.day).padStart(2, "0"); + } + + function sameDate(a, b) { + return a && b && a.year === b.year && a.month === b.month && a.day === b.day; + } + + function JalaliPicker(input) { + this.input = input; + this.field = input.closest(".jalali-field"); + this.trigger = this.field.querySelector(".jalali-trigger"); + this.selected = parseDate(input.value); + var initial = this.selected || todayJalali(); + this.year = initial.year; + this.month = initial.month; + this.popup = document.createElement("div"); + this.popup.className = "jalali-picker"; + this.popup.hidden = true; + this.popup.setAttribute("role", "dialog"); + this.popup.setAttribute("aria-label", "Persian date picker"); + this.popup.setAttribute("dir", "rtl"); + this.field.appendChild(this.popup); + + this.trigger.addEventListener("click", this.toggle.bind(this)); + this.input.addEventListener("focus", this.open.bind(this)); + this.input.addEventListener("change", this.sync.bind(this)); + this.input.addEventListener("keydown", this.onKeyDown.bind(this)); + } + + JalaliPicker.prototype.sync = function () { + var parsed = parseDate(this.input.value); + this.input.setCustomValidity(this.input.value && !parsed ? "Use a valid Persian date in YYYY-MM-DD format." : ""); + if (parsed) { + this.selected = parsed; + this.year = parsed.year; + this.month = parsed.month; + if (!this.popup.hidden) { + this.render(); + } + } + }; + + JalaliPicker.prototype.onKeyDown = function (event) { + if (event.key === "Escape") { + this.close(); + this.input.blur(); + } else if (event.key === "ArrowDown" && this.popup.hidden) { + event.preventDefault(); + this.open(); + } + }; + + JalaliPicker.prototype.toggle = function () { + if (this.popup.hidden) { + this.open(); + } else { + this.close(); + } + }; + + JalaliPicker.prototype.open = function () { + if (openPicker && openPicker !== this) { + openPicker.close(); + } + this.sync(); + this.popup.hidden = false; + this.field.classList.add("picker-open"); + this.trigger.setAttribute("aria-expanded", "true"); + openPicker = this; + this.render(); + }; + + JalaliPicker.prototype.close = function () { + this.popup.hidden = true; + this.field.classList.remove("picker-open"); + this.trigger.setAttribute("aria-expanded", "false"); + if (openPicker === this) { + openPicker = null; + } + }; + + JalaliPicker.prototype.moveMonth = function (amount) { + this.month += amount; + if (this.month < 1) { + this.month = 12; + this.year -= 1; + } else if (this.month > 12) { + this.month = 1; + this.year += 1; + } + this.render(); + }; + + JalaliPicker.prototype.choose = function (day) { + this.selected = { year: this.year, month: this.month, day: day }; + this.input.value = formatDate(this.selected); + this.input.setCustomValidity(""); + this.input.dispatchEvent(new Event("input", { bubbles: true })); + this.input.dispatchEvent(new Event("change", { bubbles: true })); + + if (this.input.dataset.jalaliRole === "start") { + var end = document.querySelector('[data-jalali-role="end"]'); + var endDate = end && parseDate(end.value); + if (end && (!endDate || end.value < this.input.value)) { + end.value = this.input.value; + end.dispatchEvent(new Event("change", { bubbles: true })); + } + } + this.close(); + this.input.focus(); + }; + + JalaliPicker.prototype.render = function () { + var picker = this; + var today = todayJalali(); + var selected = parseDate(this.input.value) || this.selected; + var firstGregorian = jalaliToGregorian(this.year, this.month, 1); + var offset = (new Date(firstGregorian.year, firstGregorian.month - 1, firstGregorian.day).getDay() + 1) % 7; + var count = daysInMonth(this.year, this.month); + + this.popup.innerHTML = + '
' + + '' + + '' + monthNames[this.month] + ' ' + this.year + '' + + '' + + '
' + + '
' + + weekNames.map(function (name) { return "" + name + ""; }).join("") + + '
' + + '
' + + '"; + + var days = this.popup.querySelector(".jalali-days"); + for (var blank = 0; blank < offset; blank += 1) { + var spacer = document.createElement("span"); + spacer.className = "jalali-blank"; + days.appendChild(spacer); + } + for (var day = 1; day <= count; day += 1) { + var date = { year: this.year, month: this.month, day: day }; + var button = document.createElement("button"); + button.type = "button"; + button.textContent = String(day); + button.dataset.day = String(day); + if ((offset + day - 1) % 7 === 6) { + button.classList.add("friday"); + } + if (sameDate(date, today)) { + button.classList.add("today"); + } + if (sameDate(date, selected)) { + button.classList.add("selected"); + button.setAttribute("aria-current", "date"); + } + button.addEventListener("click", function () { + picker.choose(Number(this.dataset.day)); + }); + days.appendChild(button); + } + + this.popup.querySelector("[data-prev]").addEventListener("click", function () { picker.moveMonth(-1); }); + this.popup.querySelector("[data-next]").addEventListener("click", function () { picker.moveMonth(1); }); + this.popup.querySelector(".jalali-today").addEventListener("click", function () { + picker.year = today.year; + picker.month = today.month; + picker.choose(today.day); + }); + }; + + document.addEventListener("DOMContentLoaded", function () { + document.querySelectorAll("[data-jalali-picker]").forEach(function (input) { + new JalaliPicker(input); + }); + document.addEventListener("pointerdown", function (event) { + if (openPicker && !openPicker.field.contains(event.target)) { + openPicker.close(); + } + }); + }); +})(); diff --git a/internal/app/static/style.css b/internal/app/static/style.css new file mode 100644 index 0000000..e95ce87 --- /dev/null +++ b/internal/app/static/style.css @@ -0,0 +1,400 @@ +:root { + --ink: #18241f; + --muted: #6d7873; + --line: #e5e9e6; + --paper: #f5f7f4; + --white: #fff; + --green: #285b48; + --green-2: #34745c; + --mint: #dcece4; + --blue: #e3eaf4; + --sand: #f1e8d6; + --red: #b44a4a; + --shadow: 0 8px 26px rgba(24, 36, 31, .055); +} + +html[data-theme="dark"] { + color-scheme: dark; + --ink: #ecf3ef; + --muted: #98a79f; + --line: #33423b; + --paper: #101713; + --white: #19231e; + --green: #76b99b; + --green-2: #8ec8ad; + --mint: #203b30; + --blue: #213447; + --sand: #3b3222; + --red: #f08b84; + --shadow: 0 10px 30px rgba(0, 0, 0, .18); +} + +* { box-sizing: border-box; } +html { min-height: 100%; background: var(--paper); } +body { margin: 0; color: var(--ink); font-family: Vazirmatn, Tahoma, ui-sans-serif, system-ui, sans-serif; font-size: 15px; } +button, input, textarea { font: inherit; } +button, a { -webkit-tap-highlight-color: transparent; } +a { color: inherit; text-decoration: none; } +h1, h2, h3, p { margin-top: 0; } +h1 { margin-bottom: 7px; font-size: clamp(1.8rem, 3vw, 2.4rem); letter-spacing: -.055em; line-height: 1.1; } +h2 { margin-bottom: 8px; font-size: 1.25rem; letter-spacing: -.025em; } +.muted { color: var(--muted); line-height: 1.65; } +.eyebrow { margin-bottom: 8px; color: var(--green-2); font-size: .69rem; font-weight: 700; letter-spacing: .16em; } + +.app-shell { display: grid; grid-template-columns: 236px 1fr; min-height: 100vh; } +.sidebar { position: sticky; top: 0; display: flex; flex-direction: column; height: 100vh; padding: 28px 18px 18px; border-right: 1px solid var(--line); background: #f9faf8; } +.brand { display: flex; align-items: center; gap: 11px; padding: 0 9px; font-size: 1.12rem; font-weight: 700; letter-spacing: -.03em; } +.brand small { display: block; margin-top: 1px; color: var(--muted); font-size: .62rem; font-weight: 500; letter-spacing: .02em; } +.brand-mark { display: grid; width: 35px; height: 35px; place-items: center; border-radius: 11px; background: var(--green); color: #fff; font-family: Vazirmatn, Tahoma, sans-serif; font-size: 1.25rem; } +.sidebar nav { margin-top: 48px; } +.sidebar nav a { display: flex; align-items: center; gap: 11px; min-height: 43px; margin: 4px 0; padding: 0 12px; border-radius: 10px; color: #53605a; font-size: .87rem; font-weight: 600; } +.sidebar nav a:hover { background: #eef2ef; color: var(--ink); } +.sidebar nav a.active { background: var(--mint); color: var(--green); } +.nav-icon { width: 20px; text-align: center; font-size: 1.08rem; } +.nav-count { display: grid; width: 21px; height: 21px; margin-left: auto; place-items: center; border-radius: 20px; background: var(--green); color: #fff; font-size: .65rem; } +.nav-label { margin: 28px 12px 8px; color: #9ba49f; font-size: .59rem; font-weight: 700; letter-spacing: .18em; } +.sidebar-user { display: grid; grid-template-columns: 36px 1fr auto; gap: 9px; align-items: center; margin-top: auto; padding: 13px 8px 0; border-top: 1px solid var(--line); } +.sidebar-user strong { display: block; overflow: hidden; font-size: .76rem; text-overflow: ellipsis; white-space: nowrap; } +.sidebar-user small { color: var(--muted); font-size: .66rem; text-transform: capitalize; } +.avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 50%; background: var(--sand); color: #725d37; font-weight: 700; text-transform: uppercase; } +.avatar.small { width: 40px; height: 40px; } +.icon-button { border: 0; background: transparent; color: var(--muted); cursor: pointer; } +.main { min-width: 0; padding: 42px clamp(28px, 5vw, 68px) 70px; } +.page-head { display: flex; justify-content: space-between; align-items: end; margin: 0 auto 28px; max-width: 1180px; } +.page-head > div > p:last-child { margin-bottom: 0; color: var(--muted); } +.today-pill { min-width: 132px; padding: 10px 15px; border: 1px solid var(--line); border-radius: 12px; background: var(--white); text-align: right; } +.today-pill span { display: block; color: var(--muted); font-size: .63rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; } +.today-pill strong { font-family: Vazirmatn, Tahoma, sans-serif; font-size: .83rem; direction: ltr; } +.notice { max-width: 1180px; margin: 0 auto 18px; padding: 12px 15px; border-radius: 10px; font-size: .86rem; } +.notice.success { border: 1px solid #bfdbcb; background: #eaf5ef; color: #286047; } +.notice.error { border: 1px solid #edc6c2; background: #fff0ee; color: #9d3e38; } +.card { border: 1px solid var(--line); border-radius: 16px; background: var(--white); box-shadow: var(--shadow); } + +.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.58fr) minmax(300px, .82fr); gap: 20px; max-width: 1180px; margin: 0 auto; } +.presence-card { padding: 24px; } +.presence-top, .section-head { display: flex; justify-content: space-between; align-items: start; } +.live-badge { display: flex; align-items: center; gap: 6px; padding: 6px 9px; border-radius: 20px; background: #edf4f0; color: var(--green); font-size: .62rem; font-weight: 700; letter-spacing: .08em; } +.live-badge i { width: 6px; height: 6px; border-radius: 50%; background: #4d9c75; box-shadow: 0 0 0 3px #d7eadf; } +.time-line { display: grid; grid-template-columns: 1fr 42px 1fr 1fr; align-items: center; margin: 20px 0 22px; padding: 17px 18px; border-radius: 12px; background: var(--paper); } +.time-line span { width: 26px; height: 1px; background: #cbd2ce; } +.time-line small, .stat small { display: block; margin-bottom: 3px; color: var(--muted); font-size: .59rem; font-weight: 700; letter-spacing: .1em; } +.time-line strong { font-size: 1.08rem; } +.checkin-actions { display: flex; gap: 10px; margin-top: 20px; } +.button { display: inline-flex; justify-content: center; align-items: center; min-height: 42px; padding: 0 17px; border: 1px solid transparent; border-radius: 9px; font-weight: 700; font-size: .81rem; cursor: pointer; } +.button.primary { background: var(--green); color: #fff; } +.button.primary:hover { background: #1e4939; } +.button.secondary { border-color: var(--line); background: var(--white); color: var(--ink); } +.button.dark { background: var(--ink); color: #fff; } +.button.full { width: 100%; } +.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; } +.stat { min-width: 0; padding: 16px; box-shadow: none; } +.stat-icon { display: grid; width: 30px; height: 30px; margin-bottom: 18px; place-items: center; border-radius: 9px; font-size: .8rem; } +.stat-icon.mint { background: var(--mint); color: var(--green); } +.stat-icon.blue { background: var(--blue); color: #42658c; } +.stat-icon.sand { background: var(--sand); color: #806538; } +.stat strong { display: block; font-size: 1.65rem; letter-spacing: -.05em; } +.stat p { margin: 1px 0 0; color: var(--muted); font-size: .66rem; } + +.calendar-card { padding: 24px; } +.calendar-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 22px; } +.calendar-head h2 { margin-bottom: 1px; font-size: 1.35rem; } +.calendar-head h2 span { color: var(--muted); font-weight: 500; } +.fa-month { margin: 0; color: var(--muted); font-family: Vazirmatn, Tahoma, sans-serif; font-size: .71rem; text-align: left; } +.calendar-nav { display: flex; gap: 5px; } +.calendar-nav a { display: grid; width: 33px; height: 33px; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: #fff; color: var(--ink); font-size: 1.2rem; cursor: pointer; } +.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); } +.weekdays span { padding-bottom: 9px; color: #949d98; font-size: .59rem; font-weight: 700; text-align: center; text-transform: uppercase; } +.days { overflow: hidden; border-top: 1px solid var(--line); border-left: 1px solid var(--line); border-radius: 8px; } +.day { position: relative; min-height: 67px; padding: 8px; border-right: 1px solid var(--line); border-bottom: 1px solid var(--line); background: #fff; } +.day:hover { z-index: 1; background: #f4f8f5; box-shadow: inset 0 0 0 1px #9bb9aa; } +.day.blank { background: #fafbfa; } +.day-number { display: grid; width: 23px; height: 23px; place-items: center; border-radius: 50%; font-size: .7rem; font-weight: 600; } +.day.today .day-number { background: var(--green); color: #fff; } +.day.friday { background: #fcfaf6; } +.day.holiday small { display: block; overflow: hidden; margin-top: 4px; color: #ae7562; font-size: .5rem; text-overflow: ellipsis; white-space: nowrap; } +.status-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; } +.day .status-dot { position: absolute; right: 8px; top: 16px; } +.status-dot.office { background: #51906f; } +.status-dot.remote { background: #6584ad; } +.status-dot.leave { background: #c39c5e; } +.calendar-legend { display: flex; gap: 18px; margin-top: 15px; color: var(--muted); font-size: .6rem; } +.calendar-legend span { display: flex; align-items: center; gap: 6px; } +.holiday-mark { width: 7px; height: 7px; border: 1.5px solid #bc7c65; border-radius: 2px; } +.recent-card { padding: 24px; } +.section-head { margin-bottom: 14px; } +.section-head a { color: var(--green); font-size: .72rem; font-weight: 700; } +.request-list { margin: 0 -24px; } +.request-row { display: grid; grid-template-columns: 38px 1fr auto; gap: 12px; align-items: center; padding: 14px 24px; border-top: 1px solid var(--line); } +.request-list.compact .request-row { padding-top: 11px; padding-bottom: 11px; } +.request-icon { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 10px; } +.request-icon.remote { background: var(--blue); color: #44688e; } +.request-icon.leave { background: var(--sand); color: #806538; } +.request-row strong { display: block; font-size: .78rem; } +.request-row small { display: block; margin-top: 3px; color: var(--muted); font-size: .66rem; } +.request-row p { margin: 6px 0 0; color: var(--muted); font-size: .75rem; } +.badge { display: inline-flex; padding: 5px 8px; border-radius: 20px; font-size: .56rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } +.badge.pending { background: #f6eddc; color: #87662c; } +.badge.approved { background: #e3f0e8; color: #337052; } +.badge.rejected, .badge.cancelled { background: #f5e6e4; color: #9d4a44; } +.badge.kind { background: #edf0ee; color: #68736e; } +.empty { padding: 22px; color: var(--muted); text-align: center; font-size: .76rem; } +.empty > span { color: #9ca9a2; font-size: 1.5rem; } +.empty a { color: var(--green); font-weight: 700; } + +.two-column { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(440px, 1.28fr); gap: 20px; max-width: 1180px; margin: auto; } +.form-card, .two-column > .card, .approval-card { padding: 26px; } +.stack-form { display: grid; gap: 18px; margin-top: 24px; } +label { color: #49564f; font-size: .72rem; font-weight: 700; } +label > small { float: right; color: #9aa39e; font-weight: 500; } +input, textarea, select { width: 100%; margin-top: 7px; padding: 11px 12px; border: 1px solid #dbe0dd; border-radius: 9px; outline: none; background: #fff; color: var(--ink); } +input:focus, textarea:focus, select:focus { border-color: #6d9b87; box-shadow: 0 0 0 3px #e2eee8; } +textarea { resize: vertical; } +.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 11px; } +.jalali-field { position: relative; display: block; clear: both; } +.jalali-field input { padding-right: 43px; } +.jalali-trigger { position: absolute; z-index: 2; top: 14px; right: 7px; display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--green); font-size: 1.08rem; cursor: pointer; } +.jalali-trigger:hover, .jalali-field.picker-open .jalali-trigger { background: var(--mint); } +.jalali-picker { position: absolute; z-index: 50; top: calc(100% + 7px); left: 0; width: min(320px, calc(100vw - 42px)); padding: 13px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); color: var(--ink); box-shadow: 0 18px 48px rgba(24, 36, 31, .2); font-weight: 500; } +.jalali-picker[hidden] { display: none; } +.jalali-picker-head { display: grid; grid-template-columns: 32px 1fr 32px; gap: 5px; align-items: center; margin-bottom: 10px; } +.jalali-picker-head strong { text-align: center; font-size: .85rem; } +.jalali-picker-head strong span { color: var(--muted); font-weight: 500; } +.jalali-picker-head button { display: grid; width: 32px; height: 32px; place-items: center; border: 1px solid var(--line); border-radius: 8px; background: var(--white); color: var(--ink); font-size: 1rem; cursor: pointer; } +.jalali-picker-head button:hover { background: var(--paper); } +.jalali-weekdays, .jalali-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; } +.jalali-weekdays span { padding: 4px 0 7px; color: var(--muted); font-size: .63rem; font-weight: 700; text-align: center; } +.jalali-days button, .jalali-blank { aspect-ratio: 1; } +.jalali-days button { display: grid; min-width: 0; place-items: center; border: 0; border-radius: 8px; background: transparent; color: var(--ink); font-size: .72rem; cursor: pointer; } +.jalali-days button:hover { background: var(--paper); } +.jalali-days button.friday { color: #b86d5a; } +.jalali-days button.today { box-shadow: inset 0 0 0 1px var(--green); } +.jalali-days button.selected { background: var(--green); color: #fff; box-shadow: none; font-weight: 700; } +.jalali-today { width: 100%; margin-top: 9px; padding: 8px; border: 0; border-top: 1px solid var(--line); background: transparent; color: var(--green); font-size: .67rem; font-weight: 700; cursor: pointer; } +.choice-cards { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; margin: 0; padding: 0; border: 0; } +.choice-cards label { position: relative; cursor: pointer; } +.choice-cards input { position: absolute; opacity: 0; } +.choice-cards span { display: grid; grid-template-columns: 32px 1fr; padding: 13px; border: 1px solid var(--line); border-radius: 11px; } +.choice-cards input:checked + span { border-color: #5c8d77; background: #f2f8f5; box-shadow: inset 0 0 0 1px #5c8d77; } +.choice-cards b { grid-row: span 2; font-size: 1rem; } +.choice-cards strong { font-size: .76rem; } +.choice-cards small { color: var(--muted); font-size: .6rem; font-weight: 500; } +.request-detail { align-items: start; } +.request-end { text-align: right; } +.text-button { margin-top: 7px; border: 0; background: none; color: var(--muted); font-size: .64rem; cursor: pointer; text-decoration: underline; } +.admin-note { padding: 6px 8px; border-radius: 6px; background: var(--paper); color: #46534d !important; } +.users-layout > .card { align-self: start; } +.user-list { margin: 0 -26px -10px; } +.user-row { display: grid; grid-template-columns: 40px 1fr auto; gap: 12px; align-items: center; padding: 14px 26px; border-top: 1px solid var(--line); } +.user-row strong { display: block; font-size: .8rem; } +.user-row small { display: block; margin-top: 3px; color: var(--muted); font-size: .66rem; } + +.filter-tabs { display: flex; gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 10px; background: #fff; } +.filter-tabs a { padding: 7px 11px; border-radius: 7px; color: var(--muted); font-size: .68rem; font-weight: 600; } +.filter-tabs a.active { background: var(--ink); color: #fff; } +.approval-card { max-width: 1180px; margin: auto; } +.approval-row { display: grid; grid-template-columns: 42px 1fr; gap: 14px; padding: 21px 0; border-bottom: 1px solid var(--line); } +.approval-row:last-child { border-bottom: 0; } +.approval-title { display: flex; gap: 7px; align-items: center; } +.approval-main > p { margin: 5px 0; color: var(--muted); font-size: .73rem; } +blockquote { margin: 10px 0; padding-left: 12px; border-left: 2px solid #d8dfdb; color: #53605a; font-size: .78rem; } +.review-form { display: grid; grid-template-columns: 1fr auto auto; gap: 7px; margin-top: 13px; } +.review-form input { margin: 0; } +.button.approve { background: var(--green); color: #fff; } +.button.reject { border-color: #e6c6c3; background: #fff; color: var(--red); } +.empty.roomy { padding: 70px; } +.empty.roomy h3 { margin: 8px 0 5px; color: var(--ink); } + +.report-card { display: grid; grid-template-columns: 75px 1fr; gap: 20px; max-width: 850px; margin: auto; padding: 32px; } +.report-illustration { display: grid; width: 66px; height: 66px; place-items: center; border-radius: 18px; background: var(--mint); color: var(--green); font-size: 1.7rem; } +.report-form { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; padding-top: 20px; border-top: 1px solid var(--line); } + +.day-page-head { align-items: center; } +.day-date-form { display: grid; grid-template-columns: 190px auto; gap: 9px; align-items: end; padding: 11px; border: 1px solid var(--line); border-radius: 13px; background: var(--white); box-shadow: var(--shadow); } +.day-date-form input { margin-top: 4px; padding-top: 8px; padding-bottom: 8px; } +.day-date-form .jalali-trigger { top: 10px; } +.day-date-form .button { min-height: 39px; } +.day-layout { display: grid; gap: 20px; max-width: 1180px; margin: 0 auto; } +.day-summary-card { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(360px, 1.15fr); align-items: center; padding: 24px 28px; } +.day-date-nav { display: grid; grid-template-columns: 37px 1fr 37px; gap: 14px; align-items: center; } +.day-date-nav > a { display: grid; width: 37px; height: 37px; place-items: center; border: 1px solid var(--line); border-radius: 9px; background: var(--white); color: var(--ink); font-size: 1.2rem; } +.day-date-nav > a:hover { background: var(--paper); } +.day-date-nav h2 { margin-bottom: 2px; font-size: 1.4rem; } +.day-date-nav div > span { color: var(--muted); font-size: .69rem; } +.day-stats { display: grid; grid-template-columns: repeat(3, 1fr); border-left: 1px solid var(--line); } +.day-stats > div { display: grid; grid-template-columns: 13px 1fr; gap: 9px; align-items: center; padding: 8px 22px; border-right: 1px solid var(--line); } +.day-stats > div:last-child { border-right: 0; } +.day-stats strong { display: block; font-size: 1.5rem; line-height: 1; } +.day-stats small { color: var(--muted); font-size: .65rem; } +.presence-mark { display: inline-block; width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; } +.presence-mark.present { background: #4d9870; box-shadow: 0 0 0 3px #dcece4; } +.presence-mark.remote { background: #6689b2; box-shadow: 0 0 0 3px #e3eaf4; } +.presence-mark.absent { background: #c2655c; box-shadow: 0 0 0 3px #f3dfdc; } +.roster-card { padding: 25px 28px; } +.roster-card .section-head { align-items: center; margin-bottom: 8px; } +.roster-legend { display: flex; gap: 15px; color: var(--muted); font-size: .62rem; } +.roster-legend span { display: flex; gap: 6px; align-items: center; } +.roster-list { margin: 0 -28px -10px; } +.roster-row { display: grid; grid-template-columns: 42px minmax(150px, .8fr) minmax(200px, 1.2fr) 92px; gap: 14px; align-items: center; min-height: 72px; padding: 13px 28px; border-top: 1px solid var(--line); } +.roster-row:hover { background: #fafcfa; } +.roster-row img.avatar { display: block; object-fit: cover; } +.roster-person strong { display: block; font-size: .82rem; } +.roster-person small, .roster-detail small { display: block; margin-top: 3px; color: var(--muted); font-size: .64rem; } +.roster-detail > span { color: #53605a; font-size: .75rem; } +.presence-badge { display: inline-flex; justify-content: center; align-items: center; gap: 7px; min-width: 86px; padding: 7px 9px; border-radius: 20px; font-size: .62rem; font-weight: 700; } +.presence-badge.present { background: #e7f2eb; color: #347052; } +.presence-badge.remote { background: #e9eff7; color: #4c6e96; } +.presence-badge.absent { background: #f8e9e7; color: #a34d47; } + +.login-page { display: grid; grid-template-columns: 1.06fr .94fr; min-height: 100vh; } +.login-story { position: relative; display: flex; flex-direction: column; overflow: hidden; padding: 46px clamp(38px, 6vw, 85px); background: var(--green); color: #fff; } +.login-story::before { position: absolute; right: -150px; bottom: -170px; width: 580px; height: 580px; border: 1px solid rgba(255,255,255,.1); border-radius: 50%; box-shadow: 0 0 0 80px rgba(255,255,255,.025), 0 0 0 160px rgba(255,255,255,.018); content: ""; } +.brand.light { position: relative; padding: 0; } +.brand.light .brand-mark { background: #fff; color: var(--green); } +.brand.light small { color: #b7cec4; } +.story-copy { position: relative; max-width: 580px; margin: auto 0 12vh; } +.story-copy .eyebrow { color: #9fc4b4; } +.story-copy h1 { margin-bottom: 24px; font-size: clamp(3rem, 5.5vw, 5rem); letter-spacing: -.06em; line-height: .98; } +.story-copy > p:last-child { max-width: 520px; color: #c7d9d1; font-size: 1.05rem; line-height: 1.7; } +.story-calendar { position: absolute; right: 9%; bottom: 7%; display: flex; gap: 65px; align-items: end; min-width: 270px; padding: 20px; border: 1px solid rgba(255,255,255,.15); border-radius: 14px; background: rgba(255,255,255,.08); backdrop-filter: blur(6px); transform: rotate(-2deg); } +.story-calendar small, .story-calendar span { display: block; color: #afc9be; font-family: Vazirmatn, Tahoma, sans-serif; font-size: .66rem; } +.story-calendar strong { display: block; margin: 2px 0; font-family: Vazirmatn, Tahoma, sans-serif; font-size: 1.3rem; } +.people-dots { display: flex; align-items: center; } +.people-dots i, .people-dots b { display: grid; width: 28px; height: 28px; margin-left: -7px; place-items: center; border: 2px solid var(--green); border-radius: 50%; background: #d4a88b; } +.people-dots i:nth-child(2) { background: #9dc2ae; } +.people-dots i:nth-child(3) { background: #b6a6cf; } +.people-dots b { background: #fff; color: var(--green); font-size: .55rem; } +.login-form-wrap { display: grid; place-items: center; padding: 40px; background: #fbfcfa; } +.login-card { width: min(100%, 395px); } +.login-card h2 { margin-bottom: 5px; font-size: 1.75rem; letter-spacing: -.045em; } +.login-card label { display: block; margin-top: 18px; } +.login-card .button.primary { margin-top: 22px; } +.separator { display: flex; align-items: center; gap: 12px; margin: 23px 0 14px; color: #9aa29e; font-size: .64rem; } +.separator::before, .separator::after { flex: 1; height: 1px; background: var(--line); content: ""; } +.oauth-row { display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; } +.login-hint { margin-top: 25px; color: var(--muted); font-size: .68rem; text-align: center; } +.login-hint code { padding: 2px 4px; border-radius: 4px; background: #edf0ee; } +.auth-switch { margin: 22px 0 0; color: var(--muted); font-size: .75rem; text-align: center; } +.auth-switch a { color: var(--green); font-weight: 700; } +.register-wrap { padding-top: 25px; padding-bottom: 25px; } +.register-card { width: min(100%, 500px); } +.register-card .form-row { margin-top: 17px; } +.register-card .form-row label { margin-top: 0; } +.register-card > label { display: block; margin-top: 17px; } +.register-card .button.primary { margin-top: 20px; } +.field-help { margin: 6px 0 0; color: #929b96; font-size: .64rem; } +.mobile-brand { display: none; } + +.sidebar-theme { display: flex; align-items: center; gap: 10px; margin-top: auto; padding: 10px 9px; color: var(--muted); font-size: .7rem; font-weight: 600; } +.sidebar-user { margin-top: 0; } +.theme-toggle { position: relative; display: grid; width: 35px; height: 35px; flex: 0 0 35px; place-items: center; overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: var(--white); color: var(--ink); cursor: pointer; box-shadow: var(--shadow); } +.theme-toggle span { position: absolute; transition: opacity .18s ease, transform .22s ease; } +.theme-sun { opacity: 0; transform: translateY(18px) rotate(30deg); } +.theme-moon { opacity: 1; transform: translateY(0); } +html[data-theme="dark"] .theme-sun { opacity: 1; transform: translateY(0) rotate(0); } +html[data-theme="dark"] .theme-moon { opacity: 0; transform: translateY(-18px); } +.auth-theme { position: fixed; z-index: 10; top: 24px; right: 24px; } + +html[data-theme="dark"] .sidebar { background: #141d18; } +html[data-theme="dark"] .sidebar nav a:hover { background: #202c26; } +html[data-theme="dark"] .day, +html[data-theme="dark"] input, +html[data-theme="dark"] textarea, +html[data-theme="dark"] select, +html[data-theme="dark"] .button.secondary, +html[data-theme="dark"] .calendar-nav a, +html[data-theme="dark"] .today-pill, +html[data-theme="dark"] .filter-tabs { background: var(--white); color: var(--ink); } +html[data-theme="dark"] .day.blank { background: #131b17; } +html[data-theme="dark"] .day.friday { background: #201f19; } +html[data-theme="dark"] .login-form-wrap { background: var(--paper); } +html[data-theme="dark"] .login-story { background: #173a2d; } +html[data-theme="dark"] .time-line, +html[data-theme="dark"] .admin-note { background: #121a16; } +html[data-theme="dark"] .login-hint code { background: #26322c; } +html[data-theme="dark"] .jalali-picker { box-shadow: 0 18px 52px rgba(0, 0, 0, .48); } +html[data-theme="dark"] .jalali-picker-head button { background: var(--white); color: var(--ink); } +html[data-theme="dark"] .jalali-days button:hover, +html[data-theme="dark"] .jalali-picker-head button:hover { background: #26342d; } +html[data-theme="dark"] .day:hover, +html[data-theme="dark"] .roster-row:hover { background: #202c26; } +html[data-theme="dark"] .roster-detail > span { color: #b4c0ba; } +html[data-theme="dark"] .presence-mark.present { box-shadow: 0 0 0 3px #274535; } +html[data-theme="dark"] .presence-mark.remote { box-shadow: 0 0 0 3px #293b50; } +html[data-theme="dark"] .presence-mark.absent { box-shadow: 0 0 0 3px #4a2c2a; } +html[data-theme="dark"] .presence-badge.present { background: #1d3b2c; color: #8fd0ae; } +html[data-theme="dark"] .presence-badge.remote { background: #26394d; color: #9ab8dc; } +html[data-theme="dark"] .presence-badge.absent { background: #432725; color: #efa09a; } +html[data-theme="dark"] .notice.success { border-color: #315c47; background: #183426; color: #91d1af; } +html[data-theme="dark"] .notice.error { border-color: #67403d; background: #35201f; color: #f1a09a; } +html[data-theme="dark"] .badge.pending { background: #453820; color: #e3bd72; } +html[data-theme="dark"] .badge.approved { background: #1d3b2c; color: #8fd0ae; } +html[data-theme="dark"] .badge.rejected, +html[data-theme="dark"] .badge.cancelled { background: #432725; color: #efa09a; } +html[data-theme="dark"] .badge.kind { background: #28332e; color: #b2bdb7; } + +@media (max-width: 980px) { + .app-shell { grid-template-columns: 74px 1fr; } + .sidebar { padding: 25px 11px 16px; } + .brand > span:last-child, .sidebar nav a:not(.active) { font-size: 0; } + .brand { padding: 0 8px; } + .sidebar nav a { justify-content: center; padding: 0; } + .nav-icon { font-size: 1.05rem; } + .nav-label, .sidebar-user > span:nth-child(2), .sidebar-user form { display: none; } + .sidebar-user { display: flex; justify-content: center; padding-left: 0; padding-right: 0; } + .sidebar-theme { justify-content: center; padding-left: 0; padding-right: 0; } + .sidebar-theme span { display: none; } + .dashboard-grid { grid-template-columns: 1fr; } + .stats-grid { grid-row: 2; } + .login-page { grid-template-columns: 1fr 1fr; } + .story-calendar { display: none; } + .two-column { grid-template-columns: 1fr; } + .day-summary-card { grid-template-columns: 1fr; gap: 20px; } + .day-stats { padding-top: 18px; border-top: 1px solid var(--line); border-left: 0; } +} + +@media (max-width: 700px) { + .app-shell { display: block; } + .sidebar { position: fixed; z-index: 20; top: auto; right: 0; bottom: 0; left: 0; width: auto; height: 64px; padding: 7px; border-top: 1px solid var(--line); border-right: 0; } + .sidebar .brand, .sidebar-user, .nav-label { display: none; } + .sidebar-theme { position: fixed; z-index: 22; top: 15px; right: 15px; margin: 0; padding: 0; } + .sidebar nav { display: flex; justify-content: center; margin: 0; } + .sidebar nav a, .sidebar nav a:not(.active) { display: flex; width: 58px; margin: 0 3px; font-size: 0; } + .nav-count { position: absolute; width: 16px; height: 16px; margin: -20px 0 0 17px; } + .main { padding: 26px 15px 90px; } + .page-head { align-items: start; } + .day-page-head { display: block; } + .day-date-form { grid-template-columns: 1fr auto; margin-top: 20px; } + .today-pill { display: none; } + .stats-grid { grid-template-columns: repeat(3, minmax(0,1fr)); } + .stat { padding: 12px; } + .stat-icon { margin-bottom: 12px; } + .time-line { grid-template-columns: 1fr 25px 1fr; } + .time-line > div:last-child { display: none; } + .checkin-actions { flex-direction: column; } + .checkin-actions form, .checkin-actions button { width: 100%; } + .calendar-card { padding: 16px; } + .day { min-height: 49px; padding: 5px; } + .day .status-dot { right: 5px; top: 10px; } + .day.holiday small { display: none; } + .calendar-legend { flex-wrap: wrap; } + .login-page { display: block; } + .login-story { display: none; } + .login-form-wrap { min-height: 100vh; padding: 28px; } + .mobile-brand { display: flex; gap: 10px; align-items: center; margin-bottom: 70px; font-size: 1.1rem; font-weight: 700; } + .form-row, .choice-cards { grid-template-columns: 1fr; } + .review-form, .report-form { grid-template-columns: 1fr; } + .report-card { grid-template-columns: 1fr; } + .report-form { grid-column: auto; } + .filter-tabs { display: none; } + .day-summary-card, .roster-card { padding: 18px; } + .day-date-nav { grid-template-columns: 33px 1fr 33px; gap: 9px; } + .day-date-nav > a { width: 33px; height: 33px; } + .day-stats > div { padding: 7px 10px; } + .roster-card .section-head { display: block; } + .roster-legend { margin-top: 12px; } + .roster-list { margin-right: -18px; margin-left: -18px; } + .roster-row { grid-template-columns: 38px 1fr auto; gap: 10px; padding: 13px 18px; } + .roster-row .avatar { width: 38px; height: 38px; } + .roster-detail { grid-column: 2 / -1; grid-row: 2; margin-top: -9px; } + .presence-badge { grid-column: 3; grid-row: 1; min-width: 76px; } +} diff --git a/internal/app/static/theme.js b/internal/app/static/theme.js new file mode 100644 index 0000000..7edbb83 --- /dev/null +++ b/internal/app/static/theme.js @@ -0,0 +1,64 @@ +(function () { + "use strict"; + + var storageKey = "hamkar-theme"; + var root = document.documentElement; + + function storedTheme() { + try { + return localStorage.getItem(storageKey); + } catch (_) { + return null; + } + } + + function preferredTheme() { + var saved = storedTheme(); + if (saved === "light" || saved === "dark") { + return saved; + } + return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + } + + function updateButtons(theme) { + document.querySelectorAll("[data-theme-toggle]").forEach(function (button) { + var dark = theme === "dark"; + button.setAttribute("aria-pressed", String(dark)); + button.setAttribute("aria-label", dark ? "Switch to light mode" : "Switch to dark mode"); + }); + } + + function applyTheme(theme, persist) { + root.dataset.theme = theme; + root.style.colorScheme = theme; + updateButtons(theme); + if (persist) { + try { + localStorage.setItem(storageKey, theme); + } catch (_) { + // The theme still applies when storage is unavailable. + } + } + } + + applyTheme(preferredTheme(), false); + + document.addEventListener("DOMContentLoaded", function () { + updateButtons(root.dataset.theme); + document.querySelectorAll("[data-theme-toggle]").forEach(function (button) { + button.addEventListener("click", function () { + applyTheme(root.dataset.theme === "dark" ? "light" : "dark", true); + }); + }); + }); + + if (window.matchMedia) { + window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", function (event) { + if (!storedTheme()) { + applyTheme(event.matches ? "dark" : "light", false); + } + }); + } +})(); diff --git a/internal/app/store.go b/internal/app/store.go new file mode 100644 index 0000000..b10aac3 --- /dev/null +++ b/internal/app/store.go @@ -0,0 +1,597 @@ +package app + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "database/sql" + "encoding/base64" + "errors" + "fmt" + "net/mail" + "os" + "strconv" + "strings" + "time" + "unicode" + "unicode/utf8" + + _ "modernc.org/sqlite" +) + +type User struct { + ID int64 + Username string + DisplayName string + Email string + Role string + AvatarURL string +} + +type Attendance struct { + ID int64 + UserID int64 + Day string + CheckIn sql.NullTime + CheckOut sql.NullTime + Mode string +} + +type Request struct { + ID int64 + UserID int64 + UserName string + Kind string + StartDate string + EndDate string + Reason string + Status string + AdminNote string + CreatedAt time.Time + ReviewedAt sql.NullString + Reviewer string + DayCount int + StartJalali string + EndJalali string +} + +type DayRosterRow struct { + User User + CheckIn string + CheckOut string + Mode string + RequestKind string +} + +type Store struct{ db *sql.DB } + +func OpenStore(path string) (*Store, error) { + db, err := sql.Open("sqlite", path+"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Ping(ctx context.Context) error { return s.db.PingContext(ctx) } + +func (s *Store) migrate() error { + const schema = ` +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT, + display_name TEXT NOT NULL, + email TEXT UNIQUE COLLATE NOCASE, + role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('member','admin')), + avatar_url TEXT NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE IF NOT EXISTS oauth_accounts ( + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY(provider, provider_user_id) +); +CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + csrf_token TEXT NOT NULL, + expires_at DATETIME NOT NULL +); +CREATE TABLE IF NOT EXISTS attendance ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + day TEXT NOT NULL, + check_in DATETIME, + check_out DATETIME, + mode TEXT NOT NULL DEFAULT 'office' CHECK(mode IN ('office','remote')), + UNIQUE(user_id, day) +); +CREATE TABLE IF NOT EXISTS requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('leave','remote')), + start_date TEXT NOT NULL, + end_date TEXT NOT NULL, + reason TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected','cancelled')), + admin_note TEXT NOT NULL DEFAULT '', + reviewed_by INTEGER REFERENCES users(id), + reviewed_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK(end_date >= start_date) +); +CREATE INDEX IF NOT EXISTS idx_attendance_day ON attendance(day); +CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status, start_date); +` + if _, err := s.db.Exec(schema); err != nil { + return err + } + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count); err != nil { + return err + } + if count == 0 { + initialPassword := os.Getenv("INITIAL_ADMIN_PASSWORD") + if initialPassword == "" { + initialPassword = "admin123" + } + hash, err := hashPassword(initialPassword) + if err != nil { + return err + } + _, err = s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) VALUES(?,?,?,?,?)`, + "admin", hash, "Workspace Admin", "admin@localhost", "admin") + return err + } + return nil +} + +func hashPassword(password string) (string, error) { + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + const rounds = 180000 + key := deriveKey([]byte(password), salt, rounds) + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", rounds, + 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 + } + rounds, err := strconv.Atoi(parts[1]) + salt, err2 := base64.RawStdEncoding.DecodeString(parts[2]) + expected, err3 := base64.RawStdEncoding.DecodeString(parts[3]) + if err != nil || err2 != nil || err3 != nil || rounds < 10000 { + return false + } + return hmac.Equal(expected, deriveKey([]byte(password), salt, rounds)) +} + +func deriveKey(password, salt []byte, rounds int) []byte { + mac := hmac.New(sha256.New, password) + mac.Write(salt) + mac.Write([]byte{0, 0, 0, 1}) + u := mac.Sum(nil) + out := append([]byte(nil), u...) + for i := 1; i < rounds; i++ { + mac.Reset() + mac.Write(u) + u = mac.Sum(nil) + for j := range out { + out[j] ^= u[j] + } + } + return out +} + +func randomToken(bytes int) (string, error) { + b := make([]byte, bytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +func tokenHash(token string) string { + sum := sha256.Sum256([]byte(token)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func (s *Store) Authenticate(identifier, password string) (*User, error) { + var u User + var hash string + err := s.db.QueryRow(`SELECT id,username,password_hash,display_name,COALESCE(email,''),role,avatar_url + FROM users WHERE username=? OR email=?`, strings.TrimSpace(identifier), strings.TrimSpace(identifier)). + Scan(&u.ID, &u.Username, &hash, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL) + if err != nil || !verifyPassword(hash, password) { + return nil, errors.New("invalid email, username, or password") + } + return &u, nil +} + +func (s *Store) CreateUser(username, password, displayName, email, role string) (int64, error) { + username = strings.TrimSpace(username) + displayName = strings.TrimSpace(displayName) + email = strings.ToLower(strings.TrimSpace(email)) + if displayName == "" || len(password) < 8 { + return 0, errors.New("name and a password of at least 8 characters are required") + } + if username == "" && email == "" { + return 0, errors.New("enter an email address or username") + } + if email != "" { + parsed, err := mail.ParseAddress(email) + if err != nil || parsed.Address != email { + return 0, errors.New("enter a valid email address") + } + } + explicitUsername := username != "" + if !explicitUsername { + username = usernameFromEmail(email) + } + if !validUsername(username) { + return 0, errors.New("username must be 3–40 letters, numbers, dots, dashes, or underscores") + } + if !explicitUsername { + base := username + for suffix := 0; ; suffix++ { + if suffix > 0 { + username = fmt.Sprintf("%s-%d", base, suffix) + } + var count int + if err := s.db.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, username).Scan(&count); err != nil { + return 0, err + } + if count == 0 { + break + } + } + } + if role != "admin" { + role = "member" + } + hash, err := hashPassword(password) + if err != nil { + return 0, err + } + result, err := s.db.Exec(`INSERT INTO users(username,password_hash,display_name,email,role) + VALUES(?,?,?,NULLIF(?,''),?)`, username, hash, displayName, email, role) + if err != nil { + if strings.Contains(strings.ToLower(err.Error()), "unique") { + return 0, errors.New("that username or email is already in use") + } + return 0, err + } + return result.LastInsertId() +} + +func usernameFromEmail(email string) string { + local := strings.SplitN(email, "@", 2)[0] + var out strings.Builder + for _, r := range local { + if unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("._-", r) { + out.WriteRune(r) + } + } + username := strings.Trim(out.String(), "._-") + if utf8.RuneCountInString(username) < 3 { + username = "member-" + username + } + if utf8.RuneCountInString(username) > 32 { + username = string([]rune(username)[:32]) + } + return username +} + +func validUsername(username string) bool { + count := utf8.RuneCountInString(username) + if count < 3 || count > 40 { + return false + } + for _, r := range username { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && !strings.ContainsRune("._-", r) { + return false + } + } + return true +} + +func (s *Store) Users() ([]User, error) { + rows, err := s.db.Query(`SELECT id,username,display_name,COALESCE(email,''),role,avatar_url FROM users ORDER BY display_name`) + if err != nil { + return nil, err + } + defer rows.Close() + var out []User + for rows.Next() { + var u User + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL); err != nil { + return nil, err + } + out = append(out, u) + } + return out, rows.Err() +} + +func (s *Store) CreateSession(userID int64) (token, csrf string, err error) { + token, err = randomToken(32) + if err != nil { + return "", "", err + } + csrf, err = randomToken(24) + if err != nil { + return "", "", err + } + _, err = s.db.Exec(`INSERT INTO sessions(token_hash,user_id,csrf_token,expires_at) VALUES(?,?,?,?)`, + tokenHash(token), userID, csrf, time.Now().Add(30*24*time.Hour)) + return +} + +func (s *Store) UpsertOAuthUser(provider, providerID, username, email, displayName, avatar string) (int64, error) { + if providerID == "" || providerID == "" { + return 0, errors.New("OAuth profile did not contain an id") + } + tx, err := s.db.Begin() + if err != nil { + return 0, err + } + defer tx.Rollback() + var userID int64 + err = tx.QueryRow(`SELECT user_id FROM oauth_accounts WHERE provider=? AND provider_user_id=?`, provider, providerID).Scan(&userID) + if err == nil { + return userID, tx.Commit() + } + if !errors.Is(err, sql.ErrNoRows) { + return 0, err + } + if email != "" { + err = tx.QueryRow(`SELECT id FROM users WHERE email=?`, email).Scan(&userID) + } + if email == "" || errors.Is(err, sql.ErrNoRows) { + username = strings.TrimSpace(username) + if username == "" { + username = provider + "-" + providerID + } + if displayName == "" { + displayName = username + } + candidate := username + for suffix := 0; ; suffix++ { + if suffix > 0 { + candidate = fmt.Sprintf("%s-%d", username, suffix) + } + var exists int + err = tx.QueryRow(`SELECT COUNT(*) FROM users WHERE username=?`, candidate).Scan(&exists) + if err != nil { + return 0, err + } + if exists == 0 { + break + } + } + result, err := tx.Exec(`INSERT INTO users(username,display_name,email,avatar_url) VALUES(?,?,NULLIF(?,''),?)`, + candidate, displayName, email, avatar) + if err != nil { + return 0, err + } + userID, err = result.LastInsertId() + if err != nil { + return 0, err + } + } else if err != nil { + return 0, err + } + if _, err := tx.Exec(`INSERT INTO oauth_accounts(provider,provider_user_id,user_id) VALUES(?,?,?)`, provider, providerID, userID); err != nil { + return 0, err + } + return userID, tx.Commit() +} + +func (s *Store) Session(token string) (*User, string, error) { + var u User + var csrf string + err := s.db.QueryRow(`SELECT u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url,s.csrf_token + FROM sessions s JOIN users u ON u.id=s.user_id + WHERE s.token_hash=? AND s.expires_at>?`, tokenHash(token), time.Now()). + Scan(&u.ID, &u.Username, &u.DisplayName, &u.Email, &u.Role, &u.AvatarURL, &csrf) + if err != nil { + return nil, "", err + } + return &u, csrf, nil +} + +func (s *Store) DeleteSession(token string) { + _, _ = s.db.Exec(`DELETE FROM sessions WHERE token_hash=?`, tokenHash(token)) +} + +func (s *Store) TodayAttendance(userID int64, day string) (*Attendance, error) { + var a Attendance + err := s.db.QueryRow(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance WHERE user_id=? AND day=?`, userID, day). + Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return &a, err +} + +func (s *Store) CheckIn(userID int64, day, mode string) error { + if mode != "remote" { + mode = "office" + } + _, err := s.db.Exec(`INSERT INTO attendance(user_id,day,check_in,mode) VALUES(?,?,?,?) + ON CONFLICT(user_id,day) DO UPDATE SET check_in=COALESCE(attendance.check_in,excluded.check_in),mode=excluded.mode`, + userID, day, time.Now(), mode) + return err +} + +func (s *Store) CheckOut(userID int64, day string) error { + result, err := s.db.Exec(`UPDATE attendance SET check_out=? WHERE user_id=? AND day=? AND check_in IS NOT NULL AND check_out IS NULL`, + time.Now(), userID, day) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return errors.New("check in before checking out") + } + return nil +} + +func (s *Store) AttendanceBetween(userID int64, start, end string) (map[string]Attendance, error) { + rows, err := s.db.Query(`SELECT id,user_id,day,check_in,check_out,mode FROM attendance + WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end) + if err != nil { + return nil, err + } + defer rows.Close() + out := map[string]Attendance{} + for rows.Next() { + var a Attendance + if err := rows.Scan(&a.ID, &a.UserID, &a.Day, &a.CheckIn, &a.CheckOut, &a.Mode); err != nil { + return nil, err + } + out[a.Day] = a + } + return out, rows.Err() +} + +func (s *Store) CreateRequest(userID int64, kind, start, end, reason string) error { + if kind != "leave" && kind != "remote" { + return errors.New("invalid request type") + } + _, err := s.db.Exec(`INSERT INTO requests(user_id,kind,start_date,end_date,reason) VALUES(?,?,?,?,?)`, + userID, kind, start, end, strings.TrimSpace(reason)) + return err +} + +func (s *Store) Requests(userID int64, admin bool, status string) ([]Request, error) { + query := `SELECT r.id,r.user_id,u.display_name,r.kind,r.start_date,r.end_date,r.reason,r.status, + r.admin_note,r.created_at,r.reviewed_at,COALESCE(a.display_name,'') + FROM requests r JOIN users u ON u.id=r.user_id LEFT JOIN users a ON a.id=r.reviewed_by` + args := []any{} + clauses := []string{} + if !admin { + clauses = append(clauses, "r.user_id=?") + args = append(args, userID) + } + if status != "" { + clauses = append(clauses, "r.status=?") + args = append(args, status) + } + if len(clauses) > 0 { + query += " WHERE " + strings.Join(clauses, " AND ") + } + query += " ORDER BY CASE r.status WHEN 'pending' THEN 0 ELSE 1 END,r.created_at DESC" + rows, err := s.db.Query(query, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []Request + for rows.Next() { + var r Request + if err := rows.Scan(&r.ID, &r.UserID, &r.UserName, &r.Kind, &r.StartDate, &r.EndDate, + &r.Reason, &r.Status, &r.AdminNote, &r.CreatedAt, &r.ReviewedAt, &r.Reviewer); err != nil { + return nil, err + } + start, _ := time.Parse("2006-01-02", r.StartDate) + end, _ := time.Parse("2006-01-02", r.EndDate) + r.DayCount = int(end.Sub(start).Hours()/24) + 1 + out = append(out, r) + } + return out, rows.Err() +} + +func (s *Store) ReviewRequest(ctx context.Context, id, reviewerID int64, status, note string) error { + if status != "approved" && status != "rejected" { + return errors.New("invalid review decision") + } + result, err := s.db.ExecContext(ctx, `UPDATE requests SET status=?,admin_note=?,reviewed_by=?,reviewed_at=? + WHERE id=? AND status='pending'`, status, strings.TrimSpace(note), reviewerID, time.Now(), id) + if err != nil { + return err + } + n, _ := result.RowsAffected() + if n == 0 { + return errors.New("request was already reviewed") + } + return nil +} + +func (s *Store) CancelRequest(id, userID int64) error { + _, err := s.db.Exec(`UPDATE requests SET status='cancelled' WHERE id=? AND user_id=? AND status='pending'`, id, userID) + return err +} + +func (s *Store) ReportRows(start, end string) (*sql.Rows, error) { + return s.db.Query(`SELECT u.display_name,u.username,a.day, + COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''), + COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''), + a.mode, + COALESCE((SELECT r.kind FROM requests r WHERE r.user_id=u.id AND r.status='approved' + AND a.day BETWEEN r.start_date AND r.end_date ORDER BY r.id DESC LIMIT 1),'') + FROM attendance a JOIN users u ON u.id=a.user_id + WHERE a.day BETWEEN ? AND ? ORDER BY a.day,u.display_name`, start, end) +} + +func (s *Store) Stats(userID int64, start, end string) (present, remote, leave int, err error) { + err = s.db.QueryRow(`SELECT + COUNT(*),COALESCE(SUM(CASE WHEN mode='remote' THEN 1 ELSE 0 END),0) + FROM attendance WHERE user_id=? AND day BETWEEN ? AND ?`, userID, start, end).Scan(&present, &remote) + if err != nil { + return + } + err = s.db.QueryRow(`SELECT COALESCE(SUM(julianday(end_date)-julianday(start_date)+1),0) + FROM requests WHERE user_id=? AND kind='leave' AND status='approved' + AND start_date<=? AND end_date>=?`, userID, end, start).Scan(&leave) + return +} + +func (s *Store) DayRoster(day string) ([]DayRosterRow, error) { + rows, err := s.db.Query(`SELECT + u.id,u.username,u.display_name,COALESCE(u.email,''),u.role,u.avatar_url, + COALESCE(substr(CAST(a.check_in AS TEXT),12,5),''), + COALESCE(substr(CAST(a.check_out AS TEXT),12,5),''), + COALESCE(a.mode,''), + COALESCE(( + SELECT r.kind FROM requests r + WHERE r.user_id=u.id AND r.status='approved' AND ? BETWEEN r.start_date AND r.end_date + ORDER BY CASE r.kind WHEN 'leave' THEN 0 ELSE 1 END,r.id DESC + LIMIT 1 + ),'') + FROM users u + LEFT JOIN attendance a ON a.user_id=u.id AND a.day=? + ORDER BY u.display_name`, day, day) + if err != nil { + return nil, err + } + defer rows.Close() + var roster []DayRosterRow + for rows.Next() { + var row DayRosterRow + if err := rows.Scan( + &row.User.ID, &row.User.Username, &row.User.DisplayName, &row.User.Email, + &row.User.Role, &row.User.AvatarURL, &row.CheckIn, &row.CheckOut, + &row.Mode, &row.RequestKind, + ); err != nil { + return nil, err + } + roster = append(roster, row) + } + return roster, rows.Err() +} diff --git a/internal/app/templates/admin.html b/internal/app/templates/admin.html new file mode 100644 index 0000000..9f52430 --- /dev/null +++ b/internal/app/templates/admin.html @@ -0,0 +1,33 @@ +{{define "admin.html"}} +{{template "shell-start" .}} +
+

ADMIN

Team approvals

Review time-off and remote-day requests.

+ +
+{{template "notice" .}} +
+ {{if .Requests}} +
+ {{range .Requests}} +
+ {{initial .UserName}} +
+
{{.UserName}}{{kindLabel .Kind}}{{statusLabel .Status}}
+

{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s)

+ {{if .Reason}}
“{{.Reason}}”
{{end}} + {{if eq .Status "pending"}} +
+ + + + +
+ {{else if .AdminNote}}

Review note: {{.AdminNote}}

{{end}} +
+
+ {{end}} +
+ {{else}}

All clear

No requests match this view.

{{end}} +
+{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/base.html b/internal/app/templates/base.html new file mode 100644 index 0000000..5865356 --- /dev/null +++ b/internal/app/templates/base.html @@ -0,0 +1,84 @@ +{{define "head"}} + + + + + + + {{.Title}} · Hamkar + + + + + + + +{{end}} + +{{define "theme-toggle"}} + +{{end}} + +{{define "sidebar"}} + +{{end}} + +{{define "shell-start"}} +{{template "head" .}} +
+ {{template "sidebar" .}} +
+{{end}} + +{{define "shell-end"}} +
+
+ + +{{end}} + +{{define "notice"}} +{{if .Flash}}
{{.Flash}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} +{{end}} diff --git a/internal/app/templates/calendar.html b/internal/app/templates/calendar.html new file mode 100644 index 0000000..35d4931 --- /dev/null +++ b/internal/app/templates/calendar.html @@ -0,0 +1,35 @@ +{{define "calendar.html"}} +
+
+
+

PERSIAN CALENDAR

+

{{.Calendar.MonthName}} {{.Calendar.Year}}

+

{{.Calendar.MonthNameFA}} {{.Calendar.Year}}

+
+
+ + +
+
+
+ SatSunMonTueWedThuFri +
+
+ {{range .Calendar.Cells}} + {{if .InMonth}} + + {{.Day}} + {{if .Status}}{{end}} + {{if .Holiday}}{{.Holiday}}{{end}} + + {{else}}
{{end}} + {{end}} +
+
+ Office + Remote + Time off + Holiday +
+
+{{end}} diff --git a/internal/app/templates/dashboard.html b/internal/app/templates/dashboard.html new file mode 100644 index 0000000..17340f6 --- /dev/null +++ b/internal/app/templates/dashboard.html @@ -0,0 +1,63 @@ +{{define "dashboard.html"}} +{{template "shell-start" .}} +
+

TEAM WORKSPACE

Good day, {{.User.DisplayName}}

Here’s your month at a glance.

+
Today{{.TodayJalali}}
+
+{{template "notice" .}} +
+
+
+

TODAY’S PRESENCE

{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}You’re checked in{{end}}{{else}}Ready when you are{{end}}

+ {{if and .Attendance (not .Attendance.CheckOut.Valid)}}ACTIVE{{else}}TODAY{{end}} +
+ {{if .Attendance}} +
+
CHECKED IN{{timeHM .Attendance.CheckIn}}
+ +
CHECKED OUT{{timeHM .Attendance.CheckOut}}
+
LOCATION{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}
+
+ {{if not .Attendance.CheckOut.Valid}} +
+ + +
+ {{end}} + {{else}} +

Start your day by choosing where you’re working.

+
+
+ + +
+
+ + +
+
+ {{end}} +
+
+
PRESENT DAYS{{.Stats.Present}}

This month

+
REMOTE DAYS{{.Stats.Remote}}

This month

+
TIME OFF{{.Stats.Leave}}

Approved days

+
+ {{template "calendar.html" .}} +
+

RECENT ACTIVITY

Your requests

View all →
+ {{if .Requests}} +
+ {{range .Requests}} +
+ {{if eq .Kind "remote"}}⌂{{else}}☼{{end}} +
{{kindLabel .Kind}}{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}}
+ {{statusLabel .Status}} +
+ {{end}} +
+ {{else}}

No requests yet.

Make a request
{{end}} +
+
+{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/day.html b/internal/app/templates/day.html new file mode 100644 index 0000000..d38ad96 --- /dev/null +++ b/internal/app/templates/day.html @@ -0,0 +1,56 @@ +{{define "day.html"}} +{{template "shell-start" .}} +
+

TEAM DAY

Who’s working?

See the team’s presence, remote work, and absences for one day.

+
+ + +
+
+{{template "notice" .}} +
+
+
+ +
+

PERSIAN CALENDAR

+

{{.Day.Jalali}}

+ {{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}} +
+ +
+
+
{{.Day.Present}}Present
+
{{.Day.Remote}}Remote
+
{{.Day.Absent}}Absent
+
+
+ +
+
+

TEAM ROSTER

{{len .Day.Members}} teammates

+
Present Remote Absent
+
+ {{if .Day.Members}} +
+ {{range .Day.Members}} +
+ {{if .User.AvatarURL}}{{else}}{{initial .User.DisplayName}}{{end}} +
{{.User.DisplayName}}@{{.User.Username}}
+
{{.Detail}}{{if .CheckIn}}{{if .CheckOut}}Completed{{else}}Active{{end}}{{end}}
+ {{.Label}} +
+ {{end}} +
+ {{else}} +

No teammates yet

Add teammates to see the daily roster.

+ {{end}} +
+
+{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/login.html b/internal/app/templates/login.html new file mode 100644 index 0000000..a307fc3 --- /dev/null +++ b/internal/app/templates/login.html @@ -0,0 +1,44 @@ +{{define "login.html"}} +{{template "head" .}} +
+
{{template "theme-toggle" .}}
+ + +
+ + +{{end}} diff --git a/internal/app/templates/register.html b/internal/app/templates/register.html new file mode 100644 index 0000000..a02607e --- /dev/null +++ b/internal/app/templates/register.html @@ -0,0 +1,51 @@ +{{define "register.html"}} +{{template "head" .}} +
+
{{template "theme-toggle" .}}
+ + +
+ + +{{end}} diff --git a/internal/app/templates/reports.html b/internal/app/templates/reports.html new file mode 100644 index 0000000..8062ab2 --- /dev/null +++ b/internal/app/templates/reports.html @@ -0,0 +1,14 @@ +{{define "reports.html"}} +{{template "shell-start" .}} +

ADMIN

Reports

Export team presence data for payroll or analysis.

+
+
+

ATTENDANCE EXPORT

Download presence records

The UTF-8 CSV includes Gregorian and Persian dates, check-in/out time, location, and approved requests. It opens directly in Excel.

+
+ + + +
+
+{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/requests.html b/internal/app/templates/requests.html new file mode 100644 index 0000000..eb3d536 --- /dev/null +++ b/internal/app/templates/requests.html @@ -0,0 +1,42 @@ +{{define "requests.html"}} +{{template "shell-start" .}} +
+

PLANNING

My requests

Ask for time off or a remote work day.

+
+{{template "notice" .}} +
+
+

NEW REQUEST

Plan a day away

+
+ +
+ + +
+
+ + +
+ + +
+
+
+

HISTORY

All requests

+ {{if .Requests}} +
+ {{range .Requests}} +
+ {{if eq .Kind "remote"}}⌂{{else}}☼{{end}} +
{{kindLabel .Kind}}{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s){{if .Reason}}

{{.Reason}}

{{end}}{{if .AdminNote}}

Manager: {{.AdminNote}}

{{end}}
+
{{statusLabel .Status}} + {{if eq .Status "pending"}}
{{end}} +
+
+ {{end}} +
+ {{else}}

You haven’t made any requests.

{{end}} +
+
+{{template "shell-end" .}} +{{end}} diff --git a/internal/app/templates/users.html b/internal/app/templates/users.html new file mode 100644 index 0000000..d32b308 --- /dev/null +++ b/internal/app/templates/users.html @@ -0,0 +1,38 @@ +{{define "users.html"}} +{{template "shell-start" .}} +
+

ADMIN

Teammates

Create local accounts and see who has access.

+
+{{template "notice" .}} +
+
+

NEW ACCOUNT

Add a teammate

+
+ + +
+ + +
+ + + +
+
+
+

DIRECTORY

{{len .Users}} teammates

+
+ {{range .Users}} +
+ {{initial .DisplayName}} +
{{.DisplayName}}@{{.Username}}{{if .Email}} · {{.Email}}{{end}}
+ {{.Role}} +
+ {{end}} +
+
+
+{{template "shell-end" .}} +{{end}} diff --git a/internal/jalali/jalali.go b/internal/jalali/jalali.go new file mode 100644 index 0000000..c1ec4dc --- /dev/null +++ b/internal/jalali/jalali.go @@ -0,0 +1,109 @@ +package jalali + +import ( + "fmt" + "time" +) + +var MonthNames = [...]string{ + "", "Farvardin", "Ordibehesht", "Khordad", "Tir", "Mordad", "Shahrivar", + "Mehr", "Aban", "Azar", "Dey", "Bahman", "Esfand", +} + +var MonthNamesFA = [...]string{ + "", "فروردین", "اردیبهشت", "خرداد", "تیر", "مرداد", "شهریور", + "مهر", "آبان", "آذر", "دی", "بهمن", "اسفند", +} + +type Date struct { + Year int + Month int + Day int +} + +func (d Date) String() string { return fmt.Sprintf("%04d-%02d-%02d", d.Year, d.Month, d.Day) } + +func IsLeap(year int) bool { + return ToGregorian(year+1, 1, 1).Sub(ToGregorian(year, 1, 1)) == 366*24*time.Hour +} + +func DaysInMonth(year, month int) int { + if month <= 6 { + return 31 + } + if month <= 11 { + return 30 + } + if IsLeap(year) { + return 30 + } + return 29 +} + +func FromTime(t time.Time) Date { + gy, gm, gd := t.Date() + jy, jm, jd := gregorianToJalali(gy, int(gm), gd) + return Date{jy, jm, jd} +} + +func ToGregorian(jy, jm, jd int) time.Time { + gy, gm, gd := jalaliToGregorian(jy, jm, jd) + return time.Date(gy, time.Month(gm), gd, 0, 0, 0, 0, time.Local) +} + +func gregorianToJalali(gy, gm, gd int) (int, int, int) { + gdm := [...]int{0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334} + gy2 := gy + if gm > 2 { + gy2++ + } + days := 355666 + 365*gy + (gy2+3)/4 - (gy2+99)/100 + (gy2+399)/400 + gd + gdm[gm-1] + jy := -1595 + 33*(days/12053) + days %= 12053 + jy += 4 * (days / 1461) + days %= 1461 + if days > 365 { + jy += (days - 1) / 365 + days = (days - 1) % 365 + } + if days < 186 { + return jy, 1 + days/31, 1 + days%31 + } + return jy, 7 + (days-186)/30, 1 + (days-186)%30 +} + +func jalaliToGregorian(jy, jm, jd int) (int, int, int) { + jy += 1595 + days := -355668 + 365*jy + (jy/33)*8 + (jy%33+3)/4 + jd + if jm < 7 { + days += (jm - 1) * 31 + } else { + days += (jm-7)*30 + 186 + } + gy := 400 * (days / 146097) + days %= 146097 + if days > 36524 { + gy += 100 * ((days - 1) / 36524) + days = (days - 1) % 36524 + if days >= 365 { + days++ + } + } + gy += 4 * (days / 1461) + days %= 1461 + if days > 365 { + gy += (days - 1) / 365 + days = (days - 1) % 365 + } + gd := days + 1 + sal := [...]int{0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31} + if (gy%4 == 0 && gy%100 != 0) || gy%400 == 0 { + sal[2] = 29 + } + gm := 1 + for gm <= 12 && gd > sal[gm] { + gd -= sal[gm] + gm++ + } + return gy, gm, gd +} diff --git a/internal/jalali/jalali_test.go b/internal/jalali/jalali_test.go new file mode 100644 index 0000000..4a9fbf9 --- /dev/null +++ b/internal/jalali/jalali_test.go @@ -0,0 +1,36 @@ +package jalali + +import ( + "testing" + "time" +) + +func TestKnownDates(t *testing.T) { + tests := []struct { + gregorian string + jalali Date + }{ + {"2024-03-20", Date{1403, 1, 1}}, + {"2025-03-21", Date{1404, 1, 1}}, + {"2026-07-28", Date{1405, 5, 6}}, + } + for _, tt := range tests { + g, _ := time.Parse("2006-01-02", tt.gregorian) + if got := FromTime(g); got != tt.jalali { + t.Errorf("%s: got %v, want %v", tt.gregorian, got, tt.jalali) + } + back := ToGregorian(tt.jalali.Year, tt.jalali.Month, tt.jalali.Day) + if got := back.Format("2006-01-02"); got != tt.gregorian { + t.Errorf("%v: got %s, want %s", tt.jalali, got, tt.gregorian) + } + } +} + +func TestMonthLength(t *testing.T) { + if got := DaysInMonth(1399, 12); got != 30 { + t.Fatalf("leap Esfand: got %d, want 30", got) + } + if got := DaysInMonth(1400, 12); got != 29 { + t.Fatalf("regular Esfand: got %d, want 29", got) + } +}