@helloleo/plugins 0.1.8 → 0.1.10
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 +257 -190
- package/package.json +1 -1
- package/src/tools/scaffold-netsuite.ts +268 -0
- package/src/tools/scaffold.ts +7 -195
package/dist/index.js
CHANGED
|
@@ -12544,6 +12544,263 @@ var RemovePlugin = async () => {
|
|
|
12544
12544
|
// src/tools/scaffold.ts
|
|
12545
12545
|
import { mkdir } from "fs/promises";
|
|
12546
12546
|
import path2 from "path";
|
|
12547
|
+
|
|
12548
|
+
// src/tools/scaffold-netsuite.ts
|
|
12549
|
+
function netsuiteTs() {
|
|
12550
|
+
return `/*
|
|
12551
|
+
* NetSuite OAuth client helper (bearer-token-in-fragment).
|
|
12552
|
+
*
|
|
12553
|
+
* Two NetSuite Integration Records back this:
|
|
12554
|
+
* 1. MCP (scope: mcp) -- used by HelloLeo at build time.
|
|
12555
|
+
* 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
|
|
12556
|
+
* Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
|
|
12557
|
+
* (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
|
|
12558
|
+
* the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
|
|
12559
|
+
* (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
|
|
12560
|
+
* third-party cookies in the HelloLeo preview iframe.
|
|
12561
|
+
*
|
|
12562
|
+
* Flow:
|
|
12563
|
+
* 1. User clicks "Sign in with NetSuite".
|
|
12564
|
+
* 2. Browser navigates to the Worker /auth/netsuite/start.
|
|
12565
|
+
* 3. Worker redirects to NetSuite's OAuth UI with PKCE.
|
|
12566
|
+
* 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
|
|
12567
|
+
* 5. Worker exchanges the code, mints a session id, then redirects to
|
|
12568
|
+
* <return_to>#nssid=<sid>. URL fragments stay in the browser only.
|
|
12569
|
+
* 6. This module reads the fragment on load, stashes the sid in
|
|
12570
|
+
* sessionStorage, and sends it as Authorization: Bearer on every
|
|
12571
|
+
* Worker call.
|
|
12572
|
+
*
|
|
12573
|
+
* NEVER use a hardcoded password gate. NEVER use sessionStorage as the
|
|
12574
|
+
* authoritative auth state -- the only acceptable check is /api/me on the
|
|
12575
|
+
* Worker. sessionStorage here is just a cache of the bearer token.
|
|
12576
|
+
*/
|
|
12577
|
+
|
|
12578
|
+
export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
|
|
12579
|
+
const TOKEN_KEY = 'nssid';
|
|
12580
|
+
|
|
12581
|
+
// Capture-and-strip the #nssid=... fragment as early as possible so URLs
|
|
12582
|
+
// shown to the user stay clean. Runs once at module load.
|
|
12583
|
+
(function bootstrapTokenFromHash() {
|
|
12584
|
+
if (typeof window === 'undefined') return;
|
|
12585
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
12586
|
+
if (!hash) return;
|
|
12587
|
+
const params = new URLSearchParams(hash);
|
|
12588
|
+
const sid = params.get(TOKEN_KEY);
|
|
12589
|
+
if (sid) {
|
|
12590
|
+
try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
|
|
12591
|
+
params.delete(TOKEN_KEY);
|
|
12592
|
+
const remaining = params.toString();
|
|
12593
|
+
const newHash = remaining ? '#' + remaining : '';
|
|
12594
|
+
window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
|
|
12595
|
+
}
|
|
12596
|
+
})();
|
|
12597
|
+
|
|
12598
|
+
export function getSessionToken(): string | null {
|
|
12599
|
+
try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
12600
|
+
}
|
|
12601
|
+
|
|
12602
|
+
export function signInWithNetSuite(returnTo: string = window.location.href) {
|
|
12603
|
+
if (!WORKER_URL) {
|
|
12604
|
+
throw new Error(
|
|
12605
|
+
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
12606
|
+
);
|
|
12607
|
+
}
|
|
12608
|
+
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
12609
|
+
url.searchParams.set('return_to', returnTo);
|
|
12610
|
+
// The app usually renders inside the HelloLeo preview iframe. NetSuite's
|
|
12611
|
+
// OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
|
|
12612
|
+
// Worker (which 302s to NetSuite) ends up loading a page the browser
|
|
12613
|
+
// refuses to render -- looks like "nothing happens" to the user. Navigate
|
|
12614
|
+
// the top frame instead so the OAuth flow runs at the top level and the
|
|
12615
|
+
// callback redirect lands on the same top frame, which then reads the
|
|
12616
|
+
// #nssid= fragment. Fallback to same-frame navigation if the top window
|
|
12617
|
+
// is cross-origin and can't be reassigned.
|
|
12618
|
+
try {
|
|
12619
|
+
if (window.top && window.top !== window) {
|
|
12620
|
+
window.top.location.assign(url.toString());
|
|
12621
|
+
return;
|
|
12622
|
+
}
|
|
12623
|
+
} catch {
|
|
12624
|
+
/* top window not accessible \u2014 fall through to same-frame navigation */
|
|
12625
|
+
}
|
|
12626
|
+
window.location.assign(url.toString());
|
|
12627
|
+
}
|
|
12628
|
+
|
|
12629
|
+
export interface NetSuiteSession {
|
|
12630
|
+
authenticated: boolean;
|
|
12631
|
+
account_id?: string;
|
|
12632
|
+
user_id?: string;
|
|
12633
|
+
email?: string;
|
|
12634
|
+
}
|
|
12635
|
+
|
|
12636
|
+
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
12637
|
+
if (!WORKER_URL) return { authenticated: false };
|
|
12638
|
+
const sid = getSessionToken();
|
|
12639
|
+
if (!sid) return { authenticated: false };
|
|
12640
|
+
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
12641
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
12642
|
+
});
|
|
12643
|
+
if (!res.ok) return { authenticated: false };
|
|
12644
|
+
const data = await res.json();
|
|
12645
|
+
return { authenticated: Boolean(data?.account_id), ...data };
|
|
12646
|
+
}
|
|
12647
|
+
|
|
12648
|
+
export async function signOutNetSuite(): Promise<void> {
|
|
12649
|
+
if (!WORKER_URL) return;
|
|
12650
|
+
const sid = getSessionToken();
|
|
12651
|
+
if (sid) {
|
|
12652
|
+
try {
|
|
12653
|
+
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
12654
|
+
method: 'POST',
|
|
12655
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
12656
|
+
});
|
|
12657
|
+
} catch {}
|
|
12658
|
+
}
|
|
12659
|
+
try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
|
|
12660
|
+
window.location.reload();
|
|
12661
|
+
}
|
|
12662
|
+
|
|
12663
|
+
/**
|
|
12664
|
+
* Helper for ALL other Worker calls (your dashboard endpoints, etc.).
|
|
12665
|
+
* Always go through this instead of bare fetch -- it injects the bearer
|
|
12666
|
+
* token and routes through the Worker so NetSuite tokens stay server-side.
|
|
12667
|
+
*/
|
|
12668
|
+
export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
|
|
12669
|
+
if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
|
|
12670
|
+
const url = pathOrUrl.startsWith('http')
|
|
12671
|
+
? pathOrUrl
|
|
12672
|
+
: \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
|
|
12673
|
+
const sid = getSessionToken();
|
|
12674
|
+
const headers = new Headers(init.headers ?? {});
|
|
12675
|
+
if (sid) headers.set('Authorization', 'Bearer ' + sid);
|
|
12676
|
+
return fetch(url, { ...init, headers });
|
|
12677
|
+
}
|
|
12678
|
+
`;
|
|
12679
|
+
}
|
|
12680
|
+
function signInButtonTsx() {
|
|
12681
|
+
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
12682
|
+
|
|
12683
|
+
export function SignInWithNetSuite() {
|
|
12684
|
+
return (
|
|
12685
|
+
<button
|
|
12686
|
+
onClick={() => signInWithNetSuite()}
|
|
12687
|
+
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
12688
|
+
>
|
|
12689
|
+
Sign in with NetSuite
|
|
12690
|
+
</button>
|
|
12691
|
+
);
|
|
12692
|
+
}
|
|
12693
|
+
`;
|
|
12694
|
+
}
|
|
12695
|
+
function netsuiteLoginPageTsx() {
|
|
12696
|
+
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
12697
|
+
|
|
12698
|
+
/**
|
|
12699
|
+
* Default unauthenticated landing page. Replaces any password gate.
|
|
12700
|
+
* Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
|
|
12701
|
+
* only call-to-action -- it points to the Worker's /auth/netsuite/start.
|
|
12702
|
+
*/
|
|
12703
|
+
export function LoginPage() {
|
|
12704
|
+
return (
|
|
12705
|
+
<div className="min-h-screen bg-background text-foreground flex">
|
|
12706
|
+
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
12707
|
+
<div className="text-xl font-semibold">NetSuite App</div>
|
|
12708
|
+
<div>
|
|
12709
|
+
<h1 className="text-4xl font-bold leading-tight">
|
|
12710
|
+
Connect your NetSuite account
|
|
12711
|
+
</h1>
|
|
12712
|
+
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
12713
|
+
Sign in with your NetSuite credentials to access your data securely.
|
|
12714
|
+
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
12715
|
+
browser.
|
|
12716
|
+
</p>
|
|
12717
|
+
</div>
|
|
12718
|
+
<div className="text-xs text-primary-foreground/60">
|
|
12719
|
+
Powered by HelloLeo
|
|
12720
|
+
</div>
|
|
12721
|
+
</div>
|
|
12722
|
+
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
12723
|
+
<div className="w-full max-w-sm space-y-6">
|
|
12724
|
+
<div>
|
|
12725
|
+
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
12726
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
12727
|
+
Use the button below to sign in. You'll be redirected to NetSuite
|
|
12728
|
+
to authorise access, then back here.
|
|
12729
|
+
</p>
|
|
12730
|
+
</div>
|
|
12731
|
+
<SignInWithNetSuite />
|
|
12732
|
+
</div>
|
|
12733
|
+
</div>
|
|
12734
|
+
</div>
|
|
12735
|
+
);
|
|
12736
|
+
}
|
|
12737
|
+
`;
|
|
12738
|
+
}
|
|
12739
|
+
function netsuiteAuthGateTsx() {
|
|
12740
|
+
return `import { useEffect, useState } from 'react';
|
|
12741
|
+
import { LoginPage } from './LoginPage';
|
|
12742
|
+
import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
|
|
12743
|
+
|
|
12744
|
+
/**
|
|
12745
|
+
* Mandatory auth gate for NetSuite projects. Wrap your real App content with
|
|
12746
|
+
* <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
|
|
12747
|
+
* is the ONLY supported authentication for a NetSuite project (no password
|
|
12748
|
+
* gates, no sessionStorage flags, no shared credential).
|
|
12749
|
+
*
|
|
12750
|
+
* Pattern in src/App.tsx:
|
|
12751
|
+
*
|
|
12752
|
+
* import { AuthGate } from './components/auth/AuthGate';
|
|
12753
|
+
* export default function App() {
|
|
12754
|
+
* return (
|
|
12755
|
+
* <AuthGate>
|
|
12756
|
+
* <Dashboard /> // your authenticated content
|
|
12757
|
+
* </AuthGate>
|
|
12758
|
+
* );
|
|
12759
|
+
* }
|
|
12760
|
+
*/
|
|
12761
|
+
export function AuthGate({ children }: { children: React.ReactNode }) {
|
|
12762
|
+
const [session, setSession] = useState<NetSuiteSession | null>(null);
|
|
12763
|
+
|
|
12764
|
+
useEffect(() => { getNetSuiteSession().then(setSession); }, []);
|
|
12765
|
+
|
|
12766
|
+
if (session === null) {
|
|
12767
|
+
return (
|
|
12768
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
12769
|
+
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
12770
|
+
</div>
|
|
12771
|
+
);
|
|
12772
|
+
}
|
|
12773
|
+
|
|
12774
|
+
if (!session.authenticated) return <LoginPage />;
|
|
12775
|
+
|
|
12776
|
+
return <>{children}</>;
|
|
12777
|
+
}
|
|
12778
|
+
`;
|
|
12779
|
+
}
|
|
12780
|
+
function netsuiteAppTsx() {
|
|
12781
|
+
return `import { AuthGate } from './components/auth/AuthGate';
|
|
12782
|
+
|
|
12783
|
+
/**
|
|
12784
|
+
* NetSuite project entry point. KEEP <AuthGate> as the outermost element --
|
|
12785
|
+
* removing it disables real OAuth and exposes NetSuite data to anonymous
|
|
12786
|
+
* users. Build your dashboard / pages inside the gate.
|
|
12787
|
+
*/
|
|
12788
|
+
export default function App() {
|
|
12789
|
+
return (
|
|
12790
|
+
<AuthGate>
|
|
12791
|
+
<div className="min-h-screen bg-background text-foreground p-8">
|
|
12792
|
+
<h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
|
|
12793
|
+
<p className="text-muted-foreground mt-2">
|
|
12794
|
+
Replace this placeholder with your dashboard.
|
|
12795
|
+
</p>
|
|
12796
|
+
</div>
|
|
12797
|
+
</AuthGate>
|
|
12798
|
+
);
|
|
12799
|
+
}
|
|
12800
|
+
`;
|
|
12801
|
+
}
|
|
12802
|
+
|
|
12803
|
+
// src/tools/scaffold.ts
|
|
12547
12804
|
function packageJson(name) {
|
|
12548
12805
|
return JSON.stringify({
|
|
12549
12806
|
name,
|
|
@@ -12908,196 +13165,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
12908
13165
|
</React.StrictMode>,
|
|
12909
13166
|
)`;
|
|
12910
13167
|
}
|
|
12911
|
-
function netsuiteTs() {
|
|
12912
|
-
return `/*
|
|
12913
|
-
* NetSuite OAuth client helper.
|
|
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.
|
|
12922
|
-
*
|
|
12923
|
-
* Flow:
|
|
12924
|
-
* 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.
|
|
12933
|
-
*
|
|
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.
|
|
12937
|
-
*/
|
|
12938
|
-
|
|
12939
|
-
export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
|
|
12940
|
-
|
|
12941
|
-
export function signInWithNetSuite(returnTo: string = window.location.pathname) {
|
|
12942
|
-
if (!WORKER_URL) {
|
|
12943
|
-
throw new Error(
|
|
12944
|
-
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
12945
|
-
);
|
|
12946
|
-
}
|
|
12947
|
-
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
12948
|
-
url.searchParams.set('return_to', returnTo);
|
|
12949
|
-
window.location.assign(url.toString());
|
|
12950
|
-
}
|
|
12951
|
-
|
|
12952
|
-
export interface NetSuiteSession {
|
|
12953
|
-
authenticated: boolean;
|
|
12954
|
-
account_id?: string;
|
|
12955
|
-
user_id?: string;
|
|
12956
|
-
email?: string;
|
|
12957
|
-
}
|
|
12958
|
-
|
|
12959
|
-
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
12960
|
-
if (!WORKER_URL) return { authenticated: false };
|
|
12961
|
-
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
12962
|
-
credentials: 'include',
|
|
12963
|
-
});
|
|
12964
|
-
if (!res.ok) return { authenticated: false };
|
|
12965
|
-
const data = await res.json();
|
|
12966
|
-
return { authenticated: Boolean(data?.account_id), ...data };
|
|
12967
|
-
}
|
|
12968
|
-
|
|
12969
|
-
export async function signOutNetSuite(): Promise<void> {
|
|
12970
|
-
if (!WORKER_URL) return;
|
|
12971
|
-
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
12972
|
-
method: 'POST',
|
|
12973
|
-
credentials: 'include',
|
|
12974
|
-
});
|
|
12975
|
-
window.location.reload();
|
|
12976
|
-
}
|
|
12977
|
-
`;
|
|
12978
|
-
}
|
|
12979
|
-
function signInButtonTsx() {
|
|
12980
|
-
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
12981
|
-
|
|
12982
|
-
export function SignInWithNetSuite() {
|
|
12983
|
-
return (
|
|
12984
|
-
<button
|
|
12985
|
-
onClick={() => signInWithNetSuite()}
|
|
12986
|
-
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
12987
|
-
>
|
|
12988
|
-
Sign in with NetSuite
|
|
12989
|
-
</button>
|
|
12990
|
-
);
|
|
12991
|
-
}
|
|
12992
|
-
`;
|
|
12993
|
-
}
|
|
12994
|
-
function netsuiteLoginPageTsx() {
|
|
12995
|
-
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
12996
|
-
|
|
12997
|
-
/**
|
|
12998
|
-
* Default unauthenticated landing page. Replaces any password gate.
|
|
12999
|
-
* Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
|
|
13000
|
-
* only call-to-action -- it points to the Worker's /auth/netsuite/start.
|
|
13001
|
-
*/
|
|
13002
|
-
export function LoginPage() {
|
|
13003
|
-
return (
|
|
13004
|
-
<div className="min-h-screen bg-background text-foreground flex">
|
|
13005
|
-
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
13006
|
-
<div className="text-xl font-semibold">NetSuite App</div>
|
|
13007
|
-
<div>
|
|
13008
|
-
<h1 className="text-4xl font-bold leading-tight">
|
|
13009
|
-
Connect your NetSuite account
|
|
13010
|
-
</h1>
|
|
13011
|
-
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
13012
|
-
Sign in with your NetSuite credentials to access your data securely.
|
|
13013
|
-
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
13014
|
-
browser.
|
|
13015
|
-
</p>
|
|
13016
|
-
</div>
|
|
13017
|
-
<div className="text-xs text-primary-foreground/60">
|
|
13018
|
-
Powered by HelloLeo
|
|
13019
|
-
</div>
|
|
13020
|
-
</div>
|
|
13021
|
-
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
13022
|
-
<div className="w-full max-w-sm space-y-6">
|
|
13023
|
-
<div>
|
|
13024
|
-
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
13025
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
13026
|
-
Use the button below to sign in. You'll be redirected to NetSuite
|
|
13027
|
-
to authorise access, then back here.
|
|
13028
|
-
</p>
|
|
13029
|
-
</div>
|
|
13030
|
-
<SignInWithNetSuite />
|
|
13031
|
-
</div>
|
|
13032
|
-
</div>
|
|
13033
|
-
</div>
|
|
13034
|
-
);
|
|
13035
|
-
}
|
|
13036
|
-
`;
|
|
13037
|
-
}
|
|
13038
|
-
function netsuiteAuthGateTsx() {
|
|
13039
|
-
return `import { useEffect, useState } from 'react';
|
|
13040
|
-
import { LoginPage } from './LoginPage';
|
|
13041
|
-
import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
|
|
13042
|
-
|
|
13043
|
-
/**
|
|
13044
|
-
* Mandatory auth gate for NetSuite projects. Wrap your real App content with
|
|
13045
|
-
* <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
|
|
13046
|
-
* is the ONLY supported authentication for a NetSuite project (no password
|
|
13047
|
-
* gates, no sessionStorage flags, no shared credential).
|
|
13048
|
-
*
|
|
13049
|
-
* Pattern in src/App.tsx:
|
|
13050
|
-
*
|
|
13051
|
-
* import { AuthGate } from './components/auth/AuthGate';
|
|
13052
|
-
* export default function App() {
|
|
13053
|
-
* return (
|
|
13054
|
-
* <AuthGate>
|
|
13055
|
-
* <Dashboard /> {/* your authenticated content */}
|
|
13056
|
-
* </AuthGate>
|
|
13057
|
-
* );
|
|
13058
|
-
* }
|
|
13059
|
-
*/
|
|
13060
|
-
export function AuthGate({ children }: { children: React.ReactNode }) {
|
|
13061
|
-
const [session, setSession] = useState<NetSuiteSession | null>(null);
|
|
13062
|
-
|
|
13063
|
-
useEffect(() => { getNetSuiteSession().then(setSession); }, []);
|
|
13064
|
-
|
|
13065
|
-
if (session === null) {
|
|
13066
|
-
return (
|
|
13067
|
-
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
13068
|
-
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
13069
|
-
</div>
|
|
13070
|
-
);
|
|
13071
|
-
}
|
|
13072
|
-
|
|
13073
|
-
if (!session.authenticated) return <LoginPage />;
|
|
13074
|
-
|
|
13075
|
-
return <>{children}</>;
|
|
13076
|
-
}
|
|
13077
|
-
`;
|
|
13078
|
-
}
|
|
13079
|
-
function netsuiteAppTsx() {
|
|
13080
|
-
return `import { AuthGate } from './components/auth/AuthGate';
|
|
13081
|
-
|
|
13082
|
-
/**
|
|
13083
|
-
* NetSuite project entry point. KEEP <AuthGate> as the outermost element --
|
|
13084
|
-
* removing it disables real OAuth and exposes NetSuite data to anonymous
|
|
13085
|
-
* users. Build your dashboard / pages inside the gate.
|
|
13086
|
-
*/
|
|
13087
|
-
export default function App() {
|
|
13088
|
-
return (
|
|
13089
|
-
<AuthGate>
|
|
13090
|
-
<div className="min-h-screen bg-background text-foreground p-8">
|
|
13091
|
-
<h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
|
|
13092
|
-
<p className="text-muted-foreground mt-2">
|
|
13093
|
-
Replace this placeholder with your dashboard.
|
|
13094
|
-
</p>
|
|
13095
|
-
</div>
|
|
13096
|
-
</AuthGate>
|
|
13097
|
-
);
|
|
13098
|
-
}
|
|
13099
|
-
`;
|
|
13100
|
-
}
|
|
13101
13168
|
var scaffold = tool({
|
|
13102
13169
|
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.",
|
|
13103
13170
|
args: {
|
package/package.json
CHANGED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NetSuite-specific scaffold templates.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from scaffold.ts so the OAuth bits (client helper, sign-in
|
|
5
|
+
* button, login page, auth gate, gated App.tsx entry point) live in one
|
|
6
|
+
* file. Imported and emitted by scaffold.ts when the caller passes
|
|
7
|
+
* `integrations: ["netsuite"]`. The string contents are byte-for-byte
|
|
8
|
+
* identical to the previous inline versions — splitting is purely
|
|
9
|
+
* organisational and does NOT change scaffold output.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function netsuiteTs(): string {
|
|
13
|
+
return `/*
|
|
14
|
+
* NetSuite OAuth client helper (bearer-token-in-fragment).
|
|
15
|
+
*
|
|
16
|
+
* Two NetSuite Integration Records back this:
|
|
17
|
+
* 1. MCP (scope: mcp) -- used by HelloLeo at build time.
|
|
18
|
+
* 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
|
|
19
|
+
* Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
|
|
20
|
+
* (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
|
|
21
|
+
* the browser. Cookies are NOT used -- frontend ↔ Worker are cross-site
|
|
22
|
+
* (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
|
|
23
|
+
* third-party cookies in the HelloLeo preview iframe.
|
|
24
|
+
*
|
|
25
|
+
* Flow:
|
|
26
|
+
* 1. User clicks "Sign in with NetSuite".
|
|
27
|
+
* 2. Browser navigates to the Worker /auth/netsuite/start.
|
|
28
|
+
* 3. Worker redirects to NetSuite's OAuth UI with PKCE.
|
|
29
|
+
* 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
|
|
30
|
+
* 5. Worker exchanges the code, mints a session id, then redirects to
|
|
31
|
+
* <return_to>#nssid=<sid>. URL fragments stay in the browser only.
|
|
32
|
+
* 6. This module reads the fragment on load, stashes the sid in
|
|
33
|
+
* sessionStorage, and sends it as Authorization: Bearer on every
|
|
34
|
+
* Worker call.
|
|
35
|
+
*
|
|
36
|
+
* NEVER use a hardcoded password gate. NEVER use sessionStorage as the
|
|
37
|
+
* authoritative auth state -- the only acceptable check is /api/me on the
|
|
38
|
+
* Worker. sessionStorage here is just a cache of the bearer token.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
|
|
42
|
+
const TOKEN_KEY = 'nssid';
|
|
43
|
+
|
|
44
|
+
// Capture-and-strip the #nssid=... fragment as early as possible so URLs
|
|
45
|
+
// shown to the user stay clean. Runs once at module load.
|
|
46
|
+
(function bootstrapTokenFromHash() {
|
|
47
|
+
if (typeof window === 'undefined') return;
|
|
48
|
+
const hash = window.location.hash.replace(/^#/, '');
|
|
49
|
+
if (!hash) return;
|
|
50
|
+
const params = new URLSearchParams(hash);
|
|
51
|
+
const sid = params.get(TOKEN_KEY);
|
|
52
|
+
if (sid) {
|
|
53
|
+
try { sessionStorage.setItem(TOKEN_KEY, sid); } catch {}
|
|
54
|
+
params.delete(TOKEN_KEY);
|
|
55
|
+
const remaining = params.toString();
|
|
56
|
+
const newHash = remaining ? '#' + remaining : '';
|
|
57
|
+
window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
export function getSessionToken(): string | null {
|
|
62
|
+
try { return sessionStorage.getItem(TOKEN_KEY); } catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function signInWithNetSuite(returnTo: string = window.location.href) {
|
|
66
|
+
if (!WORKER_URL) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
72
|
+
url.searchParams.set('return_to', returnTo);
|
|
73
|
+
// The app usually renders inside the HelloLeo preview iframe. NetSuite's
|
|
74
|
+
// OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
|
|
75
|
+
// Worker (which 302s to NetSuite) ends up loading a page the browser
|
|
76
|
+
// refuses to render -- looks like "nothing happens" to the user. Navigate
|
|
77
|
+
// the top frame instead so the OAuth flow runs at the top level and the
|
|
78
|
+
// callback redirect lands on the same top frame, which then reads the
|
|
79
|
+
// #nssid= fragment. Fallback to same-frame navigation if the top window
|
|
80
|
+
// is cross-origin and can't be reassigned.
|
|
81
|
+
try {
|
|
82
|
+
if (window.top && window.top !== window) {
|
|
83
|
+
window.top.location.assign(url.toString());
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
} catch {
|
|
87
|
+
/* top window not accessible — fall through to same-frame navigation */
|
|
88
|
+
}
|
|
89
|
+
window.location.assign(url.toString());
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface NetSuiteSession {
|
|
93
|
+
authenticated: boolean;
|
|
94
|
+
account_id?: string;
|
|
95
|
+
user_id?: string;
|
|
96
|
+
email?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
100
|
+
if (!WORKER_URL) return { authenticated: false };
|
|
101
|
+
const sid = getSessionToken();
|
|
102
|
+
if (!sid) return { authenticated: false };
|
|
103
|
+
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
104
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
105
|
+
});
|
|
106
|
+
if (!res.ok) return { authenticated: false };
|
|
107
|
+
const data = await res.json();
|
|
108
|
+
return { authenticated: Boolean(data?.account_id), ...data };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function signOutNetSuite(): Promise<void> {
|
|
112
|
+
if (!WORKER_URL) return;
|
|
113
|
+
const sid = getSessionToken();
|
|
114
|
+
if (sid) {
|
|
115
|
+
try {
|
|
116
|
+
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
117
|
+
method: 'POST',
|
|
118
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
119
|
+
});
|
|
120
|
+
} catch {}
|
|
121
|
+
}
|
|
122
|
+
try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
|
|
123
|
+
window.location.reload();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Helper for ALL other Worker calls (your dashboard endpoints, etc.).
|
|
128
|
+
* Always go through this instead of bare fetch -- it injects the bearer
|
|
129
|
+
* token and routes through the Worker so NetSuite tokens stay server-side.
|
|
130
|
+
*/
|
|
131
|
+
export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Promise<Response> {
|
|
132
|
+
if (!WORKER_URL) throw new Error('VITE_WORKER_URL is not set');
|
|
133
|
+
const url = pathOrUrl.startsWith('http')
|
|
134
|
+
? pathOrUrl
|
|
135
|
+
: \`\${WORKER_URL.replace(/\\/$/, '')}\${pathOrUrl.startsWith('/') ? '' : '/'}\${pathOrUrl}\`;
|
|
136
|
+
const sid = getSessionToken();
|
|
137
|
+
const headers = new Headers(init.headers ?? {});
|
|
138
|
+
if (sid) headers.set('Authorization', 'Bearer ' + sid);
|
|
139
|
+
return fetch(url, { ...init, headers });
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function signInButtonTsx(): string {
|
|
145
|
+
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
146
|
+
|
|
147
|
+
export function SignInWithNetSuite() {
|
|
148
|
+
return (
|
|
149
|
+
<button
|
|
150
|
+
onClick={() => signInWithNetSuite()}
|
|
151
|
+
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
152
|
+
>
|
|
153
|
+
Sign in with NetSuite
|
|
154
|
+
</button>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function netsuiteLoginPageTsx(): string {
|
|
161
|
+
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Default unauthenticated landing page. Replaces any password gate.
|
|
165
|
+
* Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
|
|
166
|
+
* only call-to-action -- it points to the Worker's /auth/netsuite/start.
|
|
167
|
+
*/
|
|
168
|
+
export function LoginPage() {
|
|
169
|
+
return (
|
|
170
|
+
<div className="min-h-screen bg-background text-foreground flex">
|
|
171
|
+
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
172
|
+
<div className="text-xl font-semibold">NetSuite App</div>
|
|
173
|
+
<div>
|
|
174
|
+
<h1 className="text-4xl font-bold leading-tight">
|
|
175
|
+
Connect your NetSuite account
|
|
176
|
+
</h1>
|
|
177
|
+
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
178
|
+
Sign in with your NetSuite credentials to access your data securely.
|
|
179
|
+
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
180
|
+
browser.
|
|
181
|
+
</p>
|
|
182
|
+
</div>
|
|
183
|
+
<div className="text-xs text-primary-foreground/60">
|
|
184
|
+
Powered by HelloLeo
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
188
|
+
<div className="w-full max-w-sm space-y-6">
|
|
189
|
+
<div>
|
|
190
|
+
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
191
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
192
|
+
Use the button below to sign in. You'll be redirected to NetSuite
|
|
193
|
+
to authorise access, then back here.
|
|
194
|
+
</p>
|
|
195
|
+
</div>
|
|
196
|
+
<SignInWithNetSuite />
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</div>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function netsuiteAuthGateTsx(): string {
|
|
206
|
+
return `import { useEffect, useState } from 'react';
|
|
207
|
+
import { LoginPage } from './LoginPage';
|
|
208
|
+
import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Mandatory auth gate for NetSuite projects. Wrap your real App content with
|
|
212
|
+
* <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
|
|
213
|
+
* is the ONLY supported authentication for a NetSuite project (no password
|
|
214
|
+
* gates, no sessionStorage flags, no shared credential).
|
|
215
|
+
*
|
|
216
|
+
* Pattern in src/App.tsx:
|
|
217
|
+
*
|
|
218
|
+
* import { AuthGate } from './components/auth/AuthGate';
|
|
219
|
+
* export default function App() {
|
|
220
|
+
* return (
|
|
221
|
+
* <AuthGate>
|
|
222
|
+
* <Dashboard /> // your authenticated content
|
|
223
|
+
* </AuthGate>
|
|
224
|
+
* );
|
|
225
|
+
* }
|
|
226
|
+
*/
|
|
227
|
+
export function AuthGate({ children }: { children: React.ReactNode }) {
|
|
228
|
+
const [session, setSession] = useState<NetSuiteSession | null>(null);
|
|
229
|
+
|
|
230
|
+
useEffect(() => { getNetSuiteSession().then(setSession); }, []);
|
|
231
|
+
|
|
232
|
+
if (session === null) {
|
|
233
|
+
return (
|
|
234
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
235
|
+
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
236
|
+
</div>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (!session.authenticated) return <LoginPage />;
|
|
241
|
+
|
|
242
|
+
return <>{children}</>;
|
|
243
|
+
}
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function netsuiteAppTsx(): string {
|
|
248
|
+
return `import { AuthGate } from './components/auth/AuthGate';
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* NetSuite project entry point. KEEP <AuthGate> as the outermost element --
|
|
252
|
+
* removing it disables real OAuth and exposes NetSuite data to anonymous
|
|
253
|
+
* users. Build your dashboard / pages inside the gate.
|
|
254
|
+
*/
|
|
255
|
+
export default function App() {
|
|
256
|
+
return (
|
|
257
|
+
<AuthGate>
|
|
258
|
+
<div className="min-h-screen bg-background text-foreground p-8">
|
|
259
|
+
<h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
|
|
260
|
+
<p className="text-muted-foreground mt-2">
|
|
261
|
+
Replace this placeholder with your dashboard.
|
|
262
|
+
</p>
|
|
263
|
+
</div>
|
|
264
|
+
</AuthGate>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
`;
|
|
268
|
+
}
|
package/src/tools/scaffold.ts
CHANGED
|
@@ -2,6 +2,13 @@ import { mkdir } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { Plugin } from "@opencode-ai/plugin";
|
|
4
4
|
import { tool } from "@opencode-ai/plugin";
|
|
5
|
+
import {
|
|
6
|
+
netsuiteAppTsx,
|
|
7
|
+
netsuiteAuthGateTsx,
|
|
8
|
+
netsuiteLoginPageTsx,
|
|
9
|
+
netsuiteTs,
|
|
10
|
+
signInButtonTsx,
|
|
11
|
+
} from "./scaffold-netsuite";
|
|
5
12
|
|
|
6
13
|
function packageJson(name: string) {
|
|
7
14
|
return JSON.stringify(
|
|
@@ -387,201 +394,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
|
387
394
|
)`;
|
|
388
395
|
}
|
|
389
396
|
|
|
390
|
-
function netsuiteTs() {
|
|
391
|
-
return `/*
|
|
392
|
-
* NetSuite OAuth client helper.
|
|
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.
|
|
401
|
-
*
|
|
402
|
-
* Flow:
|
|
403
|
-
* 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.
|
|
412
|
-
*
|
|
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.
|
|
416
|
-
*/
|
|
417
|
-
|
|
418
|
-
export const WORKER_URL = import.meta.env.VITE_WORKER_URL ?? '';
|
|
419
|
-
|
|
420
|
-
export function signInWithNetSuite(returnTo: string = window.location.pathname) {
|
|
421
|
-
if (!WORKER_URL) {
|
|
422
|
-
throw new Error(
|
|
423
|
-
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
424
|
-
);
|
|
425
|
-
}
|
|
426
|
-
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
427
|
-
url.searchParams.set('return_to', returnTo);
|
|
428
|
-
window.location.assign(url.toString());
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export interface NetSuiteSession {
|
|
432
|
-
authenticated: boolean;
|
|
433
|
-
account_id?: string;
|
|
434
|
-
user_id?: string;
|
|
435
|
-
email?: string;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
439
|
-
if (!WORKER_URL) return { authenticated: false };
|
|
440
|
-
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
441
|
-
credentials: 'include',
|
|
442
|
-
});
|
|
443
|
-
if (!res.ok) return { authenticated: false };
|
|
444
|
-
const data = await res.json();
|
|
445
|
-
return { authenticated: Boolean(data?.account_id), ...data };
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
export async function signOutNetSuite(): Promise<void> {
|
|
449
|
-
if (!WORKER_URL) return;
|
|
450
|
-
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
451
|
-
method: 'POST',
|
|
452
|
-
credentials: 'include',
|
|
453
|
-
});
|
|
454
|
-
window.location.reload();
|
|
455
|
-
}
|
|
456
|
-
`;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function signInButtonTsx() {
|
|
460
|
-
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
461
|
-
|
|
462
|
-
export function SignInWithNetSuite() {
|
|
463
|
-
return (
|
|
464
|
-
<button
|
|
465
|
-
onClick={() => signInWithNetSuite()}
|
|
466
|
-
className="inline-flex items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground shadow hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
467
|
-
>
|
|
468
|
-
Sign in with NetSuite
|
|
469
|
-
</button>
|
|
470
|
-
);
|
|
471
|
-
}
|
|
472
|
-
`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function netsuiteLoginPageTsx() {
|
|
476
|
-
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
477
|
-
|
|
478
|
-
/**
|
|
479
|
-
* Default unauthenticated landing page. Replaces any password gate.
|
|
480
|
-
* Customise copy / imagery freely but keep <SignInWithNetSuite /> as the
|
|
481
|
-
* only call-to-action -- it points to the Worker's /auth/netsuite/start.
|
|
482
|
-
*/
|
|
483
|
-
export function LoginPage() {
|
|
484
|
-
return (
|
|
485
|
-
<div className="min-h-screen bg-background text-foreground flex">
|
|
486
|
-
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
487
|
-
<div className="text-xl font-semibold">NetSuite App</div>
|
|
488
|
-
<div>
|
|
489
|
-
<h1 className="text-4xl font-bold leading-tight">
|
|
490
|
-
Connect your NetSuite account
|
|
491
|
-
</h1>
|
|
492
|
-
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
493
|
-
Sign in with your NetSuite credentials to access your data securely.
|
|
494
|
-
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
495
|
-
browser.
|
|
496
|
-
</p>
|
|
497
|
-
</div>
|
|
498
|
-
<div className="text-xs text-primary-foreground/60">
|
|
499
|
-
Powered by HelloLeo
|
|
500
|
-
</div>
|
|
501
|
-
</div>
|
|
502
|
-
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
503
|
-
<div className="w-full max-w-sm space-y-6">
|
|
504
|
-
<div>
|
|
505
|
-
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
506
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
507
|
-
Use the button below to sign in. You'll be redirected to NetSuite
|
|
508
|
-
to authorise access, then back here.
|
|
509
|
-
</p>
|
|
510
|
-
</div>
|
|
511
|
-
<SignInWithNetSuite />
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
514
|
-
</div>
|
|
515
|
-
);
|
|
516
|
-
}
|
|
517
|
-
`;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
function netsuiteAuthGateTsx() {
|
|
521
|
-
return `import { useEffect, useState } from 'react';
|
|
522
|
-
import { LoginPage } from './LoginPage';
|
|
523
|
-
import { getNetSuiteSession, type NetSuiteSession } from '../../lib/netsuite';
|
|
524
|
-
|
|
525
|
-
/**
|
|
526
|
-
* Mandatory auth gate for NetSuite projects. Wrap your real App content with
|
|
527
|
-
* <AuthGate>...</AuthGate>. Never render NetSuite data without this gate -- it
|
|
528
|
-
* is the ONLY supported authentication for a NetSuite project (no password
|
|
529
|
-
* gates, no sessionStorage flags, no shared credential).
|
|
530
|
-
*
|
|
531
|
-
* Pattern in src/App.tsx:
|
|
532
|
-
*
|
|
533
|
-
* import { AuthGate } from './components/auth/AuthGate';
|
|
534
|
-
* export default function App() {
|
|
535
|
-
* return (
|
|
536
|
-
* <AuthGate>
|
|
537
|
-
* <Dashboard /> {/* your authenticated content */}
|
|
538
|
-
* </AuthGate>
|
|
539
|
-
* );
|
|
540
|
-
* }
|
|
541
|
-
*/
|
|
542
|
-
export function AuthGate({ children }: { children: React.ReactNode }) {
|
|
543
|
-
const [session, setSession] = useState<NetSuiteSession | null>(null);
|
|
544
|
-
|
|
545
|
-
useEffect(() => { getNetSuiteSession().then(setSession); }, []);
|
|
546
|
-
|
|
547
|
-
if (session === null) {
|
|
548
|
-
return (
|
|
549
|
-
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
550
|
-
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
551
|
-
</div>
|
|
552
|
-
);
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
if (!session.authenticated) return <LoginPage />;
|
|
556
|
-
|
|
557
|
-
return <>{children}</>;
|
|
558
|
-
}
|
|
559
|
-
`;
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
function netsuiteAppTsx() {
|
|
563
|
-
return `import { AuthGate } from './components/auth/AuthGate';
|
|
564
|
-
|
|
565
|
-
/**
|
|
566
|
-
* NetSuite project entry point. KEEP <AuthGate> as the outermost element --
|
|
567
|
-
* removing it disables real OAuth and exposes NetSuite data to anonymous
|
|
568
|
-
* users. Build your dashboard / pages inside the gate.
|
|
569
|
-
*/
|
|
570
|
-
export default function App() {
|
|
571
|
-
return (
|
|
572
|
-
<AuthGate>
|
|
573
|
-
<div className="min-h-screen bg-background text-foreground p-8">
|
|
574
|
-
<h1 className="text-2xl font-bold">You are signed in to NetSuite.</h1>
|
|
575
|
-
<p className="text-muted-foreground mt-2">
|
|
576
|
-
Replace this placeholder with your dashboard.
|
|
577
|
-
</p>
|
|
578
|
-
</div>
|
|
579
|
-
</AuthGate>
|
|
580
|
-
);
|
|
581
|
-
}
|
|
582
|
-
`;
|
|
583
|
-
}
|
|
584
|
-
|
|
585
397
|
export const scaffold = tool({
|
|
586
398
|
description:
|
|
587
399
|
"Scaffold a new React + Vite + Tailwind CSS project with the HelloLeo design system. " +
|