@helloleo/plugins 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -12910,35 +12910,58 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
12910
12910
  }
12911
12911
  function netsuiteTs() {
12912
12912
  return `/*
12913
- * NetSuite OAuth client helper.
12913
+ * NetSuite OAuth client helper (bearer-token-in-fragment).
12914
12914
  *
12915
- * NetSuite uses a TWO-Integration-Record setup:
12916
- * 1. MCP record (scope: mcp) -- used by HelloLeo at build time
12917
- * to introspect the schema. Configured in Project Settings, not here.
12918
- * 2. Runtime record (scope: rest_webservices) -- used by THIS app to sign
12919
- * end-users in. Credentials are stored as HelloLeo Cloud secrets on the
12920
- * project's Worker (env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,
12921
- * env.NETSUITE_CLIENT_SECRET). They never reach the browser.
12915
+ * Two NetSuite Integration Records back this:
12916
+ * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
12917
+ * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
12918
+ * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
12919
+ * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
12920
+ * the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
12921
+ * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
12922
+ * third-party cookies in the HelloLeo preview iframe.
12922
12923
  *
12923
12924
  * Flow:
12924
12925
  * 1. User clicks "Sign in with NetSuite".
12925
- * 2. Browser hits the project's Worker at /auth/netsuite/start, which
12926
- * redirects to NetSuite's OAuth authorise endpoint with PKCE.
12927
- * 3. NetSuite redirects back to /auth/netsuite/callback on the Worker.
12928
- * The Worker exchanges the code for an access + refresh token, stores
12929
- * them in D1 keyed by a session id, and sets a session cookie.
12930
- * 4. The frontend calls /api/me on the Worker to read the signed-in user.
12931
- * 5. All NetSuite REST calls flow through the Worker so tokens stay
12932
- * server-side.
12926
+ * 2. Browser navigates to the Worker /auth/netsuite/start.
12927
+ * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
12928
+ * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
12929
+ * 5. Worker exchanges the code, mints a session id, then redirects to
12930
+ * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
12931
+ * 6. This module reads the fragment on load, stashes the sid in
12932
+ * sessionStorage, and sends it as Authorization: Bearer on every
12933
+ * Worker call.
12933
12934
  *
12934
- * IMPORTANT: NEVER use a hardcoded password or sessionStorage flag as the
12935
- * login gate -- the NetSuite OAuth flow above is the only acceptable
12936
- * authentication for a NetSuite project.
12935
+ * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
12936
+ * authoritative auth state -- the only acceptable check is /api/me on the
12937
+ * Worker. sessionStorage here is just a cache of the bearer token.
12937
12938
  */
12938
12939
 
12939
12940
  export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
12941
+ const TOKEN_KEY = 'nssid';
12940
12942
 
12941
- export function signInWithNetSuite(returnTo: string = window.location.pathname) {
12943
+ // Capture-and-strip the #nssid=... fragment as early as possible so URLs
12944
+ // shown to the user stay clean. Runs once at module load.
12945
+ (function bootstrapTokenFromHash() {
12946
+ if (typeof window === 'undefined') return;
12947
+ const hash = window.location.hash.replace(/^#/, '');
12948
+ if (!hash) return;
12949
+ const params = new URLSearchParams(hash);
12950
+ const sid = params.get(TOKEN_KEY);
12951
+ if (sid) {
12952
+ try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
12953
+ params.delete(TOKEN_KEY);
12954
+ const remaining = params.toString();
12955
+ const newHash = remaining ? '#' + remaining : '';
12956
+ window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
12957
+ }
12958
+ })();
12959
+
12960
+ export function getSessionToken(): string | null {
12961
+ try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
12962
+ }
12963
+
12964
+ export function signInWithNetSuite(returnTo: string = window.location.href) {
12942
12965
  if (!WORKER_URL) {
12943
12966
  throw new Error(
12944
12967
  'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
@@ -12958,8 +12981,10 @@ export interface NetSuiteSession {
12958
12981
 
12959
12982
  export async function getNetSuiteSession(): Promise<NetSuiteSession> {
12960
12983
  if (!WORKER_URL) return { authenticated: false };
12984
+ const sid = getSessionToken();
12985
+ if (!sid) return { authenticated: false };
12961
12986
  const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
12962
- credentials: 'include',
12987
+ headers: { Authorization: 'Bearer ' + sid },
12963
12988
  });
12964
12989
  if (!res.ok) return { authenticated: false };
12965
12990
  const data = await res.json();
@@ -12968,16 +12993,38 @@ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
12968
12993
 
12969
12994
  export async function signOutNetSuite(): Promise<void> {
12970
12995
  if (!WORKER_URL) return;
12971
- await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
12972
- method: 'POST',
12973
- credentials: 'include',
12974
- });
12996
+ const sid = getSessionToken();
12997
+ if (sid) {
12998
+ try {
12999
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
13000
+ method: 'POST',
13001
+ headers: { Authorization: 'Bearer ' + sid },
13002
+ });
13003
+ } catch {}
13004
+ }
13005
+ try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
12975
13006
  window.location.reload();
12976
13007
  }
13008
+
13009
+ /**
13010
+ * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
13011
+ * Always go through this instead of bare fetch -- it injects the bearer
13012
+ * token and routes through the Worker so NetSuite tokens stay server-side.
13013
+ */
13014
+ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
13015
+ if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
13016
+ const url = pathOrUrl.startsWith('http')
13017
+ ? pathOrUrl
13018
+ : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
13019
+ const sid = getSessionToken();
13020
+ const headers = new Headers(init.headers ?? {});
13021
+ if (sid) headers.set('Authorization', 'Bearer ' + sid);
13022
+ return fetch(url, { ...init, headers });
13023
+ }
12977
13024
  `;
12978
13025
  }
12979
13026
  function signInButtonTsx() {
12980
- return `import { signInWithNetSuite } from '../lib/netsuite';
13027
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
12981
13028
 
12982
13029
  export function SignInWithNetSuite() {
12983
13030
  return (
@@ -12991,6 +13038,113 @@ export function SignInWithNetSuite() {
12991
13038
  }
12992
13039
  `;
12993
13040
  }
13041
+ function netsuiteLoginPageTsx() {
13042
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
13043
+
13044
+ /**
13045
+ * Default unauthenticated landing page. Replaces any password gate.
13046
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
13047
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
13048
+ */
13049
+ export function LoginPage() {
13050
+ return (
13051
+ <div className="min-h-screen bg-background text-foreground flex">
13052
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
13053
+ <div className="text-xl font-semibold">NetSuite App</div>
13054
+ <div>
13055
+ <h1 className="text-4xl font-bold leading-tight">
13056
+ Connect your NetSuite account
13057
+ </h1>
13058
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
13059
+ Sign in with your NetSuite credentials to access your data securely.
13060
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
13061
+ browser.
13062
+ </p>
13063
+ </div>
13064
+ <div className="text-xs text-primary-foreground/60">
13065
+ Powered by HelloLeo
13066
+ </div>
13067
+ </div>
13068
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
13069
+ <div className="w-full max-w-sm space-y-6">
13070
+ <div>
13071
+ <h2 className="text-2xl font-semibold">Welcome</h2>
13072
+ <p className="mt-2 text-sm text-muted-foreground">
13073
+ Use the button below to sign in. You'll be redirected to NetSuite
13074
+ to authorise access, then back here.
13075
+ </p>
13076
+ </div>
13077
+ <SignInWithNetSuite />
13078
+ </div>
13079
+ </div>
13080
+ </div>
13081
+ );
13082
+ }
13083
+ `;
13084
+ }
13085
+ function netsuiteAuthGateTsx() {
13086
+ return `import { useEffect, useState } from 'react';
13087
+ import { LoginPage } from './LoginPage';
13088
+ import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
13089
+
13090
+ /**
13091
+ * Mandatory auth gate for NetSuite projects. Wrap your real App content with
13092
+ * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
13093
+ * is the ONLY supported authentication for a NetSuite project (no password
13094
+ * gates, no sessionStorage flags, no shared credential).
13095
+ *
13096
+ * Pattern in src/App.tsx:
13097
+ *
13098
+ * import { AuthGate } from './components/auth/AuthGate';
13099
+ * export default function App() {
13100
+ * return (
13101
+ * <AuthGate>
13102
+ * <Dashboard /> {/* your authenticated content */}
13103
+ * </AuthGate>
13104
+ * );
13105
+ * }
13106
+ */
13107
+ export function AuthGate({ children }: { children: React.ReactNode }) {
13108
+ const [session, setSession] = useState<NetSuiteSession | null>(null);
13109
+
13110
+ useEffect(() => { getNetSuiteSession().then(setSession); }, []);
13111
+
13112
+ if (session === null) {
13113
+ return (
13114
+ <div className="min-h-screen flex items-center justify-center bg-background">
13115
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
13116
+ </div>
13117
+ );
13118
+ }
13119
+
13120
+ if (!session.authenticated) return <LoginPage />;
13121
+
13122
+ return <>{children}</>;
13123
+ }
13124
+ `;
13125
+ }
13126
+ function netsuiteAppTsx() {
13127
+ return `import { AuthGate } from './components/auth/AuthGate';
13128
+
13129
+ /**
13130
+ * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
13131
+ * removing it disables real OAuth and exposes NetSuite data to anonymous
13132
+ * users. Build your dashboard / pages inside the gate.
13133
+ */
13134
+ export default function App() {
13135
+ return (
13136
+ <AuthGate>
13137
+ <div className="min-h-screen bg-background text-foreground p-8">
13138
+ <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
13139
+ <p className="text-muted-foreground mt-2">
13140
+ Replace this placeholder with your dashboard.
13141
+ </p>
13142
+ </div>
13143
+ </AuthGate>
13144
+ );
13145
+ }
13146
+ `;
13147
+ }
12994
13148
  var scaffold = tool({
12995
13149
  description: "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " + "Creates all mandatory boilerplate: package.json, index.html (with Google Fonts), " + "vite.config.js, tailwind.config.js, postcss.config.js, src/index.css (CSS variables " + "light+dark), src/lib/utils.ts (cn helper), and src/main.tsx. " + "Call this FIRST for any new project before writing App.tsx and components.",
12996
13150
  args: {
@@ -12998,7 +13152,7 @@ var scaffold = tool({
12998
13152
  primaryColor: tool.schema.string().optional().describe("Primary brand color. Prefer raw HSL triplet (no hsl() wrapper, no # prefix), " + 'e.g. "221.2 83.2% 53.3%". Hex like "#3b82f6" is also accepted and will ' + "be auto-converted. Default: blue."),
12999
13153
  fontFamily: tool.schema.string().optional().describe('Google Font family name, e.g. "Inter", "Poppins" (default: Inter)'),
13000
13154
  borderRadius: tool.schema.string().optional().describe('Base border radius, e.g. "0.5rem", "0.75rem" (default: 0.5rem)'),
13001
- integrations: tool.schema.array(tool.schema.string()).optional().describe("Connected integration slugs for this project (live MCP list). " + 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' + "will add src/lib/netsuite.ts (OAuth client helper) and a " + "src/components/auth/SignInWithNetSuite.tsx button so OAuth is " + "wired from the start. Forbids hardcoded password gates by " + "replacing them with the real flow against the project's Worker.")
13155
+ integrations: tool.schema.array(tool.schema.string()).optional().describe("Connected integration slugs for this project (live MCP list). " + 'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' + "will add src/lib/netsuite.ts (OAuth client helper), " + "src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx " + "and a src/App.tsx wrapped in <AuthGate>. OAuth is wired from " + "day one. Forbids hardcoded password gates -- the gate IS the " + "real flow against the project's Worker.")
13002
13156
  },
13003
13157
  async execute(args, context) {
13004
13158
  const dir = context.directory;
@@ -13029,7 +13183,13 @@ var scaffold = tool({
13029
13183
  files.push([path2.join("src", "lib", "netsuite.ts"), netsuiteTs()], [
13030
13184
  path2.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
13031
13185
  signInButtonTsx()
13032
- ]);
13186
+ ], [
13187
+ path2.join("src", "components", "auth", "LoginPage.tsx"),
13188
+ netsuiteLoginPageTsx()
13189
+ ], [
13190
+ path2.join("src", "components", "auth", "AuthGate.tsx"),
13191
+ netsuiteAuthGateTsx()
13192
+ ], [path2.join("src", "App.tsx"), netsuiteAppTsx()]);
13033
13193
  }
13034
13194
  const created = [];
13035
13195
  for (const [filePath, content] of files) {
@@ -13049,7 +13209,7 @@ var scaffold = tool({
13049
13209
  "Update package.json with any extra packages you need after scaffolding."
13050
13210
  ];
13051
13211
  if (hasNetsuite) {
13052
- out.push("", "NetSuite project detected:", " - Use <SignInWithNetSuite/> from src/components/auth/ as your", " login button. NEVER use a hardcoded password gate.", " - signInWithNetSuite() in src/lib/netsuite.ts redirects to the", " project's Worker at /auth/netsuite/start. The Worker must be", " deployed via the cloudflare-backend MCP with routes:", " GET /auth/netsuite/start -- redirect to NetSuite OAuth", " GET /auth/netsuite/callback -- code -> token exchange", " GET /api/me -- current session", " POST /auth/netsuite/logout -- clear session", " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,", " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).", " - VITE_WORKER_URL must be set in the project env so the frontend", " knows where to send users. The HelloLeo Cloud provisioner", " seeds this automatically.");
13212
+ out.push("", "NetSuite project -- auth is already wired:", " - App.tsx wraps everything in <AuthGate>. Build your dashboard", " INSIDE the gate. NEVER remove the gate or build a password", " placeholder -- real OAuth is the only allowed login.", " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated", " view. Customise the copy / imagery, keep <SignInWithNetSuite/>", " as the call-to-action.", " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at", " /auth/netsuite/start. Deploy the Worker via cloudflare-backend", " MCP with routes:", " GET /auth/netsuite/start -- redirect to NetSuite OAuth", " GET /auth/netsuite/callback -- code -> token exchange", " GET /api/me -- current session", " POST /auth/netsuite/logout -- clear session", " Worker reads env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,", " env.NETSUITE_CLIENT_SECRET (set by the runtime-setup wizard).", " - VITE_WORKER_URL must be set in the project env so the frontend", " knows where to send users. The HelloLeo Cloud provisioner", " seeds this automatically.");
13053
13213
  }
13054
13214
  return out.join(`
13055
13215
  `);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@helloleo/plugins",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "OpenCode plugins for HelloLeo",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",
@@ -389,35 +389,58 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
389
389
 
390
390
  function netsuiteTs() {
391
391
  return `/*
392
- * NetSuite OAuth client helper.
392
+ * NetSuite OAuth client helper (bearer-token-in-fragment).
393
393
  *
394
- * NetSuite uses a TWO-Integration-Record setup:
395
- * 1. MCP record (scope: mcp) -- used by HelloLeo at build time
396
- * to introspect the schema. Configured in Project Settings, not here.
397
- * 2. Runtime record (scope: rest_webservices) -- used by THIS app to sign
398
- * end-users in. Credentials are stored as HelloLeo Cloud secrets on the
399
- * project's Worker (env.NETSUITE_ACCOUNT_ID, env.NETSUITE_CLIENT_ID,
400
- * env.NETSUITE_CLIENT_SECRET). They never reach the browser.
394
+ * Two NetSuite Integration Records back this:
395
+ * 1. MCP (scope: mcp) -- used by HelloLeo at build time.
396
+ * 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
397
+ * Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
398
+ * (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
399
+ * the browser. Cookies are NOT used -- frontend ↔ Worker are cross-site
400
+ * (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
401
+ * third-party cookies in the HelloLeo preview iframe.
401
402
  *
402
403
  * Flow:
403
404
  * 1. User clicks "Sign in with NetSuite".
404
- * 2. Browser hits the project's Worker at /auth/netsuite/start, which
405
- * redirects to NetSuite's OAuth authorise endpoint with PKCE.
406
- * 3. NetSuite redirects back to /auth/netsuite/callback on the Worker.
407
- * The Worker exchanges the code for an access + refresh token, stores
408
- * them in D1 keyed by a session id, and sets a session cookie.
409
- * 4. The frontend calls /api/me on the Worker to read the signed-in user.
410
- * 5. All NetSuite REST calls flow through the Worker so tokens stay
411
- * server-side.
405
+ * 2. Browser navigates to the Worker /auth/netsuite/start.
406
+ * 3. Worker redirects to NetSuite's OAuth UI with PKCE.
407
+ * 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
408
+ * 5. Worker exchanges the code, mints a session id, then redirects to
409
+ * <return_to>#nssid=<sid>. URL fragments stay in the browser only.
410
+ * 6. This module reads the fragment on load, stashes the sid in
411
+ * sessionStorage, and sends it as Authorization: Bearer on every
412
+ * Worker call.
412
413
  *
413
- * IMPORTANT: NEVER use a hardcoded password or sessionStorage flag as the
414
- * login gate -- the NetSuite OAuth flow above is the only acceptable
415
- * authentication for a NetSuite project.
414
+ * NEVER use a hardcoded password gate. NEVER use sessionStorage as the
415
+ * authoritative auth state -- the only acceptable check is /api/me on the
416
+ * Worker. sessionStorage here is just a cache of the bearer token.
416
417
  */
417
418
 
418
419
  export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
420
+ const TOKEN_KEY = 'nssid';
421
+
422
+ // Capture-and-strip the #nssid=... fragment as early as possible so URLs
423
+ // shown to the user stay clean. Runs once at module load.
424
+ (function bootstrapTokenFromHash() {
425
+ if (typeof window === 'undefined') return;
426
+ const hash = window.location.hash.replace(/^#/, '');
427
+ if (!hash) return;
428
+ const params = new URLSearchParams(hash);
429
+ const sid = params.get(TOKEN_KEY);
430
+ if (sid) {
431
+ try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
432
+ params.delete(TOKEN_KEY);
433
+ const remaining = params.toString();
434
+ const newHash = remaining ? '#' + remaining : '';
435
+ window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
436
+ }
437
+ })();
438
+
439
+ export function getSessionToken(): string | null {
440
+ try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
441
+ }
419
442
 
420
- export function signInWithNetSuite(returnTo: string = window.location.pathname) {
443
+ export function signInWithNetSuite(returnTo: string = window.location.href) {
421
444
  if (!WORKER_URL) {
422
445
  throw new Error(
423
446
  'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
@@ -437,8 +460,10 @@ export interface NetSuiteSession {
437
460
 
438
461
  export async function getNetSuiteSession(): Promise<NetSuiteSession> {
439
462
  if (!WORKER_URL) return { authenticated: false };
463
+ const sid = getSessionToken();
464
+ if (!sid) return { authenticated: false };
440
465
  const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
441
- credentials: 'include',
466
+ headers: { Authorization: 'Bearer ' + sid },
442
467
  });
443
468
  if (!res.ok) return { authenticated: false };
444
469
  const data = await res.json();
@@ -447,17 +472,39 @@ export async function getNetSuiteSession(): Promise<NetSuiteSession> {
447
472
 
448
473
  export async function signOutNetSuite(): Promise<void> {
449
474
  if (!WORKER_URL) return;
450
- await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
451
- method: 'POST',
452
- credentials: 'include',
453
- });
475
+ const sid = getSessionToken();
476
+ if (sid) {
477
+ try {
478
+ await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
479
+ method: 'POST',
480
+ headers: { Authorization: 'Bearer ' + sid },
481
+ });
482
+ } catch {}
483
+ }
484
+ try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
454
485
  window.location.reload();
455
486
  }
487
+
488
+ /**
489
+ * Helper for ALL other Worker calls (your dashboard endpoints, etc.).
490
+ * Always go through this instead of bare fetch -- it injects the bearer
491
+ * token and routes through the Worker so NetSuite tokens stay server-side.
492
+ */
493
+ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
494
+ if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
495
+ const url = pathOrUrl.startsWith('http')
496
+ ? pathOrUrl
497
+ : \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
498
+ const sid = getSessionToken();
499
+ const headers = new Headers(init.headers ?? {});
500
+ if (sid) headers.set('Authorization', 'Bearer ' + sid);
501
+ return fetch(url, { ...init, headers });
502
+ }
456
503
  `;
457
504
  }
458
505
 
459
506
  function signInButtonTsx() {
460
- return `import { signInWithNetSuite } from '../lib/netsuite';
507
+ return `import { signInWithNetSuite } from '../../lib/netsuite';
461
508
 
462
509
  export function SignInWithNetSuite() {
463
510
  return (
@@ -472,6 +519,116 @@ export function SignInWithNetSuite() {
472
519
  `;
473
520
  }
474
521
 
522
+ function netsuiteLoginPageTsx() {
523
+ return `import { SignInWithNetSuite } from './SignInWithNetSuite';
524
+
525
+ /**
526
+ * Default unauthenticated landing page. Replaces any password gate.
527
+ * Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
528
+ * only call-to-action -- it points to the Worker's /auth/netsuite/start.
529
+ */
530
+ export function LoginPage() {
531
+ return (
532
+ <div className="min-h-screen bg-background text-foreground flex">
533
+ <div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
534
+ <div className="text-xl font-semibold">NetSuite App</div>
535
+ <div>
536
+ <h1 className="text-4xl font-bold leading-tight">
537
+ Connect your NetSuite account
538
+ </h1>
539
+ <p className="mt-4 text-primary-foreground/80 max-w-md">
540
+ Sign in with your NetSuite credentials to access your data securely.
541
+ Tokens stay server-side on HelloLeo Cloud -- they never reach the
542
+ browser.
543
+ </p>
544
+ </div>
545
+ <div className="text-xs text-primary-foreground/60">
546
+ Powered by HelloLeo
547
+ </div>
548
+ </div>
549
+ <div className="flex-1 flex items-center justify-center px-6 py-12">
550
+ <div className="w-full max-w-sm space-y-6">
551
+ <div>
552
+ <h2 className="text-2xl font-semibold">Welcome</h2>
553
+ <p className="mt-2 text-sm text-muted-foreground">
554
+ Use the button below to sign in. You'll be redirected to NetSuite
555
+ to authorise access, then back here.
556
+ </p>
557
+ </div>
558
+ <SignInWithNetSuite />
559
+ </div>
560
+ </div>
561
+ </div>
562
+ );
563
+ }
564
+ `;
565
+ }
566
+
567
+ function netsuiteAuthGateTsx() {
568
+ return `import { useEffect, useState } from 'react';
569
+ import { LoginPage } from './LoginPage';
570
+ import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
571
+
572
+ /**
573
+ * Mandatory auth gate for NetSuite projects. Wrap your real App content with
574
+ * <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
575
+ * is the ONLY supported authentication for a NetSuite project (no password
576
+ * gates, no sessionStorage flags, no shared credential).
577
+ *
578
+ * Pattern in src/App.tsx:
579
+ *
580
+ * import { AuthGate } from './components/auth/AuthGate';
581
+ * export default function App() {
582
+ * return (
583
+ * <AuthGate>
584
+ * <Dashboard /> {/* your authenticated content */}
585
+ * </AuthGate>
586
+ * );
587
+ * }
588
+ */
589
+ export function AuthGate({ children }: { children: React.ReactNode }) {
590
+ const [session, setSession] = useState<NetSuiteSession | null>(null);
591
+
592
+ useEffect(() => { getNetSuiteSession().then(setSession); }, []);
593
+
594
+ if (session === null) {
595
+ return (
596
+ <div className="min-h-screen flex items-center justify-center bg-background">
597
+ <div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
598
+ </div>
599
+ );
600
+ }
601
+
602
+ if (!session.authenticated) return <LoginPage />;
603
+
604
+ return <>{children}</>;
605
+ }
606
+ `;
607
+ }
608
+
609
+ function netsuiteAppTsx() {
610
+ return `import { AuthGate } from './components/auth/AuthGate';
611
+
612
+ /**
613
+ * NetSuite project entry point. KEEP <AuthGate> as the outermost element --
614
+ * removing it disables real OAuth and exposes NetSuite data to anonymous
615
+ * users. Build your dashboard / pages inside the gate.
616
+ */
617
+ export default function App() {
618
+ return (
619
+ <AuthGate>
620
+ <div className="min-h-screen bg-background text-foreground p-8">
621
+ <h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
622
+ <p className="text-muted-foreground mt-2">
623
+ Replace this placeholder with your dashboard.
624
+ </p>
625
+ </div>
626
+ </AuthGate>
627
+ );
628
+ }
629
+ `;
630
+ }
631
+
475
632
  export const scaffold = tool({
476
633
  description:
477
634
  "Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
@@ -510,10 +667,11 @@ export const scaffold = tool({
510
667
  .describe(
511
668
  "Connected integration slugs for this project (live MCP list). " +
512
669
  'Pass ["netsuite"] when the project uses NetSuite -- the scaffold ' +
513
- "will add src/lib/netsuite.ts (OAuth client helper) and a " +
514
- "src/components/auth/SignInWithNetSuite.tsx button so OAuth is " +
515
- "wired from the start. Forbids hardcoded password gates by " +
516
- "replacing them with the real flow against the project's Worker.",
670
+ "will add src/lib/netsuite.ts (OAuth client helper), " +
671
+ "src/components/auth/{SignInWithNetSuite,LoginPage,AuthGate}.tsx " +
672
+ "and a src/App.tsx wrapped in <AuthGate>. OAuth is wired from " +
673
+ "day one. Forbids hardcoded password gates -- the gate IS the " +
674
+ "real flow against the project's Worker.",
517
675
  ),
518
676
  },
519
677
  async execute(args, context) {
@@ -553,6 +711,15 @@ export const scaffold = tool({
553
711
  path.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
554
712
  signInButtonTsx(),
555
713
  ],
714
+ [
715
+ path.join("src", "components", "auth", "LoginPage.tsx"),
716
+ netsuiteLoginPageTsx(),
717
+ ],
718
+ [
719
+ path.join("src", "components", "auth", "AuthGate.tsx"),
720
+ netsuiteAuthGateTsx(),
721
+ ],
722
+ [path.join("src", "App.tsx"), netsuiteAppTsx()],
556
723
  );
557
724
  }
558
725
 
@@ -578,12 +745,16 @@ export const scaffold = tool({
578
745
  if (hasNetsuite) {
579
746
  out.push(
580
747
  "",
581
- "NetSuite project detected:",
582
- " - Use <SignInWithNetSuite/> from src/components/auth/ as your",
583
- " login button. NEVER use a hardcoded password gate.",
584
- " - signInWithNetSuite() in src/lib/netsuite.ts redirects to the",
585
- " project's Worker at /auth/netsuite/start. The Worker must be",
586
- " deployed via the cloudflare-backend MCP with routes:",
748
+ "NetSuite project -- auth is already wired:",
749
+ " - App.tsx wraps everything in <AuthGate>. Build your dashboard",
750
+ " INSIDE the gate. NEVER remove the gate or build a password",
751
+ " placeholder -- real OAuth is the only allowed login.",
752
+ " - LoginPage.tsx + SignInWithNetSuite.tsx are the unauthenticated",
753
+ " view. Customise the copy / imagery, keep <SignInWithNetSuite/>",
754
+ " as the call-to-action.",
755
+ " - signInWithNetSuite() in src/lib/netsuite.ts hits the Worker at",
756
+ " /auth/netsuite/start. Deploy the Worker via cloudflare-backend",
757
+ " MCP with routes:",
587
758
  " GET /auth/netsuite/start -- redirect to NetSuite OAuth",
588
759
  " GET /auth/netsuite/callback -- code -> token exchange",
589
760
  " GET /api/me -- current session",