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:
2026-08-19 07:12:56 +02:00
co-authored by Copilot
parent 58d74a29cd
commit ab3c7f44d5
27 changed files with 2764 additions and 16 deletions
+10
View File
@@ -72,6 +72,16 @@ CREATE TABLE IF NOT EXISTS addressbook_shares (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
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)
return err
+80
View File
@@ -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
}
+119
View File
@@ -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
}
+13
View File
@@ -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)
}
+100
View File
@@ -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)
}
+221
View File
@@ -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
}
+65
View File
@@ -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,
})
}
+119
View File
@@ -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
}
+123
View File
@@ -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 &amp; 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 + `"}`
}
+373
View File
@@ -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 &amp; 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
+30
View File
@@ -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>
}
+84
View File
@@ -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
+37
View File
@@ -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>
}
+63
View File
@@ -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