[object Object]

← back to Patterndesignlab

admin login (JWT cookie) + add-design-to-Shopify-as-DIG-item admin bar on design pages

29ef95e90ea42143ec08cdf8e76e1381293a9c0c · 2026-07-22 20:52:31 -0700 · Steve

Files touched

Diff

commit 29ef95e90ea42143ec08cdf8e76e1381293a9c0c
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 22 20:52:31 2026 -0700

    admin login (JWT cookie) + add-design-to-Shopify-as-DIG-item admin bar on design pages
---
 db/003_shopify_push.sql |   6 +++
 public/admin-login.html |  39 ++++++++++++++++++
 public/design.html      |  55 +++++++++++++++++++++++++
 server.js               | 107 ++++++++++++++++++++++++++++++++++++++++++++++--
 4 files changed, 204 insertions(+), 3 deletions(-)

diff --git a/db/003_shopify_push.sql b/db/003_shopify_push.sql
new file mode 100644
index 0000000..e884109
--- /dev/null
+++ b/db/003_shopify_push.sql
@@ -0,0 +1,6 @@
+-- Admin "add as DIG- item to Shopify" tracking. A pushed design remembers its
+-- Shopify product so the admin bar shows "already in Shopify" instead of
+-- creating duplicates.
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS shopify_product_id text;
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS shopify_sku text;
+ALTER TABLE designs ADD COLUMN IF NOT EXISTS shopify_pushed_at timestamptz;
diff --git a/public/admin-login.html b/public/admin-login.html
new file mode 100644
index 0000000..d4b0641
--- /dev/null
+++ b/public/admin-login.html
@@ -0,0 +1,39 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>Admin sign in · Pattern Design Lab</title>
+<link rel="stylesheet" href="/styles.css">
+</head>
+<body>
+<header class="top"><div class="wrap">
+  <a class="brand" href="/">Pattern Design<span class="dot">.</span>Lab</a>
+</div></header>
+
+<div class="wrap" style="max-width:380px;padding:60px 20px">
+  <h1 style="font-size:22px;margin-bottom:4px">Admin sign in</h1>
+  <p style="color:#6b6b6b;font-size:14px;margin-bottom:18px">Curation &amp; catalog controls.</p>
+  <form id="f">
+    <input id="u" placeholder="username" autocomplete="username" style="width:100%;height:42px;border:1px solid var(--line);border-radius:10px;padding:0 12px;margin-bottom:10px;font-size:14px">
+    <input id="p" type="password" placeholder="password" autocomplete="current-password" style="width:100%;height:42px;border:1px solid var(--line);border-radius:10px;padding:0 12px;margin-bottom:10px;font-size:14px">
+    <button class="buy" type="submit" style="width:100%">Sign in</button>
+    <p id="msg" class="note" style="margin-top:10px"></p>
+  </form>
+</div>
+
+<script>
+document.getElementById('f').onsubmit = async (e) => {
+  e.preventDefault();
+  const msg = document.getElementById('msg');
+  const r = await fetch('/api/admin/login', { method:'POST', headers:{'Content-Type':'application/json'},
+    body: JSON.stringify({ user: document.getElementById('u').value, pass: document.getElementById('p').value }) });
+  if (r.ok) {
+    const next = new URLSearchParams(location.search).get('next');
+    location.href = (next && next.startsWith('/')) ? next : '/admin';
+  } else { msg.textContent = 'Invalid credentials.'; msg.style.color = '#b3261e'; }
+};
+</script>
+</body>
+</html>
diff --git a/public/design.html b/public/design.html
index 103cd07..504b9ea 100644
--- a/public/design.html
+++ b/public/design.html
@@ -78,8 +78,10 @@ async function load(){
         </div>`).join('')}</div>`:''}
 
       <div class="tagrow">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>
+      <div id="adminbar"></div>
     </div>
   </div>`;
+  renderAdminBar();
   fetch('/api/config').then(r=>r.json()).then(c=>{
     document.getElementById('buynote').textContent = c.checkoutReady
       ? 'Secure checkout — Stripe TEST mode (no real charge).'
@@ -96,6 +98,59 @@ async function buy(){
   if(j.ok){ note.textContent='✓ '+(j.note||'Recorded — we\'ll be in touch.'); note.style.color='var(--accent-ink)'; }
   else { note.textContent='Could not start checkout: '+(j.error||'error'); }
 }
+// ---- Admin bar: "add this design to the DW Shopify store as a DIG- item" ----
+// Only renders when /api/admin/me confirms an admin session (cookie via /admin-login,
+// or basic auth while the site is gated). Product is created DRAFT by default.
+async function renderAdminBar(){
+  const el = document.getElementById('adminbar');
+  if(!el || !design) return;
+  let admin = false;
+  try { admin = (await (await fetch('/api/admin/me')).json()).admin; } catch {}
+  if(!admin){
+    el.innerHTML = `<p class="note" style="margin-top:26px"><a href="/admin-login?next=${encodeURIComponent(location.pathname)}" style="color:#9a9a9a">Admin</a></p>`;
+    return;
+  }
+  if(design.shopify_product_id){
+    el.innerHTML = `<div style="margin-top:26px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#f7f5f8">
+      <b style="font-size:14px">Admin</b>
+      <p class="note" style="margin:6px 0 0">✓ In Shopify as <b>${esc(design.shopify_sku||'')}</b> ·
+        <a href="https://admin.shopify.com/store/designer-laboratory-sandbox/products/${esc(design.shopify_product_id)}" target="_blank" style="color:var(--accent-ink)">open in Shopify admin</a></p>
+    </div>`;
+    return;
+  }
+  const num = String(design.id).replace(/[^0-9]/g,'') || design.id;
+  el.innerHTML = `<div style="margin-top:26px;padding:14px;border:1px solid var(--line);border-radius:12px;background:#f7f5f8">
+    <b style="font-size:14px">Admin · add to Shopify (DW Bespoke Studio)</b>
+    <div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap">
+      <input id="adm-sku" value="DIG-${esc(num)}" style="flex:1;min-width:130px;height:38px;border:1px solid var(--line);border-radius:8px;padding:0 10px;font-size:13px">
+      <input id="adm-price" type="number" step="0.01" value="${Number(design.price_min||149)}" title="price USD" style="width:90px;height:38px;border:1px solid var(--line);border-radius:8px;padding:0 10px;font-size:13px">
+      <select id="adm-status" style="height:38px;border:1px solid var(--line);border-radius:8px;font-size:13px">
+        <option value="draft" selected>Draft</option><option value="active">Active</option>
+      </select>
+    </div>
+    <button class="buy" style="margin-top:10px" onclick="pushShopify()">Add SKU to Shopify</button>
+    <p class="note" id="adm-note" style="margin-top:8px">Creates the product on the live DW store (Draft = not customer-visible until activated).</p>
+  </div>`;
+}
+async function pushShopify(){
+  const note = document.getElementById('adm-note');
+  note.textContent = 'Pushing to Shopify…'; note.style.color = '';
+  try{
+    const r = await fetch('/api/admin/designs/'+encodeURIComponent(design.id)+'/shopify', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ sku: document.getElementById('adm-sku').value.trim(),
+        price: Number(document.getElementById('adm-price').value),
+        status: document.getElementById('adm-status').value }) });
+    const j = await r.json();
+    if(j.ok){
+      design.shopify_product_id = j.productId; design.shopify_sku = j.sku;
+      renderAdminBar();
+    } else {
+      note.textContent = '✗ ' + (j.error||'push failed') + (j.detail?(' — '+JSON.stringify(j.detail).slice(0,200)):'');
+      note.style.color = '#b3261e';
+    }
+  }catch(e){ note.textContent = '✗ '+e.message; note.style.color = '#b3261e'; }
+}
 load();
 </script>
 </body>
diff --git a/server.js b/server.js
index ee1a82e..5f2378a 100644
--- a/server.js
+++ b/server.js
@@ -80,11 +80,21 @@ app.use(express.json());
 
 // Admin endpoints must require auth even under PUBLIC=1 — the global basic-auth above
 // steps aside in public mode, so re-check the credentials explicitly here.
-function requireAdmin(req, res, next) {
+// Accepts EITHER Basic Auth (curl/scripts) OR the pdl_admin JWT cookie set by
+// /admin-login (browser flow on the public site, where no basic-auth prompt exists).
+const ACOOKIE = 'pdl_admin';
+function isAdmin(req) {
   const h = req.headers.authorization || '';
   const [u, p] = Buffer.from(h.split(' ')[1] || '', 'base64').toString().split(':');
-  if (u === USER && p === PASS) return next();
-  res.set('WWW-Authenticate', 'Basic realm="patterndesignlab-admin"').status(401).json({ error: 'admin auth required' });
+  if (u === USER && p === PASS) return true;
+  try {
+    const t = cookieLib.parse(req.headers.cookie || '')[ACOOKIE];
+    return !!(t && jwt.verify(t, JWT_SECRET).admin);
+  } catch { return false; }
+}
+function requireAdmin(req, res, next) {
+  if (isAdmin(req)) return next();
+  res.status(401).json({ error: 'admin auth required' });
 }
 
 app.get('/favicon.ico', (req, res) => res.status(204).end());
@@ -295,6 +305,96 @@ app.post('/api/license/checkout', async (req, res) => {
   } catch (e) { console.error('checkout error', e.message); res.status(502).json({ error: 'checkout unavailable', detail: e.message }); }
 });
 
+// ================= Admin session login (for the public site, where basic-auth
+// never prompts). Same credentials as basic auth; sets a signed httpOnly cookie. =================
+app.post('/api/admin/login', (req, res) => {
+  const { user, pass } = req.body || {};
+  if (String(user || '') !== USER || String(pass || '') !== PASS) {
+    return res.status(401).json({ error: 'invalid credentials' });
+  }
+  const token = jwt.sign({ admin: true }, JWT_SECRET, { expiresIn: '12h' });
+  res.setHeader('Set-Cookie', cookieLib.serialize(ACOOKIE, token,
+    { httpOnly: true, secure: PUBLIC, sameSite: 'lax', path: '/', maxAge: 60 * 60 * 12 }));
+  res.json({ ok: true });
+});
+app.post('/api/admin/logout', (req, res) => {
+  res.setHeader('Set-Cookie', cookieLib.serialize(ACOOKIE, '', { httpOnly: true, path: '/', maxAge: 0 }));
+  res.json({ ok: true });
+});
+// The design page probes this to decide whether to show the admin bar.
+app.get('/api/admin/me', (req, res) => res.json({ admin: isAdmin(req) }));
+
+// ================= Add a design to the DW Shopify store as a DIG- item =================
+// Convention (matches the 900+ existing DIG- items): vendor "DW Bespoke Studio",
+// product_type "Wallcovering", SKU DIG-<design number>. Created as DRAFT by default —
+// the admin flips it ACTIVE in Shopify (or picks Active in the UI) so nothing goes
+// customer-facing without an explicit human choice.
+const SHOP = process.env.SHOPIFY_SHOP || 'designer-laboratory-sandbox.myshopify.com';
+const SHOP_TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
+const SHOP_API = `https://${SHOP}/admin/api/2024-10`;
+app.post('/api/admin/designs/:id/shopify', requireAdmin, async (req, res) => {
+  try {
+    if (!SHOP_TOKEN) return res.status(503).json({ error: 'SHOPIFY_ADMIN_TOKEN not configured on this server' });
+    const b = req.body || {};
+    const r = await pool.query(
+      `SELECT x.*, d.name AS designer_name FROM designs x JOIN designers d ON d.id=x.designer_id
+       WHERE (x.id=$1 OR x.slug=$1)`, [req.params.id]);
+    if (!r.rowCount) return res.status(404).json({ error: 'design not found' });
+    const design = r.rows[0];
+    // The settlement gate applies to commerce too — blocked/review designs never ship.
+    if (['vision_blocked', 'vision_review'].includes(design.settlement_status || '')) {
+      return res.status(403).json({ error: `design is settlement-${design.settlement_status} — cannot list` });
+    }
+    if (design.shopify_product_id && !b.force) {
+      return res.status(409).json({ error: 'already in Shopify', productId: design.shopify_product_id, sku: design.shopify_sku });
+    }
+    const sku = String(b.sku || `DIG-${design.id}`).trim().slice(0, 100);
+    const price = Number(b.price) > 0 ? Number(b.price).toFixed(2) : Number(design.price_min || 149).toFixed(2);
+    const status = b.status === 'active' ? 'active' : 'draft';
+    const tags = Array.from(new Set([
+      'Pattern Design Lab', 'DW Bespoke Studio', 'Wallcovering', 'Pattern',
+      design.style, design.motif, design.colorway, design.technique,
+      ...(design.tags || []),
+    ].filter(t => t && !/etsy/i.test(t)))).join(', '); // internal source tags never ship to Shopify
+    const body_html = [
+      design.style_line ? `<p>${design.style_line}</p>` : '',
+      `<p>${design.title} — an original seamless pattern by ${design.designer_name} from the Pattern Design Lab collection. Digitally printed to order as a custom wallcovering by DW Bespoke Studio.</p>`,
+    ].join('');
+    const payload = { product: {
+      title: design.title, body_html, vendor: 'DW Bespoke Studio', product_type: 'Wallcovering',
+      status, tags,
+      variants: [{
+        sku, price, taxable: true, requires_shipping: true, inventory_management: null,
+        weight: 1, weight_unit: 'lb', // DW checkout shipping is weight-based — never leave 0
+      }],
+    } };
+    // Attach the master pattern image from local disk (base64) when it exists.
+    try {
+      const imgPath = path.join(__dirname, 'public', String(design.img || '').replace(/^\//, ''));
+      if (design.img && fs.existsSync(imgPath)) {
+        payload.product.images = [{ attachment: fs.readFileSync(imgPath).toString('base64'), filename: path.basename(imgPath) }];
+      }
+    } catch (e) { console.error('image attach skipped:', e.message); }
+    if (b.dryRun) return res.json({ ok: true, dryRun: true, wouldCreate: { ...payload.product, images: payload.product.images ? ['<attached>'] : [] } });
+
+    const resp = await fetch(`${SHOP_API}/products.json`, {
+      method: 'POST',
+      headers: { 'X-Shopify-Access-Token': SHOP_TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify(payload),
+    });
+    const j = await resp.json().catch(() => ({}));
+    if (!resp.ok || !j.product) {
+      console.error('shopify create failed', resp.status, JSON.stringify(j).slice(0, 500));
+      return res.status(502).json({ error: 'Shopify create failed', status: resp.status, detail: j.errors || j });
+    }
+    await pool.query(
+      'UPDATE designs SET shopify_product_id=$1, shopify_sku=$2, shopify_pushed_at=now() WHERE id=$3',
+      [String(j.product.id), sku, design.id]);
+    res.json({ ok: true, productId: String(j.product.id), sku, status: j.product.status,
+      adminUrl: `https://admin.shopify.com/store/designer-laboratory-sandbox/products/${j.product.id}` });
+  } catch (e) { console.error('shopify push', e); res.status(500).json({ error: 'push failed', detail: e.message }); }
+});
+
 // ---- admin design list (requireAdmin: enforced even under PUBLIC=1) ----
 app.get('/api/admin/designs', requireAdmin, async (req, res) => {
   try {
@@ -355,6 +455,7 @@ app.get('/designer-signup', (req, res) => res.sendFile(path.join(__dirname, 'pub
 app.get('/designer-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-login.html')));
 app.get('/designer-dashboard', (req, res) => res.sendFile(path.join(__dirname, 'public', 'designer-dashboard.html')));
 app.get('/admin', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin.html')));
+app.get('/admin-login', (req, res) => res.sendFile(path.join(__dirname, 'public', 'admin-login.html')));
 
 app.get('/api/healthz', async (req, res) => {
   try { const r = await pool.query('SELECT count(*)::int n FROM designs'); res.json({ ok: true, service: 'patterndesignlab', designs: r.rows[0].n, public: PUBLIC, stripe: stripe ? 'test' : 'none' }); }

← 1a53e09 5x re-verify: app clean (0 real defects), B6 Firefox fixed v  ·  back to Patterndesignlab  ·  pin cookie@0.7.2 explicitly (Kamatera npm pulled v1.x with c 350abca →