Files
nidus/internal/web/dashboard.go
T
arnefandCopilot ab3c7f44d5 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>
2026-08-19 07:12:56 +02:00

120 lines
3.5 KiB
Go

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
}