feat: production release with React SPA, file attachments, and deploy tooling

Backend:
- Add file_handlers.go: presigned upload/download for item attachments
- Add item_files.go: item file and thumbnail DB operations
- Add migration 011: item_files table and thumbnail_key column
- Update items/projects/relationships DB with extended field support
- Update routes: React SPA serving from web/dist, file upload endpoints
- Update auth handlers and middleware for cookie + bearer token auth
- Remove Go HTML templates (replaced by React SPA)
- Update storage client for presigned URL generation

Frontend:
- Add TagInput component for tag/keyword entry
- Add SVG assets for Silo branding and UI icons
- Update API client and types for file uploads, auth, extended fields
- Update AuthContext for session-based auth flow
- Update LoginPage, ProjectsPage, SchemasPage, SettingsPage
- Fix tsconfig.node.json

Deployment:
- Update config.prod.yaml: single-binary SPA layout at /opt/silo
- Update silod.service: ReadOnlyPaths for /opt/silo
- Add scripts/deploy.sh: build, package, ship, migrate, start
- Update docker-compose.yaml and Dockerfile
- Add frontend-spec.md design document
This commit is contained in:
Forbes
2026-02-07 13:35:22 -06:00
parent d61f939d84
commit 50923cf56d
49 changed files with 4674 additions and 7915 deletions

7
.gitignore vendored
View File

@@ -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

25
build/package/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

737
frontend-spec.md Normal file
View File

@@ -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 + <Outlet/>
│ ├── 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<T>()`, `post<T>()`, `put<T>()`, `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<CreateItemForm>({
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<PendingAttachment[]>([]);
const [thumbnail, setThumbnail] = useState<PendingAttachment | null>(null);
const [error, setError] = useState<string | null>(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 `<div>` 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 `<div>` 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 `<input type="file" multiple>`.
**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 `<input>` (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 `<div>` 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<PendingAttachment>;
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 `<img>` 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;
}
```

3
go.mod
View File

@@ -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

2
go.sum
View File

@@ -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=

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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 ") {

View File

@@ -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")
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,528 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{if .Title}}{{.Title}} - {{end}}Silo</title>
<style>
/* Catppuccin Mocha Theme */
:root {
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--ctp-base);
color: var(--ctp-text);
line-height: 1.6;
min-height: 100vh;
}
a {
color: var(--ctp-sapphire);
text-decoration: none;
}
a:hover {
color: var(--ctp-sky);
text-decoration: underline;
}
/* Header */
.header {
background-color: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
padding: 1rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-brand h1 {
font-size: 1.5rem;
font-weight: 600;
color: var(--ctp-mauve);
}
.header-nav {
display: flex;
gap: 1.5rem;
}
.header-nav a {
color: var(--ctp-subtext1);
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
transition: all 0.2s;
}
.header-nav a:hover {
background-color: var(--ctp-surface0);
color: var(--ctp-text);
text-decoration: none;
}
.header-nav a.active {
background-color: var(--ctp-surface1);
color: var(--ctp-mauve);
}
/* Main Content */
.main {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
/* Cards */
.card {
background-color: var(--ctp-surface0);
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1rem;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--ctp-text);
}
/* Search and Filters */
.search-bar {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.search-input {
flex: 1;
min-width: 250px;
padding: 0.75rem 1rem;
background-color: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
font-size: 1rem;
}
.search-input:focus {
outline: none;
border-color: var(--ctp-mauve);
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
}
.search-input::placeholder {
color: var(--ctp-overlay0);
}
select {
padding: 0.75rem 1rem;
background-color: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
font-size: 1rem;
cursor: pointer;
}
select:focus {
outline: none;
border-color: var(--ctp-mauve);
}
/* Buttons */
.btn {
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 500;
font-size: 0.95rem;
cursor: pointer;
border: none;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.btn-primary {
background-color: var(--ctp-mauve);
color: var(--ctp-crust);
}
.btn-primary:hover {
background-color: var(--ctp-lavender);
}
.btn-secondary {
background-color: var(--ctp-surface1);
color: var(--ctp-text);
}
.btn-secondary:hover {
background-color: var(--ctp-surface2);
}
/* Table */
.table-container {
overflow-x: auto;
border-radius: 0.75rem;
border: 1px solid var(--ctp-surface1);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--ctp-surface1);
}
th {
background-color: var(--ctp-surface0);
color: var(--ctp-subtext1);
font-weight: 600;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
tr:hover {
background-color: var(--ctp-surface0);
}
tr:last-child td {
border-bottom: none;
}
.part-number {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
color: var(--ctp-peach);
font-weight: 500;
}
.item-type {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 500;
}
.item-type-part {
background-color: rgba(137, 180, 250, 0.2);
color: var(--ctp-blue);
}
.item-type-assembly {
background-color: rgba(166, 227, 161, 0.2);
color: var(--ctp-green);
}
.item-type-document {
background-color: rgba(249, 226, 175, 0.2);
color: var(--ctp-yellow);
}
.item-type-tooling {
background-color: rgba(243, 139, 168, 0.2);
color: var(--ctp-red);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
color: var(--ctp-subtext0);
}
.empty-state h3 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
color: var(--ctp-subtext1);
}
/* Modal */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.active {
display: flex;
}
.modal {
background-color: var(--ctp-surface0);
border-radius: 1rem;
padding: 2rem;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.5rem;
}
.modal-title {
font-size: 1.25rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
font-size: 1.5rem;
padding: 0.25rem;
}
.modal-close:hover {
color: var(--ctp-text);
}
/* Form */
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--ctp-subtext1);
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: var(--ctp-mauve);
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
}
/* Stats */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background-color: var(--ctp-surface0);
border-radius: 0.75rem;
padding: 1.25rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--ctp-text);
}
.stat-label {
color: var(--ctp-subtext0);
font-size: 0.9rem;
}
/* Pagination */
.pagination {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 2rem;
}
.pagination-btn {
padding: 0.5rem 1rem;
background-color: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
cursor: pointer;
}
.pagination-btn:hover {
background-color: var(--ctp-surface1);
}
.pagination-btn.active {
background-color: var(--ctp-mauve);
color: var(--ctp-crust);
border-color: var(--ctp-mauve);
}
.pagination-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading */
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--ctp-surface1);
border-top-color: var(--ctp-mauve);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Responsive */
@media (max-width: 768px) {
.header {
flex-direction: column;
gap: 1rem;
}
.search-bar {
flex-direction: column;
}
.search-input {
min-width: 100%;
}
}
</style>
</head>
<body>
<header class="header">
<div class="header-brand">
<h1>Silo</h1>
</div>
<nav class="header-nav">
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
<a href="/audit" class="{{if eq .Page "audit"}}active{{end}}">Audit</a>
<a href="/settings" class="{{if eq .Page "settings"}}active{{end}}">Settings</a>
</nav>
{{if .User}}
<div class="header-user" style="display:flex;align-items:center;gap:0.75rem;">
<span style="color:var(--ctp-subtext1);font-size:0.9rem;">{{.User.DisplayName}}</span>
<span style="display:inline-block;padding:0.15rem 0.5rem;border-radius:1rem;font-size:0.75rem;font-weight:600;
{{if eq .User.Role "admin"}}background:rgba(203,166,247,0.2);color:var(--ctp-mauve);
{{else if eq .User.Role "editor"}}background:rgba(137,180,250,0.2);color:var(--ctp-blue);
{{else}}background:rgba(148,226,213,0.2);color:var(--ctp-teal);{{end}}">{{.User.Role}}</span>
<form method="POST" action="/logout" style="display:inline;margin:0;">
<button type="submit" class="btn-secondary" style="padding:0.35rem 0.75rem;font-size:0.8rem;border-radius:0.4rem;cursor:pointer;border:none;background:var(--ctp-surface1);color:var(--ctp-subtext1);">Logout</button>
</form>
</div>
{{end}}
</header>
<main class="main">
{{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}}
</main>
{{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}}
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,222 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Login - Silo</title>
<style>
:root {
--ctp-rosewater: #f5e0dc;
--ctp-flamingo: #f2cdcd;
--ctp-pink: #f5c2e7;
--ctp-mauve: #cba6f7;
--ctp-red: #f38ba8;
--ctp-maroon: #eba0ac;
--ctp-peach: #fab387;
--ctp-yellow: #f9e2af;
--ctp-green: #a6e3a1;
--ctp-teal: #94e2d5;
--ctp-sky: #89dceb;
--ctp-sapphire: #74c7ec;
--ctp-blue: #89b4fa;
--ctp-lavender: #b4befe;
--ctp-text: #cdd6f4;
--ctp-subtext1: #bac2de;
--ctp-subtext0: #a6adc8;
--ctp-overlay2: #9399b2;
--ctp-overlay1: #7f849c;
--ctp-overlay0: #6c7086;
--ctp-surface2: #585b70;
--ctp-surface1: #45475a;
--ctp-surface0: #313244;
--ctp-base: #1e1e2e;
--ctp-mantle: #181825;
--ctp-crust: #11111b;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
sans-serif;
background-color: var(--ctp-base);
color: var(--ctp-text);
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.login-card {
background-color: var(--ctp-surface0);
border-radius: 1rem;
padding: 2.5rem;
width: 100%;
max-width: 400px;
margin: 1rem;
}
.login-title {
color: var(--ctp-mauve);
text-align: center;
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.login-subtitle {
color: var(--ctp-subtext0);
text-align: center;
font-size: 0.9rem;
margin-bottom: 2rem;
}
.error-msg {
color: var(--ctp-red);
background: rgba(243, 139, 168, 0.1);
border: 1px solid rgba(243, 139, 168, 0.2);
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: var(--ctp-subtext1);
font-size: 0.9rem;
}
.form-input {
width: 100%;
padding: 0.75rem 1rem;
background-color: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
color: var(--ctp-text);
font-size: 1rem;
}
.form-input:focus {
outline: none;
border-color: var(--ctp-mauve);
box-shadow: 0 0 0 3px rgba(203, 166, 247, 0.2);
}
.form-input::placeholder {
color: var(--ctp-overlay0);
}
.btn {
display: block;
width: 100%;
padding: 0.75rem 1.5rem;
border-radius: 0.5rem;
font-weight: 600;
font-size: 1rem;
cursor: pointer;
border: none;
transition: all 0.2s;
text-align: center;
text-decoration: none;
}
.btn-primary {
background-color: var(--ctp-mauve);
color: var(--ctp-crust);
}
.btn-primary:hover {
background-color: var(--ctp-lavender);
}
.btn-oidc {
background-color: var(--ctp-blue);
color: var(--ctp-crust);
}
.btn-oidc:hover {
background-color: var(--ctp-sapphire);
}
.divider {
display: flex;
align-items: center;
margin: 1.5rem 0;
color: var(--ctp-overlay0);
font-size: 0.85rem;
}
.divider::before,
.divider::after {
content: "";
flex: 1;
border-top: 1px solid var(--ctp-surface1);
}
.divider span {
padding: 0 1rem;
}
</style>
</head>
<body>
<div class="login-card">
<h1 class="login-title">Silo</h1>
<p class="login-subtitle">Product Lifecycle Management</p>
{{if .Error}}
<div class="error-msg">{{.Error}}</div>
{{end}}
<form
method="POST"
action="/login{{if .Next}}?next={{.Next}}{{end}}"
>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
<div class="form-group">
<label class="form-label">Username</label>
<input
type="text"
name="username"
class="form-input"
placeholder="Username or LDAP uid"
autofocus
required
value="{{.Username}}"
/>
</div>
<div class="form-group">
<label class="form-label">Password</label>
<input
type="password"
name="password"
class="form-input"
placeholder="Password"
required
/>
</div>
<button type="submit" class="btn btn-primary">Sign In</button>
</form>
{{if .OIDCEnabled}}
<div class="divider"><span>or</span></div>
<a href="/auth/oidc" class="btn btn-oidc">Sign in with Keycloak</a>
{{end}}
</div>
</body>
</html>

View File

@@ -1,300 +0,0 @@
{{define "projects_content"}}
<div class="stats-grid" id="project-stats">
<div class="stat-card">
<div class="stat-value" id="project-count">-</div>
<div class="stat-label">Total Projects</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Projects</h2>
<button class="btn btn-primary" onclick="openCreateProjectModal()">+ New Project</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th>Items</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="projects-table">
<tr>
<td colspan="6">
<div class="loading"><div class="spinner"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Project Modal -->
<div class="modal-overlay" id="create-project-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Create New Project</h3>
<button class="modal-close" onclick="closeCreateProjectModal()">&times;</button>
</div>
<form id="create-project-form" onsubmit="createProject(event)">
<div class="form-group">
<label class="form-label">Code (2-10 characters, uppercase)</label>
<input type="text" class="form-input" id="project-code" required
minlength="2" maxlength="10" pattern="[A-Za-z0-9\-]+"
placeholder="e.g., PROJ-A" style="text-transform: uppercase;" />
</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="project-name" placeholder="Project name" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" class="form-input" id="project-description" placeholder="Project description" />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeCreateProjectModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
<!-- Edit Project Modal -->
<div class="modal-overlay" id="edit-project-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit Project</h3>
<button class="modal-close" onclick="closeEditProjectModal()">&times;</button>
</div>
<form id="edit-project-form" onsubmit="saveProject(event)">
<input type="hidden" id="edit-project-code" />
<div class="form-group">
<label class="form-label">Code</label>
<input type="text" class="form-input" id="edit-project-code-display" disabled />
</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="edit-project-name" placeholder="Project name" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" class="form-input" id="edit-project-description" placeholder="Project description" />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeEditProjectModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Delete Project Modal -->
<div class="modal-overlay" id="delete-project-modal">
<div class="modal" style="max-width: 400px">
<div class="modal-header">
<h3 class="modal-title">Delete Project</h3>
<button class="modal-close" onclick="closeDeleteProjectModal()">&times;</button>
</div>
<div style="margin-bottom: 1.5rem;">
<p>Are you sure you want to permanently delete project <strong id="delete-project-code"></strong>?</p>
<p style="color: var(--ctp-red); margin-top: 0.5rem;">This action cannot be undone.</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeDeleteProjectModal()">Cancel</button>
<button type="button" class="btn btn-primary" style="background-color: var(--ctp-red)" onclick="confirmDeleteProject()">Delete</button>
</div>
</div>
</div>
{{end}} {{define "projects_scripts"}}
<script>
let projectToDelete = null;
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
async function loadProjects() {
const tbody = document.getElementById('projects-table');
try {
const response = await fetch('/api/projects');
const projects = await response.json();
document.getElementById('project-count').textContent = projects.length;
if (!projects || projects.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><h3>No projects found</h3><p>Create your first project to start organizing items.</p></div></td></tr>';
return;
}
// Fetch item counts for each project
const projectsWithCounts = await Promise.all(projects.map(async (project) => {
try {
const itemsRes = await fetch(`/api/projects/${project.code}/items`);
const items = await itemsRes.json();
return { ...project, itemCount: Array.isArray(items) ? items.length : 0 };
} catch {
return { ...project, itemCount: 0 };
}
}));
tbody.innerHTML = projectsWithCounts.map(project => `
<tr>
<td><a href="/?project=${encodeURIComponent(project.code)}" style="color: var(--ctp-peach); font-family: 'JetBrains Mono', monospace; font-weight: 500;">${project.code}</a></td>
<td>${project.name || '-'}</td>
<td>${project.description || '-'}</td>
<td>${project.itemCount}</td>
<td>${formatDate(project.created_at)}</td>
<td>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem;" onclick="openEditProjectModal('${project.code}')">Edit</button>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; background-color: var(--ctp-surface2);" onclick="openDeleteProjectModal('${project.code}')">Delete</button>
</td>
</tr>
`).join('');
} catch (error) {
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><h3>Error loading projects</h3><p>${error.message}</p></div></td></tr>`;
}
}
// Create
function openCreateProjectModal() {
document.getElementById('create-project-modal').classList.add('active');
}
function closeCreateProjectModal() {
document.getElementById('create-project-modal').classList.remove('active');
document.getElementById('create-project-form').reset();
}
async function createProject(event) {
event.preventDefault();
const data = {
code: document.getElementById('project-code').value.toUpperCase(),
name: document.getElementById('project-name').value,
description: document.getElementById('project-description').value,
};
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeCreateProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Edit
async function openEditProjectModal(code) {
document.getElementById('edit-project-modal').classList.add('active');
try {
const response = await fetch(`/api/projects/${code}`);
const project = await response.json();
document.getElementById('edit-project-code').value = code;
document.getElementById('edit-project-code-display').value = code;
document.getElementById('edit-project-name').value = project.name || '';
document.getElementById('edit-project-description').value = project.description || '';
} catch (error) {
alert(`Error loading project: ${error.message}`);
closeEditProjectModal();
}
}
function closeEditProjectModal() {
document.getElementById('edit-project-modal').classList.remove('active');
document.getElementById('edit-project-form').reset();
}
async function saveProject(event) {
event.preventDefault();
const code = document.getElementById('edit-project-code').value;
const data = {
name: document.getElementById('edit-project-name').value,
description: document.getElementById('edit-project-description').value,
};
try {
const response = await fetch(`/api/projects/${code}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeEditProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete
function openDeleteProjectModal(code) {
projectToDelete = code;
document.getElementById('delete-project-code').textContent = code;
document.getElementById('delete-project-modal').classList.add('active');
}
function closeDeleteProjectModal() {
document.getElementById('delete-project-modal').classList.remove('active');
projectToDelete = null;
}
async function confirmDeleteProject() {
if (!projectToDelete) return;
try {
const response = await fetch(`/api/projects/${projectToDelete}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeDeleteProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
loadProjects();
</script>
{{end}}

View File

@@ -1,399 +0,0 @@
{{define "schemas_content"}}
<div class="card">
<div class="card-header">
<h2 class="card-title">Part Numbering Schemas</h2>
</div>
<div id="schemas-list">
<div class="loading">
<div class="spinner"></div>
</div>
</div>
</div>
<!-- Add Value Modal -->
<div class="modal-overlay" id="add-value-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Add New Value</h3>
<button class="modal-close" onclick="closeAddValueModal()">
&times;
</button>
</div>
<form id="add-value-form" onsubmit="addValue(event)">
<input type="hidden" id="add-schema-name" />
<input type="hidden" id="add-segment-name" />
<div class="form-group">
<label class="form-label">Code</label>
<input
type="text"
class="form-input"
id="add-code"
required
placeholder="e.g., F19"
/>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="add-description"
required
placeholder="e.g., Clamps"
/>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeAddValueModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">Add Value</button>
</div>
</form>
</div>
</div>
<!-- Edit Value Modal -->
<div class="modal-overlay" id="edit-value-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit Value</h3>
<button class="modal-close" onclick="closeEditValueModal()">
&times;
</button>
</div>
<form id="edit-value-form" onsubmit="updateValue(event)">
<input type="hidden" id="edit-schema-name" />
<input type="hidden" id="edit-segment-name" />
<input type="hidden" id="edit-code" />
<div class="form-group">
<label class="form-label">Code</label>
<input
type="text"
class="form-input"
id="edit-code-display"
disabled
/>
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input
type="text"
class="form-input"
id="edit-description"
required
/>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeEditValueModal()"
>
Cancel
</button>
<button type="submit" class="btn btn-primary">
Save Changes
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal-overlay" id="delete-value-modal">
<div class="modal" style="max-width: 400px">
<div class="modal-header">
<h3 class="modal-title">Delete Value</h3>
<button class="modal-close" onclick="closeDeleteValueModal()">
&times;
</button>
</div>
<div style="margin-bottom: 1.5rem">
<p>
Are you sure you want to delete
<strong id="delete-value-code"></strong>?
</p>
<p style="color: var(--ctp-red); margin-top: 0.5rem">
This action cannot be undone.
</p>
</div>
<div class="form-actions">
<button
type="button"
class="btn btn-secondary"
onclick="closeDeleteValueModal()"
>
Cancel
</button>
<button
type="button"
class="btn btn-primary"
style="background-color: var(--ctp-red)"
onclick="confirmDeleteValue()"
>
Delete
</button>
</div>
</div>
</div>
{{end}} {{define "schemas_scripts"}}
<style>
.value-actions {
display: flex;
gap: 0.25rem;
}
.value-actions button {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
}
.add-value-btn {
margin-top: 0.5rem;
}
</style>
<script>
let deleteValueInfo = null;
async function loadSchemas() {
const container = document.getElementById("schemas-list");
try {
const response = await fetch("/api/schemas");
const schemas = await response.json();
// Filter out empty schemas
const validSchemas = schemas.filter((s) => s.name);
if (validSchemas.length === 0) {
container.innerHTML =
'<div class="empty-state"><h3>No schemas found</h3></div>';
return;
}
container.innerHTML = validSchemas
.map(
(schema) => `
<div class="card" style="margin-bottom: 1rem;">
<h3 style="color: var(--ctp-mauve); margin-bottom: 0.5rem;">${schema.name}</h3>
<p style="color: var(--ctp-subtext0); margin-bottom: 1rem;">${schema.description || ""}</p>
<p style="margin-bottom: 0.5rem;"><strong>Format:</strong> <code style="background: var(--ctp-surface1); padding: 0.25rem 0.5rem; border-radius: 0.25rem;">${schema.format}</code></p>
<p style="margin-bottom: 1rem;"><strong>Version:</strong> ${schema.version}</p>
${
schema.examples && schema.examples.length > 0
? `
<p style="margin-bottom: 0.5rem;"><strong>Examples:</strong></p>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem;">
${schema.examples.map((ex) => `<span class="part-number" style="background: var(--ctp-surface1); padding: 0.25rem 0.75rem; border-radius: 0.25rem;">${ex}</span>`).join("")}
</div>
`
: ""
}
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: var(--ctp-sapphire);">View Segments (${schema.segments.length})</summary>
${schema.segments
.map(
(seg) => `
<div style="margin-top: 1rem; padding: 1rem; background: var(--ctp-surface0); border-radius: 0.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;">
<h4 style="margin: 0; color: var(--ctp-blue);">${seg.name}</h4>
<span class="item-type item-type-part">${seg.type}</span>
</div>
<p style="color: var(--ctp-subtext0); margin-bottom: 0.5rem;">${seg.description || ""}</p>
${seg.type === "enum" && seg.values ? renderEnumValues(schema.name, seg.name, seg.values) : ""}
</div>
`,
)
.join("")}
</details>
</div>
`,
)
.join("");
} catch (error) {
container.innerHTML = `<div class="empty-state"><h3>Error loading schemas</h3><p>${error.message}</p></div>`;
}
}
function renderEnumValues(schemaName, segmentName, values) {
const sorted = Object.entries(values).sort((a, b) =>
a[0].localeCompare(b[0]),
);
return `
<div class="table-container" style="margin-top: 0.5rem;">
<table>
<thead>
<tr>
<th>Code</th>
<th>Description</th>
<th style="width: 100px;">Actions</th>
</tr>
</thead>
<tbody>
${sorted
.map(
([code, desc]) => `
<tr>
<td><code>${code}</code></td>
<td>${desc}</td>
<td>
<div class="value-actions">
<button class="btn btn-secondary" onclick="openEditValueModal('${schemaName}', '${segmentName}', '${code}', '${desc.replace(/'/g, "\\'")}')">Edit</button>
<button class="btn btn-secondary" style="background-color: var(--ctp-surface2);" onclick="openDeleteValueModal('${schemaName}', '${segmentName}', '${code}')">Delete</button>
</div>
</td>
</tr>
`,
)
.join("")}
</tbody>
</table>
</div>
<button class="btn btn-primary add-value-btn" onclick="openAddValueModal('${schemaName}', '${segmentName}')">+ Add Value</button>
`;
}
// Add Value Modal
function openAddValueModal(schemaName, segmentName) {
document.getElementById("add-schema-name").value = schemaName;
document.getElementById("add-segment-name").value = segmentName;
document.getElementById("add-value-modal").classList.add("active");
}
function closeAddValueModal() {
document.getElementById("add-value-modal").classList.remove("active");
document.getElementById("add-value-form").reset();
}
async function addValue(event) {
event.preventDefault();
const schemaName = document.getElementById("add-schema-name").value;
const segmentName = document.getElementById("add-segment-name").value;
const code = document.getElementById("add-code").value;
const description = document.getElementById("add-description").value;
try {
const response = await fetch(
`/api/schemas/${schemaName}/segments/${segmentName}/values`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, description }),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeAddValueModal();
loadSchemas();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Edit Value Modal
function openEditValueModal(schemaName, segmentName, code, description) {
document.getElementById("edit-schema-name").value = schemaName;
document.getElementById("edit-segment-name").value = segmentName;
document.getElementById("edit-code").value = code;
document.getElementById("edit-code-display").value = code;
document.getElementById("edit-description").value = description;
document.getElementById("edit-value-modal").classList.add("active");
}
function closeEditValueModal() {
document.getElementById("edit-value-modal").classList.remove("active");
document.getElementById("edit-value-form").reset();
}
async function updateValue(event) {
event.preventDefault();
const schemaName = document.getElementById("edit-schema-name").value;
const segmentName = document.getElementById("edit-segment-name").value;
const code = document.getElementById("edit-code").value;
const description = document.getElementById("edit-description").value;
try {
const response = await fetch(
`/api/schemas/${schemaName}/segments/${segmentName}/values/${code}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description }),
},
);
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeEditValueModal();
loadSchemas();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete Value Modal
function openDeleteValueModal(schemaName, segmentName, code) {
deleteValueInfo = { schemaName, segmentName, code };
document.getElementById("delete-value-code").textContent = code;
document.getElementById("delete-value-modal").classList.add("active");
}
function closeDeleteValueModal() {
document
.getElementById("delete-value-modal")
.classList.remove("active");
deleteValueInfo = null;
}
async function confirmDeleteValue() {
if (!deleteValueInfo) return;
const { schemaName, segmentName, code } = deleteValueInfo;
try {
const response = await fetch(
`/api/schemas/${schemaName}/segments/${segmentName}/values/${code}`,
{
method: "DELETE",
},
);
if (!response.ok && response.status !== 204) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeDeleteValueModal();
loadSchemas();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Close modals on overlay click
document.querySelectorAll(".modal-overlay").forEach((overlay) => {
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
overlay.classList.remove("active");
}
});
});
loadSchemas();
</script>
{{end}}

View File

@@ -1,291 +0,0 @@
{{define "settings_content"}}
<style>
.settings-section {
margin-bottom: 2rem;
}
.settings-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1.5rem;
margin-top: 1rem;
}
.settings-info dt {
color: var(--ctp-subtext0);
font-weight: 500;
}
.settings-info dd {
color: var(--ctp-text);
}
.role-badge {
display: inline-block;
padding: 0.15rem 0.5rem;
border-radius: 1rem;
font-size: 0.8rem;
font-weight: 600;
}
.role-admin {
background: rgba(203, 166, 247, 0.2);
color: var(--ctp-mauve);
}
.role-editor {
background: rgba(137, 180, 250, 0.2);
color: var(--ctp-blue);
}
.role-viewer {
background: rgba(148, 226, 213, 0.2);
color: var(--ctp-teal);
}
.token-display {
display: block;
padding: 0.75rem 1rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.85rem;
color: var(--ctp-peach);
word-break: break-all;
margin: 0.75rem 0;
}
.new-token-banner {
background: rgba(166, 227, 161, 0.1);
border: 1px solid rgba(166, 227, 161, 0.3);
border-radius: 0.75rem;
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.new-token-banner p {
color: var(--ctp-green);
font-weight: 600;
margin-bottom: 0.5rem;
}
.new-token-banner .hint {
color: var(--ctp-subtext0);
font-size: 0.85rem;
font-weight: 400;
}
.copy-btn {
padding: 0.4rem 0.75rem;
background: var(--ctp-surface1);
border: none;
border-radius: 0.4rem;
color: var(--ctp-text);
cursor: pointer;
font-size: 0.85rem;
}
.copy-btn:hover {
background: var(--ctp-surface2);
}
.token-prefix {
font-family: "JetBrains Mono", "Fira Code", monospace;
color: var(--ctp-peach);
}
.btn-danger {
background: rgba(243, 139, 168, 0.15);
color: var(--ctp-red);
border: none;
padding: 0.4rem 0.75rem;
border-radius: 0.4rem;
cursor: pointer;
font-size: 0.85rem;
}
.btn-danger:hover {
background: rgba(243, 139, 168, 0.25);
}
.create-token-form {
display: flex;
gap: 0.75rem;
align-items: flex-end;
flex-wrap: wrap;
}
.create-token-form .form-group {
margin-bottom: 0;
flex: 1;
min-width: 200px;
}
.no-tokens {
color: var(--ctp-subtext0);
padding: 2rem;
text-align: center;
}
</style>
<div class="settings-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">Account</h2>
</div>
{{if .User}}
<dl class="settings-info">
<dt>Username</dt>
<dd>{{.User.Username}}</dd>
<dt>Display Name</dt>
<dd>{{.User.DisplayName}}</dd>
<dt>Email</dt>
<dd>
{{if .User.Email}}{{.User.Email}}{{else}}<span
style="color: var(--ctp-overlay0)"
>Not set</span
>{{end}}
</dd>
<dt>Auth Source</dt>
<dd>{{.User.AuthSource}}</dd>
<dt>Role</dt>
<dd>
<span class="role-badge role-{{.User.Role}}"
>{{.User.Role}}</span
>
</dd>
</dl>
{{end}}
</div>
</div>
<div class="settings-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">API Tokens</h2>
</div>
<p
style="
color: var(--ctp-subtext0);
margin-bottom: 1.25rem;
font-size: 0.9rem;
"
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
Silo. Tokens inherit your role permissions.
</p>
{{if and .Data (index .Data "new_token")}} {{if ne (index .Data
"new_token") ""}}
<div class="new-token-banner">
<p>Your new API token (copy it now — it won't be shown again):</p>
<code class="token-display" id="new-token-value"
>{{index .Data "new_token"}}</code
>
<button
class="copy-btn"
onclick="
navigator.clipboard
.writeText(
document.getElementById('new-token-value')
.textContent,
)
.then(() => {
this.textContent = 'Copied!';
})
"
>
Copy to clipboard
</button>
<p class="hint">
Store this token securely. You will not be able to see it again.
</p>
</div>
{{end}} {{end}}
<form
method="POST"
action="/settings/tokens"
class="create-token-form"
style="margin-bottom: 1.5rem"
>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" />
<div class="form-group">
<label class="form-label">Token Name</label>
<input
type="text"
name="name"
class="form-input"
placeholder="e.g., FreeCAD workstation"
required
/>
</div>
<button
type="submit"
class="btn btn-primary"
style="padding: 0.75rem 1.25rem; white-space: nowrap"
>
Create Token
</button>
</form>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Prefix</th>
<th>Created</th>
<th>Last Used</th>
<th>Expires</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="tokens-table"></tbody>
</table>
</div>
</div>
</div>
{{end}} {{define "settings_scripts"}}
<script>
(function () {
async function loadTokens() {
try {
const resp = await fetch("/api/auth/tokens");
if (!resp.ok) return;
const tokens = await resp.json();
const tbody = document.getElementById("tokens-table");
if (!tokens || tokens.length === 0) {
tbody.innerHTML =
'<tr><td colspan="6" class="no-tokens">No API tokens yet. Create one to get started.</td></tr>';
return;
}
tbody.innerHTML = tokens
.map(
(t) => `
<tr>
<td>${escHtml(t.name)}</td>
<td><span class="token-prefix">${escHtml(t.token_prefix)}...</span></td>
<td>${formatDate(t.created_at)}</td>
<td>${t.last_used_at ? formatDate(t.last_used_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
<td>${t.expires_at ? formatDate(t.expires_at) : '<span style="color:var(--ctp-overlay0)">Never</span>'}</td>
<td>
<form method="POST" action="/settings/tokens/${t.id}/revoke" style="display:inline">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger" onclick="return confirm('Revoke this token?')">Revoke</button>
</form>
</td>
</tr>
`,
)
.join("");
} catch (e) {
console.error("Failed to load tokens:", e);
}
}
function formatDate(s) {
if (!s) return "";
const d = new Date(s);
return (
d.toLocaleDateString() +
" " +
d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
);
}
function escHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Check for newly created token in URL or page state
const params = new URLSearchParams(window.location.search);
// The token is passed via a cookie/session flash, rendered by the server if present
loadTokens();
})();
</script>
{{end}}

View File

@@ -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)
}
}

91
internal/db/item_files.go Normal file
View File

@@ -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
}

View File

@@ -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, `

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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"

71
web/assets/silo-auth.svg Normal file
View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg1"
sodipodi:docname="silo-auth.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="34.176828"
inkscape:cx="8.0171279"
inkscape:cy="9.3630691"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<!-- Padlock body -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1-6"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M8 11V7a4 4 0 0 1 8 0v4"
fill="none"
stroke="#89dceb"
id="path1" />
<rect
x="5"
y="11"
width="14"
height="10"
rx="2"
fill="#313244"
stroke="#cba6f7"
id="rect1" />
<!-- Padlock shackle -->
<!-- Keyhole -->
<circle
cx="12"
cy="16"
r="1.5"
fill="#89dceb"
stroke="none"
id="circle1" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

103
web/assets/silo-bom.svg Normal file
View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg5"
sodipodi:docname="silo-bom.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs5" />
<sodipodi:namedview
id="namedview5"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="24.166667"
inkscape:cx="5.337931"
inkscape:cy="20.193103"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg5" />
<!-- Outer box -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1-7"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.75" />
<rect
x="3"
y="3"
width="18"
height="18"
rx="2"
fill="#313244"
id="rect1" />
<!-- List lines (BOM rows) -->
<line
x1="8.5062122"
y1="8"
x2="18.035316"
y2="8"
stroke="#89dceb"
stroke-width="1.46426"
id="line1" />
<line
x1="8.5415297"
y1="12"
x2="18"
y2="12"
stroke="#89dceb"
stroke-width="1.45882"
id="line2" />
<line
x1="8.5062122"
y1="16"
x2="18.035316"
y2="16"
stroke="#89dceb"
stroke-width="1.46426"
id="line3" />
<!-- Hierarchy dots -->
<circle
cx="5.7157421"
cy="8"
r="0.67500001"
fill="#cba6f7"
id="circle3"
style="stroke-width:1.35" />
<circle
cx="5.7157421"
cy="12"
r="0.67500001"
fill="#cba6f7"
id="circle4"
style="stroke-width:1.35" />
<circle
cx="5.7157421"
cy="16"
r="0.67500001"
fill="#cba6f7"
id="circle5"
style="stroke-width:1.35" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-commit.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="34.176828"
inkscape:cx="12.347547"
inkscape:cy="11.528279"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Git commit style -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.75" />
<line
x1="12"
y1="2"
x2="12"
y2="8"
stroke="#cba6f7"
id="line1" />
<line
x1="12"
y1="16"
x2="12"
y2="22"
stroke="#cba6f7"
id="line2" />
<circle
cx="12"
cy="12"
r="4"
fill="#313244"
stroke="#a6e3a1"
id="circle1" />
<!-- Checkmark inside -->
<polyline
points="9.5 12 11 13.5 14.5 10"
stroke="#a6e3a1"
stroke-width="1.5"
fill="none"
id="polyline2"
transform="matrix(0.6781425,0,0,0.6781425,3.9573771,4.1768251)" />
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

6
web/assets/silo-info.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Info circle -->
<circle cx="12" cy="12" r="10" fill="#313244"/>
<line x1="12" y1="16" x2="12" y2="12" stroke="#89dceb" stroke-width="2"/>
<circle cx="12" cy="8" r="0.5" fill="#89dceb" stroke="#89dceb"/>
</svg>

After

Width:  |  Height:  |  Size: 377 B

96
web/assets/silo-new.svg Normal file
View File

@@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-new.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="30.375973"
y1="17.251537"
x2="33.911507"
y2="17.251539"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2" />
<line
x1="32.143738"
y1="15.483771"
x2="32.143738"
y2="19.019306"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2-6" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

97
web/assets/silo-open.svg Normal file
View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-open.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="30.121014"
y1="14.814038"
x2="32.621014"
y2="17.314039"
stroke="#a6e3a1"
stroke-width="1.5"
id="line2" />
<circle
cx="28.460087"
cy="13.153111"
r="2.0712578"
fill="#1e1e2e"
stroke="#a6e3a1"
stroke-width="1.03563"
id="circle2"
style="fill:none" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

7
web/assets/silo-pull.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cba6f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Cloud -->
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" fill="#313244"/>
<!-- Download arrow -->
<path d="M12 13v5m0 0l-2-2m2 2l2-2" stroke="#89b4fa" stroke-width="2"/>
<line x1="12" y1="9" x2="12" y2="13" stroke="#89b4fa" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 428 B

48
web/assets/silo-push.svg Normal file
View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-push.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2" />
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="48.333333"
inkscape:cx="12"
inkscape:cy="12"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" />
<!-- Cloud -->
<path
d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"
fill="#313244"
id="path1" />
<!-- Upload arrow -->
<path
d="m 11.462069,16.262069 v -5 m 0,0 -1.9999997,2 m 1.9999997,-2 2,2"
stroke="#a6e3a1"
stroke-width="2"
id="path2" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

109
web/assets/silo-save.svg Normal file
View File

@@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="#cba6f7"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
version="1.1"
id="svg2"
sodipodi:docname="silo-save.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs2">
<marker
style="overflow:visible"
id="Triangle"
refX="0"
refY="0"
orient="auto-start-reverse"
inkscape:stockid="Triangle arrow"
markerWidth="0.33"
markerHeight="0.33"
viewBox="0 0 1 1"
inkscape:isstock="true"
inkscape:collect="always"
preserveAspectRatio="xMidYMid">
<path
transform="scale(0.5)"
style="fill:context-stroke;fill-rule:evenodd;stroke:context-stroke;stroke-width:1pt"
d="M 5.77,0 -2.88,5 V -5 Z"
id="path135" />
</marker>
</defs>
<sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="32"
inkscape:cx="5.96875"
inkscape:cy="12.09375"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<!-- Folder open icon -->
<!-- Search magnifier -->
<rect
width="24"
height="24"
rx="3"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke:none;stroke-width:0.749719;stroke-dasharray:none" />
<path
d="M 6.1818179,7.6363633 V 18.63072 c 0,0.969697 1.9393931,2.096551 5.8181811,2.096551 3.878787,0 5.624399,-1.126981 5.640104,-2.096551 L 17.81818,7.6363633"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none" />
<ellipse
cx="11.999998"
cy="7.6363635"
rx="5.818182"
ry="2.181818"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-width:0.909091;stroke-dasharray:none;stroke-opacity:1" />
<path
d="m 6.1818179,7.6363633 c 0,-7.272727 11.6363621,-7.272727 11.6363621,0"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="fill:#45475a;fill-opacity:1;stroke-width:0.909091;stroke-dasharray:none"
sodipodi:nodetypes="cc" />
<g
id="g2"
transform="translate(-13.871014,1.4359614)">
<line
x1="29.299988"
y1="15.638699"
x2="29.299988"
y2="20.238056"
stroke="#a6e3a1"
stroke-width="1.71085"
id="line2-6"
style="stroke-width:1.711;stroke-dasharray:none;marker-start:url(#Triangle)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

77
web/assets/silo.svg Normal file
View File

@@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
viewBox="0 0 64 64"
fill="none"
version="1.1"
id="svg6"
sodipodi:docname="silo.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs6" />
<sodipodi:namedview
id="namedview6"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="12.81631"
inkscape:cx="31.366282"
inkscape:cy="33.472973"
inkscape:window-width="2560"
inkscape:window-height="1371"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg6" />
<!-- Silo icon - grain silo with database/sync symbolism -->
<!-- Uses Catppuccin Mocha colors -->
<!-- Silo body (cylindrical tower) -->
<rect
width="64"
height="64"
rx="8"
fill="#313244"
id="rect1"
inkscape:label="rect1"
x="0"
y="0"
style="stroke-width:2" />
<path
d="M 16,20 V 50.234483 C 16,52.901149 21.333333,56 32,56 42.666667,56 47.4671,52.9008 47.510287,50.234483 L 48,20"
fill="#313244"
stroke="#cba6f7"
stroke-width="2"
id="path1"
sodipodi:nodetypes="csssc"
style="fill:#45475a;fill-opacity:1;stroke-width:2.5;stroke-dasharray:none" />
<!-- Silo dome/roof -->
<ellipse
cx="32"
cy="20"
rx="16"
ry="6"
fill="#45475a"
stroke="#cba6f7"
stroke-width="2"
id="ellipse1"
style="stroke:#cba6f7;stroke-opacity:1;stroke-width:2.5;stroke-dasharray:none" />
<path
d="M 16,20 C 16,0 48,0 48,20"
fill="none"
stroke="#cba6f7"
stroke-width="4.65698"
stroke-linecap="round"
id="path2"
style="stroke-width:2.5;stroke-dasharray:none;fill:#45475a;fill-opacity:1"
sodipodi:nodetypes="cc" />
<!-- Horizontal bands (like database rows / silo rings) -->
<!-- Base ellipse -->
<!-- Sync arrows (circular) - represents upload/download -->
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -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<T>(url: string, options?: RequestInit): Promise<T> {
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<T>(url: string): Promise<T> {
export function post<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'POST',
method: "POST",
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function put<T>(url: string, body?: unknown): Promise<T> {
return request<T>(url, {
method: 'PUT',
method: "PUT",
body: body != null ? JSON.stringify(body) : undefined,
});
}
export function del(url: string): Promise<void> {
return request<void>(url, { method: 'DELETE' });
return request<void>(url, { method: "DELETE" });
}

View File

@@ -218,6 +218,48 @@ export interface PropertyDef {
export type PropertySchema = Record<string, PropertyDef>;
// 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;

View File

@@ -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<TagOption[]>;
}
export function TagInput({ value, onChange, placeholder, searchFn }: TagInputProps) {
const [query, setQuery] = useState('');
const [results, setResults] = useState<TagOption[]>([]);
const [open, setOpen] = useState(false);
const [highlighted, setHighlighted] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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<string, string>());
for (const r of results) labelMap.current.set(r.id, r.label);
return (
<div ref={containerRef} style={{ position: 'relative' }}>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '0.25rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'var(--ctp-base)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
cursor: 'text',
minHeight: '1.8rem',
}}
onClick={() => inputRef.current?.focus()}
>
{value.map((id) => (
<span
key={id}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.25rem',
padding: '0.1rem 0.5rem',
borderRadius: '1rem',
backgroundColor: 'rgba(203,166,247,0.15)',
color: 'var(--ctp-mauve)',
fontSize: '0.75rem',
}}
>
{labelMap.current.get(id) ?? id}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
remove(id);
}}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
color: 'var(--ctp-mauve)',
padding: 0,
fontSize: '0.8rem',
lineHeight: 1,
}}
>
×
</button>
</span>
))}
<input
ref={inputRef}
value={query}
onChange={(e) => {
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',
}}
/>
</div>
{open && results.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 10,
marginTop: '0.2rem',
backgroundColor: 'var(--ctp-surface0)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.3rem',
maxHeight: '160px',
overflowY: 'auto',
}}
>
{results.map((opt, i) => (
<div
key={opt.id}
onMouseDown={(e) => {
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}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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<void>;
logout: () => Promise<void>;
refresh: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue>({
user: null,
loading: true,
login: async () => {},
logout: async () => {},
refresh: async () => {},
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
get<User>('/api/auth/me')
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
const fetchUser = useCallback(async () => {
try {
const u = await get<User>("/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 (
<AuthContext.Provider value={{ user, loading, logout }}>
<AuthContext.Provider value={{ user, loading, login, logout, refresh }}>
{children}
</AuthContext.Provider>
);

View File

@@ -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<string | null>(null);
const [filters, setFilters] = useState<ItemFilters>(defaultFilters);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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<FuzzyResult[]>(`/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<Item[]>(`/api/items${qs ? `?${qs}` : ''}`);
result = await get<Item[]>(`/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;
});
}, []);

View File

@@ -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<AuthConfig>("/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 (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
backgroundColor: 'var(--ctp-base)',
}}
>
<p style={{ color: 'var(--ctp-subtext0)' }}>Redirecting to login...</p>
<div style={containerStyle}>
<div style={cardStyle}>
<h1 style={titleStyle}>Silo</h1>
<p style={subtitleStyle}>Product Lifecycle Management</p>
{error && <div style={errorStyle}>{error}</div>}
<form onSubmit={handleSubmit}>
<div style={formGroupStyle}>
<label style={labelStyle}>Username</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Username or LDAP uid"
autoFocus
required
style={inputStyle}
/>
</div>
<div style={formGroupStyle}>
<label style={labelStyle}>Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
required
style={inputStyle}
/>
</div>
<button type="submit" disabled={submitting} style={btnPrimaryStyle}>
{submitting ? "Signing in..." : "Sign In"}
</button>
</form>
{oidcEnabled && (
<>
<div style={dividerStyle}>
<span style={dividerLineStyle} />
<span
style={{
padding: "0 1rem",
color: "var(--ctp-overlay0)",
fontSize: "0.85rem",
}}
>
or
</span>
<span style={dividerLineStyle} />
</div>
<a href="/auth/oidc" style={btnOidcStyle}>
Sign in with Keycloak
</a>
</>
)}
</div>
</div>
);
}
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",
};

View File

@@ -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<Project[]>([]);
const { user } = useAuth();
const navigate = useNavigate();
const isEditor = user?.role === "admin" || user?.role === "editor";
const [projects, setProjects] = useState<ProjectWithCount[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
get<Project[]>('/api/projects')
.then(setProjects)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
const [mode, setMode] = useState<Mode>("list");
const [editingProject, setEditingProject] = useState<ProjectWithCount | null>(
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<Project[]>("/api/projects");
const withCounts = await Promise.all(
list.map(async (p) => {
try {
const items = await get<Item[]>(`/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 <p style={{ color: 'var(--ctp-subtext0)' }}>Loading projects...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
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 <p style={{ color: "var(--ctp-subtext0)" }}>Loading projects...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Projects ({projects.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h2>Projects ({projects.length})</h2>
{isEditor && mode === "list" && (
<button onClick={openCreate} style={btnPrimaryStyle}>
+ New Project
</button>
)}
</div>
{/* Create / Edit form */}
{(mode === "create" || mode === "edit") && (
<div style={formPaneStyle}>
<div
style={{
...formHeaderStyle,
backgroundColor:
mode === "create" ? "var(--ctp-green)" : "var(--ctp-blue)",
}}
>
<strong>
{mode === "create"
? "New Project"
: `Edit ${editingProject?.code}`}
</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<form
onSubmit={mode === "create" ? handleCreate : handleEdit}
style={{ padding: "1rem" }}
>
{formError && <div style={errorBannerStyle}>{formError}</div>}
{mode === "create" && (
<div style={fieldStyle}>
<label style={labelStyle}>
Code (2-10 characters, uppercase)
</label>
<input
type="text"
value={formCode}
onChange={(e) => setFormCode(e.target.value)}
placeholder="e.g., PROJ-A"
required
minLength={2}
maxLength={10}
style={{ ...inputStyle, textTransform: "uppercase" }}
/>
</div>
)}
<div style={fieldStyle}>
<label style={labelStyle}>Name</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Project name"
style={inputStyle}
/>
</div>
<div style={fieldStyle}>
<label style={labelStyle}>Description</label>
<input
type="text"
value={formDesc}
onChange={(e) => setFormDesc(e.target.value)}
placeholder="Project description"
style={inputStyle}
/>
</div>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "flex-end",
}}
>
<button type="button" onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
type="submit"
disabled={formSubmitting}
style={btnPrimaryStyle}
>
{formSubmitting
? "Saving..."
: mode === "create"
? "Create Project"
: "Save Changes"}
</button>
</div>
</form>
</div>
)}
{/* Delete confirmation */}
{mode === "delete" && editingProject && (
<div style={formPaneStyle}>
<div
style={{ ...formHeaderStyle, backgroundColor: "var(--ctp-red)" }}
>
<strong>Delete Project</strong>
<button onClick={cancel} style={formCloseStyle}>
Cancel
</button>
</div>
<div style={{ padding: "1.5rem", textAlign: "center" }}>
{formError && <div style={errorBannerStyle}>{formError}</div>}
<p>
Are you sure you want to permanently delete project{" "}
<strong style={{ color: "var(--ctp-peach)" }}>
{editingProject.code}
</strong>
?
</p>
<p
style={{
color: "var(--ctp-red)",
marginTop: "0.5rem",
fontSize: "0.85rem",
}}
>
This action cannot be undone.
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
justifyContent: "center",
marginTop: "1.5rem",
}}
>
<button onClick={cancel} style={btnSecondaryStyle}>
Cancel
</button>
<button
onClick={handleDelete}
disabled={formSubmitting}
style={btnDangerStyle}
>
{formSubmitting ? "Deleting..." : "Delete Permanently"}
</button>
</div>
</div>
</div>
)}
{/* Table */}
<div style={tableContainerStyle}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Name</th>
<th style={thStyle} onClick={() => handleSort("code")}>
Code{sortArrow("code")}
</th>
<th style={thStyle} onClick={() => handleSort("name")}>
Name{sortArrow("name")}
</th>
<th style={thStyle}>Description</th>
<th style={thStyle} onClick={() => handleSort("itemCount")}>
Items{sortArrow("itemCount")}
</th>
<th style={thStyle} onClick={() => handleSort("created_at")}>
Created{sortArrow("created_at")}
</th>
{isEditor && <th style={thStyle}>Actions</th>}
</tr>
</thead>
<tbody>
{projects.map((p) => (
<tr key={p.id}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{p.code}
{sorted.length === 0 ? (
<tr>
<td
colSpan={isEditor ? 6 : 5}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No projects found. Create your first project to start
organizing items.
</td>
<td style={tdStyle}>{p.name}</td>
<td style={tdStyle}>{p.description}</td>
</tr>
))}
) : (
sorted.map((p, i) => (
<tr
key={p.id}
style={{
backgroundColor:
i % 2 === 0 ? "var(--ctp-base)" : "var(--ctp-surface0)",
}}
>
<td style={tdStyle}>
<span
onClick={() =>
navigate(`/?project=${encodeURIComponent(p.code)}`)
}
style={{
color: "var(--ctp-peach)",
fontFamily: "'JetBrains Mono', monospace",
fontWeight: 500,
cursor: "pointer",
}}
>
{p.code}
</span>
</td>
<td style={tdStyle}>{p.name || "-"}</td>
<td style={tdStyle}>{p.description || "-"}</td>
<td style={tdStyle}>{p.itemCount}</td>
<td style={tdStyle}>{formatDate(p.created_at)}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() => openEdit(p)}
style={btnSmallStyle}
>
Edit
</button>
<button
onClick={() => openDelete(p)}
style={{
...btnSmallStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
@@ -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",
};

View File

@@ -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<Schema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [editState, setEditState] = useState<EnumEditState | null>(null);
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const loadSchemas = async () => {
try {
const list = await get<Schema[]>("/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<Schema[]>('/api/schemas')
.then(setSchemas)
.catch((e: Error) => setError(e.message))
.finally(() => setLoading(false));
loadSchemas();
}, []);
if (loading) return <p style={{ color: 'var(--ctp-subtext0)' }}>Loading schemas...</p>;
if (error) return <p style={{ color: 'var(--ctp-red)' }}>Error: {error}</p>;
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 <p style={{ color: "var(--ctp-subtext0)" }}>Loading schemas...</p>;
if (error) return <p style={{ color: "var(--ctp-red)" }}>Error: {error}</p>;
return (
<div>
<h2 style={{ marginBottom: '1rem' }}>Schemas ({schemas.length})</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1rem',
overflowX: 'auto',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Format</th>
<th style={thStyle}>Description</th>
<th style={thStyle}>Segments</th>
</tr>
</thead>
<tbody>
{schemas.map((s) => (
<tr key={s.name}>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace", color: 'var(--ctp-peach)' }}>
{s.name}
</td>
<td style={{ ...tdStyle, fontFamily: "'JetBrains Mono', monospace" }}>{s.format}</td>
<td style={tdStyle}>{s.description}</td>
<td style={tdStyle}>{s.segments.length}</td>
</tr>
))}
</tbody>
</table>
</div>
<h2 style={{ marginBottom: "1rem" }}>
Part Numbering Schemas ({schemas.length})
</h2>
{schemas.length === 0 ? (
<div style={emptyStyle}>No schemas found.</div>
) : (
schemas.map((schema) => (
<SchemaCard
key={schema.name}
schema={schema}
expanded={expanded}
toggleExpand={toggleExpand}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={startAdd}
onStartEdit={startEdit}
onStartDelete={startDelete}
onCancelEdit={cancelEdit}
onAdd={handleAddValue}
onUpdate={handleUpdateValue}
onDelete={handleDeleteValue}
onEditStateChange={setEditState}
/>
))
)}
</div>
);
}
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<string>;
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 (
<div style={cardStyle}>
<h3 style={{ color: "var(--ctp-mauve)", marginBottom: "0.5rem" }}>
{schema.name}
</h3>
{schema.description && (
<p style={{ color: "var(--ctp-subtext0)", marginBottom: "1rem" }}>
{schema.description}
</p>
)}
<p style={{ marginBottom: "0.5rem" }}>
<strong>Format:</strong> <code style={codeStyle}>{schema.format}</code>
</p>
<p style={{ marginBottom: "1rem" }}>
<strong>Version:</strong> {schema.version}
</p>
{schema.examples && schema.examples.length > 0 && (
<>
<p style={{ marginBottom: "0.5rem" }}>
<strong>Examples:</strong>
</p>
<div
style={{
display: "flex",
gap: "0.5rem",
flexWrap: "wrap",
marginBottom: "1rem",
}}
>
{schema.examples.map((ex) => (
<span
key={ex}
style={{
...codeStyle,
fontFamily: "'JetBrains Mono', monospace",
}}
>
{ex}
</span>
))}
</div>
</>
)}
<div
onClick={() => toggleExpand(segKey)}
style={{
cursor: "pointer",
color: "var(--ctp-sapphire)",
userSelect: "none",
marginTop: "1rem",
}}
>
{isExpanded ? "\u25BC" : "\u25B6"} View Segments (
{schema.segments.length})
</div>
{isExpanded &&
schema.segments.map((seg) => (
<SegmentBlock
key={seg.name}
schemaName={schema.name}
segment={seg}
isEditor={isEditor}
editState={editState}
formError={formError}
submitting={submitting}
onStartAdd={onStartAdd}
onStartEdit={onStartEdit}
onStartDelete={onStartDelete}
onCancelEdit={onCancelEdit}
onAdd={onAdd}
onUpdate={onUpdate}
onDelete={onDelete}
onEditStateChange={onEditStateChange}
/>
))}
</div>
);
}
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 (
<div style={segmentStyle}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "0.5rem",
}}
>
<h4 style={{ margin: 0, color: "var(--ctp-blue)" }}>{segment.name}</h4>
<span style={typeBadgeStyle}>{segment.type}</span>
</div>
{segment.description && (
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "0.5rem",
fontSize: "0.85rem",
}}
>
{segment.description}
</p>
)}
{segment.type === "enum" && entries.length > 0 && (
<div style={{ marginTop: "0.5rem", overflowX: "auto" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Code</th>
<th style={thStyle}>Description</th>
{isEditor && (
<th style={{ ...thStyle, width: 120 }}>Actions</th>
)}
</tr>
</thead>
<tbody>
{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 (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<form
onSubmit={onUpdate}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
required
style={inlineInputStyle}
autoFocus
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Save
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
);
}
if (isDeletingThis) {
return (
<tr
key={code}
style={{ backgroundColor: "rgba(243, 139, 168, 0.1)" }}
>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>
<span
style={{
color: "var(--ctp-red)",
fontSize: "0.85rem",
}}
>
Delete this value?
</span>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={onDelete}
disabled={submitting}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-red)",
color: "var(--ctp-crust)",
}}
>
{submitting ? "..." : "Delete"}
</button>
<button onClick={onCancelEdit} style={btnTinyStyle}>
Cancel
</button>
</div>
</td>
</tr>
);
}
return (
<tr key={code}>
<td style={tdStyle}>
<code style={{ fontSize: "0.85rem" }}>{code}</code>
</td>
<td style={tdStyle}>{desc}</td>
{isEditor && (
<td style={tdStyle}>
<div style={{ display: "flex", gap: "0.25rem" }}>
<button
onClick={() =>
onStartEdit(schemaName, segment.name, code, desc)
}
style={btnTinyStyle}
>
Edit
</button>
<button
onClick={() =>
onStartDelete(schemaName, segment.name, code)
}
style={{
...btnTinyStyle,
backgroundColor: "var(--ctp-surface2)",
}}
>
Delete
</button>
</div>
</td>
)}
</tr>
);
})}
{/* Add row */}
{isThisSegment(editState) && editState!.mode === "add" && (
<tr>
<td style={tdStyle}>
<input
type="text"
value={editState!.code}
onChange={(e) =>
onEditStateChange({
...editState!,
code: e.target.value,
})
}
placeholder="Code"
required
style={inlineInputStyle}
autoFocus
/>
</td>
<td style={tdStyle}>
<form
onSubmit={onAdd}
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
}}
>
<input
type="text"
value={editState!.description}
onChange={(e) =>
onEditStateChange({
...editState!,
description: e.target.value,
})
}
placeholder="Description"
required
style={inlineInputStyle}
/>
<button
type="submit"
disabled={submitting}
style={btnTinyPrimaryStyle}
>
Add
</button>
<button
type="button"
onClick={onCancelEdit}
style={btnTinyStyle}
>
Cancel
</button>
</form>
{formError && (
<div
style={{
color: "var(--ctp-red)",
fontSize: "0.75rem",
marginTop: "0.25rem",
}}
>
{formError}
</div>
)}
</td>
{isEditor && <td style={tdStyle} />}
</tr>
)}
</tbody>
</table>
</div>
)}
{segment.type === "enum" &&
isEditor &&
!(isThisSegment(editState) && editState!.mode === "add") && (
<button
onClick={() => onStartAdd(schemaName, segment.name)}
style={{ ...btnTinyPrimaryStyle, marginTop: "0.5rem" }}
>
+ Add Value
</button>
)}
</div>
);
}
// --- 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",
};

View File

@@ -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<ApiToken[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const [tokensError, setTokensError] = useState<string | null>(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<string | null>(null);
const [copied, setCopied] = useState(false);
// Revoke confirmation
const [revoking, setRevoking] = useState<string | null>(null);
const loadTokens = async () => {
try {
const list = await get<ApiToken[]>("/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<ApiTokenCreated>("/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 (
<div>
<h2 style={{ marginBottom: '1rem' }}>Settings</h2>
<div style={{
backgroundColor: 'var(--ctp-surface0)',
borderRadius: '0.75rem',
padding: '1.5rem',
}}>
<h2 style={{ marginBottom: "1rem" }}>Settings</h2>
{/* Account Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>Account</h3>
{user ? (
<>
<p>Username: <strong>{user.username}</strong></p>
<p>Display name: <strong>{user.display_name}</strong></p>
<p>Email: <strong>{user.email}</strong></p>
<p>Role: <strong>{user.role}</strong></p>
<p>Auth source: <strong>{user.auth_source}</strong></p>
</>
<dl style={dlStyle}>
<dt style={dtStyle}>Username</dt>
<dd style={ddStyle}>{user.username}</dd>
<dt style={dtStyle}>Display Name</dt>
<dd style={ddStyle}>
{user.display_name || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Email</dt>
<dd style={ddStyle}>
{user.email || <span style={mutedStyle}>Not set</span>}
</dd>
<dt style={dtStyle}>Auth Source</dt>
<dd style={ddStyle}>{user.auth_source}</dd>
<dt style={dtStyle}>Role</dt>
<dd style={ddStyle}>
<span
style={{
display: "inline-block",
padding: "0.15rem 0.5rem",
borderRadius: "1rem",
fontSize: "0.8rem",
fontWeight: 600,
...roleBadgeStyles[user.role],
}}
>
{user.role}
</span>
</dd>
</dl>
) : (
<p style={{ color: 'var(--ctp-subtext0)' }}>Not logged in</p>
<p style={mutedStyle}>Not logged in</p>
)}
</div>
{/* API Tokens Card */}
<div style={cardStyle}>
<h3 style={cardTitleStyle}>API Tokens</h3>
<p
style={{
color: "var(--ctp-subtext0)",
marginBottom: "1.25rem",
fontSize: "0.9rem",
}}
>
API tokens allow the FreeCAD plugin and scripts to authenticate with
Silo. Tokens inherit your role permissions.
</p>
{/* New token banner */}
{newToken && (
<div style={newTokenBannerStyle}>
<p
style={{
color: "var(--ctp-green)",
fontWeight: 600,
marginBottom: "0.5rem",
}}
>
Your new API token (copy it now it won't be shown again):
</p>
<code style={tokenDisplayStyle}>{newToken}</code>
<div
style={{
display: "flex",
gap: "0.5rem",
alignItems: "center",
marginTop: "0.5rem",
}}
>
<button onClick={copyToken} style={btnCopyStyle}>
{copied ? "Copied!" : "Copy to clipboard"}
</button>
<button onClick={() => setNewToken(null)} style={btnDismissStyle}>
Dismiss
</button>
</div>
<p
style={{
color: "var(--ctp-subtext0)",
fontSize: "0.85rem",
marginTop: "0.5rem",
}}
>
Store this token securely. You will not be able to see it again.
</p>
</div>
)}
{/* Create token form */}
<form onSubmit={handleCreateToken} style={createFormStyle}>
<div style={{ flex: 1, minWidth: 200 }}>
<label style={labelStyle}>Token Name</label>
<input
type="text"
value={tokenName}
onChange={(e) => setTokenName(e.target.value)}
placeholder="e.g., FreeCAD workstation"
required
style={inputStyle}
/>
</div>
<button
type="submit"
disabled={creating}
style={{ ...btnPrimaryStyle, alignSelf: "flex-end" }}
>
{creating ? "Creating..." : "Create Token"}
</button>
</form>
{createError && <div style={errorStyle}>{createError}</div>}
{/* Token list */}
{tokensLoading ? (
<p style={mutedStyle}>Loading tokens...</p>
) : tokensError ? (
<p style={{ color: "var(--ctp-red)", fontSize: "0.85rem" }}>
{tokensError}
</p>
) : (
<div style={{ overflowX: "auto", marginTop: "1rem" }}>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead>
<tr>
<th style={thStyle}>Name</th>
<th style={thStyle}>Prefix</th>
<th style={thStyle}>Created</th>
<th style={thStyle}>Last Used</th>
<th style={thStyle}>Expires</th>
<th style={thStyle}>Actions</th>
</tr>
</thead>
<tbody>
{tokens.length === 0 ? (
<tr>
<td
colSpan={6}
style={{
...tdStyle,
textAlign: "center",
padding: "2rem",
color: "var(--ctp-subtext0)",
}}
>
No API tokens yet. Create one to get started.
</td>
</tr>
) : (
tokens.map((t) => (
<tr key={t.id}>
<td style={tdStyle}>{t.name}</td>
<td style={tdStyle}>
<span
style={{
fontFamily: "'JetBrains Mono', monospace",
color: "var(--ctp-peach)",
}}
>
{t.token_prefix}...
</span>
</td>
<td style={tdStyle}>{formatDate(t.created_at)}</td>
<td style={tdStyle}>
{t.last_used_at ? (
formatDate(t.last_used_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{t.expires_at ? (
formatDate(t.expires_at)
) : (
<span style={mutedStyle}>Never</span>
)}
</td>
<td style={tdStyle}>
{revoking === t.id ? (
<div
style={{
display: "flex",
gap: "0.25rem",
alignItems: "center",
}}
>
<button
onClick={() => handleRevoke(t.id)}
style={btnRevokeConfirmStyle}
>
Confirm
</button>
<button
onClick={() => setRevoking(null)}
style={btnTinyStyle}
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setRevoking(t.id)}
style={btnDangerStyle}
>
Revoke
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
// --- Styles ---
const roleBadgeStyles: Record<string, React.CSSProperties> = {
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",
};

View File

@@ -8,7 +8,8 @@
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"composite": true,
"emitDeclarationOnly": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,