init
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
.git
|
||||
.gitea
|
||||
.agents
|
||||
.codex
|
||||
.env
|
||||
data
|
||||
teammate
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
README.md
|
||||
@@ -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=
|
||||
@@ -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" \
|
||||
.
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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' }}
|
||||
@@ -0,0 +1,6 @@
|
||||
/data/
|
||||
/teammate
|
||||
/.env
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
+22
@@ -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"]
|
||||
@@ -0,0 +1,10 @@
|
||||
.PHONY: run test build
|
||||
|
||||
run:
|
||||
go run ./cmd/server
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
build:
|
||||
go build -o teammate ./cmd/server
|
||||
@@ -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 <http://localhost:8080>. 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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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 =
|
||||
'<div class="jalali-picker-head">' +
|
||||
'<button type="button" data-next aria-label="ماه بعد">‹</button>' +
|
||||
'<strong>' + monthNames[this.month] + ' <span>' + this.year + '</span></strong>' +
|
||||
'<button type="button" data-prev aria-label="ماه قبل">›</button>' +
|
||||
'</div>' +
|
||||
'<div class="jalali-weekdays">' +
|
||||
weekNames.map(function (name) { return "<span>" + name + "</span>"; }).join("") +
|
||||
'</div>' +
|
||||
'<div class="jalali-days"></div>' +
|
||||
'<button type="button" class="jalali-today">امروز · ' + formatDate(today) + "</button>";
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -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 == "<nil>" {
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{{define "admin.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">ADMIN</p><h1>Team approvals</h1><p>Review time-off and remote-day requests.</p></div>
|
||||
<div class="filter-tabs"><a href="/admin/requests" class="active">All</a><a href="/admin/requests?status=pending">Pending</a><a href="/admin/requests?status=approved">Approved</a></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<section class="card approval-card">
|
||||
{{if .Requests}}
|
||||
<div class="request-list">
|
||||
{{range .Requests}}
|
||||
<article class="approval-row">
|
||||
<span class="avatar small">{{initial .UserName}}</span>
|
||||
<div class="approval-main">
|
||||
<div class="approval-title"><strong>{{.UserName}}</strong><span class="badge kind">{{kindLabel .Kind}}</span><span class="badge {{.Status}}">{{statusLabel .Status}}</span></div>
|
||||
<p>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s)</p>
|
||||
{{if .Reason}}<blockquote>“{{.Reason}}”</blockquote>{{end}}
|
||||
{{if eq .Status "pending"}}
|
||||
<form method="post" action="/admin/requests/{{.ID}}/review" class="review-form">
|
||||
<input type="hidden" name="csrf" value="{{$.CSRF}}">
|
||||
<input name="note" maxlength="500" placeholder="Optional note to teammate">
|
||||
<button class="button approve" name="decision" value="approved">Approve</button>
|
||||
<button class="button reject" name="decision" value="rejected">Reject</button>
|
||||
</form>
|
||||
{{else if .AdminNote}}<p class="admin-note">Review note: {{.AdminNote}}</p>{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty roomy"><span>✓</span><h3>All clear</h3><p>No requests match this view.</p></div>{{end}}
|
||||
</section>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,84 @@
|
||||
{{define "head"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{.Title}} · Hamkar</title>
|
||||
<script src="/static/theme.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/rastikerdar/vazirmatn@v33.003/Vazirmatn-font-face.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="/static/jalali-picker.js" defer></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
{{end}}
|
||||
|
||||
{{define "theme-toggle"}}
|
||||
<button type="button" class="theme-toggle" data-theme-toggle aria-label="Switch to dark mode" title="Change color mode">
|
||||
<span class="theme-sun" aria-hidden="true">☀</span>
|
||||
<span class="theme-moon" aria-hidden="true">☾</span>
|
||||
</button>
|
||||
{{end}}
|
||||
|
||||
{{define "sidebar"}}
|
||||
<aside class="sidebar">
|
||||
<a class="brand" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<nav>
|
||||
<a href="/" class="{{if eq .SelectedSection "dashboard"}}active{{end}}">
|
||||
<span class="nav-icon">⌂</span> Overview
|
||||
</a>
|
||||
<a href="/day" class="{{if eq .SelectedSection "day"}}active{{end}}">
|
||||
<span class="nav-icon">◉</span> Team day
|
||||
</a>
|
||||
<a href="/requests" class="{{if eq .SelectedSection "requests"}}active{{end}}">
|
||||
<span class="nav-icon">◫</span> My requests
|
||||
</a>
|
||||
{{if eq .User.Role "admin"}}
|
||||
<p class="nav-label">ADMIN</p>
|
||||
<a href="/admin/requests" class="{{if eq .SelectedSection "admin"}}active{{end}}">
|
||||
<span class="nav-icon">✓</span> Approvals
|
||||
{{if .PendingCount}}<span class="nav-count">{{.PendingCount}}</span>{{end}}
|
||||
</a>
|
||||
<a href="/admin/users" class="{{if eq .SelectedSection "users"}}active{{end}}">
|
||||
<span class="nav-icon">◎</span> Teammates
|
||||
</a>
|
||||
<a href="/reports" class="{{if eq .SelectedSection "reports"}}active{{end}}">
|
||||
<span class="nav-icon">↧</span> Reports
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-theme">{{template "theme-toggle" .}}<span>Appearance</span></div>
|
||||
<div class="sidebar-user">
|
||||
<span class="avatar">{{initial .User.DisplayName}}</span>
|
||||
<span><strong>{{.User.DisplayName}}</strong><small>{{.User.Role}}</small></span>
|
||||
<form method="post" action="/logout">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="icon-button" title="Sign out" aria-label="Sign out">↗</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
{{end}}
|
||||
|
||||
{{define "shell-start"}}
|
||||
{{template "head" .}}
|
||||
<div class="app-shell">
|
||||
{{template "sidebar" .}}
|
||||
<main class="main">
|
||||
{{end}}
|
||||
|
||||
{{define "shell-end"}}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "notice"}}
|
||||
{{if .Flash}}<div class="notice success">{{.Flash}}</div>{{end}}
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,35 @@
|
||||
{{define "calendar.html"}}
|
||||
<section id="calendar-panel" class="card calendar-card">
|
||||
<header class="calendar-head">
|
||||
<div>
|
||||
<p class="eyebrow">PERSIAN CALENDAR</p>
|
||||
<h2>{{.Calendar.MonthName}} <span>{{.Calendar.Year}}</span></h2>
|
||||
<p class="fa-month" lang="fa" dir="rtl">{{.Calendar.MonthNameFA}} {{.Calendar.Year}}</p>
|
||||
</div>
|
||||
<div class="calendar-nav">
|
||||
<a aria-label="Previous month" href="/?month={{.Calendar.Prev}}" hx-get="/calendar?month={{.Calendar.Prev}}" hx-target="#calendar-panel" hx-swap="outerHTML">‹</a>
|
||||
<a aria-label="Next month" href="/?month={{.Calendar.Next}}" hx-get="/calendar?month={{.Calendar.Next}}" hx-target="#calendar-panel" hx-swap="outerHTML">›</a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="calendar-grid weekdays">
|
||||
<span>Sat</span><span>Sun</span><span>Mon</span><span>Tue</span><span>Wed</span><span>Thu</span><span>Fri</span>
|
||||
</div>
|
||||
<div class="calendar-grid days">
|
||||
{{range .Calendar.Cells}}
|
||||
{{if .InMonth}}
|
||||
<a href="/day?date={{.Gregorian}}" class="day {{if .IsToday}}today{{end}} {{if .IsFriday}}friday{{end}} {{if .Holiday}}holiday{{end}}" title="View team details for this day">
|
||||
<span class="day-number">{{.Day}}</span>
|
||||
{{if .Status}}<i class="status-dot {{.Status}}" title="{{.Status}}"></i>{{end}}
|
||||
{{if .Holiday}}<small title="{{.Holiday}}">{{.Holiday}}</small>{{end}}
|
||||
</a>
|
||||
{{else}}<div class="day blank"></div>{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
<footer class="calendar-legend">
|
||||
<span><i class="status-dot office"></i> Office</span>
|
||||
<span><i class="status-dot remote"></i> Remote</span>
|
||||
<span><i class="status-dot leave"></i> Time off</span>
|
||||
<span><i class="holiday-mark"></i> Holiday</span>
|
||||
</footer>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,63 @@
|
||||
{{define "dashboard.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">TEAM WORKSPACE</p><h1>Good day, {{.User.DisplayName}}</h1><p>Here’s your month at a glance.</p></div>
|
||||
<div class="today-pill"><span>Today</span><strong>{{.TodayJalali}}</strong></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="dashboard-grid">
|
||||
<section class="card presence-card">
|
||||
<div class="presence-top">
|
||||
<div><p class="eyebrow">TODAY’S PRESENCE</p><h2>{{if .Attendance}}{{if .Attendance.CheckOut.Valid}}Day complete{{else}}You’re checked in{{end}}{{else}}Ready when you are{{end}}</h2></div>
|
||||
<span class="live-badge"><i></i> {{if and .Attendance (not .Attendance.CheckOut.Valid)}}ACTIVE{{else}}TODAY{{end}}</span>
|
||||
</div>
|
||||
{{if .Attendance}}
|
||||
<div class="time-line">
|
||||
<div><small>CHECKED IN</small><strong>{{timeHM .Attendance.CheckIn}}</strong></div>
|
||||
<span></span>
|
||||
<div><small>CHECKED OUT</small><strong>{{timeHM .Attendance.CheckOut}}</strong></div>
|
||||
<div><small>LOCATION</small><strong>{{if eq .Attendance.Mode "remote"}}Remote{{else}}Office{{end}}</strong></div>
|
||||
</div>
|
||||
{{if not .Attendance.CheckOut.Valid}}
|
||||
<form method="post" action="/attendance/check-out">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<button class="button dark full">Check out</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="muted">Start your day by choosing where you’re working.</p>
|
||||
<div class="checkin-actions">
|
||||
<form method="post" action="/attendance/check-in">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="mode" value="office">
|
||||
<button class="button primary">Check in at office</button>
|
||||
</form>
|
||||
<form method="post" action="/attendance/check-in">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}"><input type="hidden" name="mode" value="remote">
|
||||
<button class="button secondary">Working remotely</button>
|
||||
</form>
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
<section class="stats-grid">
|
||||
<div class="stat card"><span class="stat-icon mint">✓</span><small>PRESENT DAYS</small><strong>{{.Stats.Present}}</strong><p>This month</p></div>
|
||||
<div class="stat card"><span class="stat-icon blue">⌂</span><small>REMOTE DAYS</small><strong>{{.Stats.Remote}}</strong><p>This month</p></div>
|
||||
<div class="stat card"><span class="stat-icon sand">☼</span><small>TIME OFF</small><strong>{{.Stats.Leave}}</strong><p>Approved days</p></div>
|
||||
</section>
|
||||
{{template "calendar.html" .}}
|
||||
<section class="card recent-card">
|
||||
<header class="section-head"><div><p class="eyebrow">RECENT ACTIVITY</p><h2>Your requests</h2></div><a href="/requests">View all →</a></header>
|
||||
{{if .Requests}}
|
||||
<div class="request-list compact">
|
||||
{{range .Requests}}
|
||||
<div class="request-row">
|
||||
<span class="request-icon {{.Kind}}">{{if eq .Kind "remote"}}⌂{{else}}☼{{end}}</span>
|
||||
<div><strong>{{kindLabel .Kind}}</strong><small>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}}</small></div>
|
||||
<span class="badge {{.Status}}">{{statusLabel .Status}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty"><span>◌</span><p>No requests yet.</p><a href="/requests">Make a request</a></div>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "day.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head day-page-head">
|
||||
<div><p class="eyebrow">TEAM DAY</p><h1>Who’s working?</h1><p>See the team’s presence, remote work, and absences for one day.</p></div>
|
||||
<form method="get" action="/day" class="day-date-form">
|
||||
<label>Persian date
|
||||
<span class="jalali-field">
|
||||
<input name="date" value="{{.Day.Jalali}}" data-jalali-picker required autocomplete="off" inputmode="numeric" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}">
|
||||
<button type="button" class="jalali-trigger" aria-label="Choose team date" title="Open Persian calendar">▦</button>
|
||||
</span>
|
||||
</label>
|
||||
<button class="button primary" type="submit">View day</button>
|
||||
</form>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="day-layout">
|
||||
<section class="card day-summary-card">
|
||||
<div class="day-date-nav">
|
||||
<a href="/day?date={{.Day.Prev}}" aria-label="Previous day">‹</a>
|
||||
<div>
|
||||
<p class="eyebrow">PERSIAN CALENDAR</p>
|
||||
<h2>{{.Day.Jalali}}</h2>
|
||||
<span>{{.Day.Weekday}} · {{.Day.Gregorian}}{{if .Day.DayNote}} · {{.Day.DayNote}}{{end}}</span>
|
||||
</div>
|
||||
<a href="/day?date={{.Day.Next}}" aria-label="Next day">›</a>
|
||||
</div>
|
||||
<div class="day-stats">
|
||||
<div><i class="presence-mark present"></i><span><strong>{{.Day.Present}}</strong><small>Present</small></span></div>
|
||||
<div><i class="presence-mark remote"></i><span><strong>{{.Day.Remote}}</strong><small>Remote</small></span></div>
|
||||
<div><i class="presence-mark absent"></i><span><strong>{{.Day.Absent}}</strong><small>Absent</small></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card roster-card">
|
||||
<header class="section-head">
|
||||
<div><p class="eyebrow">TEAM ROSTER</p><h2>{{len .Day.Members}} teammates</h2></div>
|
||||
<div class="roster-legend"><span><i class="presence-mark present"></i> Present</span><span><i class="presence-mark remote"></i> Remote</span><span><i class="presence-mark absent"></i> Absent</span></div>
|
||||
</header>
|
||||
{{if .Day.Members}}
|
||||
<div class="roster-list">
|
||||
{{range .Day.Members}}
|
||||
<article class="roster-row">
|
||||
{{if .User.AvatarURL}}<img class="avatar" src="{{.User.AvatarURL}}" alt="">{{else}}<span class="avatar">{{initial .User.DisplayName}}</span>{{end}}
|
||||
<div class="roster-person"><strong>{{.User.DisplayName}}</strong><small>@{{.User.Username}}</small></div>
|
||||
<div class="roster-detail"><span>{{.Detail}}</span>{{if .CheckIn}}<small>{{if .CheckOut}}Completed{{else}}Active{{end}}</small>{{end}}</div>
|
||||
<span class="presence-badge {{.Status}}"><i class="presence-mark {{.Status}}"></i>{{.Label}}</span>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty roomy"><span>◌</span><h3>No teammates yet</h3><p>Add teammates to see the daily roster.</p></div>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,44 @@
|
||||
{{define "login.html"}}
|
||||
{{template "head" .}}
|
||||
<main class="login-page">
|
||||
<div class="auth-theme">{{template "theme-toggle" .}}</div>
|
||||
<section class="login-story">
|
||||
<a class="brand light" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<div class="story-copy">
|
||||
<p class="eyebrow">A lighter way to work together</p>
|
||||
<h1>Know who’s here.<br>Plan what’s next.</h1>
|
||||
<p>Presence, time off, and remote days—organized around the Persian calendar your team actually uses.</p>
|
||||
</div>
|
||||
<div class="story-calendar" aria-hidden="true">
|
||||
<div><small>امروز</small><strong>۶ مرداد</strong><span>۱۴۰۵</span></div>
|
||||
<div class="people-dots"><i></i><i></i><i></i><b>+8</b></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="login-form-wrap">
|
||||
<form class="login-card" method="post" action="/login">
|
||||
<div class="mobile-brand"><span class="brand-mark">ه</span> Hamkar</div>
|
||||
<p class="eyebrow">WELCOME BACK</p>
|
||||
<h2>Sign in to your workspace</h2>
|
||||
<p class="muted">Use your email, username, or company SSO.</p>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<label>Email or username<input name="identifier" autocomplete="username" required autofocus placeholder="you@example.com or navid"></label>
|
||||
<label>Password<input type="password" name="password" autocomplete="current-password" required placeholder="••••••••"></label>
|
||||
<button class="button primary full" type="submit">Sign in</button>
|
||||
{{if or .OAuthGitHub .OAuthGoogle}}
|
||||
<div class="separator"><span>or continue with</span></div>
|
||||
<div class="oauth-row">
|
||||
{{if .OAuthGoogle}}<a class="button secondary" href="/auth/google">Google</a>{{end}}
|
||||
{{if .OAuthGitHub}}<a class="button secondary" href="/auth/github">GitHub</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="auth-switch">New to Hamkar? <a href="/register">Create an account</a></p>
|
||||
<p class="login-hint">Demo admin: <code>admin</code> / <code>admin123</code></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,51 @@
|
||||
{{define "register.html"}}
|
||||
{{template "head" .}}
|
||||
<main class="login-page">
|
||||
<div class="auth-theme">{{template "theme-toggle" .}}</div>
|
||||
<section class="login-story register-story">
|
||||
<a class="brand light" href="/">
|
||||
<span class="brand-mark">ه</span>
|
||||
<span>Hamkar<small>Team presence</small></span>
|
||||
</a>
|
||||
<div class="story-copy">
|
||||
<p class="eyebrow">Your team, in one place</p>
|
||||
<h1>Start showing up.<br>Stay in sync.</h1>
|
||||
<p>Create an account with email or username and password—or continue with your preferred SSO provider.</p>
|
||||
</div>
|
||||
<div class="story-calendar" aria-hidden="true">
|
||||
<div><small>شروع همکاری</small><strong>حساب جدید</strong><span>خوش آمدید</span></div>
|
||||
<div class="people-dots"><i></i><i></i><i></i><b>+</b></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="login-form-wrap register-wrap">
|
||||
<form class="login-card register-card" method="post" action="/register">
|
||||
<div class="mobile-brand"><span class="brand-mark">ه</span> Hamkar</div>
|
||||
<p class="eyebrow">JOIN YOUR TEAM</p>
|
||||
<h2>Create your account</h2>
|
||||
<p class="muted">Use an email or username, plus a secure password.</p>
|
||||
{{if .Error}}<div class="notice error">{{.Error}}</div>{{end}}
|
||||
<label>Full name<input name="display_name" autocomplete="name" required autofocus maxlength="100" placeholder="Sara Ahmadi"></label>
|
||||
<div class="form-row">
|
||||
<label>Email <small>Optional</small><input type="email" name="email" autocomplete="email" maxlength="200" placeholder="sara@example.com"></label>
|
||||
<label>Username <small>Optional</small><input name="username" autocomplete="username" maxlength="40" placeholder="sara"></label>
|
||||
</div>
|
||||
<p class="field-help">Enter at least an email address or username.</p>
|
||||
<div class="form-row">
|
||||
<label>Password<input type="password" name="password" autocomplete="new-password" minlength="8" required placeholder="At least 8 characters"></label>
|
||||
<label>Confirm password<input type="password" name="password_confirm" autocomplete="new-password" minlength="8" required placeholder="Repeat password"></label>
|
||||
</div>
|
||||
<button class="button primary full" type="submit">Create account</button>
|
||||
{{if or .OAuthGitHub .OAuthGoogle}}
|
||||
<div class="separator"><span>or sign up with SSO</span></div>
|
||||
<div class="oauth-row">
|
||||
{{if .OAuthGoogle}}<a class="button secondary" href="/auth/google">Google</a>{{end}}
|
||||
{{if .OAuthGitHub}}<a class="button secondary" href="/auth/github">GitHub</a>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
<p class="auth-switch">Already have an account? <a href="/login">Sign in</a></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,14 @@
|
||||
{{define "reports.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head"><div><p class="eyebrow">ADMIN</p><h1>Reports</h1><p>Export team presence data for payroll or analysis.</p></div></header>
|
||||
<section class="card report-card">
|
||||
<div class="report-illustration">↧</div>
|
||||
<div><p class="eyebrow">ATTENDANCE EXPORT</p><h2>Download presence records</h2><p class="muted">The UTF-8 CSV includes Gregorian and Persian dates, check-in/out time, location, and approved requests. It opens directly in Excel.</p></div>
|
||||
<form method="get" action="/reports/attendance.csv" class="report-form">
|
||||
<label>From<input type="date" name="start" value="{{.ReportStart}}" required></label>
|
||||
<label>To<input type="date" name="end" value="{{.ReportEnd}}" required></label>
|
||||
<button class="button primary">Download CSV</button>
|
||||
</form>
|
||||
</section>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,42 @@
|
||||
{{define "requests.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">PLANNING</p><h1>My requests</h1><p>Ask for time off or a remote work day.</p></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="two-column">
|
||||
<section class="card form-card">
|
||||
<p class="eyebrow">NEW REQUEST</p><h2>Plan a day away</h2>
|
||||
<form method="post" action="/requests" class="stack-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<fieldset class="choice-cards">
|
||||
<label><input type="radio" name="kind" value="leave" checked><span><b>☼</b><strong>Time off</strong><small>Holiday or personal leave</small></span></label>
|
||||
<label><input type="radio" name="kind" value="remote"><span><b>⌂</b><strong>Remote day</strong><small>Work away from the office</small></span></label>
|
||||
</fieldset>
|
||||
<div class="form-row">
|
||||
<label>From <small>Persian date</small><span class="jalali-field"><input name="start_date" data-jalali-picker data-jalali-role="start" required autocomplete="off" inputmode="numeric" placeholder="1405-05-06" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose start date" title="Open Persian calendar">▦</button></span></label>
|
||||
<label>To <small>Persian date</small><span class="jalali-field"><input name="end_date" data-jalali-picker data-jalali-role="end" required autocomplete="off" inputmode="numeric" placeholder="1405-05-06" pattern="[0-9]{4}-[0-9]{2}-[0-9]{2}"><button type="button" class="jalali-trigger" aria-label="Choose end date" title="Open Persian calendar">▦</button></span></label>
|
||||
</div>
|
||||
<label>Note <small>Optional</small><textarea name="reason" rows="4" maxlength="500" placeholder="Anything your manager should know?"></textarea></label>
|
||||
<button class="button primary full">Send for approval</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="section-head"><div><p class="eyebrow">HISTORY</p><h2>All requests</h2></div></header>
|
||||
{{if .Requests}}
|
||||
<div class="request-list">
|
||||
{{range .Requests}}
|
||||
<div class="request-row request-detail">
|
||||
<span class="request-icon {{.Kind}}">{{if eq .Kind "remote"}}⌂{{else}}☼{{end}}</span>
|
||||
<div><strong>{{kindLabel .Kind}}</strong><small>{{dateFA .StartDate}}{{if ne .StartDate .EndDate}} — {{dateFA .EndDate}}{{end}} · {{.DayCount}} day(s)</small>{{if .Reason}}<p>{{.Reason}}</p>{{end}}{{if .AdminNote}}<p class="admin-note">Manager: {{.AdminNote}}</p>{{end}}</div>
|
||||
<div class="request-end"><span class="badge {{.Status}}">{{statusLabel .Status}}</span>
|
||||
{{if eq .Status "pending"}}<form method="post" action="/requests/{{.ID}}/cancel"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="text-button">Cancel</button></form>{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}<div class="empty"><span>◌</span><p>You haven’t made any requests.</p></div>{{end}}
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,38 @@
|
||||
{{define "users.html"}}
|
||||
{{template "shell-start" .}}
|
||||
<header class="page-head">
|
||||
<div><p class="eyebrow">ADMIN</p><h1>Teammates</h1><p>Create local accounts and see who has access.</p></div>
|
||||
</header>
|
||||
{{template "notice" .}}
|
||||
<div class="two-column users-layout">
|
||||
<section class="card form-card">
|
||||
<p class="eyebrow">NEW ACCOUNT</p><h2>Add a teammate</h2>
|
||||
<form method="post" action="/admin/users" class="stack-form">
|
||||
<input type="hidden" name="csrf" value="{{.CSRF}}">
|
||||
<label>Full name<input name="display_name" required maxlength="100" placeholder="Sara Ahmadi"></label>
|
||||
<div class="form-row">
|
||||
<label>Username<input name="username" required maxlength="40" autocomplete="off" placeholder="sara"></label>
|
||||
<label>Email <small>Optional</small><input type="email" name="email" maxlength="200" placeholder="sara@example.com"></label>
|
||||
</div>
|
||||
<label>Temporary password<input type="password" name="password" minlength="8" required autocomplete="new-password" placeholder="At least 8 characters"></label>
|
||||
<label>Access level
|
||||
<select name="role"><option value="member">Member</option><option value="admin">Admin</option></select>
|
||||
</label>
|
||||
<button class="button primary full">Create account</button>
|
||||
</form>
|
||||
</section>
|
||||
<section class="card">
|
||||
<header class="section-head"><div><p class="eyebrow">DIRECTORY</p><h2>{{len .Users}} teammates</h2></div></header>
|
||||
<div class="user-list">
|
||||
{{range .Users}}
|
||||
<div class="user-row">
|
||||
<span class="avatar">{{initial .DisplayName}}</span>
|
||||
<div><strong>{{.DisplayName}}</strong><small>@{{.Username}}{{if .Email}} · {{.Email}}{{end}}</small></div>
|
||||
<span class="badge {{if eq .Role "admin"}}approved{{else}}kind{{end}}">{{.Role}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{{template "shell-end" .}}
|
||||
{{end}}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user