Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 5bcce1dea0 feat(ui): bulk-action UX + keyboard nav (PHASE 4)
CI / backend (pull_request) Successful in 1m14s
CI / frontend (pull_request) Successful in 11s
BulkToolbar: primary-emphasis selection count (primary pill), responsive
flex-wrap action group, obvious clear-selection (X) affordance; keeps all
actions + Trash confirm dialog and stable props.

useListKeyboardNav: arrow-key aliases for j/k, `u` marks focused email
unread, contenteditable + modifier-chord guards; stable focusedId return.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 16:49:39 +02:00
16 changed files with 120 additions and 584 deletions
-17
View File
@@ -1,17 +0,0 @@
# Copy to deploy/.env.staging and fill in. Used by:
# docker compose -p inboxintel-staging --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up
# (or ./deploy/up.ps1 -Staging). Kept separate from deploy/.env (production) so
# staging can never touch prod credentials, DB, or Google project.
POSTGRES_PASSWORD=change-me-staging
# Use a SEPARATE Google OAuth client for staging with redirect URI:
# http://localhost:18081/signin-google
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
AI_MODE=Disabled
FRONTEND_ORIGIN=http://localhost:18081
# Staging caps the initial mailbox sync so rehearsals are fast.
MAX_MESSAGES=2000
-45
View File
@@ -1,45 +0,0 @@
name: Deploy Production
# Production promotion. The APPROVAL GATE is the git tag: production only ever
# deploys a tagged release cut on main (see docs/WORKFLOW.md §5). Cutting the tag
# is the deliberate, auditable "approve to go live" action — and the tag doubles
# as the rollback target. workflow_dispatch adds a manual "Run workflow" button
# for re-deploys/rollbacks.
#
# STATUS: inactive until (a) the Linux production server exists and (b) a
# self-hosted Gitea runner is registered on it with labels [self-hosted, production].
# Tag pushes before then will queue harmlessly. This file is the wiring, ready to
# switch on — review the deploy step for your server before first use.
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
ref:
description: 'Tag or commit to deploy (e.g. v1.2.0)'
required: true
jobs:
deploy:
runs-on: [self-hosted, production]
steps:
- uses: actions/checkout@v4
with:
# Deploy the exact tag that triggered the run (immutable), or the
# ref given to a manual dispatch.
ref: ${{ github.event.inputs.ref || github.ref_name }}
fetch-depth: 0
- name: Deploy release to production
run: |
echo "Deploying ${{ github.event.inputs.ref || github.ref_name }} to production"
# deploy/.env lives on the server (never in git). up.sh validates it,
# builds the Linux images, applies EF migrations on boot, and starts
# the stack behind the nginx reverse proxy.
./deploy/up.sh --proxy
- name: Smoke check
run: |
sleep 5
curl -fsS http://localhost/ >/dev/null && echo "Prod responding on :80" || \
{ echo "Smoke check failed"; exit 1; }
-42
View File
@@ -1,42 +0,0 @@
name: Deploy Staging
# Continuous deployment to the LOCAL staging stack. Fires when develop advances
# (i.e. after a PR is merged into develop). It re-verifies the code, then rebuilds
# and restarts the isolated staging stack on this machine.
#
# CRITICAL: staging lives on your Windows box (ports 18080/18081). A cloud or
# container runner CANNOT reach it, so this job MUST run on a self-hosted Gitea
# Actions runner registered ON that Windows machine with Docker access
# (labels: self-hosted, windows). Until that runner exists this job just waits
# in the queue (harmless, cancelable) — deploy staging manually meanwhile with:
# ./deploy/up.ps1 -Staging
on:
push:
branches: [develop]
workflow_dispatch: {} # also allow a manual "Run workflow" from the Gitea UI
jobs:
deploy:
runs-on: [self-hosted, windows]
steps:
- uses: actions/checkout@v4
# Re-run the gate on the exact merged code before it touches staging.
- name: Build + test (Release)
shell: powershell
run: |
dotnet build InboxIntel.sln -c Release --nologo
dotnet test InboxIntel.sln -c Release --no-build --nologo
- name: Redeploy staging stack
shell: powershell
run: |
docker compose -p inboxintel-staging `
--env-file deploy/.env.staging `
-f docker-compose.yml -f docker-compose.staging.yml `
up -d --build
docker compose -p inboxintel-staging ps
- name: Staging endpoints
shell: powershell
run: Write-Host "Staging up — Frontend http://localhost:18081 API http://localhost:18080/swagger"
-46
View File
@@ -1,46 +0,0 @@
name: Security
# Scans run alongside CI on every PR and on pushes to the long-lived branches.
# This is the DETECTIVE layer (backstop). The PREVENTIVE layer is the local
# pre-commit hook — this catches anything that slipped past it (e.g. --no-verify)
# and scans the full history, not just the staged diff.
on:
push:
branches: [main, develop]
pull_request:
jobs:
secrets:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history so gitleaks scans every commit
- name: Secret scan (gitleaks)
run: |
docker run --rm -v "$PWD:/repo" zricethezav/gitleaks:latest \
detect --source=/repo --redact --verbose --exit-code 1
dependencies:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore
run: dotnet restore InboxIntel.sln
- name: .NET vulnerable packages (fail on any)
run: |
dotnet list InboxIntel.sln package --vulnerable --include-transitive 2>&1 | tee vuln.txt
if grep -qiE 'Critical|High|Moderate|Low' vuln.txt; then
echo "::error::Vulnerable NuGet packages detected — see the table above."
exit 1
fi
echo "No vulnerable NuGet packages."
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: npm audit (fail on high/critical)
working-directory: frontend
run: npm audit --audit-level=high
-1
View File
@@ -54,7 +54,6 @@ lpt[1-9].*
*.code-workspace
.env.*
!.env.example
!.env.staging.example
.next/
dist/
build/
-29
View File
@@ -1,29 +0,0 @@
# Changelog
All notable changes to InboxIntel are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/); versions follow
[Semantic Versioning](https://semver.org/). See [docs/WORKFLOW.md](docs/WORKFLOW.md).
## [Unreleased]
### Added
- CI/CD pipeline (`.gitea/workflows/`): `security` (gitleaks secret scan + NuGet/npm
vulnerability gate), `deploy-staging` (auto-redeploy local staging on `develop`),
`deploy-prod` (tag-gated production promotion, inactive until the server exists).
- Formal Git workflow & environment strategy (`docs/WORKFLOW.md`).
- Staging environment overlay (`docker-compose.staging.yml`) — production-shaped
Linux containers on Windows, isolated ports/volumes.
- Version-controlled Git hooks (`scripts/git-hooks/`) + installer
(`scripts/install-hooks.ps1`): pre-commit secret/format checks, pre-push
build+test gate.
- `VERSION` file as the single source of truth for the release number.
## [0.1.0] — scaffold
### Added
- .NET 8 Clean Architecture backend (Domain/Application/Infrastructure/Api) + React/Vite SPA.
- Docker Compose stack (Postgres 16, API, frontend, optional nginx proxy).
- Gitea Actions CI (backend build+test, frontend build) on `main`/`develop` + PRs.
- Security hardening: encrypted OAuth tokens, EF global query filters (IDOR),
loopback binds, non-root containers, SSRF egress guard.
- One-command deploy scripts (`deploy/up.ps1`, `deploy/up.sh`).
[Unreleased]: https://your-gitea-host/InboxIntel/compare/v0.1.0...HEAD
-1
View File
@@ -1 +0,0 @@
0.1.0
+4 -13
View File
@@ -5,22 +5,13 @@
./deploy/down.ps1
./deploy/down.ps1 -Volumes
#>
param([switch]$Volumes, [switch]$Staging)
param([switch]$Volumes)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$root = Split-Path -Parent $PSScriptRoot
$envFile = Join-Path $PSScriptRoot '.env'
if ($Staging) {
$envFile = Join-Path $PSScriptRoot '.env.staging'
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
$project = @('-p', 'inboxintel-staging')
} else {
$envFile = Join-Path $PSScriptRoot '.env'
$composeFiles = @()
$project = @()
}
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles + @('down')
$composeArgs = @('compose', '--env-file', $envFile, 'down')
if ($Volumes) { $composeArgs += '--volumes' }
Push-Location $root
+10 -27
View File
@@ -8,28 +8,15 @@
#>
param(
[switch]$Proxy,
[switch]$Foreground,
[switch]$Staging # production-shaped staging stack: separate env, ports, volumes
[switch]$Foreground
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
$root = Split-Path -Parent $PSScriptRoot # repo root (deploy/ is one level down)
$envFile = Join-Path $PSScriptRoot '.env'
# Staging vs production: pick the env file + compose overlay + isolated project name.
if ($Staging) {
$envFile = Join-Path $PSScriptRoot '.env.staging'
$composeFiles = @('-f', 'docker-compose.yml', '-f', 'docker-compose.staging.yml')
$project = @('-p', 'inboxintel-staging')
if (-not (Test-Path $envFile)) {
throw "Missing $envFile. Create it from .env.staging.example."
}
} else {
$envFile = Join-Path $PSScriptRoot '.env'
$composeFiles = @()
$project = @()
if (-not (Test-Path $envFile)) {
throw "Missing $envFile. Create it from .env.example with your real secrets."
}
if (-not (Test-Path $envFile)) {
throw "Missing $envFile. Create it from .env.example with your real secrets."
}
# Fail fast if a required key is absent or blank.
@@ -41,23 +28,19 @@ Get-Content $envFile | ForEach-Object {
$missing = $required | Where-Object { [string]::IsNullOrWhiteSpace($envMap[$_]) }
if ($missing) { throw "deploy/.env is missing values for: $($missing -join ', ')" }
$composeArgs = @('compose') + $project + @('--env-file', $envFile) + $composeFiles
$composeArgs = @('compose', '--env-file', $envFile)
if ($Proxy) { $composeArgs += @('--profile', 'proxy') }
$composeArgs += @('up', '--build')
if (-not $Foreground) { $composeArgs += '-d' }
Push-Location $root
try {
Write-Host "Starting InboxIntel via docker compose (env: $envFile)..." -ForegroundColor Cyan
Write-Host "Starting InboxIntel via docker compose (env: deploy/.env)..." -ForegroundColor Cyan
& docker @composeArgs
if (-not $Foreground) {
& docker compose @project --env-file $envFile @composeFiles ps
if ($Staging) {
Write-Host "`n[STAGING] Frontend: http://localhost:18081 API/Swagger: http://localhost:18080/swagger" -ForegroundColor Green
} else {
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
}
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1$(if($Staging){' -Staging'})" -ForegroundColor DarkGray
& docker compose --env-file $envFile ps
Write-Host "`nFrontend: http://localhost:8081 API/Swagger: http://localhost:8080/swagger" -ForegroundColor Green
Write-Host "Logs: ./deploy/logs.ps1 Stop: ./deploy/down.ps1" -ForegroundColor DarkGray
}
}
finally { Pop-Location }
-44
View File
@@ -1,44 +0,0 @@
# Staging overlay for InboxIntel.
#
# The base docker-compose.yml IS the production definition (Linux containers,
# ASPNETCORE_ENVIRONMENT=Production). This overlay layers a *staging* variant on
# top of it so you can run a production-shaped stack locally on Windows WITHOUT
# clobbering a real production deployment's data, ports, or volumes.
#
# It differs from prod only in the ways staging is meant to differ:
# - the dev/test banner is on (App__DevMode=true)
# - the initial Gmail sync is capped so a big mailbox doesn't take forever
# - ports are shifted into the 18xxx range so staging can run alongside prod
# - a distinct project name gives it its own isolated pgdata + keys volumes
#
# Run it with the -p (project name) flag so volumes/networks are namespaced:
#
# docker compose -p inboxintel-staging \
# --env-file deploy/.env.staging \
# -f docker-compose.yml -f docker-compose.staging.yml up --build -d
#
# (deploy/up.ps1 -Staging / up.sh --staging wrap this for you.)
services:
postgres:
ports:
- "127.0.0.1:15432:5432"
api:
environment:
# Same Production runtime as prod (real config binding, real build), but
# flagged as a non-production instance so the UI shows the staging banner
# and the first sync is bounded.
App__DevMode: "true"
GmailSync__MaxMessages: ${MAX_MESSAGES:-2000}
Cors__Origins__0: ${FRONTEND_ORIGIN:-http://localhost:18081}
ports:
- "127.0.0.1:18080:8080"
frontend:
ports:
- "18081:80"
nginx:
ports:
- "18000:80"
-188
View File
@@ -1,188 +0,0 @@
# InboxIntel — Git Workflow & Environment Strategy
The single source of truth for how we branch, commit, version, and promote code
across environments. Optimised for a **solo developer on Windows 11 with a future
Linux production server**. Kept deliberately lightweight — every rule here earns
its place.
---
## 1. Branch strategy
A trimmed **GitHub Flow + a long-lived `develop`** model. Two permanent branches,
short-lived branches off `develop`.
| Branch | Lives for | Purpose | Deploys to |
|-----------------|-----------|------------------------------------------------------|------------|
| `main` | forever | Always releasable. Every commit is tagged & shippable | production |
| `develop` | forever | Integration branch. What staging runs | staging |
| `feature/*` | hoursdays| One feature or refactor | dev (local)|
| `fix/*` | hours | Non-urgent bug fix | dev (local)|
| `hotfix/*` | minuteshrs| Urgent prod fix, branched from `main` | prod (fast)|
| `release/x.y.0` | optional | Only if a release needs stabilisation before tagging | staging |
**Why this shape (not full GitFlow):** a solo dev doesn't need GitFlow's ceremony
(separate release managers, parallel release trains). But keeping `develop`
separate from `main` gives one thing that pure trunk-based can't: a **staging
environment that always mirrors `develop`** while `main` stays clean and
tag-perfect for production. `release/*` exists only when you want to freeze
features and stabilise — skip it for routine work.
### Normal flow
```
main ──────●────────────────────●────────── (tagged: v1.2.0, v1.3.0)
\ /
develop ──●──●──●──●──●──●──●──●──────────── (staging)
\ / \ /
feature/x ●─● \ /
fix/y ●───●
```
1. `git switch develop && git pull`
2. `git switch -c feature/sender-policy`
3. Commit in small conventional commits.
4. Push, open a PR **into `develop`** (Gitea). CI must be green.
5. Squash-merge. Delete the branch.
6. When `develop` is ready to ship → PR `develop → main`, tag, deploy.
---
## 2. Commit conventions — Conventional Commits
Format: `type(scope): short imperative summary`
**Types:** `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`, `perf`,
`build`, `style`, `security`.
Rules:
- Summary ≤ 72 chars, imperative mood ("add", not "added").
- One logical change per commit.
- Body explains **why**, not what (the diff shows what).
- Breaking change → add `!` (`feat!:`) and a `BREAKING CHANGE:` footer.
Examples (matching this repo's history):
```
feat(ui): F1+F2 — component primitives + rebuilt app shell
fix(security): systemic IDOR safeguard via EF global query filters
ci: add Gitea Actions build + test pipeline
```
Why: conventional commits drive **automatic semver bumps** and a generated
CHANGELOG, and make `git log` scannable. `feat` → minor, `fix` → patch,
`BREAKING CHANGE` → major.
---
## 3. Pull request / merge flow (Gitea)
Even solo, PRs are worth it: they run CI, give a diff review checkpoint, and build
a paper trail.
- **Target:** `feature/*` and `fix/*``develop`. `develop`/`hotfix/*``main`.
- **Gate:** the `CI` workflow (backend build+test, frontend build) must pass.
- **Merge style:** **squash-merge** feature branches (one clean commit on
`develop`). **Merge commit** for `develop → main` (preserves the integration
history and makes the release boundary visible).
- **Branch protection (Gitea → Settings → Branches):** protect `main` and
`develop`; require status checks to pass; disallow force-push.
---
## 4. Versioning — Semantic Versioning (`MAJOR.MINOR.PATCH`)
- **MAJOR** — breaking API/behaviour change.
- **MINOR** — backward-compatible feature.
- **PATCH** — backward-compatible fix.
The current version lives in [`VERSION`](../VERSION) and is the one place bumped
per release. Pre-1.0 while scaffolding: stay on `0.x` (minor = features, patch =
fixes; anything may change).
---
## 5. Tagging & releases
Tags are cut **only on `main`**, annotated, prefixed `v`:
```bash
git switch main && git pull
# bump VERSION + CHANGELOG in a release commit, then:
git tag -a v1.3.0 -m "v1.3.0 — sender policy + staging overlay"
git push origin main --tags
```
- Tag == the exact commit deployed to production == the rollback target.
- The tag message summarises the release; details live in `CHANGELOG.md`.
---
## 6. Hotfix process
For a bug already in production:
```bash
git switch main && git pull
git switch -c hotfix/oauth-callback-500
# fix + test
git commit -m "fix(security): guard null OAuth state on callback"
# PR hotfix/* -> main, CI green, merge
git switch main && git pull
git tag -a v1.3.1 -m "v1.3.1 hotfix — OAuth callback" && git push origin main --tags
# deploy the tag, THEN back-merge so develop doesn't lose the fix:
git switch develop && git merge main && git push
```
The **back-merge to `develop`** is the step people forget — without it the next
release silently reintroduces the bug.
---
## 7. Environments
| Env | Host | Runtime | Config source | Purpose |
|-------------|-------------------------|--------------------------------------|--------------------------|--------------------|
| Development | Windows 11 (native) | `dotnet run` + `vite` (hot reload) | `appsettings.Development.json` + user-secrets | fast iteration, debugging |
| Staging | Windows 11 (Docker) | Linux containers, `ASPNETCORE_ENVIRONMENT=Production` | `deploy/.env.staging` + `docker-compose.staging.yml` | production rehearsal |
| Production | Linux server (Docker) | identical Linux containers | `deploy/.env` on the server | live |
**Parity principle:** dev is fast (native, Windows) and *not* production-shaped —
that's fine, it's for inner-loop speed. **Staging is the parity gate**: it runs the
*same Linux images* Docker builds for production, so "works in staging" genuinely
predicts "works in prod". The only staging↔prod differences are the dev banner,
capped sync, ports, and volume namespace — see `docker-compose.staging.yml`.
### Commands
```powershell
# DEV (native, hot reload) — two terminals
dotnet run --project src/InboxIntel.Api # http://localhost:5080
cd frontend; npm run dev # http://localhost:5173
# STAGING (production-shaped, on Windows via Docker)
./deploy/up.ps1 -Staging # http://localhost:18081
./deploy/down.ps1 -Staging
# PRODUCTION-shaped run locally (smoke test the real config)
./deploy/up.ps1 # http://localhost:8081
```
Dev and staging use **different ports and different volumes**, so they can run at
the same time and never share a database.
---
## 8. Git hooks
Installed via `core.hooksPath` (run once per clone):
```powershell
./scripts/install-hooks.ps1
```
- **pre-commit** (fast): blocks committed `.env`/secrets, verifies `dotnet format`.
- **pre-push** (thorough): `dotnet build -c Release` + `dotnet test` + frontend
build — the same gate CI runs, caught before the push.
Emergency bypass: `--no-verify`. CI still enforces the gate server-side, so a
bypassed push can still be rejected by branch protection.
+49 -20
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { MailOpen, Mail, Star, Archive, Trash2 } from 'lucide-react';
import { MailOpen, Mail, Star, Archive, Trash2, X } from 'lucide-react';
import { BulkApi } from '../api/client.js';
import {
Button, useToast,
@@ -49,25 +49,54 @@ export default function BulkToolbar({ selectedIds, onDone, onClear }) {
};
return (
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm">
<span className="text-sm font-medium">{count} selected</span>
<div className="flex-1" />
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
<MailOpen /> Read
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
<Mail /> Unread
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
<Star /> Star
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
<Archive /> Archive
</Button>
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
<Trash2 /> Trash
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={onClear}>Cancel</Button>
<div
role="toolbar"
aria-label={`${count} email${count === 1 ? '' : 's'} selected`}
className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg border border-border bg-card px-3 py-2 shadow-sm"
>
{/* Selection count — primary emphasis so it reads first. */}
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="inline-flex h-7 min-w-7 items-center justify-center rounded-full bg-primary px-2 text-sm font-semibold tabular-nums text-primary-foreground"
>
{count}
</span>
<span className="text-sm font-medium text-foreground">
selected
</span>
{/* Obvious clear-selection affordance, kept next to the count. */}
<Button
variant="ghost"
size="icon-sm"
disabled={busy}
onClick={onClear}
aria-label="Clear selection"
title="Clear selection"
>
<X />
</Button>
</div>
<div className="flex-1 basis-full sm:basis-0" />
<div className="flex flex-wrap items-center gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markRead, 'read', 'Marked read')}>
<MailOpen /> Read
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.markUnread, 'unread', 'Marked unread')}>
<Mail /> Unread
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.star, 'star', 'Starred')}>
<Star /> Star
</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => apply(BulkApi.archive, 'archive', 'Archived')}>
<Archive /> Archive
</Button>
<Button variant="danger-outline" size="sm" disabled={busy} onClick={() => setConfirmTrash(true)}>
<Trash2 /> Trash
</Button>
</div>
<Dialog open={confirmTrash} onOpenChange={(o) => !busy && setConfirmTrash(o)}>
<DialogContent>
+57 -23
View File
@@ -2,9 +2,15 @@ import { useEffect, useState } from 'react';
import { BulkApi } from '../api/client.js';
/// <summary>
/// j/k move focus down/up the list, e archives the focused email, # (shift+3)
/// trashes it. Ignored while an input/textarea/select has focus, or while
/// the "/" search shortcut is active, so typing is never hijacked.
/// Keyboard navigation for an email list. Shortcuts:
/// j / ArrowDown move focus down
/// k / ArrowUp move focus up
/// e archive the focused email
/// u mark the focused email unread
/// # (shift+3) trash the focused email
/// All shortcuts are ignored while an input/textarea/select (or any
/// contenteditable) has focus, and modifier chords (Ctrl/Cmd/Alt) are left
/// alone, so typing and browser/OS shortcuts are never hijacked.
/// `onRemoved(id)` lets the caller drop the row from local state after a
/// successful archive/trash.
/// </summary>
@@ -12,31 +18,59 @@ export default function useListKeyboardNav(emails, onRemoved) {
const [focusedId, setFocusedId] = useState(null);
useEffect(() => {
const isEditable = (el) => {
if (!el) return false;
const tag = el.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
};
const handler = async (e) => {
const tag = document.activeElement?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
// Never hijack typing or modifier chords (Ctrl+C, Cmd+K, Alt+…).
if (isEditable(document.activeElement)) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
if (!emails.length) return;
const idx = emails.findIndex((x) => x.id === focusedId);
if (e.key === 'j') {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
setFocusedId(emails[next].id);
} else if (e.key === 'k') {
e.preventDefault();
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
setFocusedId(emails[prev].id);
} else if (e.key === 'e' && idx >= 0) {
e.preventDefault();
const id = emails[idx].id;
await BulkApi.archive([id]);
onRemoved(id);
} else if (e.key === '#' && idx >= 0) {
e.preventDefault();
const id = emails[idx].id;
await BulkApi.trash([id]);
onRemoved(id);
switch (e.key) {
case 'j':
case 'ArrowDown': {
e.preventDefault();
const next = idx < 0 ? 0 : Math.min(idx + 1, emails.length - 1);
setFocusedId(emails[next].id);
break;
}
case 'k':
case 'ArrowUp': {
e.preventDefault();
const prev = idx < 0 ? 0 : Math.max(idx - 1, 0);
setFocusedId(emails[prev].id);
break;
}
case 'e': {
if (idx < 0) break;
e.preventDefault();
const id = emails[idx].id;
await BulkApi.archive([id]);
onRemoved(id);
break;
}
case 'u': {
if (idx < 0) break;
e.preventDefault();
await BulkApi.markUnread([emails[idx].id]);
break;
}
case '#': {
if (idx < 0) break;
e.preventDefault();
const id = emails[idx].id;
await BulkApi.trash([id]);
onRemoved(id);
break;
}
default:
break;
}
};
-43
View File
@@ -1,43 +0,0 @@
#!/usr/bin/env sh
# InboxIntel pre-commit hook — fast checks only (keep it under a few seconds).
# Heavier build/test verification runs in pre-push. Bypass in an emergency with:
# git commit --no-verify
set -eu
echo "[pre-commit] running fast checks..."
# 1) Block obvious secrets from being committed. Scans only staged, added lines.
STAGED=$(git diff --cached --name-only --diff-filter=ACM)
if [ -n "$STAGED" ]; then
# Never allow a real .env (only *.example templates are tracked).
echo "$STAGED" | grep -E '(^|/)\.env($|\.)' | grep -v '\.example$' >/tmp/ii_env_hits 2>/dev/null || true
if [ -s /tmp/ii_env_hits ]; then
echo "[pre-commit] BLOCKED: attempting to commit an env/secret file:"
cat /tmp/ii_env_hits
echo " -> add it to .gitignore or commit .env.example instead."
exit 1
fi
# Heuristic secret scan on added lines (private keys, obvious credential assigns).
if git diff --cached --unified=0 -- $STAGED \
| grep -E '^\+' \
| grep -Ei 'BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|(client_secret|password|api[_-]?key|secret)\s*[:=]\s*["'"'"']?[A-Za-z0-9/_+=-]{16,}' \
| grep -Evi 'change-me|your-|example|placeholder|\$\{' >/tmp/ii_secret_hits 2>/dev/null; then
echo "[pre-commit] BLOCKED: possible hard-coded secret in staged changes:"
cat /tmp/ii_secret_hits
echo " -> use deploy/.env / user-secrets. Override with 'git commit --no-verify' if this is a false positive."
exit 1
fi
fi
# 2) Format .NET code (only if the tool + staged .cs files are present). Verify-only,
# non-mutating, so it never rewrites files out from under your staged diff.
if echo "$STAGED" | grep -q '\.cs$'; then
if command -v dotnet >/dev/null 2>&1 && dotnet format --help >/dev/null 2>&1; then
echo "[pre-commit] dotnet format --verify-no-changes"
dotnet format InboxIntel.sln --verify-no-changes --verbosity quiet \
|| { echo " -> run 'dotnet format InboxIntel.sln' and re-stage."; exit 1; }
fi
fi
echo "[pre-commit] OK"
-21
View File
@@ -1,21 +0,0 @@
#!/usr/bin/env sh
# InboxIntel pre-push hook — build + test gate. Mirrors what Gitea CI runs so you
# catch failures before they reach the server. Bypass with: git push --no-verify
set -eu
echo "[pre-push] building solution (Release)..."
dotnet build InboxIntel.sln -c Release --nologo \
|| { echo "[pre-push] BLOCKED: backend build failed."; exit 1; }
echo "[pre-push] running tests..."
dotnet test InboxIntel.sln -c Release --no-build --nologo \
|| { echo "[pre-push] BLOCKED: tests failed."; exit 1; }
# Frontend build (only if the app is present and npm is installed).
if [ -f frontend/package.json ] && command -v npm >/dev/null 2>&1; then
echo "[pre-push] frontend build..."
( cd frontend && npm run build --silent ) \
|| { echo "[pre-push] BLOCKED: frontend build failed."; exit 1; }
fi
echo "[pre-push] OK — safe to push."
-24
View File
@@ -1,24 +0,0 @@
<#
.SYNOPSIS
Point git at the version-controlled hooks in scripts/git-hooks.
.DESCRIPTION
Uses `git config core.hooksPath` so the hooks live in the repo (reviewable,
shared, updatable) instead of the un-tracked .git/hooks directory. Run once
per clone. Git for Windows ships the bash needed to execute the POSIX hooks.
.EXAMPLE
./scripts/install-hooks.ps1
#>
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
Push-Location $root
try {
git config core.hooksPath scripts/git-hooks
# Best-effort exec bit (matters on Linux/WSL; harmless on Windows). Only applies
# once the hooks are tracked; ignored on a first run before they're committed.
foreach ($h in 'pre-commit','pre-push') {
try { git update-index --chmod=+x "scripts/git-hooks/$h" 2>$null } catch {}
}
Write-Host "Installed git hooks -> scripts/git-hooks (core.hooksPath set)." -ForegroundColor Green
Write-Host "Bypass in an emergency with --no-verify." -ForegroundColor DarkGray
}
finally { Pop-Location }