diff --git a/.env.example b/.env.example index f4cd837..b072f12 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ NAPLATA_DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/postgres?sslmode=disable NAPLATA_LISTEN_ADDR=:8080 NAPLATA_ENV=development +NAPLATA_TEST_DATABASE_URL=postgres://postgres:mysecretpassword@localhost:5432/naplata_test?sslmode=disable diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cf6f55..0936957 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,22 @@ jobs: go: name: Go runs-on: ubuntu-latest + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: naplata_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + env: + NAPLATA_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/naplata_test?sslmode=disable steps: - uses: actions/checkout@v7 diff --git a/cmd/naplata/main.go b/cmd/naplata/main.go index 7b67b7a..8a118cc 100644 --- a/cmd/naplata/main.go +++ b/cmd/naplata/main.go @@ -62,6 +62,9 @@ func main() { // Current user mux.Handle("GET /api/v1/me", h.RequireAuth(http.HandlerFunc(h.MeEndpoint))) + // Admin + mux.Handle("GET /api/v1/admin/clients", h.RequirePermission(http.HandlerFunc(h.ListClientsEndpoint), permissions.ClientsView)) + slog.Info("server listening", "addr", cfg.ListenAddr) log.Fatal(http.ListenAndServe(cfg.ListenAddr, sessionManager.LoadAndSave(mux))) } diff --git a/internal/database/clients.go b/internal/database/clients.go new file mode 100644 index 0000000..61b99bf --- /dev/null +++ b/internal/database/clients.go @@ -0,0 +1,68 @@ +package database + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type Client struct { + ID string `db:"id" json:"id"` + Email string `db:"email" json:"email"` + GivenName string `db:"given_name" json:"given_name"` + FamilyName string `db:"family_name" json:"family_name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +type ListClientsParams struct { + Search string + Sort string + Desc bool + Limit int + Offset int +} + +var clientSortColumns = map[string]string{ + "name": "lower(u.given_name || ' ' || u.family_name)", + "created_at": "u.created_at", +} + +func (s *Store) ListClients(ctx context.Context, p ListClientsParams) ([]Client, int, error) { + order, ok := clientSortColumns[p.Sort] + if !ok { + return nil, 0, fmt.Errorf("list clients: unknown sort %q", p.Sort) + } + if p.Desc { + order += " DESC" + } + + rows, _ := s.pool.Query(ctx, ` + SELECT u.id, u.email, u.given_name, u.family_name, u.created_at, count(*) OVER () AS total + FROM users u + WHERE strpos(lower(u.given_name || ' ' || u.family_name || ' ' || u.email), lower($1)) > 0 + ORDER BY `+order+`, u.id + LIMIT $2 OFFSET $3`, + strings.TrimSpace(p.Search), p.Limit, p.Offset, + ) + + type clientRow struct { + Client + Total int `db:"total"` + } + result, err := pgx.CollectRows(rows, pgx.RowToStructByName[clientRow]) + if err != nil { + return nil, 0, fmt.Errorf("list clients: %w", err) + } + + clients := make([]Client, len(result)) + total := 0 + for i, row := range result { + clients[i] = row.Client + total = row.Total + } + + return clients, total, nil +} diff --git a/internal/database/clients_test.go b/internal/database/clients_test.go new file mode 100644 index 0000000..0a9e80b --- /dev/null +++ b/internal/database/clients_test.go @@ -0,0 +1,55 @@ +package database + +import ( + "context" + "slices" + "testing" + "time" + + "github.com/mtiluk/naplata/internal/testdb" +) + +func TestListClients(t *testing.T) { + pool := testdb.New(t) + store := NewStore(pool) + + day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + testdb.InsertUser(t, pool, "Ada Lovelace", day) + testdb.InsertUser(t, pool, "Grace Hopper", day.AddDate(0, 0, 1)) + testdb.InsertUser(t, pool, "Alan Turing", day.AddDate(0, 0, 2)) + + tests := []struct { + name string + p ListClientsParams + want []string + total int + }{ + {"newest first", ListClientsParams{Sort: "created_at", Desc: true}, []string{"Alan", "Grace", "Ada"}, 3}, + {"name ascending", ListClientsParams{Sort: "name"}, []string{"Ada", "Alan", "Grace"}, 3}, + {"name descending", ListClientsParams{Sort: "name", Desc: true}, []string{"Grace", "Alan", "Ada"}, 3}, + {"search ignores case", ListClientsParams{Sort: "name", Search: "LOVELACE"}, []string{"Ada"}, 1}, + {"search email", ListClientsParams{Sort: "name", Search: "alan@"}, []string{"Alan"}, 1}, + {"search is literal", ListClientsParams{Sort: "name", Search: "%"}, []string{}, 0}, + {"pagination", ListClientsParams{Sort: "name", Limit: 2, Offset: 1}, []string{"Alan", "Grace"}, 3}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.p.Limit == 0 { + tt.p.Limit = 10 + } + clients, total, err := store.ListClients(context.Background(), tt.p) + if err != nil { + t.Fatal(err) + } + + got := []string{} + for _, c := range clients { + got = append(got, c.GivenName) + } + if !slices.Equal(got, tt.want) || total != tt.total { + t.Errorf("got %v (total %d), want %v (total %d)", got, total, tt.want, tt.total) + } + }) + } +} diff --git a/internal/server/clients.go b/internal/server/clients.go new file mode 100644 index 0000000..e9019cd --- /dev/null +++ b/internal/server/clients.go @@ -0,0 +1,45 @@ +package server + +import ( + "log/slog" + "net/http" + "strconv" + + "github.com/mtiluk/naplata/internal/database" +) + +func (h *Handler) ListClientsEndpoint(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + + sort := query.Get("sort") + if sort != "name" { + sort = "created_at" + } + order := query.Get("order") + desc := order == "desc" || (order == "" && sort == "created_at") + page := intParam(query.Get("page"), 1, 1, 1_000_000) + perPage := intParam(query.Get("per_page"), 25, 1, 100) + + clients, total, err := h.store.ListClients(r.Context(), database.ListClientsParams{ + Search: query.Get("q"), + Sort: sort, + Desc: desc, + Limit: perPage, + Offset: (page - 1) * perPage, + }) + if err != nil { + slog.Error("list clients", "err", err) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"clients": clients, "total": total}) +} + +func intParam(value string, fallback, lo, hi int) int { + n, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return max(lo, min(n, hi)) +} diff --git a/internal/server/clients_test.go b/internal/server/clients_test.go new file mode 100644 index 0000000..4dd6d51 --- /dev/null +++ b/internal/server/clients_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/alexedwards/scs/v2" + "github.com/mtiluk/naplata/internal/database" + "github.com/mtiluk/naplata/internal/permissions" + "github.com/mtiluk/naplata/internal/testdb" +) + +func TestListClientsEndpoint(t *testing.T) { + pool := testdb.New(t) + store := database.NewStore(pool) + if err := store.SeedPermissions(context.Background(), permissions.All); err != nil { + t.Fatal(err) + } + + day := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + client := testdb.InsertUser(t, pool, "Ada Lovelace", day) + testdb.InsertUser(t, pool, "Grace Hopper", day.AddDate(0, 0, 1)) + staff := testdb.InsertUser(t, pool, "Staff Admin", day.AddDate(0, 0, 2)) + testdb.MakeStaff(t, pool, staff) + + sm := scs.New() + h := NewHandler(store, sm) + route := h.RequirePermission(http.HandlerFunc(h.ListClientsEndpoint), permissions.ClientsView) + + get := func(userID, query string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, "/api/v1/admin/clients"+query, nil) + ctx, _ := sm.Load(r.Context(), "") + if userID != "" { + sm.Put(ctx, sessionUserIDKey, userID) + } + w := httptest.NewRecorder() + route.ServeHTTP(w, r.WithContext(ctx)) + return w + } + + for _, tt := range []struct { + user string + code int + }{ + {"", http.StatusUnauthorized}, + {client, http.StatusForbidden}, + {staff, http.StatusOK}, + } { + if w := get(tt.user, ""); w.Code != tt.code { + t.Errorf("user %q: status %d, want %d", tt.user, w.Code, tt.code) + } + } + + var body struct { + Clients []database.Client + Total int + } + if err := json.NewDecoder(get(staff, "?sort=name&page=2&per_page=1").Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.Total != 3 || len(body.Clients) != 1 || body.Clients[0].GivenName != "Grace" { + t.Errorf("unexpected response: %+v", body) + } +} diff --git a/internal/testdb/testdb.go b/internal/testdb/testdb.go new file mode 100644 index 0000000..0810969 --- /dev/null +++ b/internal/testdb/testdb.go @@ -0,0 +1,83 @@ +package testdb + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/mtiluk/naplata/migrations" +) + +const lockID = 4_284_102 + +func New(t *testing.T) *pgxpool.Pool { + t.Helper() + + url := os.Getenv("NAPLATA_TEST_DATABASE_URL") + if url == "" { + t.Skip("NAPLATA_TEST_DATABASE_URL is not set") + } + + ctx := context.Background() + + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatalf("connect test database: %v", err) + } + t.Cleanup(pool.Close) + + lock, err := pool.Acquire(ctx) + if err != nil { + t.Fatalf("acquire lock connection: %v", err) + } + if _, err := lock.Exec(ctx, "SELECT pg_advisory_lock($1)", lockID); err != nil { + t.Fatalf("lock test database: %v", err) + } + t.Cleanup(func() { + lock.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", lockID) + lock.Release() + }) + + if err := migrations.Up(ctx, pool); err != nil { + t.Fatalf("migrate test database: %v", err) + } + + if _, err := pool.Exec(ctx, "TRUNCATE users, roles, permissions, sessions RESTART IDENTITY CASCADE"); err != nil { + t.Fatalf("reset test database: %v", err) + } + + return pool +} + +func InsertUser(t *testing.T, pool *pgxpool.Pool, name string, createdAt time.Time) string { + t.Helper() + + given, family, _ := strings.Cut(name, " ") + var id string + err := pool.QueryRow(context.Background(), ` + INSERT INTO users (email, given_name, family_name, password_hash, created_at) + VALUES ($1, $2, $3, 'hash', $4) + RETURNING id`, + strings.ToLower(given)+"@example.com", given, family, createdAt, + ).Scan(&id) + if err != nil { + t.Fatalf("insert user: %v", err) + } + return id +} + +func MakeStaff(t *testing.T, pool *pgxpool.Pool, userID string) { + t.Helper() + + _, err := pool.Exec(context.Background(), ` + INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = 'admin'`, + userID, + ) + if err != nil { + t.Fatalf("make staff: %v", err) + } +} diff --git a/web/package-lock.json b/web/package-lock.json index 2e04766..7ad5a77 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,10 +9,12 @@ "version": "0.0.0", "dependencies": { "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", "@tanstack/react-query": "^5.104.0", "@tanstack/react-router": "^1.170.40", + "@tanstack/react-table": "^9.3.1", "clsx": "^2.1.1", "openapi-fetch": "^0.17.0", "react": "^19.2.8", @@ -439,6 +441,15 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@hugeicons/core-free-icons": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.3.5.tgz", @@ -1521,6 +1532,26 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@tanstack/react-table": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-9.3.1.tgz", + "integrity": "sha512-E6Ya5TnY6EaGyZYIUqig/29Lr/gJSAubQkLJFqiWkUl5nRCr0Xr9hvJot+NiRTtG+TzGxHTiqUW3bnLtQxlHug==", + "license": "MIT", + "dependencies": { + "@tanstack/react-store": "^0.11.2", + "@tanstack/table-core": "9.3.1" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18" + } + }, "node_modules/@tanstack/router-core": { "version": "1.171.33", "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.33.tgz", @@ -1647,6 +1678,22 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@tanstack/table-core": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-9.3.1.tgz", + "integrity": "sha512-zQvlxSNi2qGM3L4jh85Z6hqMfdIR5qOfD9KHOU32nuKFJGN0RlJ7UvV1lsPb1fPVhpFYI0VP3jJGxLdLDpTKjQ==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.11.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/virtual-file-routes": { "version": "1.162.0", "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", diff --git a/web/package.json b/web/package.json index a1a5994..8f57dc9 100644 --- a/web/package.json +++ b/web/package.json @@ -13,10 +13,12 @@ }, "dependencies": { "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", "@hugeicons/core-free-icons": "^4.3.5", "@hugeicons/react": "^1.1.10", "@tanstack/react-query": "^5.104.0", "@tanstack/react-router": "^1.170.40", + "@tanstack/react-table": "^9.3.1", "clsx": "^2.1.1", "openapi-fetch": "^0.17.0", "react": "^19.2.8", diff --git a/web/src/api/clients/clients.ts b/web/src/api/clients/clients.ts new file mode 100644 index 0000000..55694ca --- /dev/null +++ b/web/src/api/clients/clients.ts @@ -0,0 +1,45 @@ +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; + +export type Client = { + id: string; + email: string; + given_name: string; + family_name: string; + created_at: string; +}; + +export type ClientSort = "name" | "created_at"; +export type SortOrder = "asc" | "desc"; + +export type ClientsParams = { + q?: string; + sort?: ClientSort; + order?: SortOrder; + page?: number; +}; + +export type ClientsResponse = { + clients: Client[]; + total: number; +}; + +export const CLIENTS_PER_PAGE = 10; + +export function clientsQuery({ q, sort, order, page }: ClientsParams) { + return queryOptions({ + queryKey: ["admin", "clients", { q, sort, order, page }], + queryFn: async (): Promise => { + const params = new URLSearchParams({ per_page: String(CLIENTS_PER_PAGE) }); + if (q) params.set("q", q); + if (sort) params.set("sort", sort); + if (order) params.set("order", order); + if (page) params.set("page", String(page)); + + const res = await fetch(`/api/v1/admin/clients?${params}`); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw { status: res.status, ...data }; + return data; + }, + placeholderData: keepPreviousData, + }); +} diff --git a/web/src/components/auth/auth-layout.tsx b/web/src/components/auth/auth-layout.tsx index 3350d94..4cee35a 100644 --- a/web/src/components/auth/auth-layout.tsx +++ b/web/src/components/auth/auth-layout.tsx @@ -10,7 +10,9 @@ export function AuthLayout({ title, footer, children }: AuthLayoutProps) { return (
-

Naplata

+

+ Naplata +

{title}

diff --git a/web/src/components/dashboard/dashboard-layout.tsx b/web/src/components/dashboard/dashboard-layout.tsx new file mode 100644 index 0000000..361482f --- /dev/null +++ b/web/src/components/dashboard/dashboard-layout.tsx @@ -0,0 +1,103 @@ +import { Link, type LinkProps } from "@tanstack/react-router"; +import type { IconSvgElement } from "@hugeicons/react"; +import type { ReactNode } from "react"; + +import type { User } from "@/api/auth/me"; +import type { Permission } from "@/api/auth/permissions"; +import { HugeiconsIcon, Logout03Icon } from "@/components/icons"; + +export type NavItem = { + label: string; + to: LinkProps["to"]; + icon: IconSvgElement; + permission?: Permission; +}; + +type DashboardLayoutProps = { + user: User; + title: ReactNode; + home: LinkProps["to"]; + navLabel: string; + navGroups: readonly (readonly NavItem[])[]; + account?: LinkProps["to"]; + panel?: ReactNode; + children: ReactNode; +}; + +export function DashboardLayout({ user, title, home, navLabel, navGroups, account, panel, children }: DashboardLayoutProps) { + const name = [user.given_name, user.family_name].filter(Boolean).join(" "); + const initials = (user.given_name.charAt(0) + user.family_name.charAt(0)).toUpperCase(); + + const userInfo = ( + <> +
+

{name}

+

{user.email}

+
+ + {initials} + + + ); + + return ( +
+
+ {title} + +
+ {account ? ( + + {userInfo} + + ) : ( +
{userInfo}
+ )} + + + +
+
+ +
+ + + {panel} + +
{children}
+
+
+ ); +} diff --git a/web/src/components/icons.ts b/web/src/components/icons.ts index 3aa728d..6a2a7ee 100644 --- a/web/src/components/icons.ts +++ b/web/src/components/icons.ts @@ -2,7 +2,12 @@ export { HugeiconsIcon } from "@hugeicons/react"; export { default as Activity01Icon } from "@hugeicons/core-free-icons/Activity01Icon"; export { default as Add01Icon } from "@hugeicons/core-free-icons/Add01Icon"; export { default as ArrowDataTransferHorizontalIcon } from "@hugeicons/core-free-icons/ArrowDataTransferHorizontalIcon"; +export { default as ArrowDown01Icon } from "@hugeicons/core-free-icons/ArrowDown01Icon"; +export { default as ArrowLeft01Icon } from "@hugeicons/core-free-icons/ArrowLeft01Icon"; +export { default as ArrowRight01Icon } from "@hugeicons/core-free-icons/ArrowRight01Icon"; export { default as ArrowRight02Icon } from "@hugeicons/core-free-icons/ArrowRight02Icon"; +export { default as ArrowUp01Icon } from "@hugeicons/core-free-icons/ArrowUp01Icon"; +export { default as ArrowUpDownIcon } from "@hugeicons/core-free-icons/ArrowUpDownIcon"; export { default as BookOpen01Icon } from "@hugeicons/core-free-icons/BookOpen01Icon"; export { default as Cancel01Icon } from "@hugeicons/core-free-icons/Cancel01Icon"; export { default as CreditCardIcon } from "@hugeicons/core-free-icons/CreditCardIcon"; diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx index f3b3215..41e4acc 100644 --- a/web/src/components/ui/button.tsx +++ b/web/src/components/ui/button.tsx @@ -2,16 +2,12 @@ import type { ButtonHTMLAttributes } from "react"; import { cn } from "@/lib/utils"; -export function Button({ - className, - type = "button", - ...props -}: ButtonHTMLAttributes) { +export function Button({ className, type = "button", ...props }: ButtonHTMLAttributes) { return (