Wire SPA + SSO redirect URI to /social-reports/ prefix; in-place cutover script

Phase A scaffolded the SPA at the bare origin (`/`); production lives behind
Apache at `/social-reports/`. Without these fixes, V2's built assets 404 and
Azure SSO rejects the redirect URI mismatch.

- Vite `base: /social-reports/` (overridable via VITE_BASE for dev).
- BrowserRouter basename = import.meta.env.BASE_URL.
- apiFetch + msal-browser script src + token-exchange URL all prefix BASE.
- MSAL redirectUri now matches V1's Azure-registered URI:
  `${origin}/social-reports/login.html`.
- New `<Route path="/login.html">` alias renders the same Login component
  so React Router matches the redirect URI when MSAL returns.

Deploy ergonomics (the user wants V1 gone from the server):
- v2/deploy/cutover-in-place.sh: run from /opt/social-reporting; stops V1,
  pulls main (v2/ appears, V1 dirs deleted), migrates secrets from V1's
  .env into v2/.env, swaps Apache, starts V2. Single command, no clone of
  a sibling dir needed.
- setup-v2.sh: PURGE_V1=true flag now cleans /opt/social-reporting and
  the V1 docker volume after V2 is healthy.
- rollback-to-v1.sh: re-clones the v1-archive branch when V1 is no longer
  on disk (REPO_URL required).

62/62 unit tests still pass; vite build emits assets under /social-reports/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DJP 2026-04-29 18:40:38 -04:00
parent 17a635099a
commit 5770b2579d
9 changed files with 226 additions and 27 deletions

View file

@ -14,13 +14,13 @@ For the full V2 spec see [DEVELOPER_BRIEF_V2.md](./DEVELOPER_BRIEF_V2.md).
## V1 archive
V1 source has been removed from `main`. It is preserved on the `v1-archive`
branch and the running deployment at `/opt/social-reporting` on the server.
To roll back from V2 to V1:
V1 source is preserved on the `v1-archive` branch (frozen at the last V1
commit) and is no longer kept on the deployed server. To roll back from
V2 to V1, the rollback script will re-clone `v1-archive` if needed:
```bash
# On the server
export REPO_URL="https://x-token-auth:YOUR_TOKEN@bitbucket.org/zlalani/social-reporting-tool.git"
bash /opt/social-reporting-v2/v2/deploy/rollback-to-v1.sh
```

155
v2/deploy/cutover-in-place.sh Executable file
View file

@ -0,0 +1,155 @@
#!/bin/bash
set -euo pipefail
# ═══════════════════════════════════════════════════════
# Social Reporting V2 — In-place cutover
# Run from the existing V1 deployment directory:
# cd /opt/social-reporting && bash v2/deploy/cutover-in-place.sh
# (It will git pull first, so the v2/ tree appears.)
#
# What it does:
# 1. Stops V1 docker stack (so V1's compose file is freed BEFORE git pull deletes it).
# 2. git pull origin main — drops V1 dirs, adds v2/.
# 3. Migrates secrets from /opt/social-reporting/.env into v2/.env (preserves your
# APIFY_TOKEN, ANTHROPIC_API_KEY, AZURE_*, etc.; generates a new SESSION_SECRET).
# 4. Swaps the Apache conf to V2's, reloads.
# 5. Builds + starts V2 docker stack.
# ═══════════════════════════════════════════════════════
DIR="$(pwd)"
APACHE_CONF="/etc/apache2/conf-available/social-reports.conf"
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[x]${NC} $1"; exit 1; }
[[ -d "$DIR/.git" ]] || err "Run this from the deployment dir (e.g. /opt/social-reporting). $DIR has no .git."
command -v docker >/dev/null || err "Docker not installed"
command -v apache2ctl >/dev/null || err "Apache not installed"
# ─── Sanity-confirm ───
warn "Cutover plan: stop V1 stack → git pull (V1 dirs deleted, v2/ appears) → swap Apache → start V2."
warn "Working dir: $DIR"
read -r -p "Proceed? [y/N] " ans
[[ "$ans" != "y" && "$ans" != "Y" ]] && err "Aborted"
# ─── 1. Stop V1 BEFORE git pull (the pull deletes V1's docker-compose.yml) ───
if [[ -f "$DIR/docker-compose.yml" ]]; then
log "Stopping V1 stack..."
docker compose -p social-listening down 2>/dev/null || warn "V1 was not running"
fi
# Snapshot V1's .env so we can migrate values after the pull.
V1_ENV_TMP=""
if [[ -f "$DIR/.env" ]]; then
V1_ENV_TMP="$(mktemp)"
cp "$DIR/.env" "$V1_ENV_TMP"
log "Snapshotted V1 .env to $V1_ENV_TMP"
fi
# ─── 2. git pull main (this removes V1 source, adds v2/) ───
log "Pulling main..."
git pull origin main || err "git pull failed"
[[ -d "$DIR/v2" ]] || err "After pull, v2/ directory still missing — main may not be the V2 branch"
cd "$DIR/v2"
# ─── 3. Migrate secrets to v2/.env ───
get_old() { [[ -n "$V1_ENV_TMP" && -f "$V1_ENV_TMP" ]] && grep "^$1=" "$V1_ENV_TMP" | head -1 | cut -d= -f2- || true; }
set_new() {
local key="$1" val="$2"
[[ -z "$val" ]] && return 0
if grep -q "^${key}=" .env 2>/dev/null; then
sed -i.bak "s|^${key}=.*|${key}=${val}|" .env && rm -f .env.bak
else
echo "${key}=${val}" >> .env
fi
}
if [[ ! -f "$DIR/v2/.env" ]]; then
log "Creating v2/.env from .env.example..."
cp .env.example .env
fi
# Always-fresh SESSION_SECRET (V1's was tied to V1's HMAC; cutting over invalidates anyway)
set_new SESSION_SECRET "$(openssl rand -hex 32)"
# Migrate secrets from V1 .env if present
for key in APIFY_TOKEN ANTHROPIC_API_KEY AZURE_TENANT_ID AZURE_CLIENT_ID DASH_USER DASH_PASS APIFY_LIVE_APPROVED ALLOWED_ORIGIN; do
val="$(get_old $key)"
[[ -n "$val" ]] && set_new "$key" "$val"
done
# Generate a DB password if none present
if ! grep -q '^DB_V2_PASSWORD=.\+' .env; then
DB_PW="$(openssl rand -hex 16)"
set_new DB_V2_PASSWORD "$DB_PW"
set_new DATABASE_URL "postgresql://srv2_user:${DB_PW}@db-v2:5432/social_reporting_v2"
fi
# Production knobs
set_new NODE_ENV production
set_new ALLOW_PASSWORD_FALLBACK false
# Force one VITE_AZURE_* surfacing — vite needs them at build time
TENANT="$(grep '^AZURE_TENANT_ID=' .env | head -1 | cut -d= -f2-)"
CLIENT="$(grep '^AZURE_CLIENT_ID=' .env | head -1 | cut -d= -f2-)"
set_new VITE_AZURE_TENANT_ID "$TENANT"
set_new VITE_AZURE_CLIENT_ID "$CLIENT"
if ! grep -q '^BOOTSTRAP_SUPER_ADMIN_EMAIL=.\+' .env; then
warn "BOOTSTRAP_SUPER_ADMIN_EMAIL is not set in v2/.env."
read -r -p "Email of first super-admin (must match the SSO sign-in): " admin_email
set_new BOOTSTRAP_SUPER_ADMIN_EMAIL "$admin_email"
fi
# Cleanup
[[ -n "$V1_ENV_TMP" && -f "$V1_ENV_TMP" ]] && rm -f "$V1_ENV_TMP"
log "v2/.env populated. Review with: less $DIR/v2/.env"
# ─── 4. Apache: swap conf to V2's ───
log "Swapping Apache config to V2..."
[[ -f "$APACHE_CONF" ]] && sudo cp "$APACHE_CONF" "${APACHE_CONF}.v1.bak.$(date +%s)"
sudo cp "$DIR/v2/deploy/apache-social-reports-v2.conf" "$APACHE_CONF"
for mod in proxy proxy_http headers rewrite; do
apache2ctl -M 2>/dev/null | grep -q "${mod}_module" || sudo a2enmod "$mod"
done
sudo a2enconf social-reports >/dev/null 2>&1 || true
sudo apache2ctl configtest || err "Apache config test failed"
# ─── 5. Build + start V2 ───
log "Building & starting V2 stack..."
docker compose -f docker-compose.v2.yml -f docker-compose.v2.prod.yml --env-file .env up -d --build
log "Waiting for V2 backend (port 3457)..."
for i in {1..40}; do
curl -sf http://127.0.0.1:3457/api/health >/dev/null 2>&1 && { log "V2 healthy"; break; }
[ "$i" -eq 40 ] && err "V2 not responding — docker compose -p social-reporting-v2 logs app-v2"
sleep 2
done
log "Reloading Apache..."
sudo systemctl reload apache2
# ─── Optional: clean up V1 docker volume ───
if docker volume ls --format '{{.Name}}' | grep -q '^social-listening_pgdata$'; then
warn "V1 docker volume 'social-listening_pgdata' is orphaned (V1 docker-compose.yml is gone after the pull)."
read -r -p "Remove V1 db volume too? [y/N] " yn
if [[ "$yn" == "y" || "$yn" == "Y" ]]; then
docker volume rm social-listening_pgdata && log "V1 db volume removed."
fi
fi
echo ""
echo "════════════════════════════════════════════════════"
echo -e " ${GREEN}V2 in-place cutover done.${NC}"
echo " URL: https://optical-dev.oliver.solutions/social-reports/"
echo " Backend: 127.0.0.1:3457"
echo " Dir: $DIR (v2/ subdirectory)"
echo " Logs: docker compose -p social-reporting-v2 logs -f app-v2"
echo ""
echo " First super-admin sign-in:"
grep '^BOOTSTRAP_SUPER_ADMIN_EMAIL=' v2/.env || echo " (set BOOTSTRAP_SUPER_ADMIN_EMAIL in v2/.env)"
echo "════════════════════════════════════════════════════"

View file

@ -2,8 +2,12 @@
set -euo pipefail
# Roll back from V2 → V1 at the /social-reports URL.
# V1 source must still be present at /opt/social-reporting (we don't delete it during cutover).
#
# V1 may or may not still be on disk:
# - If /opt/social-reporting/.git exists, we use it.
# - Otherwise, we re-clone the v1-archive branch (REPO_URL must be set).
REPO_URL="${REPO_URL:-}"
BACKEND_DIR_V1="/opt/social-reporting"
BACKEND_DIR_V2="/opt/social-reporting-v2"
APACHE_CONF="/etc/apache2/conf-available/social-reports.conf"
@ -13,7 +17,13 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
err() { echo -e "${RED}[x]${NC} $1"; exit 1; }
[[ -d "$BACKEND_DIR_V1/.git" ]] || err "V1 source not found at $BACKEND_DIR_V1 — cannot roll back"
if [[ ! -d "$BACKEND_DIR_V1/.git" ]]; then
[[ -z "$REPO_URL" ]] && err "V1 source not on disk and REPO_URL not set. Export REPO_URL and re-run."
warn "V1 source not on disk; cloning v1-archive branch..."
sudo mkdir -p "$BACKEND_DIR_V1"
sudo chown "$(whoami):$(whoami)" "$BACKEND_DIR_V1"
git clone -b v1-archive "$REPO_URL" "$BACKEND_DIR_V1"
fi
warn "About to roll back /social-reports from V2 → V1."
read -r -p "Proceed? [y/N] " ans
@ -29,12 +39,13 @@ sudo apache2ctl configtest || err "Apache config test failed"
log "Starting V1 stack..."
cd "$BACKEND_DIR_V1"
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
[[ -f .env ]] || { warn "V1 .env missing — copy from a backup or recreate before running again"; err "Aborting before docker up"; }
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
log "Waiting for V1..."
for i in {1..20}; do
for i in {1..30}; do
curl -sf http://127.0.0.1:3456/status >/dev/null 2>&1 && { log "V1 healthy"; break; }
[ "$i" -eq 20 ] && err "V1 not responding — docker compose logs social-listening"
[ "$i" -eq 30 ] && err "V1 not responding — docker compose logs social-listening"
sleep 2
done

View file

@ -4,13 +4,15 @@ set -euo pipefail
# ═══════════════════════════════════════════════════════
# Social Reporting V2 — Server Setup (one-time)
# Target: optical-dev.oliver.solutions
# Cuts over from V1 at the same URL. V1 source kept on disk for rollback.
# Replaces V1 at the same URL. V1 is removed from the server; rollback re-clones
# the v1-archive branch (see rollback-to-v1.sh).
# ═══════════════════════════════════════════════════════
REPO_URL="${REPO_URL:-}"
BACKEND_DIR_V2="/opt/social-reporting-v2"
BACKEND_DIR_V1="/opt/social-reporting"
APACHE_CONF="/etc/apache2/conf-available/social-reports.conf"
PURGE_V1="${PURGE_V1:-}" # set to 'true' to delete /opt/social-reporting after V2 is healthy
GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $1"; }
@ -42,17 +44,15 @@ if [[ ! -f "$BACKEND_DIR_V2/v2/.env" ]]; then
warn "Edit $BACKEND_DIR_V2/v2/.env: APIFY_TOKEN, ANTHROPIC_API_KEY, AZURE_*, BOOTSTRAP_SUPER_ADMIN_EMAIL"
fi
# ─── Cutover from V1 (graceful) ───
warn "About to cut over the /social-reports URL from V1 → V2."
warn "V1 source remains at $BACKEND_DIR_V1 (untouched). Rollback: deploy/rollback-to-v1.sh"
# ─── Cutover ───
warn "About to take over the /social-reports URL with V2."
read -r -p "Proceed? [y/N] " ans
[[ "$ans" != "y" && "$ans" != "Y" ]] && err "Aborted"
# Stop V1 stack if it's running (no-op if V1 was never deployed here).
if [[ -d "$BACKEND_DIR_V1" ]]; then
log "Stopping V1 stack..."
cd "$BACKEND_DIR_V1"
docker compose -p social-listening down || warn "V1 was not running"
cd "$BACKEND_DIR_V2"
log "Stopping V1 stack (if running)..."
(cd "$BACKEND_DIR_V1" && docker compose -p social-listening down 2>/dev/null) || warn "V1 was not running"
fi
# ─── Apache: swap conf to V2 ───
@ -80,12 +80,25 @@ done
log "Reloading Apache..."
sudo systemctl reload apache2
# ─── Optional V1 purge ───
if [[ "$PURGE_V1" == "true" && -d "$BACKEND_DIR_V1" ]]; then
warn "PURGE_V1=true — removing $BACKEND_DIR_V1 and the V1 docker volume"
docker volume rm social-listening_pgdata 2>/dev/null || warn "(V1 db volume already gone)"
sudo rm -rf "$BACKEND_DIR_V1"
log "V1 source and db volume removed. Rollback now re-clones the v1-archive branch."
fi
echo ""
echo "════════════════════════════════════════════════════"
echo -e " ${GREEN}V2 deployed!${NC}"
echo " URL: https://optical-dev.oliver.solutions/social-reports/"
echo " Backend: http://127.0.0.1:3457 (Docker)"
echo " V2 dir: $BACKEND_DIR_V2"
echo " V1 dir: $BACKEND_DIR_V1 (kept for rollback)"
if [[ "$PURGE_V1" == "true" ]]; then
echo " V1: purged"
echo " Rollback: git checkout v1-archive on a new clone, then run V1's setup.sh"
else
echo " V1 dir: $BACKEND_DIR_V1 (still on disk; remove with PURGE_V1=true on next run)"
echo " Rollback: bash $BACKEND_DIR_V2/v2/deploy/rollback-to-v1.sh"
fi
echo "════════════════════════════════════════════════════"

View file

@ -16,6 +16,8 @@ export default function App() {
return (
<Routes>
<Route path="/login" element={<Login />} />
{/* alias matches the V1 Azure-registered redirect URI (.../social-reports/login.html) */}
<Route path="/login.html" element={<Login />} />
<Route
element={
<ProtectedRoute>

View file

@ -12,8 +12,13 @@ export class ApiError extends Error {
}
}
// API base mirrors the Vite `base` (e.g. `/social-reports/`) so requests resolve
// to the Apache-proxied backend rather than the bare origin.
const BASE = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
export async function fetcher<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const url = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? path : `/${path}`}`;
const apiPath = path.startsWith('/api') ? path : `/api${path.startsWith('/') ? path : `/${path}`}`;
const url = `${BASE}${apiPath}`;
const res = await fetch(url, {
credentials: 'include',
headers: {

View file

@ -13,7 +13,8 @@ async function ensureMsalLoaded(): Promise<void> {
if (window.msal) return;
await new Promise<void>((resolve, reject) => {
const s = document.createElement('script');
s.src = '/msal-browser.min.js';
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
s.src = `${base}msal-browser.min.js`;
s.async = true;
s.onload = () => resolve();
s.onerror = () => reject(new Error('Failed to load msal-browser.min.js'));
@ -27,11 +28,16 @@ export async function getMsal() {
if (!tenantId || !clientId) {
throw new Error('Missing VITE_AZURE_TENANT_ID or VITE_AZURE_CLIENT_ID');
}
// Match the Azure-registered redirect URI from V1:
// https://optical-dev.oliver.solutions/social-reports/login.html
// BASE_URL is '/social-reports/' in prod, '/' in dev.
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/?$/, '/');
const redirectUri = `${window.location.origin}${base}login.html`;
pca = new window.msal.PublicClientApplication({
auth: {
clientId,
authority: `https://login.microsoftonline.com/${tenantId}`,
redirectUri: window.location.origin + '/login',
redirectUri,
},
cache: { cacheLocation: 'sessionStorage' },
});
@ -48,7 +54,8 @@ export async function handleRedirectAndExchange(): Promise<{ ok: boolean } | nul
const app = await getMsal();
const result = await app.handleRedirectPromise();
if (!result?.idToken) return null;
const res = await fetch('/api/sso/token-exchange', {
const base = (import.meta.env.BASE_URL ?? '/').replace(/\/$/, '');
const res = await fetch(`${base}/api/sso/token-exchange`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },

View file

@ -9,7 +9,7 @@ import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<BrowserRouter basename={import.meta.env.BASE_URL}>
<App />
</BrowserRouter>
</QueryClientProvider>

View file

@ -1,15 +1,21 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// V2 ships behind Apache at /social-reports/ — same external URL V1 used. The base
// here makes built asset URLs resolve under that prefix; pair this with React
// Router's basename + apiFetch's prefix.
//
// Override via VITE_BASE in .env to e.g. '/' for local dev or a different path.
const base = process.env.VITE_BASE ?? '/social-reports/';
export default defineConfig({
base,
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3457',
changeOrigin: true,
},
'/api': { target: 'http://localhost:3457', changeOrigin: true },
'/social-reports': { target: 'http://localhost:3457', changeOrigin: true },
},
},
build: {