Add web UI: login, dashboard, and share management (templ + Tailwind + htmx)
New internal/web package mounted at /ui/, separate from DAV Basic Auth: - Cookie-based sessions (opaque random tokens in a new web_sessions SQLite table, internal/db/sessions.go), checked against the same cfg.Users/bcrypt credentials as DAV Basic Auth. - Dashboard listing the logged-in user's own calendars/address books, who they're shared with, and what's shared with them. - Share/unshare directly from the dashboard, updated in place via htmx partial swaps (POST to create/update, DELETE to revoke). Always verifies the resource actually belongs to the logged-in user before granting a share. - Templates written in templ (internal/web/templates/*.templ, generated *_templ.go committed), styled with Tailwind CSS v4 (web/input.css, compiled to web/static/app.css), with htmx vendored as a static file for the dynamic bits. Both are embedded into the binary at build time (web/staticassets.go) so the compiled server has no Node.js/web/ runtime dependency. - Wired into cmd/server/main.go at /ui/ alongside the existing /cal/, /card/, /files/ routes; welcome page links to it. - Tests: internal/web/server_test.go covers login success/failure, the login-required redirect, dashboard rendering, share/unshare including the htmx-v2-sends-DELETE-params-as-query-string quirk, and rejecting shares of resources the user doesn't own. - Docs: README (new 'Web UI' section, updated sharing section, project layout, dependencies) and copilot-instructions updated accordingly. Makefile: new templ-generate/web-deps/web-css targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,7 +3,8 @@
|
|||||||
A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a
|
A self-hosted CalDAV, CardDAV, and WebDAV server written in Go, backed by a
|
||||||
filesystem store, with calendar/address book sharing grants tracked in a
|
filesystem store, with calendar/address book sharing grants tracked in a
|
||||||
small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated
|
small SQLite database. HTTP Basic Auth (bcrypt) with per-user isolated
|
||||||
collections.
|
collections. A small server-rendered web UI (templ + Tailwind + htmx) at
|
||||||
|
`/ui/` lets users log in and manage their shares.
|
||||||
|
|
||||||
## Build, test, lint
|
## Build, test, lint
|
||||||
|
|
||||||
@@ -14,11 +15,14 @@ make test # go test ./... -v -race
|
|||||||
make lint # golangci-lint run ./...
|
make lint # golangci-lint run ./...
|
||||||
make tidy # go mod tidy
|
make tidy # go mod tidy
|
||||||
go test ./internal/store/ -run TestStoreRoundTrip -v # single test
|
go test ./internal/store/ -run TestStoreRoundTrip -v # single test
|
||||||
|
make templ-generate # regenerate *_templ.go after editing internal/web/templates/*.templ
|
||||||
|
make web-css # templ-generate + rebuild web/static/app.css (needs `make web-deps` once, Node.js/npm)
|
||||||
```
|
```
|
||||||
|
|
||||||
Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
|
Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
|
||||||
`internal/db/shares_test.go`, `internal/caldav/backend_test.go`,
|
`internal/db/shares_test.go`, `internal/caldav/backend_test.go`,
|
||||||
`internal/carddav/backend_test.go`.
|
`internal/carddav/backend_test.go`, `internal/web/server_test.go`,
|
||||||
|
`tools/nidusctl/main_test.go`.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -116,6 +120,41 @@ Test files: `internal/store/store_test.go`, `internal/webdav/handler_test.go`,
|
|||||||
the caldav/carddav backends query the shares tables on every request
|
the caldav/carddav backends query the shares tables on every request
|
||||||
(no caching), changes take effect immediately without restarting the
|
(no caching), changes take effect immediately without restarting the
|
||||||
server.
|
server.
|
||||||
|
- `internal/web` — the web UI, mounted at `/ui/` in `cmd/server/main.go`
|
||||||
|
(`web.NewServer(cfg, st, dbase, logger).Handler(webstatic.FS())`),
|
||||||
|
entirely separate from `internal/auth`'s Basic Auth: logins go through
|
||||||
|
`/ui/login` (username/password checked against `cfg.Users` the same way
|
||||||
|
Basic Auth does, via bcrypt) and issue an opaque random session token
|
||||||
|
stored in the `web_sessions` SQLite table (`db.CreateSession`/
|
||||||
|
`SessionUser`/`DeleteSession`, see `internal/db/sessions.go`), set as an
|
||||||
|
`HttpOnly` cookie (`sessionCookieName` in `internal/web/session.go`).
|
||||||
|
`requireLogin` is the auth-guard middleware for authenticated routes,
|
||||||
|
storing the username in the request context (`userFromContext`).
|
||||||
|
`internal/web/dashboard.go` renders the logged-in user's own
|
||||||
|
calendars/address books plus who they're shared with
|
||||||
|
(`SharesOfCalendar`/`SharesOfAddressBook`) and what's shared with them
|
||||||
|
(`CalendarsSharedWith`/`AddressBooksSharedWith`). `internal/web/shares.go`
|
||||||
|
handles POST (create/update share) and DELETE (revoke) at
|
||||||
|
`/ui/shares/{calendar,addressbook}`, re-rendering just the affected
|
||||||
|
resource card for htmx's `hx-swap="outerHTML"`; it always checks
|
||||||
|
`ownsResource` first so a user can only share resources actually
|
||||||
|
configured for their own account (never someone else's, even via a
|
||||||
|
forged form post). **htmx v2 quirk**: `hx-delete` requests send
|
||||||
|
`hx-vals`/form params as URL **query string** parameters, not a request
|
||||||
|
body (unlike POST/PUT/PATCH) — `handleShare` special-cases
|
||||||
|
`r.Method == http.MethodDelete` to read from `r.URL.Query()` instead of
|
||||||
|
calling `r.ParseForm()`. Templates live in `internal/web/templates/*.templ`
|
||||||
|
(compiled to `*_templ.go` via `templ generate`/`make templ-generate` —
|
||||||
|
regenerate after editing any `.templ` file, the generated files are
|
||||||
|
committed). Styling is Tailwind v4, scanned directly over the generated
|
||||||
|
`_templ.go` files (`web/input.css`'s `@source` directives) and compiled
|
||||||
|
to `web/static/app.css` via `make web-css` (needs Node/npm — see
|
||||||
|
`web/package.json`); htmx itself is vendored as a static file
|
||||||
|
(`web/static/htmx.min.js`, not npm-installed) to avoid a CDN dependency.
|
||||||
|
Both static assets are embedded into the Go binary at build time via
|
||||||
|
`web/staticassets.go` (`//go:embed static`), so the compiled server has
|
||||||
|
no runtime dependency on Node.js or the `web/` directory being present —
|
||||||
|
Node/npm are only needed when actually changing templates/styles.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
data/
|
data/
|
||||||
config.yaml
|
config.yaml
|
||||||
|
web/node_modules/
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build run test tidy lint docker hash-password nidusctl
|
.PHONY: build run test tidy lint docker hash-password nidusctl web-deps web-css templ-generate
|
||||||
|
|
||||||
## build: compile the binary
|
## build: compile the binary
|
||||||
build:
|
build:
|
||||||
@@ -38,3 +38,16 @@ hash-password:
|
|||||||
## Usage: make nidusctl ARGS="calendar share alice work bob write"
|
## Usage: make nidusctl ARGS="calendar share alice work bob write"
|
||||||
nidusctl: build
|
nidusctl: build
|
||||||
./bin/nidusctl -config config.yaml $(ARGS)
|
./bin/nidusctl -config config.yaml $(ARGS)
|
||||||
|
|
||||||
|
## templ-generate: regenerate *_templ.go files from internal/web/templates/*.templ
|
||||||
|
templ-generate:
|
||||||
|
templ generate
|
||||||
|
|
||||||
|
## web-deps: install the Tailwind CLI (needed once, or after web/package.json changes)
|
||||||
|
web-deps:
|
||||||
|
cd web && npm install
|
||||||
|
|
||||||
|
## web-css: rebuild the compiled/embedded Tailwind CSS (web/static/app.css)
|
||||||
|
## Run this after editing any .templ file or web/input.css.
|
||||||
|
web-css: templ-generate
|
||||||
|
cd web && npx @tailwindcss/cli -i ./input.css -o ./static/app.css --minify
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ A self-hosted **CalDAV**, **CardDAV**, and **WebDAV** server written in Go.
|
|||||||
- Per-user isolated collections
|
- Per-user isolated collections
|
||||||
- **Calendar/address book sharing** — grant other users read or write
|
- **Calendar/address book sharing** — grant other users read or write
|
||||||
access to your calendars/address books
|
access to your calendars/address books
|
||||||
|
- **Web UI** — a small dashboard (login, manage shares) at `/ui/`, built
|
||||||
|
with templ + Tailwind + htmx
|
||||||
- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
|
- Auto-discovery via `/.well-known/caldav` and `/.well-known/carddav`
|
||||||
- Optional **TLS** (or use a reverse proxy)
|
- Optional **TLS** (or use a reverse proxy)
|
||||||
- Structured logging (text or JSON)
|
- Structured logging (text or JSON)
|
||||||
@@ -146,8 +148,8 @@ grantee's own home-set alongside their own calendars — no separate account
|
|||||||
or extra client configuration needed.
|
or extra client configuration needed.
|
||||||
|
|
||||||
Sharing grants are stored in a small SQLite database at
|
Sharing grants are stored in a small SQLite database at
|
||||||
`<data_dir>/nidus.db` (not in `config.yaml`) and managed with the
|
`<data_dir>/nidus.db` (not in `config.yaml`) and can be managed either via
|
||||||
`nidusctl` CLI (there's no web UI yet):
|
the `nidusctl` CLI or the web UI's dashboard (see below):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Give bob write access to alice's "work" calendar
|
# Give bob write access to alice's "work" calendar
|
||||||
@@ -173,6 +175,35 @@ Read-only shares reject any write (PUT/DELETE) with `403 Forbidden`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Web UI
|
||||||
|
|
||||||
|
A small server-rendered dashboard is served at `/ui/` (separate from the
|
||||||
|
DAV endpoints, which stay on HTTP Basic Auth):
|
||||||
|
|
||||||
|
- **Login** (`/ui/login`) — cookie-based session, stored server-side in
|
||||||
|
`nidus.db` (`web_sessions` table), independent of DAV Basic Auth.
|
||||||
|
- **Dashboard** (`/ui/`) — lists your own calendars/address books, who
|
||||||
|
they're shared with, and any resources other users have shared with you.
|
||||||
|
- **Share management** — add/remove shares directly from the dashboard
|
||||||
|
(same effect as `nidusctl`); updates happen in place via
|
||||||
|
[htmx](https://htmx.org/) without a full page reload.
|
||||||
|
- **Logout** (`/ui/logout`).
|
||||||
|
|
||||||
|
Implementation: [templ](https://templ.guide/) for type-safe Go HTML
|
||||||
|
templates, [Tailwind CSS v4](https://tailwindcss.com/) for styling, and
|
||||||
|
htmx for the sprinkles of dynamic behavior (form submission via
|
||||||
|
POST/DELETE, partial page swaps) — no separate JS build/framework needed.
|
||||||
|
The compiled CSS and the htmx bundle are embedded into the Go binary
|
||||||
|
(`web/staticassets.go`), so no Node.js is required at runtime, only when
|
||||||
|
you change styles or templates during development:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make web-deps # once, installs the Tailwind CLI (needs Node.js/npm)
|
||||||
|
make web-css # regenerate templ code + rebuild web/static/app.css
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## TLS / Reverse proxy
|
## TLS / Reverse proxy
|
||||||
|
|
||||||
### Self-signed certificate (development)
|
### Self-signed certificate (development)
|
||||||
@@ -242,15 +273,19 @@ users:
|
|||||||
caldav-server/
|
caldav-server/
|
||||||
├── cmd/server/ # main entrypoint
|
├── cmd/server/ # main entrypoint
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── auth/ # HTTP Basic Auth middleware
|
│ ├── auth/ # HTTP Basic Auth middleware (DAV endpoints)
|
||||||
│ ├── caldav/ # CalDAV backend
|
│ ├── caldav/ # CalDAV backend
|
||||||
│ ├── carddav/ # CardDAV backend
|
│ ├── carddav/ # CardDAV backend
|
||||||
│ ├── config/ # YAML config loader
|
│ ├── config/ # YAML config loader
|
||||||
│ ├── db/ # SQLite store (calendar/address book shares)
|
│ ├── db/ # SQLite store (shares, web UI sessions)
|
||||||
│ ├── store/ # filesystem storage layer
|
│ ├── store/ # filesystem storage layer
|
||||||
|
│ ├── web/ # web UI (cookie sessions, dashboard, share mgmt)
|
||||||
|
│ │ └── templates/ # templ templates (+ generated *_templ.go)
|
||||||
│ └── webdav/ # WebDAV file handler
|
│ └── webdav/ # WebDAV file handler
|
||||||
├── tools/hashpwd/ # bcrypt password hasher CLI
|
├── tools/hashpwd/ # bcrypt password hasher CLI
|
||||||
├── tools/nidusctl/ # sharing-grant admin CLI
|
├── tools/nidusctl/ # sharing-grant admin CLI
|
||||||
|
├── web/ # front-end assets: Tailwind input/config, static/
|
||||||
|
│ └── static/ # compiled app.css + htmx.min.js (embedded into the binary)
|
||||||
├── config.example.yaml # sample configuration (copy to config.yaml)
|
├── config.example.yaml # sample configuration (copy to config.yaml)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yaml
|
├── docker-compose.yaml
|
||||||
@@ -279,4 +314,12 @@ go test ./... -race
|
|||||||
| `golang.org/x/crypto` | bcrypt |
|
| `golang.org/x/crypto` | bcrypt |
|
||||||
| `golang.org/x/net` | `golang.org/x/net/webdav` |
|
| `golang.org/x/net` | `golang.org/x/net/webdav` |
|
||||||
| `gopkg.in/yaml.v3` | YAML config parsing |
|
| `gopkg.in/yaml.v3` | YAML config parsing |
|
||||||
| `modernc.org/sqlite` | Pure-Go SQLite driver (calendar/address book shares) |
|
| `modernc.org/sqlite` | Pure-Go SQLite driver (shares, web UI sessions) |
|
||||||
|
| `github.com/a-h/templ` | Type-safe Go HTML templates (web UI) |
|
||||||
|
|
||||||
|
Front-end (dev-only, not required at runtime — see [Web UI](#web-ui)):
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| Tailwind CSS v4 (`web/package.json`) | Utility-first CSS, compiled to `web/static/app.css` |
|
||||||
|
| [htmx](https://htmx.org/) (`web/static/htmx.min.js`, vendored) | Partial page updates without a JS framework |
|
||||||
|
|||||||
+10
-1
@@ -19,7 +19,9 @@ import (
|
|||||||
"github.com/yourusername/caldav-server/internal/config"
|
"github.com/yourusername/caldav-server/internal/config"
|
||||||
"github.com/yourusername/caldav-server/internal/db"
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
"github.com/yourusername/caldav-server/internal/store"
|
"github.com/yourusername/caldav-server/internal/store"
|
||||||
|
"github.com/yourusername/caldav-server/internal/web"
|
||||||
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
|
filewebdav "github.com/yourusername/caldav-server/internal/webdav"
|
||||||
|
webstatic "github.com/yourusername/caldav-server/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -79,8 +81,9 @@ func main() {
|
|||||||
calHandler := caldav.NewHandler(cfg, st, dbase, logger)
|
calHandler := caldav.NewHandler(cfg, st, dbase, logger)
|
||||||
cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
|
cardHandler := carddav.NewHandler(cfg, st, dbase, logger)
|
||||||
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger)
|
fileHandler := filewebdav.NewHandler(cfg, cfg.Storage.DataDir, logger)
|
||||||
|
webUI := web.NewServer(cfg, st, dbase, logger)
|
||||||
|
|
||||||
mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, logger)
|
mux := buildMux(cfg, authMw, calHandler, cardHandler, fileHandler, webUI, logger)
|
||||||
|
|
||||||
// ---- HTTP Server ----
|
// ---- HTTP Server ----
|
||||||
addr := net.JoinHostPort(cfg.Server.Host, fmt.Sprintf("%d", cfg.Server.Port))
|
addr := net.JoinHostPort(cfg.Server.Host, fmt.Sprintf("%d", cfg.Server.Port))
|
||||||
@@ -128,10 +131,15 @@ func buildMux(
|
|||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
authMw *auth.Middleware,
|
authMw *auth.Middleware,
|
||||||
calHandler, cardHandler, fileHandler http.Handler,
|
calHandler, cardHandler, fileHandler http.Handler,
|
||||||
|
webUI *web.Server,
|
||||||
logger *slog.Logger,
|
logger *slog.Logger,
|
||||||
) *http.ServeMux {
|
) *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// Web UI (own cookie-based auth, not Basic Auth) — dashboard, login,
|
||||||
|
// share management.
|
||||||
|
mux.Handle("/ui/", webUI.Handler(webstatic.FS()))
|
||||||
|
|
||||||
// /.well-known/ redirects for auto-discovery
|
// /.well-known/ redirects for auto-discovery
|
||||||
mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/.well-known/caldav", func(w http.ResponseWriter, r *http.Request) {
|
||||||
http.Redirect(w, r, cfg.Server.BaseURL+"/cal/", http.StatusMovedPermanently)
|
http.Redirect(w, r, cfg.Server.BaseURL+"/cal/", http.StatusMovedPermanently)
|
||||||
@@ -248,5 +256,6 @@ const welcomePage = `<!DOCTYPE html>
|
|||||||
<li><code>%s/.well-known/carddav</code></li>
|
<li><code>%s/.well-known/carddav</code></li>
|
||||||
</ul>
|
</ul>
|
||||||
<p><em>Authentication: HTTP Basic Auth</em></p>
|
<p><em>Authentication: HTTP Basic Auth</em></p>
|
||||||
|
<p><a href="/ui/">Open the web dashboard →</a></p>
|
||||||
</body>
|
</body>
|
||||||
</html>`
|
</html>`
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ module github.com/yourusername/caldav-server
|
|||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
|
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6
|
||||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
|
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9
|
||||||
github.com/emersion/go-webdav v0.6.0
|
github.com/emersion/go-webdav v0.6.0
|
||||||
golang.org/x/crypto v0.21.0
|
golang.org/x/crypto v0.48.0
|
||||||
golang.org/x/net v0.22.0
|
golang.org/x/net v0.51.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
modernc.org/sqlite v1.56.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -22,5 +24,4 @@ require (
|
|||||||
modernc.org/libc v1.74.4 // indirect
|
modernc.org/libc v1.74.4 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.56.0 // indirect
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
|
github.com/a-h/templ v0.3.1020/go.mod h1:A2DlK61v+K+NRoGnhmYbNYVmtYHcFO5/AisMvBdDxTM=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
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/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
|
github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6 h1:kHoSgklT8weIDl6R6xFpBJ5IioRdBU1v2X2aCZRVCcM=
|
||||||
@@ -6,8 +8,14 @@ github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9 h1:ATgqloALX6cHC
|
|||||||
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM=
|
||||||
github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w=
|
github.com/emersion/go-webdav v0.6.0 h1:rbnBUEXvUM2Zk65Him13LwJOBY0ISltgqM5k6T5Lq4w=
|
||||||
github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
github.com/emersion/go-webdav v0.6.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
@@ -16,21 +24,47 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8=
|
||||||
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
|
||||||
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||||
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
|||||||
@@ -72,6 +72,16 @@ CREATE TABLE IF NOT EXISTS addressbook_shares (
|
|||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
UNIQUE (owner, addressbook_name, shared_with)
|
UNIQUE (owner, addressbook_name, shared_with)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Web UI login sessions. Sessions are opaque random tokens stored server
|
||||||
|
-- side (not JWTs) so they can be revoked instantly by deleting the row.
|
||||||
|
CREATE TABLE IF NOT EXISTS web_sessions (
|
||||||
|
token TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expires_at DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_web_sessions_expires_at ON web_sessions (expires_at);
|
||||||
`
|
`
|
||||||
_, err := d.conn.Exec(schema)
|
_, err := d.conn.Exec(schema)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSessionNotFound is returned when a session token doesn't exist or has
|
||||||
|
// expired.
|
||||||
|
var ErrSessionNotFound = errors.New("session not found")
|
||||||
|
|
||||||
|
// SessionTTL is how long a web UI login session stays valid after creation.
|
||||||
|
const SessionTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// CreateSession generates a new random session token for username and
|
||||||
|
// stores it with an expiry SessionTTL from now. Returns the token to be
|
||||||
|
// set as a cookie value.
|
||||||
|
func (d *DB) CreateSession(username string) (string, error) {
|
||||||
|
token, err := randomToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
expires := time.Now().Add(SessionTTL)
|
||||||
|
_, err = d.conn.Exec(
|
||||||
|
`INSERT INTO web_sessions (token, username, expires_at) VALUES (?, ?, ?)`,
|
||||||
|
token, username, expires,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SessionUser returns the username associated with token, provided it
|
||||||
|
// exists and hasn't expired. Returns ErrSessionNotFound otherwise.
|
||||||
|
func (d *DB) SessionUser(token string) (string, error) {
|
||||||
|
var username string
|
||||||
|
var expiresAt time.Time
|
||||||
|
err := d.conn.QueryRow(
|
||||||
|
`SELECT username, expires_at FROM web_sessions WHERE token = ?`,
|
||||||
|
token,
|
||||||
|
).Scan(&username, &expiresAt)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrSessionNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if time.Now().After(expiresAt) {
|
||||||
|
_ = d.DeleteSession(token)
|
||||||
|
return "", ErrSessionNotFound
|
||||||
|
}
|
||||||
|
return username, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteSession removes a session (used on logout). It's not an error if
|
||||||
|
// the token doesn't exist.
|
||||||
|
func (d *DB) DeleteSession(token string) error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM web_sessions WHERE token = ?`, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneExpiredSessions deletes all sessions past their expiry. Intended to
|
||||||
|
// be called periodically (e.g. on server startup and via a background
|
||||||
|
// ticker) to keep the table small.
|
||||||
|
func (d *DB) PruneExpiredSessions() error {
|
||||||
|
_, err := d.conn.Exec(`DELETE FROM web_sessions WHERE expires_at < ?`, time.Now())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomToken() (string, error) {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
user, ok := s.cfg.Users[username]
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "user not found in configuration", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var resources []templates.ResourceCard
|
||||||
|
|
||||||
|
for _, calName := range user.Calendars {
|
||||||
|
card := templates.ResourceCard{Kind: "calendar", Name: calName}
|
||||||
|
if s.dbase != nil {
|
||||||
|
shares, err := s.dbase.SharesOfCalendar(username, calName)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("listing calendar shares", "error", err)
|
||||||
|
}
|
||||||
|
for _, sh := range shares {
|
||||||
|
card.Shares = append(card.Shares, templates.ShareRow{
|
||||||
|
ResourceName: calName,
|
||||||
|
SharedWith: sh.SharedWith,
|
||||||
|
Permission: string(sh.Permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bookName := range user.AddressBooks {
|
||||||
|
card := templates.ResourceCard{Kind: "addressbook", Name: bookName}
|
||||||
|
if s.dbase != nil {
|
||||||
|
shares, err := s.dbase.SharesOfAddressBook(username, bookName)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("listing address book shares", "error", err)
|
||||||
|
}
|
||||||
|
for _, sh := range shares {
|
||||||
|
card.Shares = append(card.Shares, templates.ShareRow{
|
||||||
|
ResourceName: bookName,
|
||||||
|
SharedWith: sh.SharedWith,
|
||||||
|
Permission: string(sh.Permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resources = append(resources, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sharedWithMe []templates.SharedWithMeItem
|
||||||
|
if s.dbase != nil {
|
||||||
|
calShares, err := s.dbase.CalendarsSharedWith(username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("listing calendars shared with user", "error", err)
|
||||||
|
}
|
||||||
|
for _, sh := range calShares {
|
||||||
|
sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{
|
||||||
|
Kind: "calendar", Owner: sh.Owner, Name: sh.CalendarName, Permission: string(sh.Permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
bookShares, err := s.dbase.AddressBooksSharedWith(username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("listing address books shared with user", "error", err)
|
||||||
|
}
|
||||||
|
for _, sh := range bookShares {
|
||||||
|
sharedWithMe = append(sharedWithMe, templates.SharedWithMeItem{
|
||||||
|
Kind: "addressbook", Owner: sh.Owner, Name: sh.AddressBookName, Permission: string(sh.Permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = templates.Dashboard(username, resources, sharedWithMe).Render(context.Background(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceCardFor rebuilds a single ResourceCard (used to re-render just
|
||||||
|
// the card an htmx request just changed, for partial updates).
|
||||||
|
func (s *Server) resourceCardFor(username, kind, name string) (templates.ResourceCard, error) {
|
||||||
|
card := templates.ResourceCard{Kind: kind, Name: name}
|
||||||
|
if s.dbase == nil {
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var shares []templates.ShareRow
|
||||||
|
if kind == "calendar" {
|
||||||
|
rows, err := s.dbase.SharesOfCalendar(username, name)
|
||||||
|
if err != nil {
|
||||||
|
return card, err
|
||||||
|
}
|
||||||
|
for _, sh := range rows {
|
||||||
|
shares = append(shares, templates.ShareRow{ResourceName: name, SharedWith: sh.SharedWith, Permission: string(sh.Permission)})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rows, err := s.dbase.SharesOfAddressBook(username, name)
|
||||||
|
if err != nil {
|
||||||
|
return card, err
|
||||||
|
}
|
||||||
|
for _, sh := range rows {
|
||||||
|
shares = append(shares, templates.ShareRow{ResourceName: name, SharedWith: sh.SharedWith, Permission: string(sh.Permission)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
card.Shares = shares
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isValidPermission(p string) (db.Permission, bool) {
|
||||||
|
switch db.Permission(p) {
|
||||||
|
case db.PermRead, db.PermWrite:
|
||||||
|
return db.Permission(p), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func renderLogin(w http.ResponseWriter, errMsg string) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = templates.Login(errMsg).Render(context.Background(), w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Package web implements the nidus web UI: a small server-rendered
|
||||||
|
// dashboard (templ + Tailwind, htmx for partial updates) that lets users
|
||||||
|
// log in and manage sharing of their calendars and address books. It is
|
||||||
|
// intentionally separate from the DAV Basic Auth (internal/auth) — the
|
||||||
|
// web UI uses cookie-based sessions stored in internal/db.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/config"
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server holds the dependencies needed by the web UI handlers.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.Config
|
||||||
|
store *store.Store
|
||||||
|
dbase *db.DB
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer constructs a web UI Server.
|
||||||
|
func NewServer(cfg *config.Config, st *store.Store, dbase *db.DB, logger *slog.Logger) *Server {
|
||||||
|
return &Server{cfg: cfg, store: st, dbase: dbase, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the http.Handler serving the web UI, mounted at "/ui/"
|
||||||
|
// by the caller (cmd/server). staticFS serves the compiled Tailwind CSS
|
||||||
|
// and any other static assets.
|
||||||
|
func (s *Server) Handler(staticFS http.FileSystem) http.Handler {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.Handle("/ui/static/", http.StripPrefix("/ui/static/", http.FileServer(staticFS)))
|
||||||
|
|
||||||
|
mux.HandleFunc("/ui/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("/ui/logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("/ui/", s.requireLogin(s.handleDashboard))
|
||||||
|
mux.HandleFunc("/ui/shares/calendar", s.requireLogin(s.handleCalendarShare))
|
||||||
|
mux.HandleFunc("/ui/shares/addressbook", s.requireLogin(s.handleAddressBookShare))
|
||||||
|
|
||||||
|
return mux
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate validates username/password against the configured users,
|
||||||
|
// mirroring internal/auth's Basic Auth check.
|
||||||
|
func (s *Server) authenticate(username, password string) bool {
|
||||||
|
user, ok := s.cfg.Users[username]
|
||||||
|
if !ok {
|
||||||
|
_ = bcrypt.CompareHashAndPassword([]byte("$2b$12$invalidinvalidinvalidinvalidinvalidinval"), []byte(password))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
renderLogin(w, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
w.Header().Set("Allow", "GET, POST")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
renderLogin(w, "invalid form submission")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username := strings.TrimSpace(r.PostForm.Get("username"))
|
||||||
|
password := r.PostForm.Get("password")
|
||||||
|
|
||||||
|
if !s.authenticate(username, password) {
|
||||||
|
s.logger.Warn("web login failed", "username", username, "remote_addr", r.RemoteAddr)
|
||||||
|
renderLogin(w, "invalid username or password")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := s.dbase.CreateSession(username)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("creating web session", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.setSessionCookie(w, token)
|
||||||
|
http.Redirect(w, r, "/ui/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if cookie, err := r.Cookie(sessionCookieName); err == nil {
|
||||||
|
_ = s.dbase.DeleteSession(cookie.Value)
|
||||||
|
}
|
||||||
|
s.clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/config"
|
||||||
|
"github.com/yourusername/caldav-server/internal/db"
|
||||||
|
"github.com/yourusername/caldav-server/internal/store"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestServer(t *testing.T) *Server {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
st, err := store.NewStore(filepath.Join(dir, "data"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore: %v", err)
|
||||||
|
}
|
||||||
|
dbase, err := db.Open(filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("db.Open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { dbase.Close() })
|
||||||
|
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("password"), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateFromPassword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Users: map[string]config.UserConfig{
|
||||||
|
"alice": {Password: string(hash), Calendars: []string{"work"}, AddressBooks: []string{"contacts"}},
|
||||||
|
"bob": {Password: string(hash), Calendars: []string{"personal"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
return NewServer(cfg, st, dbase, logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// loginAs performs a login request against handler and returns the
|
||||||
|
// resulting session cookie.
|
||||||
|
func loginAs(t *testing.T, handler http.Handler, username, password string) *http.Cookie {
|
||||||
|
t.Helper()
|
||||||
|
form := url.Values{"username": {username}, "password": {password}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("login: expected 303, got %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
res := rr.Result()
|
||||||
|
for _, c := range res.Cookies() {
|
||||||
|
if c.Name == sessionCookieName {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("login: no session cookie set")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginSuccessAndFailure(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
|
||||||
|
cookie := loginAs(t, handler, "alice", "password")
|
||||||
|
if cookie.Value == "" {
|
||||||
|
t.Fatal("expected non-empty session token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong password.
|
||||||
|
form := url.Values{"username": {"alice"}, "password": {"wrong"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/login", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 (re-rendered login form) on bad password, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "invalid username or password") {
|
||||||
|
t.Fatalf("expected error message in body, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardRequiresLogin(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ui/", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect to login, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if loc := rr.Result().Header.Get("Location"); loc != "/ui/login" {
|
||||||
|
t.Fatalf("expected redirect to /ui/login, got %q", loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardShowsOwnResources(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
cookie := loginAs(t, handler, "alice", "password")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ui/", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
body := rr.Body.String()
|
||||||
|
if !strings.Contains(body, "work") || !strings.Contains(body, "contacts") {
|
||||||
|
t.Fatalf("expected dashboard to list alice's calendar/address book, got: %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShareUnshareCalendarFlow(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
cookie := loginAs(t, handler, "alice", "password")
|
||||||
|
|
||||||
|
// Share alice's "work" calendar with bob, write access.
|
||||||
|
form := url.Values{"resource": {"work"}, "shared_with": {"bob"}, "permission": {"write"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/shares/calendar", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("share: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "bob") {
|
||||||
|
t.Fatalf("expected updated card to mention bob, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
shares, err := s.dbase.SharesOfCalendar("alice", "work")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SharesOfCalendar: %v", err)
|
||||||
|
}
|
||||||
|
if len(shares) != 1 || shares[0].SharedWith != "bob" {
|
||||||
|
t.Fatalf("expected one share for bob, got %+v", shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unshare — htmx v2 sends DELETE params as a URL query string.
|
||||||
|
req = httptest.NewRequest(http.MethodDelete, "/ui/shares/calendar?resource=work&shared_with=bob", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr = httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("unshare: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "Not shared with anyone") {
|
||||||
|
t.Fatalf("expected card to show no shares, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
shares, err = s.dbase.SharesOfCalendar("alice", "work")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SharesOfCalendar: %v", err)
|
||||||
|
}
|
||||||
|
if len(shares) != 0 {
|
||||||
|
t.Fatalf("expected no shares after unshare, got %+v", shares)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCannotShareResourceNotOwned(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
cookie := loginAs(t, handler, "alice", "password")
|
||||||
|
|
||||||
|
// alice doesn't own "personal" (that's bob's calendar).
|
||||||
|
form := url.Values{"resource": {"personal"}, "shared_with": {"bob"}, "permission": {"write"}}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/ui/shares/calendar", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 for unowned resource, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLogoutClearsSession(t *testing.T) {
|
||||||
|
s := newTestServer(t)
|
||||||
|
handler := s.Handler(emptyStaticFS{})
|
||||||
|
cookie := loginAs(t, handler, "alice", "password")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/ui/logout", nil)
|
||||||
|
req.AddCookie(cookie)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
if rr.Code != http.StatusSeeOther {
|
||||||
|
t.Fatalf("expected redirect on logout, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session should no longer be valid.
|
||||||
|
if _, err := s.dbase.SessionUser(cookie.Value); err == nil {
|
||||||
|
t.Fatal("expected session to be deleted after logout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// emptyStaticFS is a no-op http.FileSystem for tests that don't exercise
|
||||||
|
// static asset serving.
|
||||||
|
type emptyStaticFS struct{}
|
||||||
|
|
||||||
|
func (emptyStaticFS) Open(name string) (http.File, error) {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionCookieName = "nidus_session"
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const userCtxKey ctxKey = "web_username"
|
||||||
|
|
||||||
|
// userFromContext returns the logged-in username for the current request,
|
||||||
|
// or "" if unauthenticated.
|
||||||
|
func userFromContext(ctx context.Context) string {
|
||||||
|
u, _ := ctx.Value(userCtxKey).(string)
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireLogin wraps a handler so that it redirects to /login when no
|
||||||
|
// valid session cookie is present, otherwise it stores the username in
|
||||||
|
// the request context for downstream handlers to use.
|
||||||
|
func (s *Server) requireLogin(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
cookie, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
username, err := s.dbase.SessionUser(cookie.Value)
|
||||||
|
if err != nil {
|
||||||
|
s.clearSessionCookie(w)
|
||||||
|
http.Redirect(w, r, "/ui/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userCtxKey, username)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) setSessionCookie(w http.ResponseWriter, token string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.cfg.TLS.Enabled,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
Expires: time.Now().Add(7 * 24 * time.Hour),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) clearSessionCookie(w http.ResponseWriter) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.cfg.TLS.Enabled,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
MaxAge: -1,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/yourusername/caldav-server/internal/web/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleCalendarShare handles POST (create/update share) and DELETE
|
||||||
|
// (revoke share) for the current user's calendars, mounted at
|
||||||
|
// /ui/shares/calendar. htmx sends the resource + shared_with (+ permission
|
||||||
|
// for POST) as form values and expects the updated resource card HTML
|
||||||
|
// back for an out-of-band swap.
|
||||||
|
func (s *Server) handleCalendarShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.handleShare(w, r, "calendar")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAddressBookShare(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.handleShare(w, r, "addressbook")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleShare(w http.ResponseWriter, r *http.Request, kind string) {
|
||||||
|
username := userFromContext(r.Context())
|
||||||
|
|
||||||
|
if s.dbase == nil {
|
||||||
|
http.Error(w, "sharing is not available (no database configured)", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// htmx v2 sends DELETE request parameters (including hx-vals) as URL
|
||||||
|
// query parameters, not a request body — unlike POST/PUT/PATCH.
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
r.PostForm = r.URL.Query()
|
||||||
|
} else if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "invalid form", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resource := strings.TrimSpace(r.PostForm.Get("resource"))
|
||||||
|
sharedWith := strings.TrimSpace(r.PostForm.Get("shared_with"))
|
||||||
|
if resource == "" || sharedWith == "" {
|
||||||
|
http.Error(w, "resource and shared_with are required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sharedWith == username {
|
||||||
|
http.Error(w, "cannot share a resource with yourself", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.ownsResource(username, kind, resource) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
perm, ok := isValidPermission(r.PostForm.Get("permission"))
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "permission must be 'read' or 'write'", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if kind == "calendar" {
|
||||||
|
err = s.dbase.ShareCalendar(username, resource, sharedWith, perm)
|
||||||
|
} else {
|
||||||
|
err = s.dbase.ShareAddressBook(username, resource, sharedWith, perm)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("sharing resource", "kind", kind, "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case http.MethodDelete:
|
||||||
|
var err error
|
||||||
|
if kind == "calendar" {
|
||||||
|
err = s.dbase.UnshareCalendar(username, resource, sharedWith)
|
||||||
|
} else {
|
||||||
|
err = s.dbase.UnshareAddressBook(username, resource, sharedWith)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("unsharing resource", "kind", kind, "error", err)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
w.Header().Set("Allow", "POST, DELETE")
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
card, err := s.resourceCardFor(username, kind, resource)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("rendering resource card", "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
_ = templates.ResourceCardView(card).Render(context.Background(), w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownsResource checks that resource (a calendar or address book name) is
|
||||||
|
// actually configured for username, to prevent sharing arbitrary/other
|
||||||
|
// users' resources via a forged form post.
|
||||||
|
func (s *Server) ownsResource(username, kind, resource string) bool {
|
||||||
|
user, ok := s.cfg.Users[username]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var list []string
|
||||||
|
if kind == "calendar" {
|
||||||
|
list = user.Calendars
|
||||||
|
} else {
|
||||||
|
list = user.AddressBooks
|
||||||
|
}
|
||||||
|
for _, n := range list {
|
||||||
|
if n == resource {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
// ShareRow is a single share grant shown in the UI, for either a calendar
|
||||||
|
// or an address book (Kind distinguishes them for form targets).
|
||||||
|
type ShareRow struct {
|
||||||
|
ResourceName string
|
||||||
|
SharedWith string
|
||||||
|
Permission string // "read" or "write"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceCard describes one of the user's own calendars/address books
|
||||||
|
// plus who it's currently shared with.
|
||||||
|
type ResourceCard struct {
|
||||||
|
Kind string // "calendar" or "addressbook"
|
||||||
|
Name string
|
||||||
|
Shares []ShareRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedWithMeItem describes a resource another user has shared with the
|
||||||
|
// current user.
|
||||||
|
type SharedWithMeItem struct {
|
||||||
|
Kind string
|
||||||
|
Owner string
|
||||||
|
Name string
|
||||||
|
Permission string
|
||||||
|
}
|
||||||
|
|
||||||
|
templ Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) {
|
||||||
|
@Layout("Dashboard", username) {
|
||||||
|
<h1 class="text-2xl font-semibold mb-6">Your calendars & address books</h1>
|
||||||
|
<div id="resources" class="space-y-6">
|
||||||
|
for _, r := range resources {
|
||||||
|
@ResourceCardView(r)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
if len(sharedWithMe) > 0 {
|
||||||
|
<h2 class="text-xl font-semibold mt-10 mb-4">Shared with you</h2>
|
||||||
|
<ul class="divide-y divide-gray-200 bg-white rounded-lg border border-gray-200">
|
||||||
|
for _, item := range sharedWithMe {
|
||||||
|
<li class="px-4 py-3 flex items-center justify-between text-sm">
|
||||||
|
<span>
|
||||||
|
<span class="font-medium">{ item.Owner }</span> / { item.Name }
|
||||||
|
<span class="text-gray-400">({ item.Kind })</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-xs uppercase tracking-wide text-gray-500">{ item.Permission }</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
templ ResourceCardView(r ResourceCard) {
|
||||||
|
<div id={ "resource-" + r.Kind + "-" + r.Name } class="bg-white rounded-lg border border-gray-200 p-5">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<h2 class="font-medium">
|
||||||
|
{ r.Name }
|
||||||
|
<span class="text-xs uppercase tracking-wide text-gray-400 ml-2">{ r.Kind }</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="divide-y divide-gray-100 mb-4">
|
||||||
|
for _, sh := range r.Shares {
|
||||||
|
<li class="py-2 flex items-center justify-between text-sm">
|
||||||
|
<span>{ sh.SharedWith }</span>
|
||||||
|
<span class="flex items-center gap-3">
|
||||||
|
<span class="text-xs uppercase tracking-wide text-gray-500">{ sh.Permission }</span>
|
||||||
|
<button
|
||||||
|
class="text-red-600 hover:underline text-xs"
|
||||||
|
hx-delete={ shareEndpoint(r.Kind) }
|
||||||
|
hx-vals={ shareVals(r.Name, sh.SharedWith) }
|
||||||
|
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm={ "Remove access for " + sh.SharedWith + "?" }
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
if len(r.Shares) == 0 {
|
||||||
|
<li class="py-2 text-sm text-gray-400">Not shared with anyone yet.</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<form
|
||||||
|
class="flex items-end gap-2"
|
||||||
|
hx-post={ shareEndpoint(r.Kind) }
|
||||||
|
hx-target={ "#resource-" + r.Kind + "-" + r.Name }
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
>
|
||||||
|
<input type="hidden" name="resource" value={ r.Name }/>
|
||||||
|
<div class="flex-1">
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Username</label>
|
||||||
|
<input name="shared_with" type="text" required
|
||||||
|
class="w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs text-gray-500 mb-1">Permission</label>
|
||||||
|
<select name="permission" class="rounded-md border-gray-300 border px-2 py-1.5 text-sm">
|
||||||
|
<option value="read">read</option>
|
||||||
|
<option value="write">write</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700">
|
||||||
|
Share
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareEndpoint(kind string) string {
|
||||||
|
if kind == "calendar" {
|
||||||
|
return "/ui/shares/calendar"
|
||||||
|
}
|
||||||
|
return "/ui/shares/addressbook"
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareVals(resource, sharedWith string) string {
|
||||||
|
return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
// ShareRow is a single share grant shown in the UI, for either a calendar
|
||||||
|
// or an address book (Kind distinguishes them for form targets).
|
||||||
|
type ShareRow struct {
|
||||||
|
ResourceName string
|
||||||
|
SharedWith string
|
||||||
|
Permission string // "read" or "write"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceCard describes one of the user's own calendars/address books
|
||||||
|
// plus who it's currently shared with.
|
||||||
|
type ResourceCard struct {
|
||||||
|
Kind string // "calendar" or "addressbook"
|
||||||
|
Name string
|
||||||
|
Shares []ShareRow
|
||||||
|
}
|
||||||
|
|
||||||
|
// SharedWithMeItem describes a resource another user has shared with the
|
||||||
|
// current user.
|
||||||
|
type SharedWithMeItem struct {
|
||||||
|
Kind string
|
||||||
|
Owner string
|
||||||
|
Name string
|
||||||
|
Permission string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Dashboard(username string, resources []ResourceCard, sharedWithMe []SharedWithMeItem) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<h1 class=\"text-2xl font-semibold mb-6\">Your calendars & address books</h1><div id=\"resources\" class=\"space-y-6\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, r := range resources {
|
||||||
|
templ_7745c5c3_Err = ResourceCardView(r).Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if len(sharedWithMe) > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<h2 class=\"text-xl font-semibold mt-10 mb-4\">Shared with you</h2><ul class=\"divide-y divide-gray-200 bg-white rounded-lg border border-gray-200\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, item := range sharedWithMe {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<li class=\"px-4 py-3 flex items-center justify-between text-sm\"><span><span class=\"font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(item.Owner)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 43, Col: 45}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</span> / ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 string
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(item.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 43, Col: 68}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, " <span class=\"text-gray-400\">(")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(item.Kind)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 44, Col: 47}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, ")</span></span> <span class=\"text-xs uppercase tracking-wide text-gray-500\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 string
|
||||||
|
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(item.Permission)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 46, Col: 83}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</span></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</ul>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
templ_7745c5c3_Err = Layout("Dashboard", username).Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResourceCardView(r ResourceCard) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var7 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var7 == nil {
|
||||||
|
templ_7745c5c3_Var7 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.ResolveAttributeValue("resource-" + r.Kind + "-" + r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 55, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var8)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\" class=\"bg-white rounded-lg border border-gray-200 p-5\"><div class=\"flex items-center justify-between mb-3\"><h2 class=\"font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 string
|
||||||
|
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 58, Col: 12}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " <span class=\"text-xs uppercase tracking-wide text-gray-400 ml-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(r.Kind)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 59, Col: 77}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "</span></h2></div><ul class=\"divide-y divide-gray-100 mb-4\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
for _, sh := range r.Shares {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<li class=\"py-2 flex items-center justify-between text-sm\"><span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 string
|
||||||
|
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(sh.SharedWith)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 66, Col: 26}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</span> <span class=\"flex items-center gap-3\"><span class=\"text-xs uppercase tracking-wide text-gray-500\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(sh.Permission)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 68, Col: 81}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> <button class=\"text-red-600 hover:underline text-xs\" hx-delete=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 71, Col: 40}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" hx-vals=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareVals(r.Name, sh.SharedWith))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 72, Col: 49}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 73, Col: 55}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-swap=\"outerHTML\" hx-confirm=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue("Remove access for " + sh.SharedWith + "?")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 75, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\">Remove</button></span></li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(r.Shares) == 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<li class=\"py-2 text-sm text-gray-400\">Not shared with anyone yet.</li>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</ul><form class=\"flex items-end gap-2\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(shareEndpoint(r.Kind))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 89, Col: 34}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" hx-target=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var18 string
|
||||||
|
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue("#resource-" + r.Kind + "-" + r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 90, Col: 51}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" hx-swap=\"outerHTML\"><input type=\"hidden\" name=\"resource\" value=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(r.Name)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/dashboard.templ`, Line: 93, Col: 54}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\"><div class=\"flex-1\"><label class=\"block text-xs text-gray-500 mb-1\">Username</label> <input name=\"shared_with\" type=\"text\" required class=\"w-full rounded-md border-gray-300 border px-2 py-1.5 text-sm\"></div><div><label class=\"block text-xs text-gray-500 mb-1\">Permission</label> <select name=\"permission\" class=\"rounded-md border-gray-300 border px-2 py-1.5 text-sm\"><option value=\"read\">read</option> <option value=\"write\">write</option></select></div><button type=\"submit\" class=\"bg-indigo-600 text-white rounded-md px-3 py-1.5 text-sm font-medium hover:bg-indigo-700\">Share</button></form></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareEndpoint(kind string) string {
|
||||||
|
if kind == "calendar" {
|
||||||
|
return "/ui/shares/calendar"
|
||||||
|
}
|
||||||
|
return "/ui/shares/addressbook"
|
||||||
|
}
|
||||||
|
|
||||||
|
func shareVals(resource, sharedWith string) string {
|
||||||
|
return `{"resource": "` + resource + `", "shared_with": "` + sharedWith + `"}`
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Layout(title string, username string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full bg-gray-50">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>{ title } · nidus</title>
|
||||||
|
<link rel="stylesheet" href="/ui/static/app.css"/>
|
||||||
|
<script src="/ui/static/htmx.min.js" defer></script>
|
||||||
|
</head>
|
||||||
|
<body class="h-full text-gray-900">
|
||||||
|
<nav class="bg-white border-b border-gray-200">
|
||||||
|
<div class="max-w-4xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<a href="/ui/" class="font-semibold text-lg tracking-tight">nidus</a>
|
||||||
|
if username != "" {
|
||||||
|
<div class="flex items-center gap-4 text-sm text-gray-600">
|
||||||
|
<span>{ username }</span>
|
||||||
|
<a href="/ui/logout" class="text-red-600 hover:underline">Logout</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{ children... }
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
func Layout(title string, username string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"h-full bg-gray-50\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(title)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 9, Col: 17}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, " · nidus</title><link rel=\"stylesheet\" href=\"/ui/static/app.css\"><script src=\"/ui/static/htmx.min.js\" defer></script></head><body class=\"h-full text-gray-900\"><nav class=\"bg-white border-b border-gray-200\"><div class=\"max-w-4xl mx-auto px-4 py-3 flex items-center justify-between\"><a href=\"/ui/\" class=\"font-semibold text-lg tracking-tight\">nidus</a> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if username != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "<div class=\"flex items-center gap-4 text-sm text-gray-600\"><span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(username)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/layout.templ`, Line: 19, Col: 23}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "</span> <a href=\"/ui/logout\" class=\"text-red-600 hover:underline\">Logout</a></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "</div></nav><main class=\"max-w-4xl mx-auto px-4 py-8\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_Var1.Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "</main></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package templates
|
||||||
|
|
||||||
|
templ Login(errorMsg string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full bg-gray-50">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>Login · nidus</title>
|
||||||
|
<link rel="stylesheet" href="/ui/static/app.css"/>
|
||||||
|
</head>
|
||||||
|
<body class="h-full flex items-center justify-center">
|
||||||
|
<div class="w-full max-w-sm bg-white p-8 rounded-lg shadow-sm border border-gray-200">
|
||||||
|
<h1 class="text-xl font-semibold mb-6 text-center">nidus</h1>
|
||||||
|
if errorMsg != "" {
|
||||||
|
<p class="mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2">{ errorMsg }</p>
|
||||||
|
}
|
||||||
|
<form method="POST" action="/ui/login" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="username" class="block text-sm font-medium text-gray-700">Username</label>
|
||||||
|
<input id="username" name="username" type="text" required autofocus
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
|
||||||
|
<input id="password" name="password" type="password" required
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"/>
|
||||||
|
</div>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full bg-indigo-600 text-white rounded-md py-2 font-medium hover:bg-indigo-700">
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1020
|
||||||
|
package templates
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
func Login(errorMsg string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<!doctype html><html lang=\"en\" class=\"h-full bg-gray-50\"><head><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\"><title>Login · nidus</title><link rel=\"stylesheet\" href=\"/ui/static/app.css\"></head><body class=\"h-full flex items-center justify-center\"><div class=\"w-full max-w-sm bg-white p-8 rounded-lg shadow-sm border border-gray-200\"><h1 class=\"text-xl font-semibold mb-6 text-center\">nidus</h1>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if errorMsg != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "<p class=\"mb-4 text-sm text-red-600 bg-red-50 border border-red-200 rounded px-3 py-2\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var2 string
|
||||||
|
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(errorMsg)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/web/templates/login.templ`, Line: 16, Col: 102}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "</p>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "<form method=\"POST\" action=\"/ui/login\" class=\"space-y-4\"><div><label for=\"username\" class=\"block text-sm font-medium text-gray-700\">Username</label> <input id=\"username\" name=\"username\" type=\"text\" required autofocus class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500\"></div><div><label for=\"password\" class=\"block text-sm font-medium text-gray-700\">Password</label> <input id=\"password\" name=\"password\" type=\"password\" required class=\"mt-1 block w-full rounded-md border-gray-300 border px-3 py-2 shadow-sm focus:border-indigo-500 focus:ring-indigo-500\"></div><button type=\"submit\" class=\"w-full bg-indigo-600 text-white rounded-md py-2 font-medium hover:bg-indigo-700\">Sign in</button></form></div></body></html>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Scan generated Go templ files (and any hand-written HTML) for class names */
|
||||||
|
@source "../internal/web/templates/**/*.templ";
|
||||||
|
@source "../internal/web/templates/**/*_templ.go";
|
||||||
Generated
+1128
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "nidus-web",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build:css": "tailwindcss -i ./input.css -o ./static/app.css --minify",
|
||||||
|
"watch:css": "tailwindcss -i ./input.css -o ./static/app.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^4.3.3",
|
||||||
|
"@tailwindcss/cli": "^4.3.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,23 @@
|
|||||||
|
// Package staticassets embeds the compiled Tailwind CSS and htmx bundle
|
||||||
|
// used by the web UI, so the nidus binary is self-contained and doesn't
|
||||||
|
// need the web/ directory present at runtime.
|
||||||
|
package staticassets
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var embedded embed.FS
|
||||||
|
|
||||||
|
// FS returns an http.FileSystem serving the embedded static assets rooted
|
||||||
|
// at "static/" (i.e. FS content is served without the "static/" prefix).
|
||||||
|
func FS() http.FileSystem {
|
||||||
|
sub, err := fs.Sub(embedded, "static")
|
||||||
|
if err != nil {
|
||||||
|
panic(err) // static dir is embedded at build time; this can't fail
|
||||||
|
}
|
||||||
|
return http.FS(sub)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user