Compare commits

...

3 Commits

Author SHA1 Message Date
jason ef926050b6 login fix
Build and Push Docker Image / build (push) Successful in 14s
2026-04-22 22:42:50 -05:00
jason 82596a9ac4 md viewer fix
Build and Push Docker Image / build (push) Successful in 13s
2026-04-22 22:39:19 -05:00
jason c86b950a0d explainer file
Build and Push Docker Image / build (push) Successful in 6s
2026-04-22 22:34:52 -05:00
6 changed files with 405 additions and 19 deletions
+255
View File
@@ -0,0 +1,255 @@
# CODEDUMP
Internal dashboard for tracking AI tools and coding projects. Provides a high-level overview of what the team is building, what tools are available, and the completion status of active work — all in one place.
---
## Purpose
CODEDUMP gives teams a single place to:
- Track AI tools and coding projects with completion percentages
- Attach markdown documentation and project files
- Link to internal/external URLs and Google Drive sources
- Highlight new tools available to the team
- Manage access via admin accounts and 4-digit user PINs
---
## Tech Stack
| Layer | Technology |
|---|---|
| Frontend | React 18 + TypeScript + Vite |
| Styling | Tailwind CSS (dark mode, CSS variable theming) |
| Backend | Node.js + Express + TypeScript |
| Database | SQLite via `better-sqlite3` |
| Auth | JWT (12h expiry) + bcryptjs (admin passwords) + HMAC-SHA256 (user PINs) |
| File uploads | Multer (local disk storage) |
| Markdown | `react-markdown` + `remark-gfm` |
| Container | Docker (multi-stage build, single container) |
---
## Project Structure
```
codedump/
├── client/ # React frontend
│ ├── src/
│ │ ├── api/index.ts # Typed fetch wrappers (JWT injected automatically)
│ │ ├── hooks/
│ │ │ ├── useAuth.ts # AuthContext — JWT storage, login/logout
│ │ │ └── useSettings.ts # SettingsContext — branding, accent color
│ │ ├── components/
│ │ │ ├── layout/
│ │ │ │ ├── Layout.tsx # App shell (sidebar + outlet)
│ │ │ │ └── Sidebar.tsx # Nav — auth-aware, admin section, logout
│ │ │ ├── projects/
│ │ │ │ ├── ProjectCard.tsx # Card with progress bar, links, tags
│ │ │ │ └── ProjectForm.tsx # Create/edit form with color picker
│ │ │ ├── tools/
│ │ │ │ └── ToolCard.tsx # Tool card with NEW badge
│ │ │ └── ui/
│ │ │ ├── Badge.tsx # Status/role badges
│ │ │ ├── Button.tsx # Primary/secondary/ghost/danger variants
│ │ │ ├── Modal.tsx # Accessible overlay (Escape to close)
│ │ │ └── ProgressBar.tsx # Accent-colored progress indicator
│ │ ├── pages/
│ │ │ ├── Login.tsx # PIN pad + admin login
│ │ │ ├── Dashboard.tsx # Stats, new tools spotlight, recent projects
│ │ │ ├── Projects.tsx # Filterable project grid
│ │ │ ├── ProjectDetail.tsx # Docs viewer, completion editor, links
│ │ │ ├── Tools.tsx # Tool catalog with search + filter
│ │ │ ├── Settings.tsx # Branding, logo upload, accent color
│ │ │ └── AdminUsers.tsx # User management (admin only)
│ │ └── types/index.ts # Shared TypeScript interfaces
│ ├── index.html
│ ├── vite.config.ts # Dev proxy: /api → localhost:3000
│ ├── tailwind.config.js
│ └── tsconfig.json
├── server/ # Express backend
│ ├── src/
│ │ ├── db/schema.ts # SQLite schema, migrations, admin bootstrap
│ │ ├── lib/pinHash.ts # HMAC-SHA256 PIN hashing (deterministic lookup)
│ │ ├── middleware/auth.ts # requireAuth / requireAdmin JWT guards
│ │ └── routes/
│ │ ├── auth.ts # POST /pin, POST /login, GET /me
│ │ ├── projects.ts # CRUD + tag/category support
│ │ ├── tools.ts # CRUD
│ │ ├── uploads.ts # POST (multer) + DELETE for documents
│ │ ├── settings.ts # GET/PUT settings, POST logo upload
│ │ └── users.ts # Admin-only user CRUD
│ ├── tsconfig.json
│ └── package.json
├── Dockerfile # Multi-stage: client build → server build → runtime
├── docker-compose.yml
├── .dockerignore
├── INSTALL.md # Unraid GUI install guide
└── package.json # npm workspaces root
```
---
## Database Schema
**`users`** — Authentication accounts
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | UUID |
| `username` | TEXT UNIQUE | Display name |
| `role` | TEXT | `admin` or `user` |
| `pin_hash` | TEXT UNIQUE | HMAC-SHA256 of 4-digit PIN (users only) |
| `password_hash` | TEXT | bcrypt hash (admins only) |
| `created_at` | TEXT | ISO datetime |
**`projects`** — Core project records
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | UUID |
| `name` | TEXT | Required |
| `description` | TEXT | |
| `category` | TEXT | e.g. AI/ML, DevOps, Research |
| `status` | TEXT | `active`, `complete`, `archived` |
| `completion` | INTEGER | 0100 |
| `external_url` | TEXT | Internal or external link |
| `drive_url` | TEXT | Google Drive link |
| `tags` | TEXT | JSON array of strings |
| `accent_color` | TEXT | Hex color for card theming |
| `is_new` | INTEGER | Boolean flag |
| `created_at` / `updated_at` | TEXT | ISO datetime |
**`documents`** — Files attached to projects
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | UUID |
| `project_id` | TEXT FK | Cascades on project delete |
| `filename` | TEXT | UUID-named file on disk |
| `original_name` | TEXT | Display name |
| `mimetype` | TEXT | |
| `created_at` | TEXT | |
**`tools`** — Available AI/dev tools catalog
| Column | Type | Notes |
|---|---|---|
| `id` | TEXT PK | UUID |
| `name` | TEXT | |
| `description` | TEXT | |
| `category` | TEXT | |
| `external_url` | TEXT | |
| `is_new` | INTEGER | Boolean — shown in dashboard spotlight |
| `added_at` | TEXT | |
| `notes` | TEXT | Usage tips, caveats |
**`settings`** — Key/value app configuration
| Key | Default | Description |
|---|---|---|
| `app_title` | `CODEDUMP` | Shown in sidebar and login page |
| `company_name` | `Your Company` | Shown under the app title |
| `logo_url` | `null` | Uploaded or external URL |
| `accent_color` | `#6366f1` | CSS variable applied globally |
---
## Auth Model
Two account types with separate login flows:
**Users** — Standard team members
- Select no username; just enter their 4-digit PIN directly on the keypad
- PINs are unique system-wide (enforced by DB unique constraint)
- Stored as `HMAC-SHA256(JWT_SECRET, pin)` — deterministic, allowing single-query lookup
- Can: create/edit/delete projects and tools, upload documents, view all content
- Cannot: access Settings or User Management
**Admins** — Elevated accounts
- Log in with username + password via the "Admin Login" link on the login page
- Passwords stored as bcrypt hashes (cost factor 10)
- Can: everything users can do, plus Settings (branding/theme) and User Management
- A bootstrap admin is created on first startup from `ADMIN_USERNAME` / `ADMIN_PASSWORD` env vars
- The last admin account cannot be deleted
**JWT** — 12-hour tokens stored in `localStorage`. Expiry is checked client-side on load; the server re-validates on every request. A 401 response from the server clears the token and redirects to `/login`.
---
## Environment Variables
| Variable | Default | Description |
|---|---|---|
| `PORT` | `3000` | Server listen port |
| `DATA_DIR` | `./data` | SQLite DB + uploads root on disk |
| `MAX_UPLOAD_MB` | `50` | Max file upload size |
| `ADMIN_USERNAME` | `admin` | Bootstrap admin username (first-run only) |
| `ADMIN_PASSWORD` | `codedump2024` | Bootstrap admin password (first-run only) — change this |
| `JWT_SECRET` | *(insecure default)* | Token signing secret — set to a 32+ char random string |
Generate a strong `JWT_SECRET`:
```bash
openssl rand -hex 32
```
---
## Running Locally
```bash
# Install all workspace dependencies
npm install
# Start both server (port 3000) and client (port 5173) with hot reload
npm run dev
```
Visit `http://localhost:5173`. The Vite dev server proxies `/api/*` to `localhost:3000`.
Default admin credentials on first run: `admin` / `codedump2024`
---
## Building for Production
```bash
npm run build
# Client builds to client/dist/
# Server builds to server/dist/
# Server serves client/dist/ as static files
npm start
```
---
## Docker
```bash
# Build image
docker build -t codedump:latest .
# Run with docker-compose (recommended)
docker-compose up -d
```
Data persists in `./data/` (local) or `/mnt/user/appdata/codedump/` (Unraid).
See [INSTALL.md](./INSTALL.md) for the full Unraid GUI setup guide including port mapping, volume paths, and all environment variable fields.
---
## File Upload Details
- Accepted types: `.md`, `.txt`, `.pdf`, `.png`, `.jpg`, `.jpeg`, `.gif`, `.svg`
- Files are stored in `DATA_DIR/uploads/` with UUID filenames
- Served publicly at `/api/uploads/:filename` via `express.static` (no auth required — needed for `<img>` tags and the markdown doc viewer)
- Upload and delete operations require authentication
- Logo uploads follow the same path; the URL is saved in settings and served the same way
---
## Deployment Notes
- SQLite with WAL mode — suitable for a single-server internal tool; no separate database service needed
- All persistent state (DB + uploads) lives under `DATA_DIR` — back up that directory
- The bootstrap admin only runs if no admin account exists in the DB; changing env vars after first run does not change existing credentials
- `JWT_SECRET` must stay consistent across restarts — changing it invalidates all active sessions
+5 -2
View File
@@ -18,10 +18,13 @@ async function req<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401) {
// Token expired — clear storage and reload to login
const hadToken = Boolean(localStorage.getItem(TOKEN_KEY));
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem('codedump_user');
window.location.href = '/login';
// Only force-redirect if the user had an active session that expired.
// Without this guard, unauthenticated requests (e.g. settings fetch on
// the login page) trigger a redirect loop: 401 → /login → fetch → 401 → ...
if (hadToken) window.location.href = '/login';
throw new Error('Session expired');
}
+139
View File
@@ -0,0 +1,139 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type { Components } from 'react-markdown';
const components: Components = {
// ── Headings ────────────────────────────────────────────────────────────────
h1: ({ children }) => (
<h1 className="text-2xl font-bold text-white mt-8 mb-4 pb-3 border-b border-border first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-lg font-semibold text-white mt-7 mb-3 pb-2 border-b border-border/50 flex items-center gap-2">
<span className="inline-block w-1 h-4 rounded-sm shrink-0" style={{ background: 'var(--accent)' }} />
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-base font-semibold text-white mt-5 mb-2">{children}</h3>
),
h4: ({ children }) => (
<h4 className="text-sm font-semibold text-zinc-200 mt-4 mb-1 uppercase tracking-wide">{children}</h4>
),
// ── Paragraph ───────────────────────────────────────────────────────────────
p: ({ children }) => (
<p className="text-zinc-300 leading-7 mb-4 last:mb-0">{children}</p>
),
// ── Links ───────────────────────────────────────────────────────────────────
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-2 decoration-[var(--accent)]/50 hover:decoration-[var(--accent)] transition-colors"
style={{ color: 'var(--accent)' }}
>
{children}
</a>
),
// ── Strong / Em ─────────────────────────────────────────────────────────────
strong: ({ children }) => (
<strong className="font-semibold text-white">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-zinc-400">{children}</em>
),
// ── Horizontal rule ─────────────────────────────────────────────────────────
hr: () => <hr className="my-6 border-border" />,
// ── Blockquote ──────────────────────────────────────────────────────────────
blockquote: ({ children }) => (
<blockquote className="my-4 pl-4 border-l-2 text-zinc-400 italic bg-white/[0.02] rounded-r-lg py-3 pr-3" style={{ borderLeftColor: 'var(--accent)' }}>
{children}
</blockquote>
),
// ── Lists ───────────────────────────────────────────────────────────────────
ul: ({ children }) => (
<ul className="my-3 space-y-1.5 list-none pl-4">{children}</ul>
),
ol: ({ children }) => (
<ol className="my-3 space-y-1.5 list-decimal list-inside pl-2 text-zinc-300">{children}</ol>
),
li: ({ children }) => (
<li className="text-zinc-300 leading-relaxed flex gap-2 items-start">
<span className="mt-2.5 w-1.5 h-1.5 rounded-full shrink-0" style={{ background: 'var(--accent)', opacity: 0.7 }} />
<span className="flex-1">{children}</span>
</li>
),
// ── Inline code ─────────────────────────────────────────────────────────────
code: ({ className, children, ...props }) => {
const isBlock = Boolean(className);
if (isBlock) return <code className={className} {...props}>{children}</code>;
return (
<code className="font-mono text-[0.8em] bg-white/10 text-[#e2a4ff] px-1.5 py-0.5 rounded-md border border-white/10">
{children}
</code>
);
},
// ── Code block ──────────────────────────────────────────────────────────────
pre: ({ children }) => (
<pre className="my-4 bg-[#0d0d14] border border-border rounded-xl overflow-x-auto">
<div className="flex items-center justify-between px-4 py-2 border-b border-border/50">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500/40" />
<span className="w-3 h-3 rounded-full bg-yellow-500/40" />
<span className="w-3 h-3 rounded-full bg-green-500/40" />
</div>
</div>
<div className="p-4 text-sm font-mono text-zinc-300 leading-relaxed">
{children}
</div>
</pre>
),
// ── Tables ──────────────────────────────────────────────────────────────────
table: ({ children }) => (
<div className="my-5 overflow-x-auto rounded-xl border border-border">
<table className="w-full text-sm border-collapse">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-white/[0.04] border-b border-border">{children}</thead>
),
tbody: ({ children }) => (
<tbody className="divide-y divide-border">{children}</tbody>
),
tr: ({ children }) => (
<tr className="transition-colors hover:bg-white/[0.03]">{children}</tr>
),
th: ({ children }) => (
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-muted whitespace-nowrap">
{children}
</th>
),
td: ({ children }) => (
<td className="px-4 py-3 text-zinc-300 align-top">{children}</td>
),
};
interface Props {
content: string;
}
export default function MarkdownViewer({ content }: Props) {
return (
<div className="min-w-0">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
);
}
+2 -14
View File
@@ -1,7 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import MarkdownViewer from '../components/ui/MarkdownViewer';
import {
ArrowLeft, ExternalLink, Pencil, Trash2, Upload, FileText,
X, Save, FolderOpen, Eye, Code2
@@ -244,18 +243,7 @@ export default function ProjectDetail() {
</div>
<div className="p-6 overflow-auto max-h-[70vh]">
{docViewMode === 'preview' ? (
<div className="prose prose-invert prose-sm max-w-none
prose-headings:text-white prose-headings:font-semibold
prose-p:text-zinc-300 prose-p:leading-relaxed
prose-a:text-[var(--accent)] prose-a:no-underline hover:prose-a:underline
prose-code:bg-white/10 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm prose-code:font-mono
prose-pre:bg-surface prose-pre:border prose-pre:border-border
prose-blockquote:border-l-[var(--accent)] prose-blockquote:text-muted
prose-li:text-zinc-300 prose-strong:text-white
prose-table:text-sm prose-th:text-muted prose-td:text-zinc-300
prose-hr:border-border">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{docContent}</ReactMarkdown>
</div>
<MarkdownViewer content={docContent} />
) : (
<pre className="text-xs text-zinc-300 font-mono whitespace-pre-wrap">{docContent}</pre>
)}
+1 -1
View File
@@ -30,7 +30,7 @@ app.use('/api/uploads', express.static(UPLOAD_PATH));
app.use('/api/projects', requireAuth, projectsRouter);
app.use('/api/tools', requireAuth, toolsRouter);
app.use('/api/uploads', requireAuth, uploadsRouter); // handles POST + DELETE only
app.use('/api/settings', requireAuth, settingsRouter);
app.use('/api/settings', settingsRouter); // GET is public (branding on login page); PUT/POST require admin (per-method in router)
app.use('/api/users', usersRouter); // requireAdmin applied inside router
// Serve built React client in production
+3 -2
View File
@@ -3,6 +3,7 @@ import multer from 'multer';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import db, { UPLOAD_PATH } from '../db/schema';
import { requireAdmin } from '../middleware/auth';
const router = Router();
@@ -27,7 +28,7 @@ router.get('/', (_req: Request, res: Response) => {
res.json(settings);
});
router.put('/', (req: Request, res: Response) => {
router.put('/', requireAdmin, (req: Request, res: Response) => {
const allowed = ['app_title', 'logo_url', 'accent_color', 'company_name'];
const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)');
const update = db.transaction((body: Record<string, any>) => {
@@ -45,7 +46,7 @@ router.put('/', (req: Request, res: Response) => {
res.json(settings);
});
router.post('/logo', logoUpload.single('logo'), (req: Request, res: Response) => {
router.post('/logo', requireAdmin, logoUpload.single('logo'), (req: Request, res: Response) => {
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
const url = `/api/uploads/${req.file.filename}`;
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run('logo_url', JSON.stringify(url));