@helloleo/plugins 0.2.17 → 0.2.20
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 +949 -95
- package/dist/templates/tanstack-router-2026-06-15/src/lib/proxy.ts +39 -1
- package/dist/templates/tanstack-start-2026-06-18/src/lib/proxy.ts +38 -1
- package/dist/templates/tanstack-start-2026-07-30/src/lib/proxy.ts +30 -1
- package/package.json +1 -1
- package/src/templates/tanstack-router-2026-06-15/src/lib/proxy.ts +39 -1
- package/src/templates/tanstack-start-2026-06-18/src/lib/proxy.ts +38 -1
- package/src/templates/tanstack-start-2026-07-30/src/lib/proxy.ts +30 -1
- package/src/tools/scaffold.ts +43 -25
- package/src/tools/setup-netsuite.ts +1024 -98
package/dist/index.js
CHANGED
|
@@ -24904,13 +24904,19 @@ var SCAFFOLD_TEMPLATES = {
|
|
|
24904
24904
|
"tanstack-start": "tanstack-start-2026-07-30",
|
|
24905
24905
|
"tanstack-router": "tanstack-router-2026-06-15"
|
|
24906
24906
|
};
|
|
24907
|
+
var LEGACY_TEMPLATES = ["tanstack-start-2026-06-18"];
|
|
24907
24908
|
var SCAFFOLD_VERSION = SCAFFOLD_TEMPLATES["tanstack-start"];
|
|
24908
|
-
function resolveTemplateId() {
|
|
24909
|
+
function resolveTemplateId(type) {
|
|
24909
24910
|
const version3 = process.env.PLUGINS_SCAFFOLD_VERSION;
|
|
24910
|
-
const known =
|
|
24911
|
-
|
|
24912
|
-
|
|
24913
|
-
|
|
24911
|
+
const known = [
|
|
24912
|
+
...Object.values(SCAFFOLD_TEMPLATES),
|
|
24913
|
+
...LEGACY_TEMPLATES
|
|
24914
|
+
];
|
|
24915
|
+
const pinned = version3 && known.includes(version3) ? version3 : undefined;
|
|
24916
|
+
if (type) {
|
|
24917
|
+
return pinned?.startsWith(type) ? pinned : SCAFFOLD_TEMPLATES[type];
|
|
24918
|
+
}
|
|
24919
|
+
return pinned ?? SCAFFOLD_TEMPLATES["tanstack-router"];
|
|
24914
24920
|
}
|
|
24915
24921
|
function projectUuidFromDir(dir) {
|
|
24916
24922
|
return path3.basename(dir);
|
|
@@ -24921,13 +24927,14 @@ function integrationProxyUrl(projectUuid) {
|
|
|
24921
24927
|
}
|
|
24922
24928
|
var scaffoldOptionsSchema = exports_external2.object({
|
|
24923
24929
|
name: exports_external2.string().describe("Human-readable name for SEO, e.g. 'Shipping Platform'"),
|
|
24924
|
-
description: exports_external2.string().describe("One sentence describing the app. Used as the meta description.")
|
|
24930
|
+
description: exports_external2.string().describe("One sentence describing the app. Used as the meta description."),
|
|
24931
|
+
type: exports_external2.enum(["tanstack-start", "tanstack-router"]).optional().describe("Project type. 'tanstack-start': SSR app with API routes and a " + "database, KV and bucket \u2014 use it whenever the app needs to " + "persist data or run server-side code, and always for " + "setup_authentication (it needs server routes). " + "'tanstack-router': client-only SPA with no server and no database " + "\u2014 use it only for pure frontends that talk to external APIs. " + "Omit to use the deployment's default.")
|
|
24925
24932
|
});
|
|
24926
24933
|
function slugify(name) {
|
|
24927
24934
|
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "leo-project";
|
|
24928
24935
|
}
|
|
24929
|
-
async function writeBaseScaffold({ name, description }, dir) {
|
|
24930
|
-
const templateId = resolveTemplateId();
|
|
24936
|
+
async function writeBaseScaffold({ name, description, type }, dir) {
|
|
24937
|
+
const templateId = resolveTemplateId(type);
|
|
24931
24938
|
const projectUuid = projectUuidFromDir(dir);
|
|
24932
24939
|
const created = await renderTemplate(templateId, {
|
|
24933
24940
|
LEO_NAME: name,
|
|
@@ -24942,13 +24949,14 @@ async function writeBaseScaffold({ name, description }, dir) {
|
|
|
24942
24949
|
return [...created, leoPath];
|
|
24943
24950
|
}
|
|
24944
24951
|
var scaffold = tool({
|
|
24945
|
-
description: "Scaffold a new project with React 19, file-based routing (TanStack), " + "Tailwind v4, shadcn/ui, and react-query. Call this first for any new
|
|
24952
|
+
description: "Scaffold a new project with React 19, file-based routing (TanStack), " + "Tailwind v4, shadcn/ui, and react-query. Two types: 'tanstack-start' " + "(SSR, API routes, and a database, KV and bucket via @helloleo/runtime) " + "or 'tanstack-router' (client-only SPA, no server, no database). Call " + "this first for any new project, before writing routes or components.",
|
|
24946
24953
|
args: {
|
|
24947
24954
|
name: scaffoldOptionsSchema.shape.name,
|
|
24948
|
-
description: scaffoldOptionsSchema.shape.description
|
|
24955
|
+
description: scaffoldOptionsSchema.shape.description,
|
|
24956
|
+
type: scaffoldOptionsSchema.shape.type
|
|
24949
24957
|
},
|
|
24950
24958
|
async execute(args, context) {
|
|
24951
|
-
const isStart = resolveTemplateId()
|
|
24959
|
+
const isStart = resolveTemplateId(args.type).startsWith("tanstack-start");
|
|
24952
24960
|
const created = await writeBaseScaffold(args, context.directory);
|
|
24953
24961
|
const uiComponents = created.filter((f) => f.startsWith("src/components/ui/"));
|
|
24954
24962
|
const rest = created.filter((f) => !f.startsWith("src/components/ui/"));
|
|
@@ -25543,9 +25551,756 @@ var SetupAuthenticationPlugin = async () => {
|
|
|
25543
25551
|
};
|
|
25544
25552
|
};
|
|
25545
25553
|
// src/tools/setup-netsuite.ts
|
|
25546
|
-
import { mkdir as mkdir2 } from "fs/promises";
|
|
25554
|
+
import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
|
|
25547
25555
|
import path7 from "path";
|
|
25548
|
-
function
|
|
25556
|
+
async function readScaffoldId2(dir) {
|
|
25557
|
+
try {
|
|
25558
|
+
const meta = await Bun.file(path7.join(dir, ".leo", "project.json")).json();
|
|
25559
|
+
return typeof meta?.scaffold === "string" ? meta.scaffold : null;
|
|
25560
|
+
} catch {
|
|
25561
|
+
return null;
|
|
25562
|
+
}
|
|
25563
|
+
}
|
|
25564
|
+
async function isStartScaffold(dir) {
|
|
25565
|
+
const scaffoldId = await readScaffoldId2(dir);
|
|
25566
|
+
if (scaffoldId)
|
|
25567
|
+
return scaffoldId.startsWith("tanstack-start");
|
|
25568
|
+
try {
|
|
25569
|
+
const pkg = await Bun.file(path7.join(dir, "package.json")).json();
|
|
25570
|
+
return Boolean(pkg?.dependencies?.["@tanstack/react-start"] ?? pkg?.devDependencies?.["@tanstack/react-start"]);
|
|
25571
|
+
} catch {
|
|
25572
|
+
return false;
|
|
25573
|
+
}
|
|
25574
|
+
}
|
|
25575
|
+
function schemaAppend() {
|
|
25576
|
+
return `
|
|
25577
|
+
// --- NetSuite OAuth (added by setup_netsuite) ---
|
|
25578
|
+
// Short-lived PKCE + return-to state between /auth/netsuite/start and its
|
|
25579
|
+
// callback. Single-use: the callback deletes the row after reading it.
|
|
25580
|
+
export const netsuiteFlows = sqliteTable('netsuite_flows', {
|
|
25581
|
+
flowId: text('flow_id').primaryKey(),
|
|
25582
|
+
codeVerifier: text('code_verifier').notNull(),
|
|
25583
|
+
returnTo: text('return_to').notNull(),
|
|
25584
|
+
createdAt: integer('created_at')
|
|
25585
|
+
.notNull()
|
|
25586
|
+
.default(sql\`(unixepoch())\`),
|
|
25587
|
+
})
|
|
25588
|
+
|
|
25589
|
+
// Opaque session id -> NetSuite tokens. Tokens stay server-side; the browser
|
|
25590
|
+
// only ever holds the sid (sent as a bearer).
|
|
25591
|
+
export const netsuiteSessions = sqliteTable('netsuite_sessions', {
|
|
25592
|
+
sid: text('sid').primaryKey(),
|
|
25593
|
+
accountId: text('account_id').notNull(),
|
|
25594
|
+
accessToken: text('access_token').notNull(),
|
|
25595
|
+
refreshToken: text('refresh_token'),
|
|
25596
|
+
expiresAt: integer('expires_at'),
|
|
25597
|
+
userId: text('user_id'),
|
|
25598
|
+
email: text('email'),
|
|
25599
|
+
createdAt: integer('created_at')
|
|
25600
|
+
.notNull()
|
|
25601
|
+
.default(sql\`(unixepoch())\`),
|
|
25602
|
+
})
|
|
25603
|
+
`;
|
|
25604
|
+
}
|
|
25605
|
+
function netsuiteServerTs() {
|
|
25606
|
+
return `/*
|
|
25607
|
+
* NetSuite runtime OAuth -- server side (TanStack Start server routes).
|
|
25608
|
+
*
|
|
25609
|
+
* Runs wherever the app runs: \`vite dev\` (workerd) in the Fly preview machine
|
|
25610
|
+
* in dev, and the published Cloudflare Worker in prod. There is no separate
|
|
25611
|
+
* backend Worker -- these helpers back the /auth/netsuite/* + /api/me routes.
|
|
25612
|
+
*
|
|
25613
|
+
* Config is read from the Cloudflare env binding via @helloleo/runtime.
|
|
25614
|
+
* Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
|
|
25615
|
+
* PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
|
|
25616
|
+
* (prod) or dev vars (preview). They never reach the browser.
|
|
25617
|
+
*/
|
|
25618
|
+
import { eq } from 'drizzle-orm'
|
|
25619
|
+
import { env as runtimeEnv } from '@helloleo/runtime'
|
|
25620
|
+
import { db } from '#/db'
|
|
25621
|
+
import { netsuiteSessions } from '#/db/schema'
|
|
25622
|
+
|
|
25623
|
+
interface NsEnv {
|
|
25624
|
+
NETSUITE_ACCOUNT_ID?: string
|
|
25625
|
+
NETSUITE_CLIENT_ID?: string
|
|
25626
|
+
NETSUITE_CLIENT_SECRET?: string
|
|
25627
|
+
PUBLIC_APP_URL?: string
|
|
25628
|
+
HELLOLEO_URL?: string
|
|
25629
|
+
}
|
|
25630
|
+
const nsEnv = runtimeEnv as unknown as NsEnv
|
|
25631
|
+
|
|
25632
|
+
// Runtime record scope. The app signs end-users in for REST/SuiteQL.
|
|
25633
|
+
export const SCOPE = 'rest_webservices'
|
|
25634
|
+
const REFRESH_LEEWAY_SECONDS = 60
|
|
25635
|
+
|
|
25636
|
+
export function requireConfig() {
|
|
25637
|
+
const accountId = nsEnv.NETSUITE_ACCOUNT_ID
|
|
25638
|
+
const clientId = nsEnv.NETSUITE_CLIENT_ID
|
|
25639
|
+
if (!accountId || !clientId) {
|
|
25640
|
+
throw new Error(
|
|
25641
|
+
'NetSuite not configured: set NETSUITE_ACCOUNT_ID and NETSUITE_CLIENT_ID (and NETSUITE_CLIENT_SECRET for a confidential client) as HelloLeo Cloud secrets / dev vars.',
|
|
25642
|
+
)
|
|
25643
|
+
}
|
|
25644
|
+
return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
|
|
25645
|
+
}
|
|
25646
|
+
|
|
25647
|
+
// Account id -> DNS host form: lowercase, underscores to hyphens.
|
|
25648
|
+
function host(accountId: string) {
|
|
25649
|
+
return accountId.trim().toLowerCase().replace(/_/g, '-')
|
|
25650
|
+
}
|
|
25651
|
+
export function authorizeUrl(accountId: string) {
|
|
25652
|
+
return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
|
|
25653
|
+
}
|
|
25654
|
+
export function tokenUrl(accountId: string) {
|
|
25655
|
+
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
|
|
25656
|
+
}
|
|
25657
|
+
export function restBase(accountId: string) {
|
|
25658
|
+
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
|
|
25659
|
+
}
|
|
25660
|
+
|
|
25661
|
+
function stripTrailingSlash(s: string) {
|
|
25662
|
+
return s.endsWith('/') ? s.slice(0, -1) : s
|
|
25663
|
+
}
|
|
25664
|
+
|
|
25665
|
+
// Public origin of THIS app, used to build the redirect URI. Prefer an explicit
|
|
25666
|
+
// PUBLIC_APP_URL; else derive from the request (honouring the preview proxy's
|
|
25667
|
+
// forwarded headers). Must match a redirect URI registered on the NetSuite
|
|
25668
|
+
// Integration Record exactly.
|
|
25669
|
+
export function appOrigin(request: Request) {
|
|
25670
|
+
// 1) Explicit override wins (set by the platform per environment).
|
|
25671
|
+
if (nsEnv.PUBLIC_APP_URL) return stripTrailingSlash(nsEnv.PUBLIC_APP_URL)
|
|
25672
|
+
// 2) HELLOLEO_URL is injected on every deploy (the app's canonical origin,
|
|
25673
|
+
// a bare host with no scheme), so it's set even when the NetSuite
|
|
25674
|
+
// wizard's PUBLIC_APP_URL pin wasn't.
|
|
25675
|
+
if (nsEnv.HELLOLEO_URL) return \`https://\${stripTrailingSlash(nsEnv.HELLOLEO_URL)}\`
|
|
25676
|
+
// 3) Preview/prod proxies rewrite Host, so trust the forwarded/original host
|
|
25677
|
+
// they set (HelloLeo's nginx sends X-Original-Host) \u2014 that's the public URL
|
|
25678
|
+
// the browser loaded, not an internal upstream host.
|
|
25679
|
+
const url = new URL(request.url)
|
|
25680
|
+
const proto = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')
|
|
25681
|
+
const hostHeader =
|
|
25682
|
+
request.headers.get('x-forwarded-host') ||
|
|
25683
|
+
request.headers.get('x-original-host') ||
|
|
25684
|
+
url.host
|
|
25685
|
+
return \`\${proto}://\${hostHeader}\`
|
|
25686
|
+
}
|
|
25687
|
+
export function redirectUri(request: Request) {
|
|
25688
|
+
return \`\${appOrigin(request)}/auth/netsuite/callback\`
|
|
25689
|
+
}
|
|
25690
|
+
|
|
25691
|
+
// Coerce ?return_to= to our OWN origin. The callback appends the session bearer
|
|
25692
|
+
// as a #nssid=... fragment; an off-origin return_to would leak that live
|
|
25693
|
+
// credential to an attacker-controlled page (open-redirect -> token theft).
|
|
25694
|
+
export function safeReturnTo(request: Request, raw: string | null): string {
|
|
25695
|
+
const origin = appOrigin(request)
|
|
25696
|
+
if (!raw) return origin
|
|
25697
|
+
try {
|
|
25698
|
+
const u = new URL(raw, origin)
|
|
25699
|
+
return u.origin === origin ? u.toString() : origin
|
|
25700
|
+
} catch {
|
|
25701
|
+
return origin
|
|
25702
|
+
}
|
|
25703
|
+
}
|
|
25704
|
+
|
|
25705
|
+
// --- PKCE (Web Crypto: works on workerd and Node) ---
|
|
25706
|
+
function b64url(bytes: Uint8Array) {
|
|
25707
|
+
let s = ''
|
|
25708
|
+
for (const b of bytes) s += String.fromCharCode(b)
|
|
25709
|
+
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
25710
|
+
}
|
|
25711
|
+
export async function pkce() {
|
|
25712
|
+
const verifier = b64url(crypto.getRandomValues(new Uint8Array(48)))
|
|
25713
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
25714
|
+
const challenge = b64url(new Uint8Array(digest))
|
|
25715
|
+
return { verifier, challenge }
|
|
25716
|
+
}
|
|
25717
|
+
|
|
25718
|
+
interface TokenResponse {
|
|
25719
|
+
access_token?: string
|
|
25720
|
+
refresh_token?: string
|
|
25721
|
+
expires_in?: number
|
|
25722
|
+
error?: string
|
|
25723
|
+
error_description?: string
|
|
25724
|
+
}
|
|
25725
|
+
|
|
25726
|
+
// Confidential client -> HTTP Basic. Public (PKCE-only) -> client_id in body.
|
|
25727
|
+
function authHeaders(clientId: string, clientSecret: string, params: Record<string, string>) {
|
|
25728
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
25729
|
+
if (clientSecret) headers.Authorization = 'Basic ' + btoa(\`\${clientId}:\${clientSecret}\`)
|
|
25730
|
+
else params.client_id = clientId
|
|
25731
|
+
return headers
|
|
25732
|
+
}
|
|
25733
|
+
|
|
25734
|
+
// POST the token endpoint and parse tolerantly. NetSuite returns JSON for both
|
|
25735
|
+
// success and OAuth errors, but a gateway (502/504) can return HTML -- never let
|
|
25736
|
+
// res.json() throw into the route handler (that would surface as an opaque 500
|
|
25737
|
+
// instead of a clean "token exchange failed").
|
|
25738
|
+
async function postToken(
|
|
25739
|
+
accountId: string,
|
|
25740
|
+
params: Record<string, string>,
|
|
25741
|
+
headers: Record<string, string>,
|
|
25742
|
+
): Promise<TokenResponse> {
|
|
25743
|
+
let res: Response
|
|
25744
|
+
try {
|
|
25745
|
+
res = await fetch(tokenUrl(accountId), { method: 'POST', headers, body: new URLSearchParams(params) })
|
|
25746
|
+
} catch (e) {
|
|
25747
|
+
return { error: 'network_error', error_description: String(e) }
|
|
25748
|
+
}
|
|
25749
|
+
const text = await res.text()
|
|
25750
|
+
let data: TokenResponse = {}
|
|
25751
|
+
try {
|
|
25752
|
+
data = JSON.parse(text) as TokenResponse
|
|
25753
|
+
} catch {
|
|
25754
|
+
// non-JSON body (e.g. gateway HTML) -- leave data empty, fall through
|
|
25755
|
+
}
|
|
25756
|
+
if (!res.ok && !data.error) {
|
|
25757
|
+
data = { error: 'token_request_failed', error_description: 'HTTP ' + res.status }
|
|
25758
|
+
}
|
|
25759
|
+
return data
|
|
25760
|
+
}
|
|
25761
|
+
|
|
25762
|
+
export async function exchangeCode(request: Request, code: string, verifier: string) {
|
|
25763
|
+
const { accountId, clientId, clientSecret } = requireConfig()
|
|
25764
|
+
const params: Record<string, string> = {
|
|
25765
|
+
grant_type: 'authorization_code',
|
|
25766
|
+
code,
|
|
25767
|
+
redirect_uri: redirectUri(request),
|
|
25768
|
+
code_verifier: verifier,
|
|
25769
|
+
}
|
|
25770
|
+
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
25771
|
+
}
|
|
25772
|
+
|
|
25773
|
+
async function refreshToken(refresh: string) {
|
|
25774
|
+
const { accountId, clientId, clientSecret } = requireConfig()
|
|
25775
|
+
const params: Record<string, string> = { grant_type: 'refresh_token', refresh_token: refresh }
|
|
25776
|
+
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
25777
|
+
}
|
|
25778
|
+
|
|
25779
|
+
export interface NsSession {
|
|
25780
|
+
sid: string
|
|
25781
|
+
accountId: string
|
|
25782
|
+
accessToken: string
|
|
25783
|
+
refreshToken: string | null
|
|
25784
|
+
expiresAt: number | null
|
|
25785
|
+
userId: string | null
|
|
25786
|
+
email: string | null
|
|
25787
|
+
}
|
|
25788
|
+
|
|
25789
|
+
// Load a session by sid, transparently refreshing the access token when it's
|
|
25790
|
+
// within the leeway of expiry. Returns null if unknown or unrefreshable.
|
|
25791
|
+
export async function getSession(sid: string): Promise<NsSession | null> {
|
|
25792
|
+
if (!sid) return null
|
|
25793
|
+
const rows = await db.select().from(netsuiteSessions).where(eq(netsuiteSessions.sid, sid)).limit(1)
|
|
25794
|
+
const s = rows[0]
|
|
25795
|
+
if (!s) return null
|
|
25796
|
+
const now = Math.floor(Date.now() / 1000)
|
|
25797
|
+
// A missing expiry is treated as EXPIRED (force a refresh) rather than trusting
|
|
25798
|
+
// the token forever -- NetSuite normally sends expires_in, so this is defensive.
|
|
25799
|
+
const expired = s.expiresAt == null ? true : now + REFRESH_LEEWAY_SECONDS >= s.expiresAt
|
|
25800
|
+
if (expired) {
|
|
25801
|
+
// Token expired (or about to). It MUST refresh; if it can't, the session is
|
|
25802
|
+
// dead \u2014 delete it and return null so /api/me reports signed-out and the user
|
|
25803
|
+
// re-auths, rather than a "logged in" app whose every NetSuite call 401s.
|
|
25804
|
+
if (!s.refreshToken) { await deleteSession(sid); return null }
|
|
25805
|
+
const tok = await refreshToken(s.refreshToken)
|
|
25806
|
+
if (!tok.access_token) { await deleteSession(sid); return null }
|
|
25807
|
+
const expiresAt = now + (tok.expires_in || 3600)
|
|
25808
|
+
const refreshTok = tok.refresh_token ?? s.refreshToken
|
|
25809
|
+
await db
|
|
25810
|
+
.update(netsuiteSessions)
|
|
25811
|
+
.set({ accessToken: tok.access_token, refreshToken: refreshTok, expiresAt })
|
|
25812
|
+
.where(eq(netsuiteSessions.sid, sid))
|
|
25813
|
+
return { ...s, accessToken: tok.access_token, refreshToken: refreshTok, expiresAt }
|
|
25814
|
+
}
|
|
25815
|
+
return s
|
|
25816
|
+
}
|
|
25817
|
+
|
|
25818
|
+
export async function deleteSession(sid: string) {
|
|
25819
|
+
if (sid) await db.delete(netsuiteSessions).where(eq(netsuiteSessions.sid, sid))
|
|
25820
|
+
}
|
|
25821
|
+
|
|
25822
|
+
export function bearer(request: Request) {
|
|
25823
|
+
const h = request.headers.get('authorization') || ''
|
|
25824
|
+
return h.startsWith('Bearer ') ? h.slice(7) : ''
|
|
25825
|
+
}
|
|
25826
|
+
|
|
25827
|
+
/**
|
|
25828
|
+
* Call NetSuite REST / SuiteQL for the signed-in session. Use this from your
|
|
25829
|
+
* data routes (src/routes/api/*). \`path\` is relative to the account's
|
|
25830
|
+
* suitetalk host, e.g. '/services/rest/query/v1/suiteql'. Returns the raw
|
|
25831
|
+
* fetch Response (401 if the sid is unknown). Access token is auto-refreshed.
|
|
25832
|
+
*/
|
|
25833
|
+
export async function netsuiteFetch(sid: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
25834
|
+
const session = await getSession(sid)
|
|
25835
|
+
if (!session) return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })
|
|
25836
|
+
const url = path.startsWith('http') ? path : \`\${restBase(session.accountId)}\${path}\`
|
|
25837
|
+
const headers = new Headers(init.headers)
|
|
25838
|
+
headers.set('Authorization', \`Bearer \${session.accessToken}\`)
|
|
25839
|
+
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
|
25840
|
+
return fetch(url, { ...init, headers })
|
|
25841
|
+
}
|
|
25842
|
+
`;
|
|
25843
|
+
}
|
|
25844
|
+
function startRouteTs() {
|
|
25845
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25846
|
+
import { db } from '#/db'
|
|
25847
|
+
import { netsuiteFlows } from '#/db/schema'
|
|
25848
|
+
import {
|
|
25849
|
+
authorizeUrl,
|
|
25850
|
+
pkce,
|
|
25851
|
+
redirectUri,
|
|
25852
|
+
requireConfig,
|
|
25853
|
+
safeReturnTo,
|
|
25854
|
+
SCOPE,
|
|
25855
|
+
} from '#/lib/netsuite-server'
|
|
25856
|
+
|
|
25857
|
+
// GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
|
|
25858
|
+
export const Route = createFileRoute('/auth/netsuite/start')({
|
|
25859
|
+
server: {
|
|
25860
|
+
handlers: {
|
|
25861
|
+
GET: async ({ request }) => {
|
|
25862
|
+
const { accountId, clientId } = requireConfig()
|
|
25863
|
+
const url = new URL(request.url)
|
|
25864
|
+
// Same-origin only: the callback returns the bearer in a URL fragment.
|
|
25865
|
+
const returnTo = safeReturnTo(request, url.searchParams.get('return_to'))
|
|
25866
|
+
|
|
25867
|
+
const { verifier, challenge } = await pkce()
|
|
25868
|
+
const flowId = crypto.randomUUID()
|
|
25869
|
+
await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
|
|
25870
|
+
|
|
25871
|
+
const authorize = new URL(authorizeUrl(accountId))
|
|
25872
|
+
authorize.searchParams.set('response_type', 'code')
|
|
25873
|
+
authorize.searchParams.set('client_id', clientId)
|
|
25874
|
+
authorize.searchParams.set('redirect_uri', redirectUri(request))
|
|
25875
|
+
authorize.searchParams.set('scope', SCOPE)
|
|
25876
|
+
authorize.searchParams.set('state', flowId)
|
|
25877
|
+
authorize.searchParams.set('code_challenge', challenge)
|
|
25878
|
+
authorize.searchParams.set('code_challenge_method', 'S256')
|
|
25879
|
+
|
|
25880
|
+
return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
|
|
25881
|
+
},
|
|
25882
|
+
},
|
|
25883
|
+
},
|
|
25884
|
+
})
|
|
25885
|
+
`;
|
|
25886
|
+
}
|
|
25887
|
+
function callbackRouteTs() {
|
|
25888
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25889
|
+
import { eq } from 'drizzle-orm'
|
|
25890
|
+
import { db } from '#/db'
|
|
25891
|
+
import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
|
|
25892
|
+
import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
|
|
25893
|
+
|
|
25894
|
+
// GET /auth/netsuite/callback -- exchange the code, mint a session, hand the
|
|
25895
|
+
// bearer back to the SPA via the URL fragment (#nssid=...). The fragment
|
|
25896
|
+
// survives the HelloLeo preview iframe, where third-party cookies are dropped.
|
|
25897
|
+
export const Route = createFileRoute('/auth/netsuite/callback')({
|
|
25898
|
+
server: {
|
|
25899
|
+
handlers: {
|
|
25900
|
+
GET: async ({ request }) => {
|
|
25901
|
+
const url = new URL(request.url)
|
|
25902
|
+
const err = url.searchParams.get('error')
|
|
25903
|
+
if (err) return new Response(\`NetSuite authorization failed: \${err}\`, { status: 400 })
|
|
25904
|
+
|
|
25905
|
+
const code = url.searchParams.get('code')
|
|
25906
|
+
const state = url.searchParams.get('state')
|
|
25907
|
+
if (!code || !state) return new Response('Missing code or state', { status: 400 })
|
|
25908
|
+
|
|
25909
|
+
const rows = await db
|
|
25910
|
+
.select()
|
|
25911
|
+
.from(netsuiteFlows)
|
|
25912
|
+
.where(eq(netsuiteFlows.flowId, state))
|
|
25913
|
+
.limit(1)
|
|
25914
|
+
const flow = rows[0]
|
|
25915
|
+
if (!flow) return new Response('Unknown or expired flow -- start again', { status: 400 })
|
|
25916
|
+
// Single-use: burn the flow row immediately.
|
|
25917
|
+
await db.delete(netsuiteFlows).where(eq(netsuiteFlows.flowId, state))
|
|
25918
|
+
// Reject stale flows: the PKCE verifier must not stay valid indefinitely
|
|
25919
|
+
// for an abandoned or leaked authorize URL (createdAt is unixepoch seconds).
|
|
25920
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
25921
|
+
if (flow.createdAt && nowSec - flow.createdAt > 600) {
|
|
25922
|
+
return new Response('Flow expired -- start again', { status: 400 })
|
|
25923
|
+
}
|
|
25924
|
+
|
|
25925
|
+
const tok = await exchangeCode(request, code, flow.codeVerifier)
|
|
25926
|
+
if (!tok.access_token) {
|
|
25927
|
+
return new Response(
|
|
25928
|
+
\`Token exchange failed: \${tok.error_description || tok.error || 'unknown error'}\`,
|
|
25929
|
+
{ status: 400 },
|
|
25930
|
+
)
|
|
25931
|
+
}
|
|
25932
|
+
|
|
25933
|
+
const sid = crypto.randomUUID()
|
|
25934
|
+
const now = Math.floor(Date.now() / 1000)
|
|
25935
|
+
await db.insert(netsuiteSessions).values({
|
|
25936
|
+
sid,
|
|
25937
|
+
accountId: requireConfig().accountId,
|
|
25938
|
+
accessToken: tok.access_token,
|
|
25939
|
+
refreshToken: tok.refresh_token ?? null,
|
|
25940
|
+
expiresAt: now + (tok.expires_in || 3600),
|
|
25941
|
+
})
|
|
25942
|
+
|
|
25943
|
+
const dest = new URL(flow.returnTo)
|
|
25944
|
+
dest.hash = \`nssid=\${sid}\`
|
|
25945
|
+
return new Response(null, { status: 302, headers: { Location: dest.toString() } })
|
|
25946
|
+
},
|
|
25947
|
+
},
|
|
25948
|
+
},
|
|
25949
|
+
})
|
|
25950
|
+
`;
|
|
25951
|
+
}
|
|
25952
|
+
function logoutRouteTs() {
|
|
25953
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25954
|
+
import { bearer, deleteSession } from '#/lib/netsuite-server'
|
|
25955
|
+
|
|
25956
|
+
// POST /auth/netsuite/logout -- destroy the current session.
|
|
25957
|
+
export const Route = createFileRoute('/auth/netsuite/logout')({
|
|
25958
|
+
server: {
|
|
25959
|
+
handlers: {
|
|
25960
|
+
POST: async ({ request }) => {
|
|
25961
|
+
await deleteSession(bearer(request))
|
|
25962
|
+
return Response.json({ ok: true })
|
|
25963
|
+
},
|
|
25964
|
+
},
|
|
25965
|
+
},
|
|
25966
|
+
})
|
|
25967
|
+
`;
|
|
25968
|
+
}
|
|
25969
|
+
function meRouteTs() {
|
|
25970
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25971
|
+
import { bearer, getSession } from '#/lib/netsuite-server'
|
|
25972
|
+
|
|
25973
|
+
// GET /api/me -- resolve the bearer sid to a session. The ONLY authoritative
|
|
25974
|
+
// auth check; the client must never trust sessionStorage on its own.
|
|
25975
|
+
export const Route = createFileRoute('/api/me')({
|
|
25976
|
+
server: {
|
|
25977
|
+
handlers: {
|
|
25978
|
+
GET: async ({ request }) => {
|
|
25979
|
+
const session = await getSession(bearer(request))
|
|
25980
|
+
if (!session) return Response.json({ authenticated: false })
|
|
25981
|
+
return Response.json({
|
|
25982
|
+
authenticated: true,
|
|
25983
|
+
account_id: session.accountId,
|
|
25984
|
+
})
|
|
25985
|
+
},
|
|
25986
|
+
},
|
|
25987
|
+
},
|
|
25988
|
+
})
|
|
25989
|
+
`;
|
|
25990
|
+
}
|
|
25991
|
+
function configRouteTs() {
|
|
25992
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25993
|
+
import { env as runtimeEnv } from '@helloleo/runtime'
|
|
25994
|
+
import { appOrigin, redirectUri } from '#/lib/netsuite-server'
|
|
25995
|
+
|
|
25996
|
+
// GET /auth/netsuite/config -- single source of truth for the redirect URI.
|
|
25997
|
+
// Whatever this returns is EXACTLY what /auth/netsuite/start sends NetSuite, so
|
|
25998
|
+
// show the user THIS value to register on their Integration Record. Never
|
|
25999
|
+
// hardcode or guess a callback URL.
|
|
26000
|
+
export const Route = createFileRoute('/auth/netsuite/config')({
|
|
26001
|
+
server: {
|
|
26002
|
+
handlers: {
|
|
26003
|
+
GET: async ({ request }) => {
|
|
26004
|
+
const e = runtimeEnv as unknown as {
|
|
26005
|
+
NETSUITE_ACCOUNT_ID?: string
|
|
26006
|
+
NETSUITE_CLIENT_ID?: string
|
|
26007
|
+
}
|
|
26008
|
+
return Response.json({
|
|
26009
|
+
redirect_uri: redirectUri(request),
|
|
26010
|
+
origin: appOrigin(request),
|
|
26011
|
+
configured: Boolean(e.NETSUITE_ACCOUNT_ID && e.NETSUITE_CLIENT_ID),
|
|
26012
|
+
})
|
|
26013
|
+
},
|
|
26014
|
+
},
|
|
26015
|
+
},
|
|
26016
|
+
})
|
|
26017
|
+
`;
|
|
26018
|
+
}
|
|
26019
|
+
function netsuiteClientTs() {
|
|
26020
|
+
return `/*
|
|
26021
|
+
* NetSuite auth -- client (same-origin). The OAuth backend is THIS app's own
|
|
26022
|
+
* server routes (src/routes/auth/netsuite/* + src/routes/api/me.ts), so every
|
|
26023
|
+
* call is relative -- no separate Worker, no VITE_WORKER_URL.
|
|
26024
|
+
*
|
|
26025
|
+
* The browser only ever holds an opaque session id (sid), handed back in the
|
|
26026
|
+
* callback's URL fragment (#nssid=...) so it survives the HelloLeo preview
|
|
26027
|
+
* iframe (third-party cookies are partitioned/dropped there). The sid rides as
|
|
26028
|
+
* Authorization: Bearer on every call; NetSuite tokens stay server-side. The
|
|
26029
|
+
* only authoritative auth check is GET /api/me.
|
|
26030
|
+
*
|
|
26031
|
+
* NEVER use a password gate. NEVER trust sessionStorage as the auth state --
|
|
26032
|
+
* it's just a cache of the bearer.
|
|
26033
|
+
*/
|
|
26034
|
+
const TOKEN_KEY = 'nssid'
|
|
26035
|
+
|
|
26036
|
+
// Capture-and-strip #nssid=... as early as possible so URLs stay clean.
|
|
26037
|
+
;(function bootstrapTokenFromHash() {
|
|
26038
|
+
if (typeof window === 'undefined') return
|
|
26039
|
+
const hash = window.location.hash.replace(/^#/, '')
|
|
26040
|
+
if (!hash) return
|
|
26041
|
+
const params = new URLSearchParams(hash)
|
|
26042
|
+
const sid = params.get(TOKEN_KEY)
|
|
26043
|
+
if (sid) {
|
|
26044
|
+
try {
|
|
26045
|
+
sessionStorage.setItem(TOKEN_KEY, sid)
|
|
26046
|
+
} catch {}
|
|
26047
|
+
params.delete(TOKEN_KEY)
|
|
26048
|
+
const rest = params.toString()
|
|
26049
|
+
window.history.replaceState(
|
|
26050
|
+
null,
|
|
26051
|
+
'',
|
|
26052
|
+
window.location.pathname + window.location.search + (rest ? '#' + rest : ''),
|
|
26053
|
+
)
|
|
26054
|
+
}
|
|
26055
|
+
})()
|
|
26056
|
+
|
|
26057
|
+
export function getSessionToken(): string | null {
|
|
26058
|
+
try {
|
|
26059
|
+
return sessionStorage.getItem(TOKEN_KEY)
|
|
26060
|
+
} catch {
|
|
26061
|
+
return null
|
|
26062
|
+
}
|
|
26063
|
+
}
|
|
26064
|
+
|
|
26065
|
+
export function signInWithNetSuite(returnTo: string = window.location.href) {
|
|
26066
|
+
const url = new URL('/auth/netsuite/start', window.location.origin)
|
|
26067
|
+
url.searchParams.set('return_to', returnTo)
|
|
26068
|
+
// The app renders inside the HelloLeo preview iframe; /start 302s to
|
|
26069
|
+
// NetSuite, whose OAuth page sends X-Frame-Options: DENY. Navigate the TOP
|
|
26070
|
+
// frame so the flow runs at top level and the callback lands there to read
|
|
26071
|
+
// #nssid=. Assign .href (NOT .assign(): reading .assign off a cross-origin
|
|
26072
|
+
// window.top.location throws, dropping us into the iframe fallback ->
|
|
26073
|
+
// "refused to connect").
|
|
26074
|
+
try {
|
|
26075
|
+
if (window.top && window.top !== window) {
|
|
26076
|
+
window.top.location.href = url.toString()
|
|
26077
|
+
return
|
|
26078
|
+
}
|
|
26079
|
+
} catch {
|
|
26080
|
+
/* top window unreachable -- fall through to same-frame navigation */
|
|
26081
|
+
}
|
|
26082
|
+
window.location.href = url.toString()
|
|
26083
|
+
}
|
|
26084
|
+
|
|
26085
|
+
export interface NetSuiteSession {
|
|
26086
|
+
authenticated: boolean
|
|
26087
|
+
account_id?: string
|
|
26088
|
+
}
|
|
26089
|
+
|
|
26090
|
+
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
26091
|
+
const sid = getSessionToken()
|
|
26092
|
+
if (!sid) return { authenticated: false }
|
|
26093
|
+
const res = await fetch('/api/me', { headers: { Authorization: 'Bearer ' + sid } })
|
|
26094
|
+
if (!res.ok) return { authenticated: false }
|
|
26095
|
+
// res.json() is \`unknown\` under workers-types -- assert the /api/me shape.
|
|
26096
|
+
const data = (await res.json()) as NetSuiteSession
|
|
26097
|
+
return { ...data, authenticated: Boolean(data?.authenticated) }
|
|
26098
|
+
}
|
|
26099
|
+
|
|
26100
|
+
export async function signOutNetSuite(): Promise<void> {
|
|
26101
|
+
const sid = getSessionToken()
|
|
26102
|
+
if (sid) {
|
|
26103
|
+
try {
|
|
26104
|
+
await fetch('/auth/netsuite/logout', {
|
|
26105
|
+
method: 'POST',
|
|
26106
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
26107
|
+
})
|
|
26108
|
+
} catch {}
|
|
26109
|
+
}
|
|
26110
|
+
try {
|
|
26111
|
+
sessionStorage.removeItem(TOKEN_KEY)
|
|
26112
|
+
} catch {}
|
|
26113
|
+
window.location.reload()
|
|
26114
|
+
}
|
|
26115
|
+
|
|
26116
|
+
/**
|
|
26117
|
+
* Fetch YOUR data routes (src/routes/api/*) with the bearer attached. Use this
|
|
26118
|
+
* instead of bare fetch for authenticated calls.
|
|
26119
|
+
*/
|
|
26120
|
+
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
26121
|
+
const sid = getSessionToken()
|
|
26122
|
+
const headers = new Headers(init.headers)
|
|
26123
|
+
if (sid) headers.set('Authorization', 'Bearer ' + sid)
|
|
26124
|
+
return fetch(path, { ...init, headers })
|
|
26125
|
+
}
|
|
26126
|
+
|
|
26127
|
+
/**
|
|
26128
|
+
* The exact redirect URI this app will send NetSuite, read from the server so
|
|
26129
|
+
* it can never drift from what /auth/netsuite/start actually uses. Show it to
|
|
26130
|
+
* whoever registers the Integration Record; never hardcode a callback URL.
|
|
26131
|
+
*/
|
|
26132
|
+
export async function getNetSuiteRedirectUri(): Promise<string | null> {
|
|
26133
|
+
try {
|
|
26134
|
+
const res = await fetch('/auth/netsuite/config')
|
|
26135
|
+
if (!res.ok) return null
|
|
26136
|
+
const data = (await res.json()) as { redirect_uri?: string }
|
|
26137
|
+
return data?.redirect_uri ?? null
|
|
26138
|
+
} catch {
|
|
26139
|
+
return null
|
|
26140
|
+
}
|
|
26141
|
+
}
|
|
26142
|
+
`;
|
|
26143
|
+
}
|
|
26144
|
+
function startAuthTsx() {
|
|
26145
|
+
return `import * as React from 'react'
|
|
26146
|
+
import {
|
|
26147
|
+
getNetSuiteSession,
|
|
26148
|
+
signInWithNetSuite,
|
|
26149
|
+
signOutNetSuite,
|
|
26150
|
+
type NetSuiteSession,
|
|
26151
|
+
} from './netsuite'
|
|
26152
|
+
|
|
26153
|
+
/**
|
|
26154
|
+
* NetSuite auth context. Holds the session (resolved client-side from /api/me
|
|
26155
|
+
* via getNetSuiteSession) and exposes sign-in / sign-out. /api/me is the only
|
|
26156
|
+
* authoritative check -- never trust sessionStorage directly.
|
|
26157
|
+
*/
|
|
26158
|
+
export interface NetSuiteAuthContextValue {
|
|
26159
|
+
pending: boolean
|
|
26160
|
+
isAuthenticated: boolean
|
|
26161
|
+
session: NetSuiteSession | null
|
|
26162
|
+
signIn: () => void
|
|
26163
|
+
signOut: () => Promise<void>
|
|
26164
|
+
refresh: () => Promise<void>
|
|
26165
|
+
}
|
|
26166
|
+
|
|
26167
|
+
const NetSuiteAuthContext = React.createContext<NetSuiteAuthContextValue | null>(null)
|
|
26168
|
+
|
|
26169
|
+
export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }) {
|
|
26170
|
+
const [session, setSession] = React.useState<NetSuiteSession | null>(null)
|
|
26171
|
+
// Start pending on BOTH server and client render so SSR and first paint match
|
|
26172
|
+
// (a spinner), avoiding a hydration mismatch. The effect (client-only)
|
|
26173
|
+
// resolves it.
|
|
26174
|
+
const [pending, setPending] = React.useState(true)
|
|
26175
|
+
|
|
26176
|
+
const refresh = React.useCallback(async () => {
|
|
26177
|
+
setPending(true)
|
|
26178
|
+
try {
|
|
26179
|
+
setSession(await getNetSuiteSession())
|
|
26180
|
+
} finally {
|
|
26181
|
+
setPending(false)
|
|
26182
|
+
}
|
|
26183
|
+
}, [])
|
|
26184
|
+
|
|
26185
|
+
React.useEffect(() => {
|
|
26186
|
+
refresh()
|
|
26187
|
+
}, [refresh])
|
|
26188
|
+
|
|
26189
|
+
const value: NetSuiteAuthContextValue = {
|
|
26190
|
+
pending,
|
|
26191
|
+
isAuthenticated: Boolean(session?.authenticated),
|
|
26192
|
+
session,
|
|
26193
|
+
signIn: () => signInWithNetSuite(),
|
|
26194
|
+
signOut: signOutNetSuite,
|
|
26195
|
+
refresh,
|
|
26196
|
+
}
|
|
26197
|
+
|
|
26198
|
+
return <NetSuiteAuthContext.Provider value={value}>{children}</NetSuiteAuthContext.Provider>
|
|
26199
|
+
}
|
|
26200
|
+
|
|
26201
|
+
export function useNetSuiteAuth(): NetSuiteAuthContextValue {
|
|
26202
|
+
const ctx = React.useContext(NetSuiteAuthContext)
|
|
26203
|
+
if (!ctx) {
|
|
26204
|
+
throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
|
|
26205
|
+
}
|
|
26206
|
+
return ctx
|
|
26207
|
+
}
|
|
26208
|
+
`;
|
|
26209
|
+
}
|
|
26210
|
+
function startAuthLayoutTsx() {
|
|
26211
|
+
return `import type { ReactNode } from 'react'
|
|
26212
|
+
import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
|
|
26213
|
+
import { LoginPage } from './LoginPage'
|
|
26214
|
+
|
|
26215
|
+
/**
|
|
26216
|
+
* NetSuite auth layout. The ONE manual wiring step: wrap the root route's
|
|
26217
|
+
* <Outlet /> with it in src/routes/__root.tsx:
|
|
26218
|
+
*
|
|
26219
|
+
* import { AuthLayout } from '../components/auth/AuthLayout'
|
|
26220
|
+
* ...
|
|
26221
|
+
* function RootComponent() {
|
|
26222
|
+
* return (
|
|
26223
|
+
* <RootDocument>
|
|
26224
|
+
* <AuthLayout>
|
|
26225
|
+
* <Outlet />
|
|
26226
|
+
* </AuthLayout>
|
|
26227
|
+
* </RootDocument>
|
|
26228
|
+
* )
|
|
26229
|
+
* }
|
|
26230
|
+
*
|
|
26231
|
+
* It provides auth context and gates rendering: a spinner while the session
|
|
26232
|
+
* resolves, <LoginPage /> for signed-out users, your routes once signed in.
|
|
26233
|
+
* NEVER swap this for a password gate -- real OAuth is the only login.
|
|
26234
|
+
*/
|
|
26235
|
+
export function AuthLayout({ children }: { children: ReactNode }) {
|
|
26236
|
+
return (
|
|
26237
|
+
<NetSuiteAuthProvider>
|
|
26238
|
+
<Gate>{children}</Gate>
|
|
26239
|
+
</NetSuiteAuthProvider>
|
|
26240
|
+
)
|
|
26241
|
+
}
|
|
26242
|
+
|
|
26243
|
+
function Gate({ children }: { children: ReactNode }) {
|
|
26244
|
+
const { pending, isAuthenticated } = useNetSuiteAuth()
|
|
26245
|
+
|
|
26246
|
+
if (pending) {
|
|
26247
|
+
return (
|
|
26248
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
26249
|
+
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
26250
|
+
</div>
|
|
26251
|
+
)
|
|
26252
|
+
}
|
|
26253
|
+
|
|
26254
|
+
if (!isAuthenticated) return <LoginPage />
|
|
26255
|
+
|
|
26256
|
+
return <>{children}</>
|
|
26257
|
+
}
|
|
26258
|
+
`;
|
|
26259
|
+
}
|
|
26260
|
+
function startLoginPageTsx() {
|
|
26261
|
+
return `import { SignInWithNetSuite } from './SignInWithNetSuite'
|
|
26262
|
+
|
|
26263
|
+
/**
|
|
26264
|
+
* Unauthenticated gate -- a deliberately bare placeholder. DESIGN THIS PAGE
|
|
26265
|
+
* to match the app you are building: its real name, its brand, its layout
|
|
26266
|
+
* language. Do NOT ship this default as-is.
|
|
26267
|
+
*
|
|
26268
|
+
* The only hard requirements:
|
|
26269
|
+
* - keep <SignInWithNetSuite /> as the ONLY call-to-action (it starts the
|
|
26270
|
+
* OAuth flow at /auth/netsuite/start)
|
|
26271
|
+
* - never add a password form -- real OAuth is the only login
|
|
26272
|
+
*/
|
|
26273
|
+
export function LoginPage() {
|
|
26274
|
+
return (
|
|
26275
|
+
<main className="min-h-screen flex items-center justify-center bg-background text-foreground p-4">
|
|
26276
|
+
<div className="flex flex-col items-center gap-4 text-center">
|
|
26277
|
+
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
26278
|
+
<p className="text-sm text-muted-foreground">
|
|
26279
|
+
Connect your NetSuite account to continue.
|
|
26280
|
+
</p>
|
|
26281
|
+
<SignInWithNetSuite />
|
|
26282
|
+
</div>
|
|
26283
|
+
</main>
|
|
26284
|
+
)
|
|
26285
|
+
}
|
|
26286
|
+
`;
|
|
26287
|
+
}
|
|
26288
|
+
function startSignInButtonTsx() {
|
|
26289
|
+
return `import { signInWithNetSuite } from '../../lib/netsuite'
|
|
26290
|
+
|
|
26291
|
+
export function SignInWithNetSuite() {
|
|
26292
|
+
return (
|
|
26293
|
+
<button
|
|
26294
|
+
onClick={() => signInWithNetSuite()}
|
|
26295
|
+
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"
|
|
26296
|
+
>
|
|
26297
|
+
Sign in with NetSuite
|
|
26298
|
+
</button>
|
|
26299
|
+
)
|
|
26300
|
+
}
|
|
26301
|
+
`;
|
|
26302
|
+
}
|
|
26303
|
+
function spaNetsuiteTs() {
|
|
25549
26304
|
return `/*
|
|
25550
26305
|
* NetSuite OAuth client helper (bearer-token-in-fragment).
|
|
25551
26306
|
*
|
|
@@ -25702,7 +26457,7 @@ export async function workerFetch(pathOrUrl: string, init: RequestInit = {}): Pr
|
|
|
25702
26457
|
}
|
|
25703
26458
|
`;
|
|
25704
26459
|
}
|
|
25705
|
-
function
|
|
26460
|
+
function spaSignInButtonTsx() {
|
|
25706
26461
|
return `import { signInWithNetSuite } from '../../lib/netsuite';
|
|
25707
26462
|
|
|
25708
26463
|
export function SignInWithNetSuite() {
|
|
@@ -25717,51 +26472,35 @@ export function SignInWithNetSuite() {
|
|
|
25717
26472
|
}
|
|
25718
26473
|
`;
|
|
25719
26474
|
}
|
|
25720
|
-
function
|
|
26475
|
+
function spaLoginPageTsx() {
|
|
25721
26476
|
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
25722
26477
|
|
|
25723
26478
|
/**
|
|
25724
|
-
*
|
|
25725
|
-
*
|
|
25726
|
-
*
|
|
26479
|
+
* Unauthenticated gate -- a deliberately bare placeholder. DESIGN THIS PAGE
|
|
26480
|
+
* to match the app you are building: its real name, its brand, its layout
|
|
26481
|
+
* language. Do NOT ship this default as-is.
|
|
26482
|
+
*
|
|
26483
|
+
* The only hard requirements:
|
|
26484
|
+
* - keep <SignInWithNetSuite /> as the ONLY call-to-action (it starts the
|
|
26485
|
+
* OAuth flow on the Worker's /auth/netsuite/start)
|
|
26486
|
+
* - never add a password form -- real OAuth is the only login
|
|
25727
26487
|
*/
|
|
25728
26488
|
export function LoginPage() {
|
|
25729
26489
|
return (
|
|
25730
|
-
<
|
|
25731
|
-
<div className="
|
|
25732
|
-
<
|
|
25733
|
-
<
|
|
25734
|
-
|
|
25735
|
-
|
|
25736
|
-
|
|
25737
|
-
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
25738
|
-
Sign in with your NetSuite credentials to access your data securely.
|
|
25739
|
-
Tokens stay server-side on HelloLeo Cloud -- they never reach the
|
|
25740
|
-
browser.
|
|
25741
|
-
</p>
|
|
25742
|
-
</div>
|
|
25743
|
-
<div className="text-xs text-primary-foreground/60">
|
|
25744
|
-
Powered by HelloLeo
|
|
25745
|
-
</div>
|
|
25746
|
-
</div>
|
|
25747
|
-
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
25748
|
-
<div className="w-full max-w-sm space-y-6">
|
|
25749
|
-
<div>
|
|
25750
|
-
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
25751
|
-
<p className="mt-2 text-sm text-muted-foreground">
|
|
25752
|
-
Use the button below to sign in. You'll be redirected to NetSuite
|
|
25753
|
-
to authorise access, then back here.
|
|
25754
|
-
</p>
|
|
25755
|
-
</div>
|
|
25756
|
-
<SignInWithNetSuite />
|
|
25757
|
-
</div>
|
|
26490
|
+
<main className="min-h-screen flex items-center justify-center bg-background text-foreground p-4">
|
|
26491
|
+
<div className="flex flex-col items-center gap-4 text-center">
|
|
26492
|
+
<h1 className="text-2xl font-semibold">Sign in</h1>
|
|
26493
|
+
<p className="text-sm text-muted-foreground">
|
|
26494
|
+
Connect your NetSuite account to continue.
|
|
26495
|
+
</p>
|
|
26496
|
+
<SignInWithNetSuite />
|
|
25758
26497
|
</div>
|
|
25759
|
-
</
|
|
26498
|
+
</main>
|
|
25760
26499
|
);
|
|
25761
26500
|
}
|
|
25762
26501
|
`;
|
|
25763
26502
|
}
|
|
25764
|
-
function
|
|
26503
|
+
function spaAuthTsx() {
|
|
25765
26504
|
return `import * as React from 'react';
|
|
25766
26505
|
import {
|
|
25767
26506
|
getNetSuiteSession,
|
|
@@ -25833,7 +26572,7 @@ export function useNetSuiteAuth(): NetSuiteAuthContextValue {
|
|
|
25833
26572
|
}
|
|
25834
26573
|
`;
|
|
25835
26574
|
}
|
|
25836
|
-
function
|
|
26575
|
+
function spaAuthLayoutTsx() {
|
|
25837
26576
|
return `import type { ReactNode } from 'react';
|
|
25838
26577
|
import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth';
|
|
25839
26578
|
import { LoginPage } from './LoginPage';
|
|
@@ -25887,57 +26626,172 @@ function Gate({ children }: { children: ReactNode }) {
|
|
|
25887
26626
|
}
|
|
25888
26627
|
`;
|
|
25889
26628
|
}
|
|
26629
|
+
async function executeStart(dir) {
|
|
26630
|
+
await mkdir2(path7.join(dir, "src", "lib"), { recursive: true });
|
|
26631
|
+
await mkdir2(path7.join(dir, "src", "components", "auth"), {
|
|
26632
|
+
recursive: true
|
|
26633
|
+
});
|
|
26634
|
+
await mkdir2(path7.join(dir, "src", "routes", "auth", "netsuite"), {
|
|
26635
|
+
recursive: true
|
|
26636
|
+
});
|
|
26637
|
+
await mkdir2(path7.join(dir, "src", "routes", "api"), { recursive: true });
|
|
26638
|
+
const schemaImports = `import { sql } from 'drizzle-orm'
|
|
26639
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
26640
|
+
`;
|
|
26641
|
+
const schemaPath = path7.join(dir, "src", "db", "schema.ts");
|
|
26642
|
+
let schemaAppended = false;
|
|
26643
|
+
try {
|
|
26644
|
+
const current = await readFile3(schemaPath, "utf8");
|
|
26645
|
+
if (!current.includes("netsuite_flows")) {
|
|
26646
|
+
const hasImports = /^import .*drizzle-orm/m.test(current);
|
|
26647
|
+
await Bun.write(schemaPath, (hasImports ? current : schemaImports + current) + schemaAppend());
|
|
26648
|
+
schemaAppended = true;
|
|
26649
|
+
}
|
|
26650
|
+
} catch {
|
|
26651
|
+
await Bun.write(schemaPath, schemaImports + schemaAppend());
|
|
26652
|
+
schemaAppended = true;
|
|
26653
|
+
}
|
|
26654
|
+
const files = [
|
|
26655
|
+
[path7.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
|
|
26656
|
+
[
|
|
26657
|
+
path7.join("src", "routes", "auth", "netsuite", "start.ts"),
|
|
26658
|
+
startRouteTs()
|
|
26659
|
+
],
|
|
26660
|
+
[
|
|
26661
|
+
path7.join("src", "routes", "auth", "netsuite", "callback.ts"),
|
|
26662
|
+
callbackRouteTs()
|
|
26663
|
+
],
|
|
26664
|
+
[
|
|
26665
|
+
path7.join("src", "routes", "auth", "netsuite", "logout.ts"),
|
|
26666
|
+
logoutRouteTs()
|
|
26667
|
+
],
|
|
26668
|
+
[
|
|
26669
|
+
path7.join("src", "routes", "auth", "netsuite", "config.ts"),
|
|
26670
|
+
configRouteTs()
|
|
26671
|
+
],
|
|
26672
|
+
[path7.join("src", "routes", "api", "me.ts"), meRouteTs()],
|
|
26673
|
+
[path7.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
|
|
26674
|
+
[path7.join("src", "lib", "auth.tsx"), startAuthTsx()],
|
|
26675
|
+
[
|
|
26676
|
+
path7.join("src", "components", "auth", "AuthLayout.tsx"),
|
|
26677
|
+
startAuthLayoutTsx()
|
|
26678
|
+
],
|
|
26679
|
+
[
|
|
26680
|
+
path7.join("src", "components", "auth", "LoginPage.tsx"),
|
|
26681
|
+
startLoginPageTsx()
|
|
26682
|
+
],
|
|
26683
|
+
[
|
|
26684
|
+
path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
|
|
26685
|
+
startSignInButtonTsx()
|
|
26686
|
+
]
|
|
26687
|
+
];
|
|
26688
|
+
for (const [filePath, content] of files) {
|
|
26689
|
+
await Bun.write(path7.join(dir, filePath), content);
|
|
26690
|
+
}
|
|
26691
|
+
return [
|
|
26692
|
+
`Wrote ${files.length} NetSuite files${schemaAppended ? " + appended src/db/schema.ts" : ""}:`,
|
|
26693
|
+
"DOCS -- the authoritative setup guide is https://docs.helloleo.dev/integrations/netsuite . When you tell the user what to do in NetSuite, follow and link these steps verbatim; do not invent steps.",
|
|
26694
|
+
" - Record #2 (runtime): https://docs.helloleo.dev/integrations/netsuite#step-4-%E2%80%94-create-integration-record-2-runtime",
|
|
26695
|
+
" - Send runtime creds to Leo: https://docs.helloleo.dev/integrations/netsuite#step-5-%E2%80%94-send-the-runtime-credentials-to-leo",
|
|
26696
|
+
"",
|
|
26697
|
+
" server (OAuth backend lives in the app -- no separate Worker):",
|
|
26698
|
+
" - src/lib/netsuite-server.ts (env, PKCE, token exchange, session refresh, netsuiteFetch)",
|
|
26699
|
+
" - src/routes/auth/netsuite/start.ts GET begin PKCE flow",
|
|
26700
|
+
" - src/routes/auth/netsuite/callback.ts GET code -> tokens -> session",
|
|
26701
|
+
" - src/routes/auth/netsuite/logout.ts POST destroy session",
|
|
26702
|
+
" - src/routes/auth/netsuite/config.ts GET the exact redirect URI to register",
|
|
26703
|
+
" - src/routes/api/me.ts GET bearer -> session (auth check)",
|
|
26704
|
+
" - src/db/schema.ts += netsuite_flows + netsuite_sessions",
|
|
26705
|
+
" client:",
|
|
26706
|
+
" - src/lib/netsuite.ts (same-origin: signIn, session, apiFetch)",
|
|
26707
|
+
" - src/lib/auth.tsx (NetSuiteAuthProvider + useNetSuiteAuth)",
|
|
26708
|
+
" - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx",
|
|
26709
|
+
"",
|
|
26710
|
+
"WIRE IT UP (one manual step): in src/routes/__root.tsx, wrap <Outlet /> with <AuthLayout>:",
|
|
26711
|
+
"",
|
|
26712
|
+
" import { AuthLayout } from '../components/auth/AuthLayout'",
|
|
26713
|
+
"",
|
|
26714
|
+
" <AuthLayout>",
|
|
26715
|
+
" <Outlet />",
|
|
26716
|
+
" </AuthLayout>",
|
|
26717
|
+
"",
|
|
26718
|
+
"Then:",
|
|
26719
|
+
"- DESIGN THE LOGIN PAGE: src/components/auth/LoginPage.tsx is a bare placeholder on purpose. Restyle it to match THIS app (name, brand, layout) as part of building the UI -- do not ship the default. Keep <SignInWithNetSuite /> as the only call-to-action; never add a password form.",
|
|
26720
|
+
"- Build your dashboard inside <AuthLayout>. Read the session with useNetSuiteAuth().",
|
|
26721
|
+
"- Data routes: add src/routes/api/*.ts and call netsuiteFetch(bearer(request), '/services/rest/query/v1/suiteql', {...}) from the handler.",
|
|
26722
|
+
"- MIGRATIONS: schema.ts changed (netsuite_flows + netsuite_sessions). Migrations are generated and applied automatically; if the dev server complains about missing tables, run `bun run db:generate` once (never hand-edit files under drizzle/).",
|
|
26723
|
+
"",
|
|
26724
|
+
"TWO INTEGRATION RECORDS -- do not confuse them:",
|
|
26725
|
+
"- Record #1 (scope: mcp): already connected, used ONLY to build the app (schema discovery). This login does NOT use it.",
|
|
26726
|
+
"- Record #2 (scope: rest_webservices): THIS login uses it -- the end user signs into NetSuite through it, and the redirect URI is registered on THIS record. If it doesn't exist yet, tell the user to create it (a second Integration Record, scope rest_webservices only).",
|
|
26727
|
+
"",
|
|
26728
|
+
"CONFIG -- NETSUITE_ACCOUNT_ID, NETSUITE_CLIENT_ID, NETSUITE_CLIENT_SECRET (secret optional for PKCE public clients). Never hardcode; never put in code. Same var names in every environment, different SOURCE:",
|
|
26729
|
+
"- DEV (Fly preview): read from the project .env. HelloLeo writes them there (the setup wizard, or the env tooling). In dev the app uses .env -- do NOT tell the user to use HelloLeo Cloud here.",
|
|
26730
|
+
"- PROD (published Worker): the SAME names live as HelloLeo Cloud secrets; the publish step promotes them from .env onto the Worker automatically. Same code path (@helloleo/runtime env), just a different source per environment.",
|
|
26731
|
+
"- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
|
|
26732
|
+
"- REDIRECT URI -- never guess or hardcode it. It is derived server-side and auto-corrects behind the preview proxy. Read the exact value from GET /auth/netsuite/config (or getNetSuiteRedirectUri() on the client) and tell the user to register THAT on Integration Record #2 (scope: rest_webservices). ONE redirect URI per record: register exactly the value /auth/netsuite/config returns for the environment the user signs into. If login must work in BOTH the preview and the published app, use a separate Integration Record per environment (same env var names, different NETSUITE_CLIENT_ID/_SECRET values per environment) -- do not stack URLs on one record.",
|
|
26733
|
+
"- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login."
|
|
26734
|
+
].join(`
|
|
26735
|
+
`);
|
|
26736
|
+
}
|
|
26737
|
+
async function executeSpa(dir) {
|
|
26738
|
+
await mkdir2(path7.join(dir, "src", "components", "auth"), {
|
|
26739
|
+
recursive: true
|
|
26740
|
+
});
|
|
26741
|
+
const nsFiles = [
|
|
26742
|
+
[path7.join("src", "lib", "netsuite.ts"), spaNetsuiteTs()],
|
|
26743
|
+
[path7.join("src", "lib", "auth.tsx"), spaAuthTsx()],
|
|
26744
|
+
[
|
|
26745
|
+
path7.join("src", "components", "auth", "AuthLayout.tsx"),
|
|
26746
|
+
spaAuthLayoutTsx()
|
|
26747
|
+
],
|
|
26748
|
+
[
|
|
26749
|
+
path7.join("src", "components", "auth", "LoginPage.tsx"),
|
|
26750
|
+
spaLoginPageTsx()
|
|
26751
|
+
],
|
|
26752
|
+
[
|
|
26753
|
+
path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
|
|
26754
|
+
spaSignInButtonTsx()
|
|
26755
|
+
]
|
|
26756
|
+
];
|
|
26757
|
+
for (const [filePath, content] of nsFiles) {
|
|
26758
|
+
await Bun.write(path7.join(dir, filePath), content);
|
|
26759
|
+
}
|
|
26760
|
+
return [
|
|
26761
|
+
`Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
|
|
26762
|
+
" - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
|
|
26763
|
+
" - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
|
|
26764
|
+
" - src/components/auth/AuthLayout.tsx (provider and auth gate)",
|
|
26765
|
+
" - src/components/auth/LoginPage.tsx (unauthenticated view)",
|
|
26766
|
+
" - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
|
|
26767
|
+
"",
|
|
26768
|
+
"Notes:",
|
|
26769
|
+
"- DESIGN THE LOGIN PAGE: src/components/auth/LoginPage.tsx is a bare placeholder on purpose. Restyle it to match THIS app (name, brand, layout) as part of building the UI -- do not ship the default. Keep <SignInWithNetSuite /> as the only call-to-action; never add a password form.",
|
|
26770
|
+
"- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
|
|
26771
|
+
"- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
|
|
26772
|
+
"- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
|
|
26773
|
+
"- The auth worker (deployed via the cloudflare-backend MCP) serves /auth/netsuite/start, /auth/netsuite/callback, /api/me and /auth/netsuite/logout. It reads env.NETSUITE_ACCOUNT_ID, _CLIENT_ID and _CLIENT_SECRET. VITE_WORKER_URL must be set (HelloLeo Cloud seeds it).",
|
|
26774
|
+
"- POPUP FLOW (required so login works inside the HelloLeo preview iframe): the client opens /auth/netsuite/start in a popup with ?popup=1. The worker MUST carry that flag through OAuth (persist it on the flow row in /start, read it back in /callback via state). In /auth/netsuite/callback, when the flow was popup, DO NOT redirect to return_to#nssid \u2014 instead return an HTML page whose script does: window.opener.postMessage({ type: 'netsuite-auth', nssid: SID }, '*'); window.close(); (fall back to the return_to#nssid redirect only when there is no opener / not a popup). Without this, the preview iframe (partitioned sessionStorage) keeps showing the login after sign-in.",
|
|
26775
|
+
"",
|
|
26776
|
+
"Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
|
|
26777
|
+
"",
|
|
26778
|
+
" import { AuthLayout } from '../components/auth/AuthLayout'",
|
|
26779
|
+
"",
|
|
26780
|
+
" <AuthLayout>",
|
|
26781
|
+
" <Outlet />",
|
|
26782
|
+
" </AuthLayout>"
|
|
26783
|
+
].join(`
|
|
26784
|
+
`);
|
|
26785
|
+
}
|
|
25890
26786
|
var setupNetsuite = tool({
|
|
25891
|
-
description: "Add NetSuite OAuth
|
|
26787
|
+
description: "Add NetSuite runtime OAuth (end-user LOGIN) to an existing project. " + "Call scaffold first, then this. Works on both scaffold types and " + "picks the variant automatically: on tanstack-start the app hosts " + "its own OAuth routes (/auth/netsuite/* + /api/me, same-origin " + "client); on tanstack-router (SPA) it writes the client + auth " + "components and the OAuth backend is the project's HelloLeo Cloud " + "Worker (VITE_WORKER_URL). This login uses NetSuite Integration " + "Record #2 (scope: rest_webservices) -- a SEPARATE record from the " + "MCP one (scope: mcp) that was used only to BUILD the app; do not " + "confuse them. Real OAuth from day one -- never replace <AuthLayout> " + "with a password placeholder. Setup guide (link the user to it): " + "https://docs.helloleo.dev/integrations/netsuite",
|
|
25892
26788
|
args: {},
|
|
25893
26789
|
async execute(_args, context) {
|
|
25894
26790
|
const dir = context.directory;
|
|
25895
|
-
await
|
|
25896
|
-
|
|
25897
|
-
});
|
|
25898
|
-
const nsFiles = [
|
|
25899
|
-
[path7.join("src", "lib", "netsuite.ts"), netsuiteTs()],
|
|
25900
|
-
[path7.join("src", "lib", "auth.tsx"), authTsx()],
|
|
25901
|
-
[
|
|
25902
|
-
path7.join("src", "components", "auth", "AuthLayout.tsx"),
|
|
25903
|
-
authLayoutTsx()
|
|
25904
|
-
],
|
|
25905
|
-
[
|
|
25906
|
-
path7.join("src", "components", "auth", "LoginPage.tsx"),
|
|
25907
|
-
netsuiteLoginPageTsx()
|
|
25908
|
-
],
|
|
25909
|
-
[
|
|
25910
|
-
path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
|
|
25911
|
-
signInButtonTsx()
|
|
25912
|
-
]
|
|
25913
|
-
];
|
|
25914
|
-
for (const [filePath, content] of nsFiles) {
|
|
25915
|
-
await Bun.write(path7.join(dir, filePath), content);
|
|
26791
|
+
if (await isStartScaffold(dir)) {
|
|
26792
|
+
return executeStart(dir);
|
|
25916
26793
|
}
|
|
25917
|
-
return
|
|
25918
|
-
`Wrote ${nsFiles.length} NetSuite OAuth files (all additive, nothing overwritten):`,
|
|
25919
|
-
" - src/lib/netsuite.ts (OAuth client: signIn, session, workerFetch)",
|
|
25920
|
-
" - src/lib/auth.tsx (NetSuiteAuthProvider and useNetSuiteAuth)",
|
|
25921
|
-
" - src/components/auth/AuthLayout.tsx (provider and auth gate)",
|
|
25922
|
-
" - src/components/auth/LoginPage.tsx (unauthenticated view)",
|
|
25923
|
-
" - src/components/auth/SignInWithNetSuite.tsx (sign-in button)",
|
|
25924
|
-
"",
|
|
25925
|
-
"Notes:",
|
|
25926
|
-
"- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
|
|
25927
|
-
"- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
|
|
25928
|
-
"- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
|
|
25929
|
-
"- The auth worker (deployed via the cloudflare-backend MCP) serves /auth/netsuite/start, /auth/netsuite/callback, /api/me and /auth/netsuite/logout. It reads env.NETSUITE_ACCOUNT_ID, _CLIENT_ID and _CLIENT_SECRET. VITE_WORKER_URL must be set (HelloLeo Cloud seeds it).",
|
|
25930
|
-
"- POPUP FLOW (required so login works inside the HelloLeo preview iframe): the client opens /auth/netsuite/start in a popup with ?popup=1. The worker MUST carry that flag through OAuth (persist it on the flow row in /start, read it back in /callback via state). In /auth/netsuite/callback, when the flow was popup, DO NOT redirect to return_to#nssid \u2014 instead return an HTML page whose script does: window.opener.postMessage({ type: 'netsuite-auth', nssid: SID }, '*'); window.close(); (fall back to the return_to#nssid redirect only when there is no opener / not a popup). Without this, the preview iframe (partitioned sessionStorage) keeps showing the login after sign-in.",
|
|
25931
|
-
"",
|
|
25932
|
-
"Next: in src/routes/__root.tsx, import AuthLayout and wrap <Outlet /> with it. Keep any existing providers.",
|
|
25933
|
-
"",
|
|
25934
|
-
" import { AuthLayout } from '../components/auth/AuthLayout'",
|
|
25935
|
-
"",
|
|
25936
|
-
" <AuthLayout>",
|
|
25937
|
-
" <Outlet />",
|
|
25938
|
-
" </AuthLayout>"
|
|
25939
|
-
].join(`
|
|
25940
|
-
`);
|
|
26794
|
+
return executeSpa(dir);
|
|
25941
26795
|
}
|
|
25942
26796
|
});
|
|
25943
26797
|
var SetupNetsuitePlugin = async () => {
|