diff --git a/.gitignore b/.gitignore index 5e3bfc0..0527ec8 100644 --- a/.gitignore +++ b/.gitignore @@ -38,7 +38,9 @@ __pycache__/ *.egg-info/ .eggs/ dist/ -build/ +# Python build output (not build/package/ which holds our Dockerfile) +/build/* +!/build/package/ # FreeCAD *.FCStd1 @@ -47,3 +49,6 @@ build/ # Web frontend web/node_modules/ web/dist/ +web/*.tsbuildinfo +web/vite.config.d.ts +web/vite.config.js diff --git a/build/package/Dockerfile b/build/package/Dockerfile new file mode 100644 index 0000000..d5fa656 --- /dev/null +++ b/build/package/Dockerfile @@ -0,0 +1,25 @@ +FROM node:22-alpine AS frontend +WORKDIR /app +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM golang:1.24-alpine AS builder +RUN apk add --no-cache git +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend /app/dist ./web/dist +RUN CGO_ENABLED=0 go build -o /silod ./cmd/silod +RUN CGO_ENABLED=0 go build -o /silo ./cmd/silo + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates wget +COPY --from=builder /silod /usr/local/bin/silod +COPY --from=builder /silo /usr/local/bin/silo +COPY --from=frontend /app/dist /var/www/silo +EXPOSE 8080 +ENTRYPOINT ["silod"] +CMD ["-config", "/etc/silo/config.yaml"] diff --git a/deployments/config.prod.yaml b/deployments/config.prod.yaml index 469f1a9..cd0508c 100644 --- a/deployments/config.prod.yaml +++ b/deployments/config.prod.yaml @@ -1,13 +1,22 @@ # Silo Production Configuration -# For deployment on dedicated VM using external PostgreSQL and MinIO +# Single-binary deployment: silod serves API + React SPA # -# Credentials are provided via environment variables: +# Layout on silo.kindred.internal: +# /opt/silo/bin/silod - server binary +# /opt/silo/web/dist/ - built React frontend (served automatically) +# /opt/silo/schemas/ - part number schemas +# /etc/silo/config.yaml - this file +# /etc/silo/silod.env - secrets (env vars) +# +# Credentials via environment variables (set in /etc/silo/silod.env): # SILO_DB_PASSWORD # SILO_MINIO_ACCESS_KEY # SILO_MINIO_SECRET_KEY +# SILO_SESSION_SECRET +# SILO_ADMIN_PASSWORD server: - host: "127.0.0.1" # Listen only on localhost (nginx handles external traffic) + host: "0.0.0.0" port: 8080 base_url: "https://silo.kindred.internal" @@ -29,24 +38,19 @@ storage: region: "us-east-1" schemas: - directory: "/etc/silo/schemas" + directory: "/opt/silo/schemas" default: "kindred-rd" freecad: uri_scheme: "silo" - executable: "/usr/bin/freecad" -# Authentication -# Set via SILO_SESSION_SECRET, SILO_OIDC_CLIENT_SECRET, SILO_LDAP_BIND_PASSWORD env vars auth: enabled: true session_secret: "" # Set via SILO_SESSION_SECRET - local: enabled: true default_admin_username: "admin" default_admin_password: "" # Set via SILO_ADMIN_PASSWORD - ldap: enabled: true url: "ldaps://ipa.kindred.internal" @@ -65,18 +69,8 @@ auth: viewer: - "cn=silo-viewers,cn=groups,cn=accounts,dc=kindred,dc=internal" tls_skip_verify: false - oidc: enabled: false - issuer_url: "https://keycloak.kindred.internal/realms/silo" - client_id: "silo" - client_secret: "" # Set via SILO_OIDC_CLIENT_SECRET - redirect_url: "https://silo.kindred.internal/auth/callback" - scopes: ["openid", "profile", "email"] - admin_role: "silo-admin" - editor_role: "silo-editor" - default_role: "viewer" - cors: allowed_origins: - "https://silo.kindred.internal" diff --git a/deployments/docker-compose.yaml b/deployments/docker-compose.yaml index 3a6167e..d733fe1 100644 --- a/deployments/docker-compose.yaml +++ b/deployments/docker-compose.yaml @@ -64,7 +64,7 @@ services: SILO_OIDC_CLIENT_SECRET: ${SILO_OIDC_CLIENT_SECRET:-} SILO_LDAP_BIND_PASSWORD: ${SILO_LDAP_BIND_PASSWORD:-} SILO_ADMIN_USERNAME: ${SILO_ADMIN_USERNAME:-admin} - SILO_ADMIN_PASSWORD: ${SILO_ADMIN_PASSWORD:-} + SILO_ADMIN_PASSWORD: ${SILO_ADMIN_PASSWORD:-admin} ports: - "8080:8080" volumes: diff --git a/deployments/systemd/silod.service b/deployments/systemd/silod.service index a96a627..c65da90 100644 --- a/deployments/systemd/silod.service +++ b/deployments/systemd/silod.service @@ -9,7 +9,7 @@ Type=simple User=silo Group=silo -# Working directory +# Working directory (web/dist is served relative to this) WorkingDirectory=/opt/silo # Environment file for secrets @@ -27,8 +27,7 @@ NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes -ReadOnlyPaths=/etc/silo -ReadWritePaths=/var/log/silo +ReadOnlyPaths=/etc/silo /opt/silo # Resource limits LimitNOFILE=65535 diff --git a/frontend-spec.md b/frontend-spec.md new file mode 100644 index 0000000..6354d28 --- /dev/null +++ b/frontend-spec.md @@ -0,0 +1,737 @@ +# Silo Frontend Specification + +Current as of 2026-02-06. Tracks the React + Vite + TypeScript frontend migration (epic #6). + +## Overview + +The Silo web UI is being migrated from server-rendered Go templates with vanilla JavaScript (~7,000 lines across 7 templates) to a React single-page application. The Go API server remains unchanged — it serves JSON at `/api/*` and the React app consumes it. + +**Stack**: React 19, React Router 7, Vite 6, TypeScript 5.7 +**Theme**: Catppuccin Mocha (dark) via CSS custom properties +**Styling**: Inline React styles using `React.CSSProperties` — no CSS modules, no Tailwind, no styled-components +**State**: Local `useState` + custom hooks. No global state library (no Redux, Zustand, etc.) +**Dependencies**: Minimal — only `react`, `react-dom`, `react-router-dom`. No axios, no tanstack-query. + +## Migration Status + +| Phase | Issue | Title | Status | +|-------|-------|-------|--------| +| 1 | #7 | Scaffold React + Vite + TS, shared layout, auth, API client | Code complete | +| 2 | #8 | Migrate Items page with UI improvements | Code complete | +| 3 | #9 | Migrate Projects, Schemas, Settings, Login pages | Code complete | +| 4 | #10 | Remove Go templates, Docker integration, cleanup | Not started | + +## Architecture + +``` +Browser + └── React SPA (served at /app/* during transition, / after Phase 4) + ├── Vite dev server (development) → proxies /api/* to Go backend + └── Static files in web/dist/ (production) → served by Go binary + +Go Server (silod) + ├── /api/* JSON REST API (unchanged) + ├── /login, /logout Session auth endpoints (form POST) + ├── /auth/oidc OIDC redirect flow + ├── /app/* React SPA static files (current transition) + └── /* Go template pages (removed in Phase 4) +``` + +### Auth Flow + +1. React app loads, `AuthProvider` calls `GET /api/auth/me` +2. If 401 → render `LoginPage` (React form) +3. Login form POSTs `application/x-www-form-urlencoded` to `/login` (Go handler sets session cookie) +4. On success, `AuthProvider.refresh()` re-fetches `/api/auth/me`, user state populates, app renders +5. OIDC: link to `/auth/oidc` triggers Go-served redirect flow, callback sets session, user returns to app +6. API client auto-redirects to `/login` on any 401 response + +### Public API Endpoint + +`GET /api/auth/config` — returns `{ oidc_enabled: bool, local_enabled: bool }` so the login page can conditionally show the OIDC button without hardcoding. + +## File Structure + +``` +web/ +├── index.html +├── package.json +├── tsconfig.json +├── tsconfig.node.json +├── vite.config.ts +└── src/ + ├── main.tsx Entry point, renders AuthProvider + BrowserRouter + App + ├── App.tsx Route definitions, auth guard + ├── api/ + │ ├── client.ts fetch wrapper: get, post, put, del + ApiError class + │ └── types.ts All TypeScript interfaces (272 lines) + ├── context/ + │ └── AuthContext.tsx AuthProvider with login/logout/refresh methods + ├── hooks/ + │ ├── useAuth.ts Context consumer hook + │ ├── useItems.ts Items fetching with search, filters, pagination, debounce + │ └── useLocalStorage.ts Typed localStorage persistence hook + ├── styles/ + │ ├── theme.css Catppuccin Mocha CSS custom properties + │ └── global.css Base element styles + ├── components/ + │ ├── AppShell.tsx Header nav + user info + + │ ├── ContextMenu.tsx Reusable right-click positioned menu + │ └── items/ Items page components (16 files) + │ ├── ItemsToolbar.tsx Search, filters, layout toggle, action buttons + │ ├── ItemTable.tsx Sortable table, column config, compact rows + │ ├── ItemDetail.tsx 5-tab detail panel (Main, Properties, Revisions, BOM, Where Used) + │ ├── MainTab.tsx Metadata display, project tags editor, file info + │ ├── PropertiesTab.tsx Form/JSON dual-mode property editor + │ ├── RevisionsTab.tsx Revision list, compare diff, status, rollback + │ ├── BOMTab.tsx BOM table with inline CRUD, cost calculations + │ ├── WhereUsedTab.tsx Parent assemblies table + │ ├── SplitPanel.tsx Resizable horizontal/vertical layout container + │ ├── FooterStats.tsx Fixed bottom bar with item counts + │ ├── CreateItemPane.tsx In-pane create form with schema category properties + │ ├── EditItemPane.tsx In-pane edit form + │ ├── DeleteItemPane.tsx In-pane delete confirmation + │ └── ImportItemsPane.tsx CSV upload with dry-run/import flow + └── pages/ + ├── LoginPage.tsx Username/password form + OIDC button + ├── ItemsPage.tsx Orchestrator: toolbar, split panel, table, detail/CRUD panes + ├── ProjectsPage.tsx Project CRUD with sortable table, in-pane forms + ├── SchemasPage.tsx Schema browser with collapsible segments, enum value CRUD + ├── SettingsPage.tsx Account info, API token management + └── AuditPage.tsx Audit completeness (placeholder, expanded in Issue #5) +``` + +**Total**: 32 source files, ~5,300 lines of TypeScript/TSX. + +## Design System + +### Theme + +Catppuccin Mocha dark theme. All colors referenced via CSS custom properties: + +| Token | Use | +|-------|-----| +| `--ctp-base` | Page background, input backgrounds | +| `--ctp-mantle` | Header background | +| `--ctp-surface0` | Card backgrounds, even table rows | +| `--ctp-surface1` | Borders, dividers, hover states | +| `--ctp-surface2` | Secondary button backgrounds | +| `--ctp-text` | Primary text | +| `--ctp-subtext0/1` | Secondary/muted text, labels | +| `--ctp-overlay0` | Placeholder text | +| `--ctp-mauve` | Brand accent, primary buttons, nav active | +| `--ctp-blue` | Editor role badge, edit headers | +| `--ctp-green` | Success banners, create headers | +| `--ctp-red` | Errors, delete actions, danger buttons | +| `--ctp-peach` | Part numbers, project codes, token prefixes | +| `--ctp-teal` | Viewer role badge | +| `--ctp-sapphire` | Links, collapsible toggles | +| `--ctp-crust` | Dark text on colored backgrounds | + +### Typography + +- Body: system font stack (Inter, -apple-system, etc.) +- Monospace: JetBrains Mono (part numbers, codes, tokens) +- Table cells: 0.85rem +- Labels: 0.85rem, weight 500 +- Table headers: 0.8rem, uppercase, letter-spacing 0.05em + +### Component Patterns + +**Tables**: Inline styles, compact rows (28-32px), alternating `base`/`surface0` backgrounds, sortable headers with arrow indicators, right-click column config (Items page). + +**Forms**: In-pane forms (Infor ERP-style) — not modal overlays. Create/Edit/Delete forms render in the detail pane area with a colored header bar (green=create, blue=edit, red=delete). Cancel returns to previous view. + +**Cards**: `surface0` background, `0.75rem` border radius, `1.5rem` padding. + +**Buttons**: Primary (`mauve` bg, `crust` text), secondary (`surface1` bg), danger (`red` bg or translucent red bg with red text). + +**Errors**: Red text with translucent red background banner, `0.4rem` border radius. + +**Role badges**: Colored pill badges — admin=mauve, editor=blue, viewer=teal. + +## Page Specifications + +### Items Page (completed in #8) + +The most complex page. Master-detail layout with resizable split panel. + +**Toolbar**: Debounced search (300ms) with scope toggle (All/PN/Description), type and project filter dropdowns, layout toggle (horizontal/vertical), export/import/create buttons. + +**Table**: 7 configurable columns (part_number, item_type, description, revision, projects, created, actions). Visibility stored per layout mode in localStorage. Right-click header opens ContextMenu with checkboxes. Compact rows, zebra striping, click to select. + +**Detail panel**: 5 tabs — Main (metadata + project tags + file info), Properties (form/JSON editor, save creates revision), Revisions (compare, status management, rollback), BOM (inline CRUD, cost calculations, CSV export), Where Used (parent assemblies). + +**CRUD panes**: In-pane forms for Create (schema category properties, project tags), Edit (basic fields), Delete (confirmation), Import (CSV upload with dry-run). + +**Footer**: Fixed 28px bottom bar showing Total | Parts | Assemblies | Documents counts, reactive to filters. + +**State**: `PaneMode` discriminated union manages which pane is shown. `useItems` hook handles fetching, search, filters, pagination. `useLocalStorage` persists layout and column preferences. + +### Projects Page (completed in #9) + +Sortable table with columns: Code, Name, Description, Items (count fetched per project), Created, Actions. + +**CRUD**: In-pane forms above the table. Create requires code (2-10 chars, auto-uppercase), name, description. Edit allows name and description changes. Delete shows confirmation with project code. + +**Navigation**: Click project code navigates to Items page with `?project=CODE` filter. + +**Permissions**: Create/Edit/Delete buttons only visible to editor/admin roles. + +### Schemas Page (completed in #9) + +Schema cards with collapsible segment details. Each schema shows name, description, format string, version, and example part numbers. + +**Segments**: Expandable list showing segment name, type badge, description. Enum segments include a values table with code and description columns. + +**Enum CRUD**: Inline table operations — add row at bottom, edit replaces the row, delete highlights the row with confirmation. All operations call `POST/PUT/DELETE /api/schemas/{name}/segments/{segment}/values/{code}`. + +### Settings Page (completed in #9) + +Two cards: + +**Account**: Read-only grid showing username, display name, email, auth source, role (with colored badge). Data from `useAuth()` context. + +**API Tokens**: Create form (name input + button), one-time token display in green banner with copy-to-clipboard, token list table (name, prefix, created, last used, expires, revoke). Revoke has inline confirm step. Uses `GET/POST/DELETE /api/auth/tokens`. + +### Login Page (completed in #9) + +Standalone centered card (no AppShell). Username/password form, OIDC button shown conditionally based on `GET /api/auth/config`. Error messages in red banner. Submit calls `AuthContext.login()` which POSTs form data to `/login` then re-fetches the user. + +### Audit Page (placeholder) + +Basic table showing audit completeness data from `GET /api/audit/completeness`. Will be expanded as part of Issue #5 (Component Audit UI with completeness scoring and inline editing). + +## API Client + +`web/src/api/client.ts` — thin wrapper around `fetch`: + +- Always sends `credentials: 'include'` for session cookies +- Always sets `Content-Type: application/json` +- 401 responses redirect to `/login` +- Non-OK responses parsed as `{ error, message }` and thrown as `ApiError` +- 204 responses return `undefined` +- Exports: `get()`, `post()`, `put()`, `del()` + +## Type Definitions + +`web/src/api/types.ts` — 272 lines covering all API response and request shapes: + +**Core models**: User, Item, Project, Schema, SchemaSegment, Revision, BOMEntry +**Audit**: AuditFieldResult, AuditItemResult, AuditSummary, AuditCompletenessResponse +**Search**: FuzzyResult (extends Item with score) +**BOM**: WhereUsedEntry, AddBOMEntryRequest, UpdateBOMEntryRequest +**Items**: CreateItemRequest, UpdateItemRequest, CreateRevisionRequest +**Projects**: CreateProjectRequest, UpdateProjectRequest +**Schemas**: CreateSchemaValueRequest, UpdateSchemaValueRequest, PropertyDef, PropertySchema +**Auth**: AuthConfig, ApiToken, ApiTokenCreated +**Revisions**: RevisionComparison +**Import**: CSVImportResult, CSVImportError +**Errors**: ErrorResponse + +## Remaining Work + +### Issue #10: Remove Go Templates + Docker Integration + +The final phase completes the migration: + +**Remove Go templates**: Delete `internal/api/templates/` (7 HTML files), remove template loading code, remove web handler route group and CSRF middleware for web routes, remove `HandleIndex`, `HandleProjects`, `HandleSchemas`, `HandleSettings` handlers. + +**SPA serving**: Serve `web/dist/` at `/` with SPA fallback (non-API routes return `index.html`). Either `go:embed` for single-binary deployment or filesystem serving. `/api/*` routes must take precedence. Cache headers for Vite's hashed assets. + +**Docker**: Update `build/package/Dockerfile` to multi-stage — Stage 1: Node (`npm ci && npm run build`), Stage 2: Go build with `web/dist/`, Final: minimal Alpine image with single binary. + +**Makefile**: Existing `web-install`, `web-dev`, `web-build` targets need verification. `make build` should include the web build step. `make clean` should include `web/dist/`. + +**Acceptance criteria**: Single Docker image serves both API and React frontend. `make build` produces working binary. No Go template code remains. All pages accessible at React Router paths. + +### Issue #5: Component Audit UI (future) + +After migration completes, the Audit page will be expanded with completeness scoring, inline editing, tier filtering, and category breakdowns. This will be built natively in React using the patterns established in the migration. + +## Development + +```bash +# Install dependencies +cd web && npm install + +# Dev server (proxies /api/* to Go backend on :8080) +npm run dev + +# Type check +npx tsc --noEmit + +# Production build +npm run build +``` + +Vite dev server runs on port 5173 with proxy config in `vite.config.ts` forwarding `/api/*`, `/login`, `/logout`, `/auth/*` to the Go backend. + +## Conventions + +- **No modals for CRUD** — use in-pane forms (Infor ERP-style pattern) +- **No shared component library extraction** until a pattern repeats 3+ times +- **Inline styles only** — all styling via `React.CSSProperties` objects, using Catppuccin CSS variables +- **No class components** — functional components with hooks only +- **Permission checks**: derive `isEditor` from `user.role` in each page, conditionally render write actions +- **Error handling**: try/catch with error state, display in red banners inline +- **Data fetching**: `useEffect` + API client on mount, loading/error/data states +- **Persistence**: `useLocalStorage` hook for user preferences (layout mode, column visibility) + +## New Frontend Tasks + +# CreateItemPane Redesign Specification + +**Date**: 2026-02-06 +**Scope**: Replace existing `CreateItemPane.tsx` with a two-column layout, multi-stage category picker, file attachment via MinIO, and full use of screen real estate. +**Parent**: Items page (`ItemsPage.tsx`) — renders in the detail pane area per existing in-pane CRUD pattern. + +--- + +## Layout + +The pane uses a CSS Grid two-column layout instead of the current single-column form: + +``` +┌──────────────────────────────────────────────────────┬──────────────┐ +│ Header: "New Item" [green bar] Cancel │ Create │ │ +├──────────────────────────────────────────────────────┤ │ +│ │ Auto- │ +│ ── Identity ────────────────────────────────────── │ assigned │ +│ [Part Number *] [Type * v] │ metadata │ +│ [Description ] │ │ +│ Category * [Domain │ Group │ Subtype ] │──────────────│ +│ Mechanical│ Structural│ Bracket │ │ │ +│ Electrical│ Bearings │ Plate │ │ Attachments │ +│ ... │ ... │ ... │ │ ┌─ ─ ─ ─ ┐ │ +│ ── Sourcing ────────────────────────────────────── │ │ Drop │ │ +│ [Sourcing Type v] [Standard Cost $ ] │ │ zone │ │ +│ [Unit of Measure v] [Sourcing Link ] │ └─ ─ ─ ─ ┘ │ +│ │ file.FCStd │ +│ ── Details ─────────────────────────────────────── │ drawing.pdf │ +│ [Long Description ] │ │ +│ [Projects: [tag][tag] type to search... ] │──────────────│ +│ │ Thumbnail │ +│ │ [preview] │ +└──────────────────────────────────────────────────────┴──────────────┘ +``` + +Grid definition: `grid-template-columns: 1fr 320px`. The left column scrolls independently if content overflows. The right sidebar is a flex column with sections separated by `--ctp-surface1` borders. + +## File Location + +`web/src/components/items/CreateItemPane.tsx` (replaces existing file) + +New supporting files: + +| File | Purpose | +|------|---------| +| `web/src/components/items/CategoryPicker.tsx` | Multi-stage category selector | +| `web/src/components/items/FileDropZone.tsx` | Drag-and-drop file upload with MinIO presigned URLs | +| `web/src/components/items/TagInput.tsx` | Multi-select tag input for projects | +| `web/src/hooks/useCategories.ts` | Fetches category tree from schema data | +| `web/src/hooks/useFileUpload.ts` | Manages presigned URL upload flow | + +## Component Breakdown + +### CreateItemPane + +Top-level orchestrator. Manages form state, submission, and layout. + +**Props** (unchanged interface): + +```typescript +interface CreateItemPaneProps { + onCreated: (item: Item) => void; + onCancel: () => void; +} +``` + +**State**: + +```typescript +const [form, setForm] = useState({ + part_number: '', + item_type: 'part', + description: '', + category_path: [], // e.g. ['Mechanical', 'Structural', 'Bracket'] + sourcing_type: 'manufactured', + standard_cost: '', + unit_of_measure: 'ea', + sourcing_link: '', + long_description: '', + project_ids: [], +}); +const [attachments, setAttachments] = useState([]); +const [thumbnail, setThumbnail] = useState(null); +const [error, setError] = useState(null); +const [submitting, setSubmitting] = useState(false); +``` + +**Submission flow**: + +1. Validate required fields (part_number, item_type, category_path length === 3). +2. `POST /api/items` with form data → returns created `Item` with UUID. +3. For each attachment in `attachments[]`, call the file association endpoint: `POST /api/items/{id}/files` with the MinIO object key returned from upload. +4. If thumbnail exists, `PUT /api/items/{id}/thumbnail` with the object key. +5. Call `onCreated(item)`. + +If step 2 fails, show error banner. If file association fails, show warning but still navigate (item was created, files can be re-attached). + +**Header bar**: Green (`--ctp-green` background, `--ctp-crust` text) per existing create-pane convention. "New Item" title on left, Cancel (ghost button) and Create Item (primary button, `--ctp-green` bg) on right. + +### CategoryPicker + +Three-column scrollable list for hierarchical category selection. + +**Props**: + +```typescript +interface CategoryPickerProps { + value: string[]; // current selection path, e.g. ['Mechanical', 'Structural'] + onChange: (path: string[]) => void; + categories: CategoryNode[]; // top-level nodes +} + +interface CategoryNode { + name: string; + children?: CategoryNode[]; +} +``` + +**Rendering**: Three side-by-side `
` columns inside a container with `border: 1px solid var(--ctp-surface1)` and `border-radius: 0.4rem`. Each column has: + +- A sticky header row (10px uppercase, `--ctp-overlay0` text, `--ctp-mantle` background) labeling the tier. Labels come from the schema definition if available, otherwise "Level 1", "Level 2", "Level 3". +- A scrollable list of options. Each option is a `
` row, 28px height, `0.85rem` font. Hover: `--ctp-surface0` background. Selected: translucent mauve background (`rgba(203, 166, 247, 0.12)`), `--ctp-mauve` text, weight 600. +- If a node has children, show a `›` chevron on the right side of the row. + +Column 1 always shows all top-level nodes. Column 2 shows children of the selected Column 1 node. Column 3 shows children of the selected Column 2 node. If nothing is selected in a column, the next column shows an empty state with muted text: "Select a [tier name]". + +Below the picker, render a breadcrumb trail: `Mechanical › Structural › Bracket` in `--ctp-mauve` with `›` separators in `--ctp-overlay0`. Only show segments that are selected. + +**Data source**: Categories are derived from schemas. The `useCategories` hook calls `GET /api/schemas` and transforms the response into a `CategoryNode[]` tree. The exact mapping depends on how schemas define category hierarchies — if schemas don't currently support hierarchical categories, this requires a backend addition (see Backend Changes section). + +**Max height**: 180px per column with `overflow-y: auto`. + +### FileDropZone + +Handles drag-and-drop and click-to-browse file uploads with MinIO presigned URL flow. + +**Props**: + +```typescript +interface FileDropZoneProps { + files: PendingAttachment[]; + onFilesAdded: (files: PendingAttachment[]) => void; + onFileRemoved: (index: number) => void; + accept?: string; // e.g. '.FCStd,.step,.stl,.pdf,.png,.jpg' +} + +interface PendingAttachment { + file: File; + objectKey: string; // MinIO key after upload + uploadProgress: number; // 0-100 + uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; + error?: string; +} +``` + +**Drop zone UI**: Dashed `2px` border using `--ctp-surface1`, `border-radius: 0.5rem`, centered content with a paperclip icon (Unicode 📎 or inline SVG), "Drop files here or **browse**" text, and accepted formats in `--ctp-overlay0` at 10px. + +States: +- **Default**: dashed border `--ctp-surface1` +- **Drag over**: dashed border `--ctp-mauve`, background `rgba(203, 166, 247, 0.05)` +- **Uploading**: show progress per file in the file list + +Clicking the zone opens a hidden ``. + +**File list**: Rendered below the drop zone. Each file shows: +- Type icon: colored 28×28 rounded square. Color mapping: `.FCStd`/`.step`/`.stl` → `--ctp-blue` ("CAD"), `.pdf` → `--ctp-red` ("PDF"), `.png`/`.jpg` → `--ctp-green` ("IMG"), other → `--ctp-overlay1` ("FILE"). +- File name (truncated with ellipsis). +- File size + type label in `--ctp-overlay0` at 10px. +- Upload progress bar (thin 2px bar under the file item, `--ctp-mauve` fill) when uploading. +- Remove button (`×`) on the right, `--ctp-overlay0` → `--ctp-red` on hover. + +**Upload flow** (managed by `useFileUpload` hook): + +1. On file selection/drop, immediately request a presigned upload URL: `POST /api/uploads/presign` with `{ filename, content_type, size }`. +2. Backend returns `{ object_key, upload_url, expires_at }`. +3. `PUT` the file directly to the presigned MinIO URL using `XMLHttpRequest` (for progress tracking). +4. On completion, update `PendingAttachment.uploadStatus` to `'complete'` and store the `object_key`. +5. The `object_key` is later sent to the item creation endpoint to associate the file. + +If the presigned URL endpoint doesn't exist yet, see Backend Changes. + +### TagInput + +Reusable multi-select input for projects (and potentially other tag-like fields). + +**Props**: + +```typescript +interface TagInputProps { + value: string[]; // selected project IDs + onChange: (ids: string[]) => void; + placeholder?: string; + searchFn: (query: string) => Promise<{ id: string; label: string }[]>; +} +``` + +**Rendering**: Container styled like a form input (`--ctp-crust` bg, `--ctp-surface1` border, `border-radius: 0.4rem`). Inside: +- Selected tags as inline pills: `rgba(203, 166, 247, 0.15)` bg, `--ctp-mauve` text, 11px font, with `×` remove button. +- A bare `` (no border/bg) that grows to fill remaining width, `min-width: 80px`. + +**Behavior**: On typing, debounce 200ms, call `searchFn(query)`. Show a dropdown below the input with matching results. Click or Enter selects. Already-selected items are excluded from results. Escape or blur closes the dropdown. + +The dropdown is an absolutely-positioned `
` below the input container, `--ctp-crust` background, `--ctp-surface1` border, `border-radius: 0.4rem`, `max-height: 160px`, `overflow-y: auto`. Each row is 28px, hover `--ctp-surface0`. + +**For projects**: `searchFn` calls `GET /api/projects?q={query}` and maps to `{ id: project.id, label: project.code + ' — ' + project.name }`. + +### useCategories Hook + +```typescript +function useCategories(): { + categories: CategoryNode[]; + loading: boolean; + error: string | null; +} +``` + +Fetches `GET /api/schemas` on mount and transforms into a category tree. Caches in a module-level variable so repeated renders don't refetch. If the API doesn't currently support hierarchical categories, this returns a flat list as a single-tier picker until the backend is extended. + +### useFileUpload Hook + +```typescript +function useFileUpload(): { + upload: (file: File) => Promise; + uploading: boolean; +} +``` + +Encapsulates the presigned URL flow. Returns a function that takes a `File`, gets a presigned URL, uploads via XHR with progress tracking, and returns the completed `PendingAttachment`. The component manages the array of attachments in its own state. + +## Styling + +All styling via inline `React.CSSProperties` objects, per project convention. Reference Catppuccin tokens through `var(--ctp-*)` strings. No CSS modules, no Tailwind, no class names. + +Common style patterns to extract as `const` objects at the top of each file: + +```typescript +const styles = { + container: { + display: 'grid', + gridTemplateColumns: '1fr 320px', + height: '100%', + overflow: 'hidden', + } as React.CSSProperties, + + formArea: { + padding: '1.5rem 2rem', + overflowY: 'auto', + } as React.CSSProperties, + + formGrid: { + display: 'grid', + gridTemplateColumns: '1fr 1fr', + gap: '1.25rem 1.5rem', + maxWidth: '800px', + } as React.CSSProperties, + + sidebar: { + background: 'var(--ctp-mantle)', + borderLeft: '1px solid var(--ctp-surface0)', + display: 'flex', + flexDirection: 'column' as const, + overflowY: 'auto', + } as React.CSSProperties, + + // ... etc +}; +``` + +## Form Sections + +The form is visually divided by section headers. Each header is a flex row containing a label (11px uppercase, `--ctp-overlay0`) and a `flex: 1` horizontal line (`1px solid --ctp-surface0`). Sections span `grid-column: 1 / -1`. + +| Section | Fields | +|---------|--------| +| Identity | Part Number*, Type*, Description, Category* | +| Sourcing | Sourcing Type, Standard Cost, Unit of Measure, Sourcing Link | +| Details | Long Description, Projects | + +## Sidebar Sections + +The right sidebar is divided into three sections with `borderBottom: 1px solid var(--ctp-surface0)`: + +**Auto-assigned metadata**: Read-only key-value rows showing: +- UUID: "On create" in `--ctp-teal` italic +- Revision: "A" (hardcoded initial) +- Created By: current user's display name from `useAuth()` + +**Attachments**: `FileDropZone` component. Takes `flex: 1` to fill available space. + +**Thumbnail**: A 4:3 aspect ratio placeholder box (`--ctp-crust` bg, `--ctp-surface0` border) with centered text "Generated from CAD file or upload manually". Clicking opens file picker filtered to images. If a thumbnail is uploaded, show it as an `` with `object-fit: cover`. + +## Backend Changes Required + +The following API additions are needed. These should be tracked as sub-tasks or a separate issue. + +### 1. Presigned Upload URL + +``` +POST /api/uploads/presign +Request: { "filename": "bracket.FCStd", "content_type": "application/octet-stream", "size": 2400000 } +Response: { "object_key": "uploads/tmp/{uuid}/{filename}", "upload_url": "https://minio.../...", "expires_at": "2026-02-06T..." } +``` + +The Go handler generates a presigned PUT URL via the MinIO SDK. Objects are uploaded to a temporary prefix. On item creation, they're moved/linked to the item's permanent prefix. + +### 2. File Association + +``` +POST /api/items/{id}/files +Request: { "object_key": "uploads/tmp/{uuid}/bracket.FCStd", "filename": "bracket.FCStd", "content_type": "...", "size": 2400000 } +Response: { "file_id": "uuid", "filename": "...", "size": ..., "created_at": "..." } +``` + +Moves the object from the temp prefix to `items/{item_id}/files/{file_id}` and creates a row in a new `item_files` table. + +### 3. Thumbnail + +``` +PUT /api/items/{id}/thumbnail +Request: { "object_key": "uploads/tmp/{uuid}/thumb.png" } +Response: 204 +``` + +Stores the thumbnail at `items/{item_id}/thumbnail.png` in MinIO. Updates `item.thumbnail_key` column. + +### 4. Hierarchical Categories + +If schemas don't currently support a hierarchical category tree, one of these approaches: + +**Option A — Schema-driven**: Add a `category_tree` JSON column to the `schemas` table that defines the hierarchy. The `GET /api/schemas` response already returns schemas; the frontend transforms this into the picker tree. + +**Option B — Dedicated table**: + +```sql +CREATE TABLE categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + parent_id UUID REFERENCES categories(id), + sort_order INT NOT NULL DEFAULT 0, + UNIQUE(parent_id, name) +); +``` + +With endpoints: +``` +GET /api/categories → flat list with parent_id, frontend builds tree +POST /api/categories → { name, parent_id? } +PUT /api/categories/{id} → { name, sort_order } +DELETE /api/categories/{id} → cascade check +``` + +**Recommendation**: Option B is more flexible and keeps categories as a first-class entity. The three-tier picker doesn't need to be limited to exactly three levels — it can render as many columns as the deepest category path, but three is the practical default (Domain → Group → Subtype). + +### 5. Database Schema Addition + +```sql +CREATE TABLE item_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size BIGINT NOT NULL DEFAULT 0, + object_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_item_files_item ON item_files(item_id); + +ALTER TABLE items ADD COLUMN thumbnail_key TEXT; +ALTER TABLE items ADD COLUMN category_id UUID REFERENCES categories(id); +ALTER TABLE items ADD COLUMN sourcing_type TEXT NOT NULL DEFAULT 'manufactured'; +ALTER TABLE items ADD COLUMN sourcing_link TEXT; +ALTER TABLE items ADD COLUMN standard_cost NUMERIC(12,2); +ALTER TABLE items ADD COLUMN unit_of_measure TEXT NOT NULL DEFAULT 'ea'; +ALTER TABLE items ADD COLUMN long_description TEXT; +``` + +## Implementation Order + +1. **TagInput component** — reusable, no backend changes needed, uses existing projects API. +2. **CategoryPicker component** — start with flat/mock data, wire to real API after backend adds categories. +3. **FileDropZone + useFileUpload** — requires presigned URL backend endpoint first. +4. **CreateItemPane rewrite** — compose the above into the two-column layout. +5. **Backend: categories table + endpoints** — unblocks real category data. +6. **Backend: presigned uploads + item_files** — unblocks file attachments. +7. **Backend: items table migration** — adds new columns (sourcing_type, standard_cost, etc.). + +Steps 1-2 can start immediately. Steps 5-7 can run in parallel once specified. Step 4 ties it all together. + +## Types to Add + +Add to `web/src/api/types.ts`: + +```typescript +// Categories +interface Category { + id: string; + name: string; + parent_id: string | null; + sort_order: number; +} + +interface CategoryNode { + name: string; + id: string; + children?: CategoryNode[]; +} + +// File uploads +interface PresignRequest { + filename: string; + content_type: string; + size: number; +} + +interface PresignResponse { + object_key: string; + upload_url: string; + expires_at: string; +} + +interface ItemFile { + id: string; + item_id: string; + filename: string; + content_type: string; + size: number; + object_key: string; + created_at: string; +} + +// Extended create request +interface CreateItemRequest { + part_number: string; + item_type: 'part' | 'assembly' | 'document'; + description?: string; + category_id?: string; + sourcing_type?: 'manufactured' | 'purchased' | 'phantom'; + standard_cost?: number; + unit_of_measure?: string; + sourcing_link?: string; + long_description?: string; + project_ids?: string[]; +} + +// Pending upload (frontend only, not an API type) +interface PendingAttachment { + file: File; + objectKey: string; + uploadProgress: number; + uploadStatus: 'pending' | 'uploading' | 'complete' | 'error'; + error?: string; +} +``` diff --git a/go.mod b/go.mod index 45aad72..6c0b886 100644 --- a/go.mod +++ b/go.mod @@ -9,8 +9,8 @@ require ( github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/cors v1.2.1 github.com/go-ldap/ldap/v3 v3.4.12 + github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.5.4 - github.com/justinas/nosurf v1.2.0 github.com/minio/minio-go/v7 v7.0.66 github.com/rs/zerolog v1.32.0 github.com/sahilm/fuzzy v0.1.1 @@ -24,7 +24,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect diff --git a/go.sum b/go.sum index a3f51f7..da5ca9b 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,6 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/justinas/nosurf v1.2.0 h1:yMs1bSRrNiwXk4AS6n8vL2Ssgpb9CB25T/4xrixaK0s= -github.com/justinas/nosurf v1.2.0/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/internal/api/auth_handlers.go b/internal/api/auth_handlers.go index 99f7ab1..599364d 100644 --- a/internal/api/auth_handlers.go +++ b/internal/api/auth_handlers.go @@ -5,42 +5,24 @@ import ( "encoding/hex" "encoding/json" "net/http" - "strconv" "strings" "time" "github.com/go-chi/chi/v5" - "github.com/justinas/nosurf" "github.com/kindredsystems/silo/internal/auth" ) -// loginPageData holds template data for the login page. -type loginPageData struct { - Error string - Username string - Next string - CSRFToken string - OIDCEnabled bool +// HandleAuthConfig returns public auth configuration for the login page. +func (s *Server) HandleAuthConfig(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "oidc_enabled": s.oidc != nil, + "local_enabled": s.authConfig != nil && s.authConfig.Local.Enabled, + }) } -// HandleLoginPage renders the login page. +// HandleLoginPage redirects to the SPA (React handles the login UI). func (s *Server) HandleLoginPage(w http.ResponseWriter, r *http.Request) { - // If auth is disabled, redirect to home - if s.authConfig == nil || !s.authConfig.Enabled { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - - // If already logged in, redirect to home - if s.sessions != nil { - userID := s.sessions.GetString(r.Context(), "user_id") - if userID != "" { - http.Redirect(w, r, "/", http.StatusSeeOther) - return - } - } - - s.renderLogin(w, r, "") + http.Redirect(w, r, "/", http.StatusSeeOther) } // HandleLogin processes the login form submission. @@ -54,21 +36,21 @@ func (s *Server) HandleLogin(w http.ResponseWriter, r *http.Request) { password := r.FormValue("password") if username == "" || password == "" { - s.renderLogin(w, r, "Username and password are required") + writeError(w, http.StatusBadRequest, "invalid_request", "Username and password are required") return } user, err := s.auth.Authenticate(r.Context(), username, password) if err != nil { s.logger.Warn().Str("username", username).Err(err).Msg("login failed") - s.renderLogin(w, r, "Invalid username or password") + writeError(w, http.StatusUnauthorized, "invalid_credentials", "Invalid username or password") return } // Create session if err := s.sessions.RenewToken(r.Context()); err != nil { s.logger.Error().Err(err).Msg("failed to renew session token") - s.renderLogin(w, r, "Internal error, please try again") + writeError(w, http.StatusInternalServerError, "internal_error", "Internal error, please try again") return } s.sessions.Put(r.Context(), "user_id", user.ID) @@ -183,8 +165,8 @@ func (s *Server) HandleGetCurrentUser(w http.ResponseWriter, r *http.Request) { // createTokenRequest is the request body for token creation. type createTokenRequest struct { - Name string `json:"name"` - ExpiresInDays *int `json:"expires_in_days,omitempty"` + Name string `json:"name"` + ExpiresInDays *int `json:"expires_in_days,omitempty"` } // HandleCreateToken creates a new API token (JSON API). @@ -283,87 +265,6 @@ func (s *Server) HandleRevokeToken(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// HandleSettingsPage renders the settings page. -func (s *Server) HandleSettingsPage(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - tokens, _ := s.auth.ListTokens(r.Context(), user.ID) - - // Retrieve one-time new token from session (if just created) - var newToken string - if s.sessions != nil { - newToken = s.sessions.PopString(r.Context(), "new_token") - } - - data := PageData{ - Title: "Settings", - Page: "settings", - User: user, - CSRFToken: nosurf.Token(r), - Data: map[string]any{ - "tokens": tokens, - "new_token": newToken, - }, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := s.webHandler.templates.ExecuteTemplate(w, "base.html", data); err != nil { - s.logger.Error().Err(err).Msg("failed to render settings page") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -// HandleCreateTokenWeb creates a token via the web form. -func (s *Server) HandleCreateTokenWeb(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - name := strings.TrimSpace(r.FormValue("name")) - if name == "" { - name = "Unnamed token" - } - - var expiresAt *time.Time - if daysStr := r.FormValue("expires_in_days"); daysStr != "" { - if d, err := strconv.Atoi(daysStr); err == nil && d > 0 { - t := time.Now().AddDate(0, 0, d) - expiresAt = &t - } - } - - rawToken, _, err := s.auth.GenerateToken(r.Context(), user.ID, name, nil, expiresAt) - if err != nil { - s.logger.Error().Err(err).Msg("failed to generate token") - http.Redirect(w, r, "/settings", http.StatusSeeOther) - return - } - - // Store the raw token in session for one-time display - s.sessions.Put(r.Context(), "new_token", rawToken) - http.Redirect(w, r, "/settings", http.StatusSeeOther) -} - -// HandleRevokeTokenWeb revokes a token via the web form. -func (s *Server) HandleRevokeTokenWeb(w http.ResponseWriter, r *http.Request) { - user := auth.UserFromContext(r.Context()) - tokenID := chi.URLParam(r, "id") - _ = s.auth.RevokeToken(r.Context(), user.ID, tokenID) - http.Redirect(w, r, "/settings", http.StatusSeeOther) -} - -func (s *Server) renderLogin(w http.ResponseWriter, r *http.Request, errMsg string) { - data := loginPageData{ - Error: errMsg, - Username: r.FormValue("username"), - Next: r.URL.Query().Get("next"), - CSRFToken: nosurf.Token(r), - OIDCEnabled: s.oidc != nil, - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := s.webHandler.templates.ExecuteTemplate(w, "login.html", data); err != nil { - s.logger.Error().Err(err).Msg("failed to render login page") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - func generateRandomState() (string, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { diff --git a/internal/api/bom_handlers.go b/internal/api/bom_handlers.go index 119d6c3..4f4e5eb 100644 --- a/internal/api/bom_handlers.go +++ b/internal/api/bom_handlers.go @@ -455,6 +455,140 @@ func whereUsedToResponse(e *db.BOMEntry) WhereUsedResponse { } } +// Flat BOM and cost response types + +// FlatBOMResponse is the response for GET /api/items/{partNumber}/bom/flat. +type FlatBOMResponse struct { + PartNumber string `json:"part_number"` + FlatBOM []FlatBOMLineResponse `json:"flat_bom"` +} + +// FlatBOMLineResponse represents one consolidated leaf part in a flat BOM. +type FlatBOMLineResponse struct { + PartNumber string `json:"part_number"` + Description string `json:"description"` + TotalQuantity float64 `json:"total_quantity"` +} + +// CostResponse is the response for GET /api/items/{partNumber}/bom/cost. +type CostResponse struct { + PartNumber string `json:"part_number"` + TotalCost float64 `json:"total_cost"` + CostBreakdown []CostLineResponse `json:"cost_breakdown"` +} + +// CostLineResponse represents one line in the cost breakdown. +type CostLineResponse struct { + PartNumber string `json:"part_number"` + Description string `json:"description"` + TotalQuantity float64 `json:"total_quantity"` + UnitCost float64 `json:"unit_cost"` + ExtendedCost float64 `json:"extended_cost"` +} + +// HandleGetFlatBOM returns a flattened, consolidated BOM with rolled-up +// quantities for leaf parts only. +func (s *Server) HandleGetFlatBOM(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + entries, err := s.relationships.GetFlatBOM(ctx, item.ID) + if err != nil { + if strings.Contains(err.Error(), "cycle detected") { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "cycle_detected", + "detail": err.Error(), + }) + return + } + s.logger.Error().Err(err).Msg("failed to get flat BOM") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM") + return + } + + lines := make([]FlatBOMLineResponse, len(entries)) + for i, e := range entries { + lines[i] = FlatBOMLineResponse{ + PartNumber: e.PartNumber, + Description: e.Description, + TotalQuantity: e.TotalQuantity, + } + } + + writeJSON(w, http.StatusOK, FlatBOMResponse{ + PartNumber: partNumber, + FlatBOM: lines, + }) +} + +// HandleGetBOMCost returns the total assembly cost by combining the flat BOM +// with each leaf part's standard_cost. +func (s *Server) HandleGetBOMCost(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + entries, err := s.relationships.GetFlatBOM(ctx, item.ID) + if err != nil { + if strings.Contains(err.Error(), "cycle detected") { + writeJSON(w, http.StatusConflict, map[string]string{ + "error": "cycle_detected", + "detail": err.Error(), + }) + return + } + s.logger.Error().Err(err).Msg("failed to get flat BOM for costing") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to flatten BOM") + return + } + + var totalCost float64 + breakdown := make([]CostLineResponse, len(entries)) + for i, e := range entries { + unitCost := 0.0 + leaf, err := s.items.GetByID(ctx, e.ItemID) + if err == nil && leaf != nil && leaf.StandardCost != nil { + unitCost = *leaf.StandardCost + } + extCost := e.TotalQuantity * unitCost + totalCost += extCost + breakdown[i] = CostLineResponse{ + PartNumber: e.PartNumber, + Description: e.Description, + TotalQuantity: e.TotalQuantity, + UnitCost: unitCost, + ExtendedCost: extCost, + } + } + + writeJSON(w, http.StatusOK, CostResponse{ + PartNumber: partNumber, + TotalCost: totalCost, + CostBreakdown: breakdown, + }) +} + // BOM CSV headers matching the user-specified format. var bomCSVHeaders = []string{ "Item", "Level", "Source", "PN", "Seller Description", diff --git a/internal/api/file_handlers.go b/internal/api/file_handlers.go new file mode 100644 index 0000000..1482d8a --- /dev/null +++ b/internal/api/file_handlers.go @@ -0,0 +1,316 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/kindredsystems/silo/internal/db" +) + +// presignUploadRequest is the request body for generating a presigned upload URL. +type presignUploadRequest struct { + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` +} + +// HandlePresignUpload generates a presigned PUT URL for direct browser upload to MinIO. +func (s *Server) HandlePresignUpload(w http.ResponseWriter, r *http.Request) { + if s.storage == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") + return + } + + var req presignUploadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.Filename == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "Filename is required") + return + } + if req.Size > 500*1024*1024 { + writeError(w, http.StatusBadRequest, "invalid_request", "File size exceeds 500MB limit") + return + } + if req.ContentType == "" { + req.ContentType = "application/octet-stream" + } + + // Generate a unique temp key + id := uuid.New().String() + objectKey := fmt.Sprintf("uploads/tmp/%s/%s", id, req.Filename) + + expiry := 15 * time.Minute + u, err := s.storage.PresignPut(r.Context(), objectKey, expiry) + if err != nil { + s.logger.Error().Err(err).Msg("failed to generate presigned URL") + writeError(w, http.StatusInternalServerError, "presign_failed", "Failed to generate upload URL") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "object_key": objectKey, + "upload_url": u.String(), + "expires_at": time.Now().Add(expiry).Format(time.RFC3339), + }) +} + +// itemFileResponse is the JSON representation of an item file attachment. +type itemFileResponse struct { + ID string `json:"id"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` + ObjectKey string `json:"object_key"` + CreatedAt string `json:"created_at"` +} + +func itemFileToResponse(f *db.ItemFile) itemFileResponse { + return itemFileResponse{ + ID: f.ID, + Filename: f.Filename, + ContentType: f.ContentType, + Size: f.Size, + ObjectKey: f.ObjectKey, + CreatedAt: f.CreatedAt.Format(time.RFC3339), + } +} + +// HandleListItemFiles lists file attachments for an item. +func (s *Server) HandleListItemFiles(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + files, err := s.itemFiles.ListByItem(ctx, item.ID) + if err != nil { + s.logger.Error().Err(err).Msg("failed to list item files") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list files") + return + } + + result := make([]itemFileResponse, 0, len(files)) + for _, f := range files { + result = append(result, itemFileToResponse(f)) + } + + writeJSON(w, http.StatusOK, result) +} + +// associateFileRequest is the request body for associating an uploaded file with an item. +type associateFileRequest struct { + ObjectKey string `json:"object_key"` + Filename string `json:"filename"` + ContentType string `json:"content_type"` + Size int64 `json:"size"` +} + +// HandleAssociateItemFile moves a temp upload to permanent storage and creates a DB record. +func (s *Server) HandleAssociateItemFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + if s.storage == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req associateFileRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.ObjectKey == "" || req.Filename == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "object_key and filename are required") + return + } + + // Security: only allow associating files from the temp upload prefix + if !strings.HasPrefix(req.ObjectKey, "uploads/tmp/") { + writeError(w, http.StatusBadRequest, "invalid_request", "object_key must be a temp upload") + return + } + + if req.ContentType == "" { + req.ContentType = "application/octet-stream" + } + + // Create DB record first to get the file ID + itemFile := &db.ItemFile{ + ItemID: item.ID, + Filename: req.Filename, + ContentType: req.ContentType, + Size: req.Size, + ObjectKey: "", // will be set after copy + } + + // Generate permanent key + fileID := uuid.New().String() + permanentKey := fmt.Sprintf("items/%s/files/%s/%s", item.ID, fileID, req.Filename) + + // Copy from temp to permanent location + if err := s.storage.Copy(ctx, req.ObjectKey, permanentKey); err != nil { + s.logger.Error().Err(err).Str("src", req.ObjectKey).Str("dst", permanentKey).Msg("failed to copy file") + writeError(w, http.StatusInternalServerError, "copy_failed", "Failed to move file to permanent storage") + return + } + + // Delete the temp object (best-effort) + _ = s.storage.Delete(ctx, req.ObjectKey) + + // Save DB record with permanent key + itemFile.ObjectKey = permanentKey + if err := s.itemFiles.Create(ctx, itemFile); err != nil { + s.logger.Error().Err(err).Msg("failed to create item file record") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save file record") + return + } + + s.logger.Info(). + Str("part_number", partNumber). + Str("file_id", itemFile.ID). + Str("filename", req.Filename). + Int64("size", req.Size). + Msg("file associated with item") + + writeJSON(w, http.StatusCreated, itemFileToResponse(itemFile)) +} + +// HandleDeleteItemFile deletes a file attachment and its storage object. +func (s *Server) HandleDeleteItemFile(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + fileID := chi.URLParam(r, "fileId") + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + // Get the file record to find the storage key + file, err := s.itemFiles.Get(ctx, fileID) + if err != nil { + writeError(w, http.StatusNotFound, "not_found", "File not found") + return + } + + // Verify the file belongs to this item + if file.ItemID != item.ID { + writeError(w, http.StatusNotFound, "not_found", "File not found") + return + } + + // Delete from storage (best-effort) + if s.storage != nil { + _ = s.storage.Delete(ctx, file.ObjectKey) + } + + // Delete DB record + if err := s.itemFiles.Delete(ctx, fileID); err != nil { + s.logger.Error().Err(err).Msg("failed to delete item file record") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to delete file") + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// setThumbnailRequest is the request body for setting an item thumbnail. +type setThumbnailRequest struct { + ObjectKey string `json:"object_key"` +} + +// HandleSetItemThumbnail copies a temp upload to the item thumbnail location. +func (s *Server) HandleSetItemThumbnail(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + partNumber := chi.URLParam(r, "partNumber") + + if s.storage == nil { + writeError(w, http.StatusServiceUnavailable, "storage_unavailable", "File storage not configured") + return + } + + item, err := s.items.GetByPartNumber(ctx, partNumber) + if err != nil { + s.logger.Error().Err(err).Msg("failed to get item") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get item") + return + } + if item == nil { + writeError(w, http.StatusNotFound, "not_found", "Item not found") + return + } + + var req setThumbnailRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid_json", err.Error()) + return + } + + if req.ObjectKey == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "object_key is required") + return + } + + // Security: only allow from temp upload prefix + if !strings.HasPrefix(req.ObjectKey, "uploads/tmp/") { + writeError(w, http.StatusBadRequest, "invalid_request", "object_key must be a temp upload") + return + } + + // Copy to permanent thumbnail location + thumbnailKey := fmt.Sprintf("items/%s/thumbnail.png", item.ID) + if err := s.storage.Copy(ctx, req.ObjectKey, thumbnailKey); err != nil { + s.logger.Error().Err(err).Msg("failed to copy thumbnail") + writeError(w, http.StatusInternalServerError, "copy_failed", "Failed to set thumbnail") + return + } + + // Delete temp object (best-effort) + _ = s.storage.Delete(ctx, req.ObjectKey) + + // Update DB + if err := s.items.SetThumbnailKey(ctx, item.ID, thumbnailKey); err != nil { + s.logger.Error().Err(err).Msg("failed to update thumbnail key") + writeError(w, http.StatusInternalServerError, "internal_error", "Failed to save thumbnail") + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index d09f826..83bec81 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -39,7 +39,7 @@ type Server struct { sessions *scs.SessionManager oidc *auth.OIDCBackend authConfig *config.AuthConfig - webHandler *WebHandler + itemFiles *db.ItemFileRepository } // NewServer creates a new API server. @@ -57,6 +57,7 @@ func NewServer( items := db.NewItemRepository(database) projects := db.NewProjectRepository(database) relationships := db.NewRelationshipRepository(database) + itemFiles := db.NewItemFileRepository(database) seqStore := &dbSequenceStore{db: database, schemas: schemas} partgen := partnum.NewGenerator(schemas, seqStore) @@ -74,6 +75,7 @@ func NewServer( sessions: sessionManager, oidc: oidcBackend, authConfig: authCfg, + itemFiles: itemFiles, } } @@ -238,6 +240,7 @@ type ItemResponse struct { SourcingLink *string `json:"sourcing_link,omitempty"` LongDescription *string `json:"long_description,omitempty"` StandardCost *float64 `json:"standard_cost,omitempty"` + ThumbnailKey *string `json:"thumbnail_key,omitempty"` Properties map[string]any `json:"properties,omitempty"` } @@ -1126,6 +1129,7 @@ func itemToResponse(item *db.Item) ItemResponse { SourcingLink: item.SourcingLink, LongDescription: item.LongDescription, StandardCost: item.StandardCost, + ThumbnailKey: item.ThumbnailKey, } } diff --git a/internal/api/middleware.go b/internal/api/middleware.go index d6e8012..f8e0fc4 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -7,7 +7,6 @@ import ( "time" "github.com/go-chi/chi/v5/middleware" - "github.com/justinas/nosurf" "github.com/kindredsystems/silo/internal/auth" "github.com/rs/zerolog" ) @@ -137,25 +136,6 @@ func (s *Server) RequireRole(minimum string) func(http.Handler) http.Handler { } } -// CSRFProtect wraps nosurf for browser-based form submissions. -// API routes (using Bearer token auth) are exempt. -func (s *Server) CSRFProtect(next http.Handler) http.Handler { - csrfHandler := nosurf.New(next) - csrfHandler.SetBaseCookie(http.Cookie{ - HttpOnly: true, - Secure: s.authConfig != nil && s.authConfig.Enabled, - SameSite: http.SameSiteLaxMode, - Path: "/", - }) - csrfHandler.ExemptGlob("/api/*") - csrfHandler.ExemptPath("/health") - csrfHandler.ExemptPath("/ready") - csrfHandler.SetFailureHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - writeError(w, http.StatusForbidden, "csrf_failed", "CSRF token validation failed") - })) - return csrfHandler -} - func extractBearerToken(r *http.Request) string { h := r.Header.Get("Authorization") if strings.HasPrefix(h, "Bearer ") { diff --git a/internal/api/routes.go b/internal/api/routes.go index 40bc295..bac65c1 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -1,9 +1,9 @@ package api import ( - "io/fs" "net/http" "os" + "path/filepath" "strings" "github.com/go-chi/chi/v5" @@ -46,12 +46,6 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Use(server.sessions.LoadAndSave) } - // Web handler for HTML pages - webHandler, err := NewWebHandler(logger, server) - if err != nil { - logger.Fatal().Err(err).Msg("failed to create web handler") - } - // Health endpoints (no auth) r.Get("/health", server.HandleHealth) r.Get("/ready", server.HandleReady) @@ -63,19 +57,8 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/auth/oidc", server.HandleOIDCLogin) r.Get("/auth/callback", server.HandleOIDCCallback) - // Web UI routes (require auth + CSRF) - r.Group(func(r chi.Router) { - r.Use(server.RequireAuth) - r.Use(server.CSRFProtect) - - r.Get("/", webHandler.HandleIndex) - r.Get("/projects", webHandler.HandleProjectsPage) - r.Get("/schemas", webHandler.HandleSchemasPage) - r.Get("/audit", webHandler.HandleAuditPage) - r.Get("/settings", server.HandleSettingsPage) - r.Post("/settings/tokens", server.HandleCreateTokenWeb) - r.Post("/settings/tokens/{id}/revoke", server.HandleRevokeTokenWeb) - }) + // Public API endpoints (no auth required) + r.Get("/api/auth/config", server.HandleAuthConfig) // API routes (require auth, no CSRF — token auth instead) r.Route("/api", func(r chi.Router) { @@ -89,6 +72,12 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Delete("/{id}", server.HandleRevokeToken) }) + // Presigned uploads (editor) + r.Group(func(r chi.Router) { + r.Use(server.RequireRole(auth.RoleEditor)) + r.Post("/uploads/presign", server.HandlePresignUpload) + }) + // Schemas (read: viewer, write: editor) r.Route("/schemas", func(r chi.Router) { r.Get("/", server.HandleListSchemas) @@ -142,10 +131,13 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Get("/revisions", server.HandleListRevisions) r.Get("/revisions/compare", server.HandleCompareRevisions) r.Get("/revisions/{revision}", server.HandleGetRevision) + r.Get("/files", server.HandleListItemFiles) r.Get("/file", server.HandleDownloadLatestFile) r.Get("/file/{revision}", server.HandleDownloadFile) r.Get("/bom", server.HandleGetBOM) r.Get("/bom/expanded", server.HandleGetExpandedBOM) + r.Get("/bom/flat", server.HandleGetFlatBOM) + r.Get("/bom/cost", server.HandleGetBOMCost) r.Get("/bom/where-used", server.HandleGetWhereUsed) r.Get("/bom/export.csv", server.HandleExportBOMCSV) r.Get("/bom/export.ods", server.HandleExportBOMODS) @@ -160,6 +152,9 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { r.Patch("/revisions/{revision}", server.HandleUpdateRevision) r.Post("/revisions/{revision}/rollback", server.HandleRollbackRevision) r.Post("/file", server.HandleUploadFile) + r.Post("/files", server.HandleAssociateItemFile) + r.Delete("/files/{fileId}", server.HandleDeleteItemFile) + r.Put("/thumbnail", server.HandleSetItemThumbnail) r.Post("/bom", server.HandleAddBOMEntry) r.Post("/bom/import", server.HandleImportBOMCSV) r.Put("/bom/{childPartNumber}", server.HandleUpdateBOMEntry) @@ -201,19 +196,19 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler { }) }) - // React SPA (transition period — served at /app/) - if webDir, err := os.Stat("web/dist"); err == nil && webDir.IsDir() { - webFS := os.DirFS("web/dist") - r.Get("/app/*", func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/app/") - if path == "" { - path = "index.html" + // React SPA — serve from web/dist at root, fallback to index.html + if info, err := os.Stat("web/dist"); err == nil && info.IsDir() { + spa := http.FileServerFS(os.DirFS("web/dist")) + r.NotFound(func(w http.ResponseWriter, req *http.Request) { + // Try to serve a static file first + path := strings.TrimPrefix(req.URL.Path, "/") + if f, err := os.Open(filepath.Join("web/dist", path)); err == nil { + f.Close() + spa.ServeHTTP(w, req) + return } - // Try to serve the requested file; fall back to index.html for SPA routing - if _, err := fs.Stat(webFS, path); err != nil { - path = "index.html" - } - http.ServeFileFS(w, r, webFS, path) + // Otherwise serve index.html for SPA client-side routing + http.ServeFileFS(w, req, os.DirFS("web/dist"), "index.html") }) } diff --git a/internal/api/templates/audit.html b/internal/api/templates/audit.html deleted file mode 100644 index f150c5b..0000000 --- a/internal/api/templates/audit.html +++ /dev/null @@ -1,1014 +0,0 @@ -{{define "audit_content"}} - -
-
- 0 - Critical -
-
- 0 - Low -
-
- 0 - Partial -
-
- 0 - Good -
-
- 0 - Complete -
-
- - -
-
- - - Total Items -
-
- - - Avg Score -
-
- - - Mfg w/o BOM -
-
- - -
-
-

Component Audit

-
- -
-
- -
- - -
-
-
- - - - - - - - - - - - - - -
ScorePart NumberDescriptionCategorySourcingMissing
-
- -
- -
-
- - - - - -

Select an item to audit

-

Click a row to see field-by-field breakdown

-
- -
-
- - -{{end}} - -{{define "audit_scripts"}} - -{{end}} diff --git a/internal/api/templates/base.html b/internal/api/templates/base.html deleted file mode 100644 index 03c8892..0000000 --- a/internal/api/templates/base.html +++ /dev/null @@ -1,528 +0,0 @@ - - - - - - {{if .Title}}{{.Title}} - {{end}}Silo - - - -
-
-

Silo

-
- - {{if .User}} -
- {{.User.DisplayName}} - {{.User.Role}} -
- -
-
- {{end}} -
- -
- {{if eq .Page "items"}} - {{template "items_content" .}} - {{else if eq .Page "projects"}} - {{template "projects_content" .}} - {{else if eq .Page "schemas"}} - {{template "schemas_content" .}} - {{else if eq .Page "audit"}} - {{template "audit_content" .}} - {{else if eq .Page "settings"}} - {{template "settings_content" .}} - {{end}} -
- - {{if eq .Page "items"}} - {{template "items_scripts" .}} - {{else if eq .Page "projects"}} - {{template "projects_scripts" .}} - {{else if eq .Page "schemas"}} - {{template "schemas_scripts" .}} - {{else if eq .Page "audit"}} - {{template "audit_scripts" .}} - {{else if eq .Page "settings"}} - {{template "settings_scripts" .}} - {{end}} - - diff --git a/internal/api/templates/items.html b/internal/api/templates/items.html deleted file mode 100644 index b49e6aa..0000000 --- a/internal/api/templates/items.html +++ /dev/null @@ -1,4243 +0,0 @@ -{{define "items_content"}} -
-
-
-
-
Total Items
-
-
-
-
-
Parts
-
-
-
-
-
Assemblies
-
-
-
-
-
Documents
-
-
- - -
-
-

Items

-
- -
- - -
- -
- - -
- -
- - -
- -
-
- - -
- - - - - -
- -
-
- - - - - - - - - - - - - - - - -
Part NumberTypeDescriptionRevisionCreatedActions
-
-
-
-
-
- -
- - -
-
- - - - -

Select an item to view details

-

- Click a row in the item list, or use Ctrl+F to search -

-
- -
-
- - - - - - - - - - - - -{{end}} {{define "items_scripts"}} - - -{{end}} diff --git a/internal/api/templates/login.html b/internal/api/templates/login.html deleted file mode 100644 index 6c20fd3..0000000 --- a/internal/api/templates/login.html +++ /dev/null @@ -1,222 +0,0 @@ - - - - - - Login - Silo - - - - - - diff --git a/internal/api/templates/projects.html b/internal/api/templates/projects.html deleted file mode 100644 index fd8123a..0000000 --- a/internal/api/templates/projects.html +++ /dev/null @@ -1,300 +0,0 @@ -{{define "projects_content"}} -
-
-
-
-
Total Projects
-
-
- -
-
-

Projects

- -
- -
- - - - - - - - - - - - - - - - -
CodeNameDescriptionItemsCreatedActions
-
-
-
-
- - - - - - - - - -{{end}} {{define "projects_scripts"}} - -{{end}} diff --git a/internal/api/templates/schemas.html b/internal/api/templates/schemas.html deleted file mode 100644 index 96b4918..0000000 --- a/internal/api/templates/schemas.html +++ /dev/null @@ -1,399 +0,0 @@ -{{define "schemas_content"}} -
-
-

Part Numbering Schemas

-
- -
-
-
-
-
-
- - - - - - - - - -{{end}} {{define "schemas_scripts"}} - - -{{end}} diff --git a/internal/api/templates/settings.html b/internal/api/templates/settings.html deleted file mode 100644 index e495731..0000000 --- a/internal/api/templates/settings.html +++ /dev/null @@ -1,291 +0,0 @@ -{{define "settings_content"}} - - -
-
-
-

Account

-
- {{if .User}} -
-
Username
-
{{.User.Username}}
-
Display Name
-
{{.User.DisplayName}}
-
Email
-
- {{if .User.Email}}{{.User.Email}}{{else}}Not set{{end}} -
-
Auth Source
-
{{.User.AuthSource}}
-
Role
-
- {{.User.Role}} -
-
- {{end}} -
-
- -
-
-
-

API Tokens

-
-

- API tokens allow the FreeCAD plugin and scripts to authenticate with - Silo. Tokens inherit your role permissions. -

- - {{if and .Data (index .Data "new_token")}} {{if ne (index .Data - "new_token") ""}} -
-

Your new API token (copy it now — it won't be shown again):

- {{index .Data "new_token"}} - -

- Store this token securely. You will not be able to see it again. -

-
- {{end}} {{end}} - -
- -
- - -
- -
- -
- - - - - - - - - - - - -
NamePrefixCreatedLast UsedExpiresActions
-
-
-
-{{end}} {{define "settings_scripts"}} - -{{end}} diff --git a/internal/api/web.go b/internal/api/web.go deleted file mode 100644 index 6f7464e..0000000 --- a/internal/api/web.go +++ /dev/null @@ -1,118 +0,0 @@ -package api - -import ( - "embed" - "html/template" - "net/http" - - "github.com/justinas/nosurf" - "github.com/kindredsystems/silo/internal/auth" - "github.com/rs/zerolog" -) - -//go:embed templates/*.html -var templatesFS embed.FS - -// WebHandler serves HTML pages. -type WebHandler struct { - templates *template.Template - logger zerolog.Logger - server *Server -} - -// NewWebHandler creates a new web handler. -func NewWebHandler(logger zerolog.Logger, server *Server) (*WebHandler, error) { - tmpl, err := template.ParseFS(templatesFS, "templates/*.html") - if err != nil { - return nil, err - } - - wh := &WebHandler{ - templates: tmpl, - logger: logger, - server: server, - } - - // Store reference on server for auth handlers to use templates - server.webHandler = wh - - return wh, nil -} - -// PageData holds data for page rendering. -type PageData struct { - Title string - Page string - Data any - User *auth.User - CSRFToken string -} - -// HandleIndex serves the main items page. -func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - data := PageData{ - Title: "Items", - Page: "items", - User: auth.UserFromContext(r.Context()), - CSRFToken: nosurf.Token(r), - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { - h.logger.Error().Err(err).Msg("failed to render template") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -// HandleProjectsPage serves the projects page. -func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) { - data := PageData{ - Title: "Projects", - Page: "projects", - User: auth.UserFromContext(r.Context()), - CSRFToken: nosurf.Token(r), - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { - h.logger.Error().Err(err).Msg("failed to render template") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -// HandleSchemasPage serves the schemas page. -func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) { - data := PageData{ - Title: "Schemas", - Page: "schemas", - User: auth.UserFromContext(r.Context()), - CSRFToken: nosurf.Token(r), - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { - h.logger.Error().Err(err).Msg("failed to render template") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} - -// HandleAuditPage serves the component audit page. -func (h *WebHandler) HandleAuditPage(w http.ResponseWriter, r *http.Request) { - data := PageData{ - Title: "Audit", - Page: "audit", - User: auth.UserFromContext(r.Context()), - CSRFToken: nosurf.Token(r), - } - - w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil { - h.logger.Error().Err(err).Msg("failed to render template") - http.Error(w, "Internal Server Error", http.StatusInternalServerError) - } -} diff --git a/internal/db/item_files.go b/internal/db/item_files.go new file mode 100644 index 0000000..736c430 --- /dev/null +++ b/internal/db/item_files.go @@ -0,0 +1,91 @@ +package db + +import ( + "context" + "fmt" + "time" +) + +// ItemFile represents a file attachment on an item. +type ItemFile struct { + ID string + ItemID string + Filename string + ContentType string + Size int64 + ObjectKey string + CreatedAt time.Time +} + +// ItemFileRepository provides item_files database operations. +type ItemFileRepository struct { + db *DB +} + +// NewItemFileRepository creates a new item file repository. +func NewItemFileRepository(db *DB) *ItemFileRepository { + return &ItemFileRepository{db: db} +} + +// Create inserts a new item file record. +func (r *ItemFileRepository) Create(ctx context.Context, f *ItemFile) error { + err := r.db.pool.QueryRow(ctx, + `INSERT INTO item_files (item_id, filename, content_type, size, object_key) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, created_at`, + f.ItemID, f.Filename, f.ContentType, f.Size, f.ObjectKey, + ).Scan(&f.ID, &f.CreatedAt) + if err != nil { + return fmt.Errorf("creating item file: %w", err) + } + return nil +} + +// ListByItem returns all file attachments for an item. +func (r *ItemFileRepository) ListByItem(ctx context.Context, itemID string) ([]*ItemFile, error) { + rows, err := r.db.pool.Query(ctx, + `SELECT id, item_id, filename, content_type, size, object_key, created_at + FROM item_files WHERE item_id = $1 ORDER BY created_at`, + itemID, + ) + if err != nil { + return nil, fmt.Errorf("listing item files: %w", err) + } + defer rows.Close() + + var files []*ItemFile + for rows.Next() { + f := &ItemFile{} + if err := rows.Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.CreatedAt); err != nil { + return nil, fmt.Errorf("scanning item file: %w", err) + } + files = append(files, f) + } + return files, nil +} + +// Get returns a single item file by ID. +func (r *ItemFileRepository) Get(ctx context.Context, id string) (*ItemFile, error) { + f := &ItemFile{} + err := r.db.pool.QueryRow(ctx, + `SELECT id, item_id, filename, content_type, size, object_key, created_at + FROM item_files WHERE id = $1`, + id, + ).Scan(&f.ID, &f.ItemID, &f.Filename, &f.ContentType, &f.Size, &f.ObjectKey, &f.CreatedAt) + if err != nil { + return nil, fmt.Errorf("getting item file: %w", err) + } + return f, nil +} + +// Delete removes an item file record. +func (r *ItemFileRepository) Delete(ctx context.Context, id string) error { + tag, err := r.db.pool.Exec(ctx, `DELETE FROM item_files WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("deleting item file: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("item file not found") + } + return nil +} diff --git a/internal/db/items.go b/internal/db/items.go index ad1f40f..e8c28f0 100644 --- a/internal/db/items.go +++ b/internal/db/items.go @@ -28,6 +28,7 @@ type Item struct { SourcingLink *string // URL to supplier/datasheet LongDescription *string // extended description StandardCost *float64 // baseline unit cost + ThumbnailKey *string // MinIO key for item thumbnail } // Revision represents a revision record. @@ -132,7 +133,8 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, - sourcing_type, sourcing_link, long_description, standard_cost + sourcing_type, sourcing_link, long_description, standard_cost, + thumbnail_key FROM items WHERE part_number = $1 AND archived_at IS NULL `, partNumber).Scan( @@ -140,6 +142,7 @@ func (r *ItemRepository) GetByPartNumber(ctx context.Context, partNumber string) &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.ThumbnailKey, ) if err == pgx.ErrNoRows { return nil, nil @@ -157,7 +160,8 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, cad_synced_at, cad_file_path, - sourcing_type, sourcing_link, long_description, standard_cost + sourcing_type, sourcing_link, long_description, standard_cost, + thumbnail_key FROM items WHERE id = $1 `, id).Scan( @@ -165,6 +169,7 @@ func (r *ItemRepository) GetByID(ctx context.Context, id string) (*Item, error) &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.ThumbnailKey, ) if err == pgx.ErrNoRows { return nil, nil @@ -187,7 +192,8 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e query = ` SELECT DISTINCT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost + i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.thumbnail_key FROM items i JOIN item_projects ip ON ip.item_id = i.id JOIN projects p ON p.id = ip.project_id @@ -199,7 +205,8 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e query = ` SELECT id, part_number, schema_id, item_type, description, created_at, updated_at, archived_at, current_revision, - sourcing_type, sourcing_link, long_description, standard_cost + sourcing_type, sourcing_link, long_description, standard_cost, + thumbnail_key FROM items WHERE archived_at IS NULL ` @@ -251,6 +258,7 @@ func (r *ItemRepository) List(ctx context.Context, opts ListOptions) ([]*Item, e &item.ID, &item.PartNumber, &item.SchemaID, &item.ItemType, &item.Description, &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.ThumbnailKey, ) if err != nil { return nil, fmt.Errorf("scanning item: %w", err) @@ -679,6 +687,18 @@ func (r *ItemRepository) Update(ctx context.Context, id string, fields UpdateIte return nil } +// SetThumbnailKey updates the thumbnail_key on an item. +func (r *ItemRepository) SetThumbnailKey(ctx context.Context, itemID string, key string) error { + _, err := r.db.pool.Exec(ctx, + `UPDATE items SET thumbnail_key = $1, updated_at = now() WHERE id = $2`, + key, itemID, + ) + if err != nil { + return fmt.Errorf("setting thumbnail key: %w", err) + } + return nil +} + // Delete permanently removes an item and all its revisions. func (r *ItemRepository) Delete(ctx context.Context, id string) error { _, err := r.db.pool.Exec(ctx, ` diff --git a/internal/db/projects.go b/internal/db/projects.go index 7c71524..cee95d1 100644 --- a/internal/db/projects.go +++ b/internal/db/projects.go @@ -240,7 +240,8 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st SELECT i.id, i.part_number, i.schema_id, i.item_type, i.description, i.created_at, i.updated_at, i.archived_at, i.current_revision, i.cad_synced_at, i.cad_file_path, - i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost + i.sourcing_type, i.sourcing_link, i.long_description, i.standard_cost, + i.thumbnail_key FROM items i JOIN item_projects ip ON ip.item_id = i.id WHERE ip.project_id = $1 AND i.archived_at IS NULL @@ -259,6 +260,7 @@ func (r *ProjectRepository) GetItemsForProject(ctx context.Context, projectID st &item.CreatedAt, &item.UpdatedAt, &item.ArchivedAt, &item.CurrentRevision, &item.CADSyncedAt, &item.CADFilePath, &item.SourcingType, &item.SourcingLink, &item.LongDescription, &item.StandardCost, + &item.ThumbnailKey, ); err != nil { return nil, err } diff --git a/internal/db/relationships.go b/internal/db/relationships.go index 37ce28d..8f6ecb3 100644 --- a/internal/db/relationships.go +++ b/internal/db/relationships.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "sort" + "strings" "time" "github.com/jackc/pgx/v5" @@ -421,6 +423,123 @@ func (r *RelationshipRepository) HasCycle(ctx context.Context, parentItemID, chi return hasCycle, nil } +// FlatBOMEntry represents a leaf part with its total rolled-up quantity +// across the entire BOM tree. +type FlatBOMEntry struct { + ItemID string + PartNumber string + Description string + TotalQuantity float64 +} + +// GetFlatBOM returns a consolidated list of leaf parts (parts with no BOM +// children) with total quantities rolled up through the tree. Quantities +// are multiplied through each nesting level. Cycles are detected and +// returned as an error containing the offending path. +func (r *RelationshipRepository) GetFlatBOM(ctx context.Context, rootItemID string) ([]*FlatBOMEntry, error) { + type stackItem struct { + itemID string + partNumber string + description string + qty float64 + path []string // part numbers visited on this branch for cycle detection + } + + // Seed the stack with the root's direct children. + rootBOM, err := r.GetBOM(ctx, rootItemID) + if err != nil { + return nil, fmt.Errorf("getting root BOM: %w", err) + } + + // Find root part number for the cycle path. + var rootPN string + if len(rootBOM) > 0 { + rootPN = rootBOM[0].ParentPartNumber + } + + stack := make([]stackItem, 0, len(rootBOM)) + for _, e := range rootBOM { + qty := 1.0 + if e.Quantity != nil { + qty = *e.Quantity + } + stack = append(stack, stackItem{ + itemID: e.ChildItemID, + partNumber: e.ChildPartNumber, + description: e.ChildDescription, + qty: qty, + path: []string{rootPN}, + }) + } + + // Accumulate leaf quantities keyed by item ID. + leaves := make(map[string]*FlatBOMEntry) + + for len(stack) > 0 { + // Pop + cur := stack[len(stack)-1] + stack = stack[:len(stack)-1] + + // Cycle detection: check if current part number is already in the path. + for _, pn := range cur.path { + if pn == cur.partNumber { + cyclePath := append(cur.path, cur.partNumber) + return nil, fmt.Errorf("BOM cycle detected: %s", strings.Join(cyclePath, " → ")) + } + } + + // Get this item's children. + children, err := r.GetBOM(ctx, cur.itemID) + if err != nil { + return nil, fmt.Errorf("getting BOM for %s: %w", cur.partNumber, err) + } + + if len(children) == 0 { + // Leaf node — accumulate quantity. + if existing, ok := leaves[cur.itemID]; ok { + existing.TotalQuantity += cur.qty + } else { + leaves[cur.itemID] = &FlatBOMEntry{ + ItemID: cur.itemID, + PartNumber: cur.partNumber, + Description: cur.description, + TotalQuantity: cur.qty, + } + } + } else { + // Sub-assembly — push children with multiplied quantity. + newPath := make([]string, len(cur.path)+1) + copy(newPath, cur.path) + newPath[len(cur.path)] = cur.partNumber + + for _, child := range children { + childQty := 1.0 + if child.Quantity != nil { + childQty = *child.Quantity + } + stack = append(stack, stackItem{ + itemID: child.ChildItemID, + partNumber: child.ChildPartNumber, + description: child.ChildDescription, + qty: cur.qty * childQty, + path: newPath, + }) + } + } + } + + // Sort by part number. + result := make([]*FlatBOMEntry, 0, len(leaves)) + for _, e := range leaves { + result = append(result, e) + } + sort.Slice(result, func(i, j int) bool { + return result[i].PartNumber < result[j].PartNumber + }) + + return result, nil +} + // scanBOMEntries reads rows into BOMEntry slices. func scanBOMEntries(rows pgx.Rows) ([]*BOMEntry, error) { var entries []*BOMEntry diff --git a/internal/storage/storage.go b/internal/storage/storage.go index c0e9c61..81d9a4c 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -5,6 +5,8 @@ import ( "context" "fmt" "io" + "net/url" + "time" "github.com/minio/minio-go/v7" "github.com/minio/minio-go/v7/pkg/credentials" @@ -110,6 +112,36 @@ func (s *Storage) Delete(ctx context.Context, key string) error { return nil } +// Bucket returns the bucket name. +func (s *Storage) Bucket() string { + return s.bucket +} + +// PresignPut generates a presigned PUT URL for direct browser upload. +func (s *Storage) PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error) { + u, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry) + if err != nil { + return nil, fmt.Errorf("generating presigned put URL: %w", err) + } + return u, nil +} + +// Copy copies an object within the same bucket from srcKey to dstKey. +func (s *Storage) Copy(ctx context.Context, srcKey, dstKey string) error { + src := minio.CopySrcOptions{ + Bucket: s.bucket, + Object: srcKey, + } + dst := minio.CopyDestOptions{ + Bucket: s.bucket, + Object: dstKey, + } + if _, err := s.client.CopyObject(ctx, dst, src); err != nil { + return fmt.Errorf("copying object: %w", err) + } + return nil +} + // FileKey generates a storage key for an item file. func FileKey(partNumber string, revision int) string { return fmt.Sprintf("items/%s/rev%d.FCStd", partNumber, revision) diff --git a/migrations/011_item_files.sql b/migrations/011_item_files.sql new file mode 100644 index 0000000..12a05f3 --- /dev/null +++ b/migrations/011_item_files.sql @@ -0,0 +1,18 @@ +-- Migration 011: Item File Attachments +-- +-- Adds an item_files table for multiple file attachments per item (not tied to revisions), +-- and a thumbnail_key column on items for item-level thumbnails. + +CREATE TABLE item_files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + content_type TEXT NOT NULL DEFAULT 'application/octet-stream', + size BIGINT NOT NULL DEFAULT 0, + object_key TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_item_files_item ON item_files(item_id); + +ALTER TABLE items ADD COLUMN IF NOT EXISTS thumbnail_key TEXT; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b1dce5e..f93df11 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -1,458 +1,154 @@ -#!/usr/bin/env bash +#!/bin/bash +# Deploy Silo to silo.kindred.internal # -# Silo Deployment Script -# Pulls from git and deploys silod on the local machine +# Usage: ./scripts/deploy.sh [host] +# host defaults to silo.kindred.internal # -# Usage: -# sudo ./scripts/deploy.sh [options] -# -# Options: -# --no-pull Skip git pull (use current checkout) -# --no-build Skip build (use existing binary) -# --restart-only Only restart the service -# --status Show service status and exit -# --help Show this help message -# -# This script should be run on silo.kindred.internal as root or with sudo. +# Prerequisites: +# - SSH access to the target host +# - /etc/silo/silod.env must exist on target with credentials filled in +# - PostgreSQL reachable from target at psql.kindred.internal +# - MinIO reachable from target at minio.kindred.internal set -euo pipefail -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -NC='\033[0m' # No Color - -# Configuration -REPO_URL="${SILO_REPO_URL:-https://gitea.kindred.internal/kindred/silo-0062.git}" -REPO_BRANCH="${SILO_BRANCH:-main}" -INSTALL_DIR="/opt/silo" +TARGET="${1:-silo.kindred.internal}" +DEPLOY_DIR="/opt/silo" CONFIG_DIR="/etc/silo" -BINARY_NAME="silod" -SERVICE_NAME="silod" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="${SCRIPT_DIR}/.." -# Flags -DO_PULL=true -DO_BUILD=true -RESTART_ONLY=false -STATUS_ONLY=false +echo "=== Silo Deploy to ${TARGET} ===" -# Functions -log_info() { - echo -e "${BLUE}[INFO]${NC} $*" -} +# --- Build locally --- +echo "[1/6] Building Go binary..." +cd "$PROJECT_DIR" +GOOS=linux GOARCH=amd64 go build -o silod ./cmd/silod -log_success() { - echo -e "${GREEN}[OK]${NC} $*" -} +echo "[2/6] Building React frontend..." +cd "$PROJECT_DIR/web" +npm run build -log_warn() { - echo -e "${YELLOW}[WARN]${NC} $*" -} +# --- Package --- +echo "[3/6] Packaging..." +STAGING=$(mktemp -d) +trap "rm -rf $STAGING" EXIT -log_error() { - echo -e "${RED}[ERROR]${NC} $*" >&2 -} +mkdir -p "$STAGING/bin" +mkdir -p "$STAGING/web" +mkdir -p "$STAGING/schemas" +mkdir -p "$STAGING/migrations" -die() { - log_error "$*" +cp "$PROJECT_DIR/silod" "$STAGING/bin/silod" +cp -r "$PROJECT_DIR/web/dist" "$STAGING/web/dist" +cp "$PROJECT_DIR/schemas/"*.yaml "$STAGING/schemas/" +cp "$PROJECT_DIR/migrations/"*.sql "$STAGING/migrations/" +cp "$PROJECT_DIR/deployments/config.prod.yaml" "$STAGING/config.yaml" +cp "$PROJECT_DIR/deployments/systemd/silod.service" "$STAGING/silod.service" +cp "$PROJECT_DIR/deployments/systemd/silod.env.example" "$STAGING/silod.env.example" + +TARBALL=$(mktemp --suffix=.tar.gz) +tar -czf "$TARBALL" -C "$STAGING" . +echo " Package: $(du -h "$TARBALL" | cut -f1)" + +# --- Deploy --- +echo "[4/6] Uploading to ${TARGET}..." +scp "$TARBALL" "${TARGET}:/tmp/silo-deploy.tar.gz" + +echo "[5/6] Installing on ${TARGET}..." +ssh "$TARGET" bash -s <<'REMOTE' +set -euo pipefail + +DEPLOY_DIR="/opt/silo" +CONFIG_DIR="/etc/silo" + +# Create directories +sudo mkdir -p "$DEPLOY_DIR/bin" "$DEPLOY_DIR/web" "$DEPLOY_DIR/schemas" "$DEPLOY_DIR/migrations" +sudo mkdir -p "$CONFIG_DIR" + +# Stop service if running +if systemctl is-active --quiet silod 2>/dev/null; then + echo " Stopping silod..." + sudo systemctl stop silod +fi + +# Extract +echo " Extracting..." +sudo tar -xzf /tmp/silo-deploy.tar.gz -C "$DEPLOY_DIR" +sudo chmod +x "$DEPLOY_DIR/bin/silod" +rm -f /tmp/silo-deploy.tar.gz + +# Install config if not present (don't overwrite existing) +if [ ! -f "$CONFIG_DIR/config.yaml" ]; then + echo " Installing default config..." + sudo cp "$DEPLOY_DIR/config.yaml" "$CONFIG_DIR/config.yaml" 2>/dev/null || true +fi + +# Install env template if not present +if [ ! -f "$CONFIG_DIR/silod.env" ]; then + echo " WARNING: /etc/silo/silod.env does not exist!" + echo " Copying template..." + sudo cp "$DEPLOY_DIR/silod.env.example" "$CONFIG_DIR/silod.env" + echo " Edit /etc/silo/silod.env with your credentials before starting the service." +fi + +# Install systemd service +echo " Installing systemd service..." +sudo cp "$DEPLOY_DIR/silod.service" /etc/systemd/system/silod.service + +# Set ownership +sudo chown -R silo:silo "$DEPLOY_DIR" 2>/dev/null || true +sudo chmod 600 "$CONFIG_DIR/silod.env" 2>/dev/null || true + +echo " Files installed to $DEPLOY_DIR" +REMOTE + +echo "[6/6] Running migrations and starting service..." +ssh "$TARGET" bash -s <<'REMOTE' +set -euo pipefail + +DEPLOY_DIR="/opt/silo" +CONFIG_DIR="/etc/silo" + +# Source env for migration +if [ -f "$CONFIG_DIR/silod.env" ]; then + set -a + source "$CONFIG_DIR/silod.env" + set +a +fi + +# Run migrations +if command -v psql &>/dev/null && [ -n "${SILO_DB_PASSWORD:-}" ]; then + echo " Running migrations..." + for f in "$DEPLOY_DIR/migrations/"*.sql; do + echo " $(basename "$f")" + PGPASSWORD="$SILO_DB_PASSWORD" psql \ + -h psql.kindred.internal -p 5432 \ + -U silo -d silo \ + -f "$f" -q 2>&1 | grep -v "already exists" || true + done + echo " Migrations complete." +else + echo " WARNING: psql not available or SILO_DB_PASSWORD not set, skipping migrations." + echo " Run migrations manually: PGPASSWORD=... psql -h psql.kindred.internal -U silo -d silo -f /opt/silo/migrations/NNN_name.sql" +fi + +# Start service +echo " Starting silod..." +sudo systemctl daemon-reload +sudo systemctl enable silod +sudo systemctl start silod +sleep 2 +if systemctl is-active --quiet silod; then + echo " silod is running!" +else + echo " ERROR: silod failed to start. Check: journalctl -u silod -n 50" exit 1 -} - -show_help() { - head -18 "$0" | grep -E '^#' | sed 's/^# *//' - exit 0 -} - -check_root() { - if [[ $EUID -ne 0 ]]; then - die "This script must be run as root (use sudo)" - fi -} - -check_dependencies() { - log_info "Checking dependencies..." - - # Add common Go install locations to PATH - if [[ -d /usr/local/go/bin ]]; then - export PATH=$PATH:/usr/local/go/bin - fi - if [[ -d /opt/go/bin ]]; then - export PATH=$PATH:/opt/go/bin - fi - - local missing=() - - command -v git >/dev/null 2>&1 || missing+=("git") - command -v go >/dev/null 2>&1 || missing+=("go (golang)") - command -v systemctl >/dev/null 2>&1 || missing+=("systemctl") - - if [[ ${#missing[@]} -gt 0 ]]; then - die "Missing required commands: ${missing[*]}" - fi - - # Check Go version - local go_version - go_version=$(go version | grep -oP 'go\d+\.\d+' | head -1) - log_info "Found Go version: ${go_version}" - - log_success "All dependencies available" -} - -setup_directories() { - log_info "Setting up directories..." - - # Create directories if they don't exist - mkdir -p "${INSTALL_DIR}/bin" - mkdir -p "${INSTALL_DIR}/src" - mkdir -p "${CONFIG_DIR}/schemas" - mkdir -p /var/log/silo - - # Create silo user if it doesn't exist - if ! id -u silo >/dev/null 2>&1; then - useradd -r -m -d "${INSTALL_DIR}" -s /sbin/nologin -c "Silo Service" silo - log_info "Created silo user" - fi - - log_success "Directories ready" -} - -git_pull() { - log_info "Pulling latest code from ${REPO_BRANCH}..." - - local src_dir="${INSTALL_DIR}/src" - - if [[ -d "${src_dir}/.git" ]]; then - # Existing checkout - pull updates - cd "${src_dir}" - git fetch origin - git checkout "${REPO_BRANCH}" - git reset --hard "origin/${REPO_BRANCH}" - log_success "Updated to $(git rev-parse --short HEAD)" - else - # Fresh clone - log_info "Cloning repository..." - rm -rf "${src_dir}" - git clone --branch "${REPO_BRANCH}" "${REPO_URL}" "${src_dir}" - cd "${src_dir}" - log_success "Cloned $(git rev-parse --short HEAD)" - fi - - # Show version info - local version - version=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD) - log_info "Version: ${version}" -} - -build_binary() { - log_info "Building ${BINARY_NAME}..." - - local src_dir="${INSTALL_DIR}/src" - cd "${src_dir}" - - # Get version from git - local version - version=$(git describe --tags --always --dirty 2>/dev/null || git rev-parse --short HEAD) - - local ldflags="-w -s -X main.Version=${version}" - - # Build - CGO_ENABLED=0 go build -ldflags="${ldflags}" -o "${INSTALL_DIR}/bin/${BINARY_NAME}" ./cmd/silod - - if [[ ! -f "${INSTALL_DIR}/bin/${BINARY_NAME}" ]]; then - die "Build failed: binary not found" - fi - - chmod 755 "${INSTALL_DIR}/bin/${BINARY_NAME}" - - local size - size=$(du -h "${INSTALL_DIR}/bin/${BINARY_NAME}" | cut -f1) - log_success "Built ${BINARY_NAME} (${size})" -} - -install_config() { - log_info "Installing configuration..." - - local src_dir="${INSTALL_DIR}/src" - - # Install config file if it doesn't exist or is different - if [[ ! -f "${CONFIG_DIR}/config.yaml" ]]; then - cp "${src_dir}/deployments/config.prod.yaml" "${CONFIG_DIR}/config.yaml" - chmod 644 "${CONFIG_DIR}/config.yaml" - chown root:silo "${CONFIG_DIR}/config.yaml" - log_info "Installed config.yaml" - else - log_info "Config file exists, not overwriting" - fi - - # Install schemas (always update) - rm -rf "${CONFIG_DIR}/schemas/"* - cp -r "${src_dir}/schemas/"* "${CONFIG_DIR}/schemas/" - chmod -R 644 "${CONFIG_DIR}/schemas/"* - chown -R root:silo "${CONFIG_DIR}/schemas" - log_success "Schemas installed" - - # Check environment file - if [[ ! -f "${CONFIG_DIR}/silod.env" ]]; then - cp "${src_dir}/deployments/systemd/silod.env.example" "${CONFIG_DIR}/silod.env" - chmod 600 "${CONFIG_DIR}/silod.env" - chown root:silo "${CONFIG_DIR}/silod.env" - log_warn "Created ${CONFIG_DIR}/silod.env - EDIT THIS FILE WITH CREDENTIALS!" - fi -} - -install_systemd() { - log_info "Installing systemd service..." - - local src_dir="${INSTALL_DIR}/src" - local service_file="${src_dir}/deployments/systemd/silod.service" - - if [[ -f "${service_file}" ]]; then - cp "${service_file}" /etc/systemd/system/silod.service - chmod 644 /etc/systemd/system/silod.service - systemctl daemon-reload - log_success "Systemd service installed" - else - die "Service file not found: ${service_file}" - fi -} - -set_permissions() { - log_info "Setting permissions..." - - chown -R silo:silo "${INSTALL_DIR}" - chown root:silo "${CONFIG_DIR}" - chmod 750 "${CONFIG_DIR}" - chown silo:silo /var/log/silo - chmod 750 /var/log/silo - - # Binary should be owned by root but executable by silo - chown root:root "${INSTALL_DIR}/bin/${BINARY_NAME}" - chmod 755 "${INSTALL_DIR}/bin/${BINARY_NAME}" - - log_success "Permissions set" -} - -restart_service() { - log_info "Restarting ${SERVICE_NAME} service..." - - # Enable if not already - systemctl enable "${SERVICE_NAME}" >/dev/null 2>&1 || true - - # Restart - systemctl restart "${SERVICE_NAME}" - - # Wait for startup - sleep 2 - - if systemctl is-active --quiet "${SERVICE_NAME}"; then - log_success "Service started successfully" - else - log_error "Service failed to start" - journalctl -u "${SERVICE_NAME}" -n 20 --no-pager || true - die "Deployment failed: service not running" - fi -} - -run_migrations() { - log_info "Running database migrations..." - - local src_dir="${INSTALL_DIR}/src" - local migrations_dir="${src_dir}/migrations" - local env_file="${CONFIG_DIR}/silod.env" - - if [[ ! -d "${migrations_dir}" ]]; then - die "Migrations directory not found: ${migrations_dir}" - fi - - # Source env file for DB password - if [[ -f "${env_file}" ]]; then - # shellcheck disable=SC1090 - source "${env_file}" - fi - - # Read DB config from production config - local db_host db_port db_name db_user db_password - db_host=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'host:' | head -1 | awk '{print $2}' | tr -d '"') - db_port=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'port:' | head -1 | awk '{print $2}') - db_name=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'name:' | head -1 | awk '{print $2}' | tr -d '"') - db_user=$(grep -A10 '^database:' "${CONFIG_DIR}/config.yaml" | grep 'user:' | head -1 | awk '{print $2}' | tr -d '"') - db_password="${SILO_DB_PASSWORD:-}" - - db_host="${db_host:-localhost}" - db_port="${db_port:-5432}" - db_name="${db_name:-silo}" - db_user="${db_user:-silo}" - - if [[ -z "${db_password}" ]]; then - log_warn "SILO_DB_PASSWORD not set — skipping migrations" - log_warn "Run migrations manually: psql -h ${db_host} -U ${db_user} -d ${db_name} -f migrations/009_auth.sql" - return 0 - fi - - # Check psql is available - if ! command -v psql >/dev/null 2>&1; then - log_warn "psql not found — skipping automatic migrations" - log_warn "Run migrations manually: psql -h ${db_host} -U ${db_user} -d ${db_name} -f migrations/009_auth.sql" - return 0 - fi - - # Wait for database to be reachable - local retries=0 - while ! PGPASSWORD="${db_password}" psql -h "${db_host}" -p "${db_port}" -U "${db_user}" -d "${db_name}" -c '\q' 2>/dev/null; do - retries=$((retries + 1)) - if [[ ${retries} -ge 5 ]]; then - log_warn "Could not connect to database after 5 attempts — skipping migrations" - return 0 - fi - log_info "Waiting for database... (attempt ${retries}/5)" - sleep 2 - done - - # Apply each migration, skipping ones that have already been applied - local applied=0 - local skipped=0 - for migration in "${migrations_dir}"/*.sql; do - if [[ ! -f "${migration}" ]]; then - continue - fi - local name - name=$(basename "${migration}") - if PGPASSWORD="${db_password}" psql -h "${db_host}" -p "${db_port}" \ - -U "${db_user}" -d "${db_name}" -f "${migration}" 2>/dev/null; then - log_info "Applied: ${name}" - applied=$((applied + 1)) - else - skipped=$((skipped + 1)) - fi - done - - log_success "Migrations complete (${applied} applied, ${skipped} already present)" -} - -verify_deployment() { - log_info "Verifying deployment..." - - # Wait a moment for service to fully start - sleep 2 - - # Check health endpoint - local health_status - health_status=$(curl -sf http://localhost:8080/health 2>/dev/null || echo "FAILED") - - if [[ "${health_status}" == *"ok"* ]] || [[ "${health_status}" == *"healthy"* ]] || [[ "${health_status}" == "{}" ]]; then - log_success "Health check passed" - else - log_warn "Health check returned: ${health_status}" - fi - - # Check ready endpoint (includes DB and MinIO) - local ready_status - ready_status=$(curl -sf http://localhost:8080/ready 2>/dev/null || echo "FAILED") - - if [[ "${ready_status}" == *"ok"* ]] || [[ "${ready_status}" == *"ready"* ]] || [[ "${ready_status}" == "{}" ]]; then - log_success "Readiness check passed (DB and MinIO connected)" - else - log_warn "Readiness check returned: ${ready_status}" - log_warn "Check credentials in ${CONFIG_DIR}/silod.env" - fi - - # Show version - log_info "Deployed version: $("${INSTALL_DIR}/bin/${BINARY_NAME}" --version 2>/dev/null || echo 'unknown')" -} - -show_status() { - echo "" - log_info "Service Status" - echo "============================================" - systemctl status "${SERVICE_NAME}" --no-pager -l || true - echo "" - echo "Recent logs:" - journalctl -u "${SERVICE_NAME}" -n 10 --no-pager || true -} - -# Parse arguments -while [[ $# -gt 0 ]]; do - case $1 in - --no-pull) - DO_PULL=false - shift - ;; - --no-build) - DO_BUILD=false - shift - ;; - --restart-only) - RESTART_ONLY=true - shift - ;; - --status) - STATUS_ONLY=true - shift - ;; - --help|-h) - show_help - ;; - *) - die "Unknown option: $1" - ;; - esac -done - -# Main execution -main() { - echo "" - log_info "Silo Deployment Script" - log_info "======================" - echo "" - - check_root - - if [[ "${STATUS_ONLY}" == "true" ]]; then - show_status - exit 0 - fi - - if [[ "${RESTART_ONLY}" == "true" ]]; then - restart_service - verify_deployment - exit 0 - fi - - check_dependencies - setup_directories - - if [[ "${DO_PULL}" == "true" ]]; then - git_pull - else - log_info "Skipping git pull (--no-pull)" - cd "${INSTALL_DIR}/src" - fi - - if [[ "${DO_BUILD}" == "true" ]]; then - build_binary - else - log_info "Skipping build (--no-build)" - fi - - install_config - run_migrations - install_systemd - set_permissions - restart_service - verify_deployment - - echo "" - log_success "============================================" - log_success "Deployment complete!" - log_success "============================================" - echo "" - echo "Useful commands:" - echo " sudo systemctl status silod # Check service status" - echo " sudo journalctl -u silod -f # Follow logs" - echo " curl http://localhost:8080/health # Health check" - echo "" -} - -main "$@" +fi +REMOTE + +echo "" +echo "=== Deploy complete ===" +echo " Backend: https://${TARGET} (port 8080)" +echo " React SPA served from /opt/silo/web/dist/" +echo " Logs: ssh ${TARGET} journalctl -u silod -f" diff --git a/web/assets/silo-auth.svg b/web/assets/silo-auth.svg new file mode 100644 index 0000000..38e34fa --- /dev/null +++ b/web/assets/silo-auth.svg @@ -0,0 +1,71 @@ + + + + + + + + + + + + diff --git a/web/assets/silo-bom.svg b/web/assets/silo-bom.svg new file mode 100644 index 0000000..d58e4d6 --- /dev/null +++ b/web/assets/silo-bom.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + diff --git a/web/assets/silo-commit.svg b/web/assets/silo-commit.svg new file mode 100644 index 0000000..3d10e93 --- /dev/null +++ b/web/assets/silo-commit.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + diff --git a/web/assets/silo-info.svg b/web/assets/silo-info.svg new file mode 100644 index 0000000..2a48196 --- /dev/null +++ b/web/assets/silo-info.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/assets/silo-new.svg b/web/assets/silo-new.svg new file mode 100644 index 0000000..4d495a6 --- /dev/null +++ b/web/assets/silo-new.svg @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + diff --git a/web/assets/silo-open.svg b/web/assets/silo-open.svg new file mode 100644 index 0000000..4b4c54d --- /dev/null +++ b/web/assets/silo-open.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + diff --git a/web/assets/silo-pull.svg b/web/assets/silo-pull.svg new file mode 100644 index 0000000..8c25cec --- /dev/null +++ b/web/assets/silo-pull.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/web/assets/silo-push.svg b/web/assets/silo-push.svg new file mode 100644 index 0000000..718f125 --- /dev/null +++ b/web/assets/silo-push.svg @@ -0,0 +1,48 @@ + + + + + + + + + diff --git a/web/assets/silo-save.svg b/web/assets/silo-save.svg new file mode 100644 index 0000000..f77ff0f --- /dev/null +++ b/web/assets/silo-save.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + diff --git a/web/assets/silo.svg b/web/assets/silo.svg new file mode 100644 index 0000000..0461067 --- /dev/null +++ b/web/assets/silo.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f407ca2..de13389 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -1,4 +1,4 @@ -import type { ErrorResponse } from './types'; +import type { ErrorResponse } from "./types"; export class ApiError extends Error { constructor( @@ -7,29 +7,28 @@ export class ApiError extends Error { message?: string, ) { super(message ?? error); - this.name = 'ApiError'; + this.name = "ApiError"; } } async function request(url: string, options?: RequestInit): Promise { const res = await fetch(url, { ...options, - credentials: 'include', + credentials: "include", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", ...options?.headers, }, }); if (res.status === 401) { - window.location.href = '/login'; - throw new ApiError(401, 'unauthorized'); + throw new ApiError(401, "unauthorized"); } if (!res.ok) { let body: ErrorResponse | undefined; try { - body = await res.json() as ErrorResponse; + body = (await res.json()) as ErrorResponse; } catch { // non-JSON error response } @@ -53,18 +52,18 @@ export function get(url: string): Promise { export function post(url: string, body?: unknown): Promise { return request(url, { - method: 'POST', + method: "POST", body: body != null ? JSON.stringify(body) : undefined, }); } export function put(url: string, body?: unknown): Promise { return request(url, { - method: 'PUT', + method: "PUT", body: body != null ? JSON.stringify(body) : undefined, }); } export function del(url: string): Promise { - return request(url, { method: 'DELETE' }); + return request(url, { method: "DELETE" }); } diff --git a/web/src/api/types.ts b/web/src/api/types.ts index d6b66e0..e6db599 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -218,6 +218,48 @@ export interface PropertyDef { export type PropertySchema = Record; +// API Token +export interface ApiToken { + id: string; + name: string; + token_prefix: string; + last_used_at?: string; + expires_at?: string; + created_at: string; +} + +export interface ApiTokenCreated extends ApiToken { + token: string; +} + +// Auth config (public endpoint) +export interface AuthConfig { + oidc_enabled: boolean; + local_enabled: boolean; +} + +// Project requests +export interface CreateProjectRequest { + code: string; + name?: string; + description?: string; +} + +export interface UpdateProjectRequest { + name?: string; + description?: string; +} + +// Schema enum value requests +export interface CreateSchemaValueRequest { + code: string; + description: string; +} + +export interface UpdateSchemaValueRequest { + description: string; +} + // Revision comparison export interface RevisionComparison { from: number; diff --git a/web/src/components/TagInput.tsx b/web/src/components/TagInput.tsx new file mode 100644 index 0000000..4519fc8 --- /dev/null +++ b/web/src/components/TagInput.tsx @@ -0,0 +1,222 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; + +export interface TagOption { + id: string; + label: string; +} + +interface TagInputProps { + value: string[]; + onChange: (ids: string[]) => void; + placeholder?: string; + searchFn: (query: string) => Promise; +} + +export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) { + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(false); + const [highlighted, setHighlighted] = useState(0); + const inputRef = useRef(null); + const containerRef = useRef(null); + const debounceRef = useRef | undefined>(undefined); + + // Debounced search + const search = useCallback( + (q: string) => { + if (debounceRef.current) clearTimeout(debounceRef.current); + if (q.trim() === '') { + // Show all results when input is empty but focused + debounceRef.current = setTimeout(() => { + searchFn('').then((opts) => { + setResults(opts.filter((o) => !value.includes(o.id))); + setHighlighted(0); + }).catch(() => setResults([])); + }, 100); + return; + } + debounceRef.current = setTimeout(() => { + searchFn(q).then((opts) => { + setResults(opts.filter((o) => !value.includes(o.id))); + setHighlighted(0); + }).catch(() => setResults([])); + }, 200); + }, + [searchFn, value], + ); + + // Re-filter when value changes (exclude newly selected) + useEffect(() => { + if (open) search(query); + }, [value]); // eslint-disable-line react-hooks/exhaustive-deps + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + const select = (id: string) => { + onChange([...value, id]); + setQuery(''); + setOpen(false); + inputRef.current?.focus(); + }; + + const remove = (id: string) => { + onChange(value.filter((v) => v !== id)); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Backspace' && query === '' && value.length > 0) { + onChange(value.slice(0, -1)); + return; + } + if (e.key === 'Escape') { + setOpen(false); + return; + } + if (!open || results.length === 0) return; + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlighted((h) => (h + 1) % results.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlighted((h) => (h - 1 + results.length) % results.length); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (results[highlighted]) select(results[highlighted].id); + } + }; + + // Find label for a selected id from latest results (fallback to id) + const labelMap = useRef(new Map()); + for (const r of results) labelMap.current.set(r.id, r.label); + + return ( +
+
inputRef.current?.focus()} + > + {value.map((id) => ( + + {labelMap.current.get(id) ?? id} + + + ))} + { + setQuery(e.target.value); + setOpen(true); + search(e.target.value); + }} + onFocus={() => { + setOpen(true); + search(query); + }} + onKeyDown={handleKeyDown} + placeholder={value.length === 0 ? placeholder : undefined} + style={{ + flex: 1, + minWidth: '4rem', + border: 'none', + outline: 'none', + background: 'transparent', + color: 'var(--ctp-text)', + fontSize: '0.85rem', + padding: '0.1rem 0', + }} + /> +
+ {open && results.length > 0 && ( +
+ {results.map((opt, i) => ( +
{ + e.preventDefault(); + select(opt.id); + }} + onMouseEnter={() => setHighlighted(i)} + style={{ + padding: '0.25rem 0.5rem', + height: '28px', + display: 'flex', + alignItems: 'center', + fontSize: '0.8rem', + cursor: 'pointer', + color: 'var(--ctp-text)', + backgroundColor: + i === highlighted ? 'var(--ctp-surface1)' : 'transparent', + }} + > + {opt.label} +
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/context/AuthContext.tsx b/web/src/context/AuthContext.tsx index 9e2c13e..ada69c6 100644 --- a/web/src/context/AuthContext.tsx +++ b/web/src/context/AuthContext.tsx @@ -1,37 +1,86 @@ -import { createContext, useEffect, useState, type ReactNode } from 'react'; -import type { User } from '../api/types'; -import { get } from '../api/client'; +import { + createContext, + useEffect, + useState, + useCallback, + type ReactNode, +} from "react"; +import type { User } from "../api/types"; +import { get } from "../api/client"; export interface AuthContextValue { user: User | null; loading: boolean; + login: (username: string, password: string) => Promise; logout: () => Promise; + refresh: () => Promise; } export const AuthContext = createContext({ user: null, loading: true, + login: async () => {}, logout: async () => {}, + refresh: async () => {}, }); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); - useEffect(() => { - get('/api/auth/me') - .then(setUser) - .catch(() => setUser(null)) - .finally(() => setLoading(false)); + const fetchUser = useCallback(async () => { + try { + const u = await get("/api/auth/me"); + setUser(u); + } catch { + setUser(null); + } }, []); + useEffect(() => { + fetchUser().finally(() => setLoading(false)); + }, [fetchUser]); + + const login = async (username: string, password: string) => { + const body = new URLSearchParams({ username, password }); + const res = await fetch("/login", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body, + redirect: "manual", + }); + // Go handler returns 302 on success, JSON error on failure + if ( + res.type === "opaqueredirect" || + res.status === 302 || + res.status === 0 + ) { + await fetchUser(); + return; + } + // Parse JSON error response + let message = "Invalid username or password"; + try { + const err = await res.json(); + if (err.message) message = err.message; + } catch { + // non-JSON response, use default message + } + throw new Error(message); + }; + const logout = async () => { - await fetch('/logout', { method: 'POST', credentials: 'include' }); - window.location.href = '/login'; + await fetch("/logout", { method: "POST", credentials: "include" }); + setUser(null); + }; + + const refresh = async () => { + await fetchUser(); }; return ( - + {children} ); diff --git a/web/src/hooks/useItems.ts b/web/src/hooks/useItems.ts index cd2439b..3281c9d 100644 --- a/web/src/hooks/useItems.ts +++ b/web/src/hooks/useItems.ts @@ -1,10 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; -import { get } from '../api/client'; -import type { Item, FuzzyResult } from '../api/types'; +import { useState, useEffect, useCallback, useRef } from "react"; +import { get } from "../api/client"; +import type { Item, FuzzyResult } from "../api/types"; export interface ItemFilters { search: string; - searchScope: 'all' | 'part_number' | 'description'; + searchScope: "all" | "part_number" | "description"; type: string; project: string; page: number; @@ -12,10 +12,10 @@ export interface ItemFilters { } const defaultFilters: ItemFilters = { - search: '', - searchScope: 'all', - type: '', - project: '', + search: "", + searchScope: "all", + type: "", + project: "", page: 1, pageSize: 50, }; @@ -25,7 +25,7 @@ export function useItems() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [filters, setFilters] = useState(defaultFilters); - const debounceRef = useRef>(); + const debounceRef = useRef>(undefined); const fetchItems = useCallback(async (f: ItemFilters) => { setLoading(true); @@ -34,23 +34,23 @@ export function useItems() { let result: Item[]; if (f.search) { const params = new URLSearchParams({ q: f.search }); - if (f.searchScope !== 'all') params.set('fields', f.searchScope); - if (f.type) params.set('type', f.type); - if (f.project) params.set('project', f.project); - params.set('limit', String(f.pageSize)); + if (f.searchScope !== "all") params.set("fields", f.searchScope); + if (f.type) params.set("type", f.type); + if (f.project) params.set("project", f.project); + params.set("limit", String(f.pageSize)); result = await get(`/api/items/search?${params}`); } else { const params = new URLSearchParams(); - if (f.type) params.set('type', f.type); - if (f.project) params.set('project', f.project); - params.set('limit', String(f.pageSize)); - params.set('offset', String((f.page - 1) * f.pageSize)); + if (f.type) params.set("type", f.type); + if (f.project) params.set("project", f.project); + params.set("limit", String(f.pageSize)); + params.set("offset", String((f.page - 1) * f.pageSize)); const qs = params.toString(); - result = await get(`/api/items${qs ? `?${qs}` : ''}`); + result = await get(`/api/items${qs ? `?${qs}` : ""}`); } setItems(result); } catch (e) { - setError(e instanceof Error ? e.message : 'Failed to load items'); + setError(e instanceof Error ? e.message : "Failed to load items"); } finally { setLoading(false); } @@ -76,7 +76,7 @@ export function useItems() { setFilters((prev) => { const next = { ...prev, ...partial }; // Reset to page 1 when filters change (but not when page itself changes) - if (!('page' in partial)) next.page = 1; + if (!("page" in partial)) next.page = 1; return next; }); }, []); diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx index 66a9eed..1974b55 100644 --- a/web/src/pages/LoginPage.tsx +++ b/web/src/pages/LoginPage.tsx @@ -1,22 +1,200 @@ -import { useEffect } from 'react'; +import { useEffect, useState, type FormEvent } from "react"; +import { useAuth } from "../hooks/useAuth"; +import { get } from "../api/client"; +import type { AuthConfig } from "../api/types"; export function LoginPage() { - // During transition, redirect to the Go-served login page + const { login } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [oidcEnabled, setOidcEnabled] = useState(false); + useEffect(() => { - window.location.href = '/login'; + get("/api/auth/config") + .then((cfg) => setOidcEnabled(cfg.oidc_enabled)) + .catch(() => {}); }, []); + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!username.trim() || !password) return; + setError(""); + setSubmitting(true); + try { + await login(username.trim(), password); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setSubmitting(false); + } + }; + return ( -
-

Redirecting to login...

+
+
+

Silo

+

Product Lifecycle Management

+ + {error &&
{error}
} + +
+
+ + setUsername(e.target.value)} + placeholder="Username or LDAP uid" + autoFocus + required + style={inputStyle} + /> +
+
+ + setPassword(e.target.value)} + placeholder="Password" + required + style={inputStyle} + /> +
+ +
+ + {oidcEnabled && ( + <> +
+ + + or + + +
+ + Sign in with Keycloak + + + )} +
); } + +const containerStyle: React.CSSProperties = { + display: "flex", + justifyContent: "center", + alignItems: "center", + minHeight: "100vh", + backgroundColor: "var(--ctp-base)", +}; + +const cardStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "1rem", + padding: "2.5rem", + width: "100%", + maxWidth: 400, + margin: "1rem", +}; + +const titleStyle: React.CSSProperties = { + color: "var(--ctp-mauve)", + textAlign: "center", + fontSize: "2rem", + fontWeight: 700, + marginBottom: "0.25rem", +}; + +const subtitleStyle: React.CSSProperties = { + color: "var(--ctp-subtext0)", + textAlign: "center", + fontSize: "0.9rem", + marginBottom: "2rem", +}; + +const errorStyle: React.CSSProperties = { + color: "var(--ctp-red)", + background: "rgba(243, 139, 168, 0.1)", + border: "1px solid rgba(243, 139, 168, 0.2)", + padding: "0.75rem 1rem", + borderRadius: "0.5rem", + marginBottom: "1rem", + fontSize: "0.9rem", +}; + +const formGroupStyle: React.CSSProperties = { + marginBottom: "1.25rem", +}; + +const labelStyle: React.CSSProperties = { + display: "block", + marginBottom: "0.5rem", + fontWeight: 500, + color: "var(--ctp-subtext1)", + fontSize: "0.9rem", +}; + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.75rem 1rem", + backgroundColor: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.5rem", + color: "var(--ctp-text)", + fontSize: "1rem", + boxSizing: "border-box", +}; + +const btnPrimaryStyle: React.CSSProperties = { + display: "block", + width: "100%", + padding: "0.75rem 1.5rem", + borderRadius: "0.5rem", + fontWeight: 600, + fontSize: "1rem", + cursor: "pointer", + border: "none", + backgroundColor: "var(--ctp-mauve)", + color: "var(--ctp-crust)", + textAlign: "center", +}; + +const dividerStyle: React.CSSProperties = { + display: "flex", + alignItems: "center", + margin: "1.5rem 0", +}; + +const dividerLineStyle: React.CSSProperties = { + flex: 1, + borderTop: "1px solid var(--ctp-surface1)", +}; + +const btnOidcStyle: React.CSSProperties = { + display: "block", + width: "100%", + padding: "0.75rem 1.5rem", + borderRadius: "0.5rem", + fontWeight: 600, + fontSize: "1rem", + cursor: "pointer", + border: "none", + backgroundColor: "var(--ctp-blue)", + color: "var(--ctp-crust)", + textAlign: "center", + textDecoration: "none", + boxSizing: "border-box", +}; diff --git a/web/src/pages/ProjectsPage.tsx b/web/src/pages/ProjectsPage.tsx index 1bbe70e..80ce67f 100644 --- a/web/src/pages/ProjectsPage.tsx +++ b/web/src/pages/ProjectsPage.tsx @@ -1,49 +1,438 @@ -import { useEffect, useState } from 'react'; -import { get } from '../api/client'; -import type { Project } from '../api/types'; +import { useEffect, useState, useCallback, type FormEvent } from "react"; +import { useNavigate } from "react-router-dom"; +import { get, post, put, del } from "../api/client"; +import { useAuth } from "../hooks/useAuth"; +import type { + Project, + Item, + CreateProjectRequest, + UpdateProjectRequest, +} from "../api/types"; + +type Mode = "list" | "create" | "edit" | "delete"; + +interface ProjectWithCount extends Project { + itemCount: number; +} export function ProjectsPage() { - const [projects, setProjects] = useState([]); + const { user } = useAuth(); + const navigate = useNavigate(); + const isEditor = user?.role === "admin" || user?.role === "editor"; + + const [projects, setProjects] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - get('/api/projects') - .then(setProjects) - .catch((e: Error) => setError(e.message)) - .finally(() => setLoading(false)); + const [mode, setMode] = useState("list"); + const [editingProject, setEditingProject] = useState( + null, + ); + + // Form state + const [formCode, setFormCode] = useState(""); + const [formName, setFormName] = useState(""); + const [formDesc, setFormDesc] = useState(""); + const [formError, setFormError] = useState(""); + const [formSubmitting, setFormSubmitting] = useState(false); + + // Sort state + const [sortKey, setSortKey] = useState< + "code" | "name" | "itemCount" | "created_at" + >("code"); + const [sortAsc, setSortAsc] = useState(true); + + const loadProjects = useCallback(async () => { + try { + const list = await get("/api/projects"); + const withCounts = await Promise.all( + list.map(async (p) => { + try { + const items = await get(`/api/projects/${p.code}/items`); + return { ...p, itemCount: items.length }; + } catch { + return { ...p, itemCount: 0 }; + } + }), + ); + setProjects(withCounts); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load projects"); + } finally { + setLoading(false); + } }, []); - if (loading) return

Loading projects...

; - if (error) return

Error: {error}

; + useEffect(() => { + loadProjects(); + }, [loadProjects]); + + const openCreate = () => { + setFormCode(""); + setFormName(""); + setFormDesc(""); + setFormError(""); + setMode("create"); + }; + + const openEdit = (p: ProjectWithCount) => { + setEditingProject(p); + setFormCode(p.code); + setFormName(p.name ?? ""); + setFormDesc(p.description ?? ""); + setFormError(""); + setMode("edit"); + }; + + const openDelete = (p: ProjectWithCount) => { + setEditingProject(p); + setMode("delete"); + }; + + const cancel = () => { + setMode("list"); + setEditingProject(null); + setFormError(""); + }; + + const handleCreate = async (e: FormEvent) => { + e.preventDefault(); + setFormError(""); + setFormSubmitting(true); + try { + const req: CreateProjectRequest = { + code: formCode.toUpperCase(), + name: formName || undefined, + description: formDesc || undefined, + }; + await post("/api/projects", req); + cancel(); + await loadProjects(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to create project"); + } finally { + setFormSubmitting(false); + } + }; + + const handleEdit = async (e: FormEvent) => { + e.preventDefault(); + if (!editingProject) return; + setFormError(""); + setFormSubmitting(true); + try { + const req: UpdateProjectRequest = { + name: formName, + description: formDesc, + }; + await put(`/api/projects/${editingProject.code}`, req); + cancel(); + await loadProjects(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to update project"); + } finally { + setFormSubmitting(false); + } + }; + + const handleDelete = async () => { + if (!editingProject) return; + setFormSubmitting(true); + try { + await del(`/api/projects/${editingProject.code}`); + cancel(); + await loadProjects(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to delete project"); + } finally { + setFormSubmitting(false); + } + }; + + const handleSort = (key: typeof sortKey) => { + if (sortKey === key) { + setSortAsc(!sortAsc); + } else { + setSortKey(key); + setSortAsc(true); + } + }; + + const sorted = [...projects].sort((a, b) => { + let cmp = 0; + if (sortKey === "code") cmp = a.code.localeCompare(b.code); + else if (sortKey === "name") + cmp = (a.name ?? "").localeCompare(b.name ?? ""); + else if (sortKey === "itemCount") cmp = a.itemCount - b.itemCount; + else if (sortKey === "created_at") + cmp = a.created_at.localeCompare(b.created_at); + return sortAsc ? cmp : -cmp; + }); + + const formatDate = (s: string) => { + const d = new Date(s); + return d.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + }; + + const sortArrow = (key: typeof sortKey) => + sortKey === key ? (sortAsc ? " \u25B2" : " \u25BC") : ""; + + if (loading) + return

Loading projects...

; + if (error) return

Error: {error}

; return (
-

Projects ({projects.length})

-
- + {/* Header */} +
+

Projects ({projects.length})

+ {isEditor && mode === "list" && ( + + )} +
+ + {/* Create / Edit form */} + {(mode === "create" || mode === "edit") && ( +
+
+ + {mode === "create" + ? "New Project" + : `Edit ${editingProject?.code}`} + + +
+
+ {formError &&
{formError}
} + {mode === "create" && ( +
+ + setFormCode(e.target.value)} + placeholder="e.g., PROJ-A" + required + minLength={2} + maxLength={10} + style={{ ...inputStyle, textTransform: "uppercase" }} + /> +
+ )} +
+ + setFormName(e.target.value)} + placeholder="Project name" + style={inputStyle} + /> +
+
+ + setFormDesc(e.target.value)} + placeholder="Project description" + style={inputStyle} + /> +
+
+ + +
+ +
+ )} + + {/* Delete confirmation */} + {mode === "delete" && editingProject && ( +
+
+ Delete Project + +
+
+ {formError &&
{formError}
} +

+ Are you sure you want to permanently delete project{" "} + + {editingProject.code} + + ? +

+

+ This action cannot be undone. +

+
+ + +
+
+
+ )} + + {/* Table */} +
+
- - + + + + + {isEditor && } - {projects.map((p) => ( - - + - - - ))} + ) : ( + sorted.map((p, i) => ( + + + + + + + {isEditor && ( + + )} + + )) + )}
CodeName handleSort("code")}> + Code{sortArrow("code")} + handleSort("name")}> + Name{sortArrow("name")} + Description handleSort("itemCount")}> + Items{sortArrow("itemCount")} + handleSort("created_at")}> + Created{sortArrow("created_at")} + Actions
- {p.code} + {sorted.length === 0 ? ( +
+ No projects found. Create your first project to start + organizing items. {p.name}{p.description}
+ + navigate(`/?project=${encodeURIComponent(p.code)}`) + } + style={{ + color: "var(--ctp-peach)", + fontFamily: "'JetBrains Mono', monospace", + fontWeight: 500, + cursor: "pointer", + }} + > + {p.code} + + {p.name || "-"}{p.description || "-"}{p.itemCount}{formatDate(p.created_at)} +
+ + +
+
@@ -51,18 +440,129 @@ export function ProjectsPage() { ); } -const thStyle: React.CSSProperties = { - padding: '0.75rem 1rem', - textAlign: 'left', - borderBottom: '1px solid var(--ctp-surface1)', - color: 'var(--ctp-subtext1)', +// Styles +const btnPrimaryStyle: React.CSSProperties = { + padding: "0.5rem 1rem", + borderRadius: "0.4rem", + border: "none", + backgroundColor: "var(--ctp-mauve)", + color: "var(--ctp-crust)", fontWeight: 600, - fontSize: '0.85rem', - textTransform: 'uppercase', - letterSpacing: '0.05em', + fontSize: "0.85rem", + cursor: "pointer", +}; + +const btnSecondaryStyle: React.CSSProperties = { + padding: "0.5rem 1rem", + borderRadius: "0.4rem", + border: "none", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-text)", + fontSize: "0.85rem", + cursor: "pointer", +}; + +const btnDangerStyle: React.CSSProperties = { + padding: "0.5rem 1rem", + borderRadius: "0.4rem", + border: "none", + backgroundColor: "var(--ctp-red)", + color: "var(--ctp-crust)", + fontWeight: 600, + fontSize: "0.85rem", + cursor: "pointer", +}; + +const btnSmallStyle: React.CSSProperties = { + padding: "0.3rem 0.6rem", + borderRadius: "0.3rem", + border: "none", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-text)", + fontSize: "0.8rem", + cursor: "pointer", +}; + +const formPaneStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "0.5rem", + marginBottom: "1rem", + overflow: "hidden", +}; + +const formHeaderStyle: React.CSSProperties = { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + padding: "0.5rem 1rem", + color: "var(--ctp-crust)", + fontSize: "0.9rem", +}; + +const formCloseStyle: React.CSSProperties = { + background: "none", + border: "none", + color: "inherit", + cursor: "pointer", + fontSize: "0.85rem", + fontWeight: 600, +}; + +const errorBannerStyle: React.CSSProperties = { + color: "var(--ctp-red)", + background: "rgba(243, 139, 168, 0.1)", + border: "1px solid rgba(243, 139, 168, 0.2)", + padding: "0.5rem 0.75rem", + borderRadius: "0.4rem", + marginBottom: "0.75rem", + fontSize: "0.85rem", +}; + +const fieldStyle: React.CSSProperties = { + marginBottom: "0.75rem", +}; + +const labelStyle: React.CSSProperties = { + display: "block", + marginBottom: "0.35rem", + fontWeight: 500, + color: "var(--ctp-subtext1)", + fontSize: "0.85rem", +}; + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.5rem 0.75rem", + backgroundColor: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "0.9rem", + boxSizing: "border-box", +}; + +const tableContainerStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "0.75rem", + padding: "0.5rem", + overflowX: "auto", +}; + +const thStyle: React.CSSProperties = { + padding: "0.5rem 0.75rem", + textAlign: "left", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + fontWeight: 600, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", + cursor: "pointer", + userSelect: "none", }; const tdStyle: React.CSSProperties = { - padding: '0.75rem 1rem', - borderBottom: '1px solid var(--ctp-surface1)', + padding: "0.35rem 0.75rem", + borderBottom: "1px solid var(--ctp-surface1)", + fontSize: "0.85rem", }; diff --git a/web/src/pages/SchemasPage.tsx b/web/src/pages/SchemasPage.tsx index a9dda75..0c8d6d1 100644 --- a/web/src/pages/SchemasPage.tsx +++ b/web/src/pages/SchemasPage.tsx @@ -1,70 +1,737 @@ -import { useEffect, useState } from 'react'; -import { get } from '../api/client'; -import type { Schema } from '../api/types'; +import { useEffect, useState, type FormEvent } from "react"; +import { get, post, put, del } from "../api/client"; +import { useAuth } from "../hooks/useAuth"; +import type { Schema, SchemaSegment } from "../api/types"; + +interface EnumEditState { + schemaName: string; + segmentName: string; + code: string; + description: string; + mode: "add" | "edit" | "delete"; +} export function SchemasPage() { + const { user } = useAuth(); + const isEditor = user?.role === "admin" || user?.role === "editor"; + const [schemas, setSchemas] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [expanded, setExpanded] = useState>(new Set()); + const [editState, setEditState] = useState(null); + const [formError, setFormError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const loadSchemas = async () => { + try { + const list = await get("/api/schemas"); + setSchemas(list.filter((s) => s.name)); + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load schemas"); + } finally { + setLoading(false); + } + }; useEffect(() => { - get('/api/schemas') - .then(setSchemas) - .catch((e: Error) => setError(e.message)) - .finally(() => setLoading(false)); + loadSchemas(); }, []); - if (loading) return

Loading schemas...

; - if (error) return

Error: {error}

; + const toggleExpand = (key: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + const startAdd = (schemaName: string, segmentName: string) => { + setEditState({ + schemaName, + segmentName, + code: "", + description: "", + mode: "add", + }); + setFormError(""); + }; + + const startEdit = ( + schemaName: string, + segmentName: string, + code: string, + description: string, + ) => { + setEditState({ schemaName, segmentName, code, description, mode: "edit" }); + setFormError(""); + }; + + const startDelete = ( + schemaName: string, + segmentName: string, + code: string, + ) => { + setEditState({ + schemaName, + segmentName, + code, + description: "", + mode: "delete", + }); + setFormError(""); + }; + + const cancelEdit = () => { + setEditState(null); + setFormError(""); + }; + + const handleAddValue = async (e: FormEvent) => { + e.preventDefault(); + if (!editState) return; + setFormError(""); + setSubmitting(true); + try { + await post( + `/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values`, + { code: editState.code, description: editState.description }, + ); + cancelEdit(); + await loadSchemas(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to add value"); + } finally { + setSubmitting(false); + } + }; + + const handleUpdateValue = async (e: FormEvent) => { + e.preventDefault(); + if (!editState) return; + setFormError(""); + setSubmitting(true); + try { + await put( + `/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`, + { description: editState.description }, + ); + cancelEdit(); + await loadSchemas(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to update value"); + } finally { + setSubmitting(false); + } + }; + + const handleDeleteValue = async () => { + if (!editState) return; + setSubmitting(true); + try { + await del( + `/api/schemas/${editState.schemaName}/segments/${editState.segmentName}/values/${editState.code}`, + ); + cancelEdit(); + await loadSchemas(); + } catch (e) { + setFormError(e instanceof Error ? e.message : "Failed to delete value"); + } finally { + setSubmitting(false); + } + }; + + if (loading) + return

Loading schemas...

; + if (error) return

Error: {error}

; return (
-

Schemas ({schemas.length})

-
- - - - - - - - - - - {schemas.map((s) => ( - - - - - - - ))} - -
NameFormatDescriptionSegments
- {s.name} - {s.format}{s.description}{s.segments.length}
-
+

+ Part Numbering Schemas ({schemas.length}) +

+ + {schemas.length === 0 ? ( +
No schemas found.
+ ) : ( + schemas.map((schema) => ( + + )) + )}
); } -const thStyle: React.CSSProperties = { - padding: '0.75rem 1rem', - textAlign: 'left', - borderBottom: '1px solid var(--ctp-surface1)', - color: 'var(--ctp-subtext1)', +// --- Sub-components (local to this file) --- + +interface SchemaCardProps { + schema: Schema; + expanded: Set; + toggleExpand: (key: string) => void; + isEditor: boolean; + editState: EnumEditState | null; + formError: string; + submitting: boolean; + onStartAdd: (schemaName: string, segmentName: string) => void; + onStartEdit: ( + schemaName: string, + segmentName: string, + code: string, + desc: string, + ) => void; + onStartDelete: ( + schemaName: string, + segmentName: string, + code: string, + ) => void; + onCancelEdit: () => void; + onAdd: (e: FormEvent) => void; + onUpdate: (e: FormEvent) => void; + onDelete: () => void; + onEditStateChange: (state: EnumEditState | null) => void; +} + +function SchemaCard({ + schema, + expanded, + toggleExpand, + isEditor, + editState, + formError, + submitting, + onStartAdd, + onStartEdit, + onStartDelete, + onCancelEdit, + onAdd, + onUpdate, + onDelete, + onEditStateChange, +}: SchemaCardProps) { + const segKey = `seg-${schema.name}`; + const isExpanded = expanded.has(segKey); + + return ( +
+

+ {schema.name} +

+ {schema.description && ( +

+ {schema.description} +

+ )} +

+ Format: {schema.format} +

+

+ Version: {schema.version} +

+ + {schema.examples && schema.examples.length > 0 && ( + <> +

+ Examples: +

+
+ {schema.examples.map((ex) => ( + + {ex} + + ))} +
+ + )} + +
toggleExpand(segKey)} + style={{ + cursor: "pointer", + color: "var(--ctp-sapphire)", + userSelect: "none", + marginTop: "1rem", + }} + > + {isExpanded ? "\u25BC" : "\u25B6"} View Segments ( + {schema.segments.length}) +
+ + {isExpanded && + schema.segments.map((seg) => ( + + ))} +
+ ); +} + +interface SegmentBlockProps { + schemaName: string; + segment: SchemaSegment; + isEditor: boolean; + editState: EnumEditState | null; + formError: string; + submitting: boolean; + onStartAdd: (schemaName: string, segmentName: string) => void; + onStartEdit: ( + schemaName: string, + segmentName: string, + code: string, + desc: string, + ) => void; + onStartDelete: ( + schemaName: string, + segmentName: string, + code: string, + ) => void; + onCancelEdit: () => void; + onAdd: (e: FormEvent) => void; + onUpdate: (e: FormEvent) => void; + onDelete: () => void; + onEditStateChange: (state: EnumEditState | null) => void; +} + +function SegmentBlock({ + schemaName, + segment, + isEditor, + editState, + formError, + submitting, + onStartAdd, + onStartEdit, + onStartDelete, + onCancelEdit, + onAdd, + onUpdate, + onDelete, + onEditStateChange, +}: SegmentBlockProps) { + const isThisSegment = (es: EnumEditState | null) => + es !== null && + es.schemaName === schemaName && + es.segmentName === segment.name; + + const entries = segment.values + ? Object.entries(segment.values).sort((a, b) => a[0].localeCompare(b[0])) + : []; + + return ( +
+
+

{segment.name}

+ {segment.type} +
+ {segment.description && ( +

+ {segment.description} +

+ )} + + {segment.type === "enum" && entries.length > 0 && ( +
+ + + + + + {isEditor && ( + + )} + + + + {entries.map(([code, desc]) => { + const isEditingThis = + isThisSegment(editState) && + editState!.code === code && + editState!.mode === "edit"; + const isDeletingThis = + isThisSegment(editState) && + editState!.code === code && + editState!.mode === "delete"; + + if (isEditingThis) { + return ( + + + + {isEditor && + ); + } + + if (isDeletingThis) { + return ( + + + + + + ); + } + + return ( + + + + {isEditor && ( + + )} + + ); + })} + + {/* Add row */} + {isThisSegment(editState) && editState!.mode === "add" && ( + + + + {isEditor && + )} + +
CodeDescriptionActions
+ {code} + +
+ + onEditStateChange({ + ...editState!, + description: e.target.value, + }) + } + required + style={inlineInputStyle} + autoFocus + /> + + +
+ {formError && ( +
+ {formError} +
+ )} +
} +
+ {code} + + + Delete this value? + + {formError && ( +
+ {formError} +
+ )} +
+
+ + +
+
+ {code} + {desc} +
+ + +
+
+ + onEditStateChange({ + ...editState!, + code: e.target.value, + }) + } + placeholder="Code" + required + style={inlineInputStyle} + autoFocus + /> + +
+ + onEditStateChange({ + ...editState!, + description: e.target.value, + }) + } + placeholder="Description" + required + style={inlineInputStyle} + /> + + +
+ {formError && ( +
+ {formError} +
+ )} +
} +
+
+ )} + + {segment.type === "enum" && + isEditor && + !(isThisSegment(editState) && editState!.mode === "add") && ( + + )} +
+ ); +} + +// --- Styles --- + +const cardStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "0.75rem", + padding: "1.25rem", + marginBottom: "1rem", +}; + +const codeStyle: React.CSSProperties = { + background: "var(--ctp-surface1)", + padding: "0.25rem 0.5rem", + borderRadius: "0.25rem", + fontSize: "0.85rem", +}; + +const segmentStyle: React.CSSProperties = { + marginTop: "1rem", + padding: "1rem", + background: "var(--ctp-base)", + borderRadius: "0.5rem", +}; + +const typeBadgeStyle: React.CSSProperties = { + display: "inline-block", + padding: "0.15rem 0.5rem", + borderRadius: "0.25rem", + fontSize: "0.75rem", fontWeight: 600, - fontSize: '0.85rem', - textTransform: 'uppercase', - letterSpacing: '0.05em', + backgroundColor: "rgba(166, 227, 161, 0.15)", + color: "var(--ctp-green)", +}; + +const emptyStyle: React.CSSProperties = { + textAlign: "center", + padding: "2rem", + color: "var(--ctp-subtext0)", +}; + +const thStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + textAlign: "left", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + fontWeight: 600, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", }; const tdStyle: React.CSSProperties = { - padding: '0.75rem 1rem', - borderBottom: '1px solid var(--ctp-surface1)', + padding: "0.3rem 0.75rem", + borderBottom: "1px solid var(--ctp-surface1)", + fontSize: "0.85rem", +}; + +const btnTinyStyle: React.CSSProperties = { + padding: "0.2rem 0.5rem", + borderRadius: "0.25rem", + border: "none", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-text)", + fontSize: "0.75rem", + cursor: "pointer", +}; + +const btnTinyPrimaryStyle: React.CSSProperties = { + padding: "0.2rem 0.5rem", + borderRadius: "0.25rem", + border: "none", + backgroundColor: "var(--ctp-mauve)", + color: "var(--ctp-crust)", + fontSize: "0.75rem", + fontWeight: 600, + cursor: "pointer", +}; + +const inlineInputStyle: React.CSSProperties = { + padding: "0.25rem 0.5rem", + backgroundColor: "var(--ctp-surface0)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.25rem", + color: "var(--ctp-text)", + fontSize: "0.85rem", + width: "100%", + boxSizing: "border-box", }; diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index 4971553..7d429c0 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -1,28 +1,488 @@ -import { useAuth } from '../hooks/useAuth'; +import { useEffect, useState, type FormEvent } from "react"; +import { get, post, del } from "../api/client"; +import { useAuth } from "../hooks/useAuth"; +import type { ApiToken, ApiTokenCreated } from "../api/types"; export function SettingsPage() { const { user } = useAuth(); + const [tokens, setTokens] = useState([]); + const [tokensLoading, setTokensLoading] = useState(true); + const [tokensError, setTokensError] = useState(null); + + // Create token form + const [tokenName, setTokenName] = useState(""); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(""); + + // Newly created token (show once) + const [newToken, setNewToken] = useState(null); + const [copied, setCopied] = useState(false); + + // Revoke confirmation + const [revoking, setRevoking] = useState(null); + + const loadTokens = async () => { + try { + const list = await get("/api/auth/tokens"); + setTokens(list); + setTokensError(null); + } catch (e) { + setTokensError(e instanceof Error ? e.message : "Failed to load tokens"); + } finally { + setTokensLoading(false); + } + }; + + useEffect(() => { + loadTokens(); + }, []); + + const handleCreateToken = async (e: FormEvent) => { + e.preventDefault(); + if (!tokenName.trim()) return; + setCreateError(""); + setCreating(true); + try { + const result = await post("/api/auth/tokens", { + name: tokenName.trim(), + }); + setNewToken(result.token); + setCopied(false); + setTokenName(""); + await loadTokens(); + } catch (e) { + setCreateError(e instanceof Error ? e.message : "Failed to create token"); + } finally { + setCreating(false); + } + }; + + const handleRevoke = async (id: string) => { + try { + await del(`/api/auth/tokens/${id}`); + setRevoking(null); + await loadTokens(); + } catch { + // silently fail — token may already be revoked + } + }; + + const copyToken = async () => { + if (!newToken) return; + try { + await navigator.clipboard.writeText(newToken); + setCopied(true); + } catch { + // fallback: select the text + } + }; + + const formatDate = (s?: string) => { + if (!s) return "Never"; + const d = new Date(s); + return ( + d.toLocaleDateString() + + " " + + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ); + }; + return (
-

Settings

-
+

Settings

+ + {/* Account Card */} +
+

Account

{user ? ( - <> -

Username: {user.username}

-

Display name: {user.display_name}

-

Email: {user.email}

-

Role: {user.role}

-

Auth source: {user.auth_source}

- +
+
Username
+
{user.username}
+
Display Name
+
+ {user.display_name || Not set} +
+
Email
+
+ {user.email || Not set} +
+
Auth Source
+
{user.auth_source}
+
Role
+
+ + {user.role} + +
+
) : ( -

Not logged in

+

Not logged in

+ )} +
+ + {/* API Tokens Card */} +
+

API Tokens

+

+ API tokens allow the FreeCAD plugin and scripts to authenticate with + Silo. Tokens inherit your role permissions. +

+ + {/* New token banner */} + {newToken && ( +
+

+ Your new API token (copy it now — it won't be shown again): +

+ {newToken} +
+ + +
+

+ Store this token securely. You will not be able to see it again. +

+
+ )} + + {/* Create token form */} +
+
+ + setTokenName(e.target.value)} + placeholder="e.g., FreeCAD workstation" + required + style={inputStyle} + /> +
+ +
+ {createError &&
{createError}
} + + {/* Token list */} + {tokensLoading ? ( +

Loading tokens...

+ ) : tokensError ? ( +

+ {tokensError} +

+ ) : ( +
+ + + + + + + + + + + + + {tokens.length === 0 ? ( + + + + ) : ( + tokens.map((t) => ( + + + + + + + + + )) + )} + +
NamePrefixCreatedLast UsedExpiresActions
+ No API tokens yet. Create one to get started. +
{t.name} + + {t.token_prefix}... + + {formatDate(t.created_at)} + {t.last_used_at ? ( + formatDate(t.last_used_at) + ) : ( + Never + )} + + {t.expires_at ? ( + formatDate(t.expires_at) + ) : ( + Never + )} + + {revoking === t.id ? ( +
+ + +
+ ) : ( + + )} +
+
)}
); } + +// --- Styles --- + +const roleBadgeStyles: Record = { + admin: { background: "rgba(203, 166, 247, 0.2)", color: "var(--ctp-mauve)" }, + editor: { background: "rgba(137, 180, 250, 0.2)", color: "var(--ctp-blue)" }, + viewer: { background: "rgba(148, 226, 213, 0.2)", color: "var(--ctp-teal)" }, +}; + +const cardStyle: React.CSSProperties = { + backgroundColor: "var(--ctp-surface0)", + borderRadius: "0.75rem", + padding: "1.5rem", + marginBottom: "1.5rem", +}; + +const cardTitleStyle: React.CSSProperties = { + marginBottom: "1rem", + fontSize: "1.1rem", +}; + +const dlStyle: React.CSSProperties = { + display: "grid", + gridTemplateColumns: "auto 1fr", + gap: "0.5rem 1.5rem", +}; + +const dtStyle: React.CSSProperties = { + color: "var(--ctp-subtext0)", + fontWeight: 500, + fontSize: "0.9rem", +}; + +const ddStyle: React.CSSProperties = { + margin: 0, + fontSize: "0.9rem", +}; + +const mutedStyle: React.CSSProperties = { + color: "var(--ctp-overlay0)", +}; + +const newTokenBannerStyle: React.CSSProperties = { + background: "rgba(166, 227, 161, 0.1)", + border: "1px solid rgba(166, 227, 161, 0.3)", + borderRadius: "0.75rem", + padding: "1.25rem", + marginBottom: "1.5rem", +}; + +const tokenDisplayStyle: React.CSSProperties = { + display: "block", + padding: "0.75rem 1rem", + background: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.5rem", + fontFamily: "'JetBrains Mono', 'Fira Code', monospace", + fontSize: "0.85rem", + color: "var(--ctp-peach)", + wordBreak: "break-all", +}; + +const createFormStyle: React.CSSProperties = { + display: "flex", + gap: "0.75rem", + alignItems: "flex-end", + flexWrap: "wrap", + marginBottom: "0.5rem", +}; + +const labelStyle: React.CSSProperties = { + display: "block", + marginBottom: "0.35rem", + fontWeight: 500, + color: "var(--ctp-subtext1)", + fontSize: "0.85rem", +}; + +const inputStyle: React.CSSProperties = { + width: "100%", + padding: "0.5rem 0.75rem", + backgroundColor: "var(--ctp-base)", + border: "1px solid var(--ctp-surface1)", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + fontSize: "0.9rem", + boxSizing: "border-box", +}; + +const btnPrimaryStyle: React.CSSProperties = { + padding: "0.5rem 1rem", + borderRadius: "0.4rem", + border: "none", + backgroundColor: "var(--ctp-mauve)", + color: "var(--ctp-crust)", + fontWeight: 600, + fontSize: "0.85rem", + cursor: "pointer", + whiteSpace: "nowrap", +}; + +const btnCopyStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + background: "var(--ctp-surface1)", + border: "none", + borderRadius: "0.4rem", + color: "var(--ctp-text)", + cursor: "pointer", + fontSize: "0.85rem", +}; + +const btnDismissStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + background: "none", + border: "none", + color: "var(--ctp-subtext0)", + cursor: "pointer", + fontSize: "0.85rem", +}; + +const btnDangerStyle: React.CSSProperties = { + background: "rgba(243, 139, 168, 0.15)", + color: "var(--ctp-red)", + border: "none", + padding: "0.3rem 0.6rem", + borderRadius: "0.3rem", + cursor: "pointer", + fontSize: "0.8rem", +}; + +const btnRevokeConfirmStyle: React.CSSProperties = { + background: "var(--ctp-red)", + color: "var(--ctp-crust)", + border: "none", + padding: "0.2rem 0.5rem", + borderRadius: "0.25rem", + cursor: "pointer", + fontSize: "0.75rem", + fontWeight: 600, +}; + +const btnTinyStyle: React.CSSProperties = { + padding: "0.2rem 0.5rem", + borderRadius: "0.25rem", + border: "none", + backgroundColor: "var(--ctp-surface1)", + color: "var(--ctp-text)", + fontSize: "0.75rem", + cursor: "pointer", +}; + +const errorStyle: React.CSSProperties = { + color: "var(--ctp-red)", + fontSize: "0.85rem", + marginTop: "0.25rem", +}; + +const thStyle: React.CSSProperties = { + padding: "0.5rem 0.75rem", + textAlign: "left", + borderBottom: "1px solid var(--ctp-surface1)", + color: "var(--ctp-subtext1)", + fontWeight: 600, + fontSize: "0.8rem", + textTransform: "uppercase", + letterSpacing: "0.05em", +}; + +const tdStyle: React.CSSProperties = { + padding: "0.4rem 0.75rem", + borderBottom: "1px solid var(--ctp-surface1)", + fontSize: "0.85rem", +}; diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json index 4c2e14a..8dd16b0 100644 --- a/web/tsconfig.node.json +++ b/web/tsconfig.node.json @@ -8,7 +8,8 @@ "allowImportingTsExtensions": true, "isolatedModules": true, "moduleDetection": "force", - "noEmit": true, + "composite": true, + "emitDeclarationOnly": true, "strict": true, "noUnusedLocals": true, "noUnusedParameters": true,