@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 +189 -29
- package/package.json +1 -1
- package/src/tools/scaffold.ts +207 -36
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
|
|
12916
|
-
* 1. MCP
|
|
12917
|
-
*
|
|
12918
|
-
*
|
|
12919
|
-
*
|
|
12920
|
-
*
|
|
12921
|
-
*
|
|
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
|
|
12926
|
-
*
|
|
12927
|
-
*
|
|
12928
|
-
*
|
|
12929
|
-
*
|
|
12930
|
-
*
|
|
12931
|
-
*
|
|
12932
|
-
*
|
|
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
|
-
*
|
|
12935
|
-
*
|
|
12936
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
12972
|
-
|
|
12973
|
-
|
|
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 '
|
|
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)
|
|
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
|
|
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
package/src/tools/scaffold.ts
CHANGED
|
@@ -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
|
|
395
|
-
* 1. MCP
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
*
|
|
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
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
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
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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 '
|
|
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)
|
|
514
|
-
"src/components/auth/SignInWithNetSuite.tsx
|
|
515
|
-
"
|
|
516
|
-
"
|
|
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
|
|
582
|
-
" -
|
|
583
|
-
"
|
|
584
|
-
"
|
|
585
|
-
"
|
|
586
|
-
"
|
|
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",
|