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
7
.gitignore
vendored
@@ -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
@@ -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"]
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
316
internal/api/file_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ") {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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()">×</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()">×</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()">×</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}}
|
||||
@@ -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()">
|
||||
×
|
||||
</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()">
|
||||
×
|
||||
</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()">
|
||||
×
|
||||
</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}}
|
||||
@@ -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}}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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, `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
migrations/011_item_files.sql
Normal 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;
|
||||
@@ -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
@@ -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
@@ -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 |
77
web/assets/silo-commit.svg
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 |
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
222
web/src/components/TagInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"composite": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
|
||||