@helloleo/plugins 0.2.0-beta.6 → 0.2.0-beta.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +1159 -703
- package/dist/templates/tanstack-start-2026-06-18/package.json +1 -0
- package/package.json +1 -1
- package/src/index.ts +1 -1
- package/src/templates/tanstack-start-2026-06-18/package.json +1 -0
- package/src/tools/scaffold.ts +1 -0
- package/src/tools/setup-authentication/auth-files.ts +132 -0
- package/src/tools/setup-authentication/constants.ts +19 -0
- package/src/tools/setup-authentication/db-schema.ts +96 -0
- package/src/tools/setup-authentication/index.ts +120 -0
- package/src/tools/setup-authentication/package-json.ts +17 -0
- package/src/tools/setup-authentication/pages.ts +241 -0
- package/src/tools/setup-netsuite.ts +772 -241
- package/src/tools/setup-password-auth.ts +0 -600
package/dist/index.js
CHANGED
|
@@ -24944,6 +24944,7 @@ var scaffold = tool({
|
|
|
24944
24944
|
"- Always fetch data with react-query (useQuery and useMutation).",
|
|
24945
24945
|
"",
|
|
24946
24946
|
"Next: read src/routes/index.tsx and continue with the user's request.",
|
|
24947
|
+
"For authentication (password and/or OAuth), call setup_authentication.",
|
|
24947
24948
|
"For NetSuite projects, call setup_netsuite."
|
|
24948
24949
|
].join(`
|
|
24949
24950
|
`);
|
|
@@ -24954,631 +24955,901 @@ var ScaffoldPlugin = async () => {
|
|
|
24954
24955
|
tool: { scaffold }
|
|
24955
24956
|
};
|
|
24956
24957
|
};
|
|
24957
|
-
// src/tools/setup-
|
|
24958
|
-
import
|
|
24959
|
-
import path4 from "path";
|
|
24960
|
-
function netsuiteTs() {
|
|
24961
|
-
return `/*
|
|
24962
|
-
* NetSuite OAuth client helper (bearer-token-in-fragment).
|
|
24963
|
-
*
|
|
24964
|
-
* Two NetSuite Integration Records back this:
|
|
24965
|
-
* 1. MCP (scope: mcp) -- used by HelloLeo at build time.
|
|
24966
|
-
* 2. Runtime (scope: rest_webservices) -- used here at sign-in time.
|
|
24967
|
-
* Runtime credentials live as HelloLeo Cloud secrets on the project's Worker
|
|
24968
|
-
* (env.NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET). They never reach
|
|
24969
|
-
* the browser. Cookies are NOT used -- frontend \u2194 Worker are cross-site
|
|
24970
|
-
* (e.g. *.helloleo.dev <-> *.workers.dev) and Chrome partitions / drops
|
|
24971
|
-
* third-party cookies in the HelloLeo preview iframe.
|
|
24972
|
-
*
|
|
24973
|
-
* Flow:
|
|
24974
|
-
* 1. User clicks "Sign in with NetSuite".
|
|
24975
|
-
* 2. Browser navigates to the Worker /auth/netsuite/start.
|
|
24976
|
-
* 3. Worker redirects to NetSuite's OAuth UI with PKCE.
|
|
24977
|
-
* 4. NetSuite redirects back to the Worker /auth/netsuite/callback.
|
|
24978
|
-
* 5. Worker exchanges the code, mints a session id, then redirects to
|
|
24979
|
-
* <return_to>#nssid=<sid>. URL fragments stay in the browser only.
|
|
24980
|
-
* 6. This module reads the fragment on load, stashes the sid in
|
|
24981
|
-
* sessionStorage, and sends it as Authorization: Bearer on every
|
|
24982
|
-
* Worker call.
|
|
24983
|
-
*
|
|
24984
|
-
* NEVER use a hardcoded password gate. NEVER use sessionStorage as the
|
|
24985
|
-
* authoritative auth state -- the only acceptable check is /api/me on the
|
|
24986
|
-
* Worker. sessionStorage here is just a cache of the bearer token.
|
|
24987
|
-
*/
|
|
24958
|
+
// src/tools/setup-authentication/index.ts
|
|
24959
|
+
import path6 from "path";
|
|
24988
24960
|
|
|
24989
|
-
|
|
24990
|
-
|
|
24961
|
+
// src/tools/setup-authentication/auth-files.ts
|
|
24962
|
+
function authLibTs(providers) {
|
|
24963
|
+
const hasPassword = providers.includes("password");
|
|
24964
|
+
const oauth = providers.filter((p) => p !== "password");
|
|
24965
|
+
const envFields = [
|
|
24966
|
+
"BETTER_AUTH_SECRET?: string",
|
|
24967
|
+
"HELLOLEO_URL?: string",
|
|
24968
|
+
...oauth.flatMap((p) => {
|
|
24969
|
+
const prefix = p.toUpperCase();
|
|
24970
|
+
return [
|
|
24971
|
+
`${prefix}_CLIENT_ID?: string`,
|
|
24972
|
+
`${prefix}_CLIENT_SECRET?: string`
|
|
24973
|
+
];
|
|
24974
|
+
})
|
|
24975
|
+
].join(`
|
|
24976
|
+
`);
|
|
24977
|
+
const socialProvidersBlock = oauth.length ? `
|
|
24978
|
+
socialProviders: {
|
|
24979
|
+
${oauth.map((p) => {
|
|
24980
|
+
const prefix = p.toUpperCase();
|
|
24981
|
+
return ` ${p}: { clientId: requireEnv(e.${prefix}_CLIENT_ID, '${prefix}_CLIENT_ID'), clientSecret: requireEnv(e.${prefix}_CLIENT_SECRET, '${prefix}_CLIENT_SECRET') },`;
|
|
24982
|
+
}).join(`
|
|
24983
|
+
`)}
|
|
24984
|
+
},` : "";
|
|
24985
|
+
const requireEnvFn = oauth.length ? `
|
|
24986
|
+
function requireEnv(value: string | undefined, name: string): string {
|
|
24987
|
+
if (!value) {
|
|
24988
|
+
throw new Error(\`\${name} is not set. Add it to .env (dev) or run \\\`wrangler secret put \${name}\\\` (prod).\`)
|
|
24989
|
+
}
|
|
24990
|
+
return value
|
|
24991
|
+
}
|
|
24992
|
+
` : "";
|
|
24993
|
+
return `import { betterAuth } from 'better-auth'
|
|
24994
|
+
import { drizzleAdapter } from '@better-auth/drizzle-adapter'
|
|
24995
|
+
import { tanstackStartCookies } from 'better-auth/tanstack-start'
|
|
24996
|
+
import { db } from '#/db'
|
|
24997
|
+
import * as schema from '#/db/schema'
|
|
24998
|
+
import { env } from '@helloleo/runtime'
|
|
24991
24999
|
|
|
24992
|
-
|
|
24993
|
-
|
|
24994
|
-
|
|
24995
|
-
|
|
24996
|
-
|
|
24997
|
-
|
|
24998
|
-
|
|
24999
|
-
|
|
25000
|
-
|
|
25001
|
-
|
|
25002
|
-
params.delete(TOKEN_KEY);
|
|
25003
|
-
const remaining = params.toString();
|
|
25004
|
-
const newHash = remaining ? '#' + remaining : '';
|
|
25005
|
-
window.history.replaceState(null, '', window.location.pathname + window.location.search + newHash);
|
|
25000
|
+
/*
|
|
25001
|
+
* buildAuth() only runs from INSIDE a request (see getAuth() below) --
|
|
25002
|
+
* never call it at module scope. \`db\` is a lazy Proxy over the D1 binding
|
|
25003
|
+
* and env.* Worker secrets don't exist until a request is in flight;
|
|
25004
|
+
* touching either while workerd boots the isolate throws.
|
|
25005
|
+
*/
|
|
25006
|
+
${requireEnvFn}
|
|
25007
|
+
function buildAuth() {
|
|
25008
|
+
const e = env as {
|
|
25009
|
+
${envFields}
|
|
25006
25010
|
}
|
|
25007
|
-
})();
|
|
25008
25011
|
|
|
25009
|
-
|
|
25010
|
-
|
|
25012
|
+
return betterAuth({
|
|
25013
|
+
secret: e.BETTER_AUTH_SECRET,
|
|
25014
|
+
baseURL: e.HELLOLEO_URL || 'http://localhost:3000',
|
|
25015
|
+
database: drizzleAdapter(db, { provider: 'sqlite', schema }),
|
|
25016
|
+
emailAndPassword: {
|
|
25017
|
+
enabled: ${hasPassword},
|
|
25018
|
+
},${socialProvidersBlock}
|
|
25019
|
+
plugins: [tanstackStartCookies()],
|
|
25020
|
+
})
|
|
25011
25021
|
}
|
|
25012
25022
|
|
|
25013
|
-
|
|
25014
|
-
if (!WORKER_URL) {
|
|
25015
|
-
throw new Error(
|
|
25016
|
-
'VITE_WORKER_URL is not set. HelloLeo Cloud must be provisioned before sign-in works.',
|
|
25017
|
-
);
|
|
25018
|
-
}
|
|
25019
|
-
const url = new URL(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/start\`);
|
|
25020
|
-
url.searchParams.set('return_to', returnTo);
|
|
25021
|
-
// The app usually renders inside the HelloLeo preview iframe. NetSuite's
|
|
25022
|
-
// OAuth page sets X-Frame-Options: DENY, so navigating the iframe to the
|
|
25023
|
-
// Worker (which 302s to NetSuite) ends up loading a page the browser
|
|
25024
|
-
// refuses to render -- looks like "nothing happens" to the user. Navigate
|
|
25025
|
-
// the top frame instead so the OAuth flow runs at the top level and the
|
|
25026
|
-
// callback redirect lands on the same top frame, which then reads the
|
|
25027
|
-
// #nssid= fragment. Use \`location.href =\` (NOT \`location.assign()\`):
|
|
25028
|
-
// reading the \`.assign\` method off a cross-origin \`window.top.location\`
|
|
25029
|
-
// throws a SecurityError, which would drop us into the same-frame fallback
|
|
25030
|
-
// and load NetSuite inside the iframe (X-Frame-Options: DENY -> "refused to
|
|
25031
|
-
// connect"). Assigning \`.href\` is a navigation, allowed cross-origin.
|
|
25032
|
-
try {
|
|
25033
|
-
if (window.top && window.top !== window) {
|
|
25034
|
-
window.top.location.href = url.toString();
|
|
25035
|
-
return;
|
|
25036
|
-
}
|
|
25037
|
-
} catch {
|
|
25038
|
-
/* top window not accessible \u2014 fall through to same-frame navigation */
|
|
25039
|
-
}
|
|
25040
|
-
window.location.href = url.toString();
|
|
25041
|
-
}
|
|
25023
|
+
let instance: ReturnType<typeof buildAuth> | undefined
|
|
25042
25024
|
|
|
25043
|
-
export
|
|
25044
|
-
|
|
25045
|
-
|
|
25046
|
-
user_id?: string;
|
|
25047
|
-
email?: string;
|
|
25025
|
+
export function getAuth() {
|
|
25026
|
+
if (!instance) instance = buildAuth()
|
|
25027
|
+
return instance
|
|
25048
25028
|
}
|
|
25049
|
-
|
|
25050
|
-
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
25051
|
-
if (!WORKER_URL) return { authenticated: false };
|
|
25052
|
-
const sid = getSessionToken();
|
|
25053
|
-
if (!sid) return { authenticated: false };
|
|
25054
|
-
const res = await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/api/me\`, {
|
|
25055
|
-
headers: { Authorization: 'Bearer ' + sid },
|
|
25056
|
-
});
|
|
25057
|
-
if (!res.ok) return { authenticated: false };
|
|
25058
|
-
const data = await res.json();
|
|
25059
|
-
return { authenticated: Boolean(data?.account_id), ...data };
|
|
25029
|
+
`;
|
|
25060
25030
|
}
|
|
25031
|
+
function authClientTs() {
|
|
25032
|
+
return `import { createAuthClient } from 'better-auth/react'
|
|
25061
25033
|
|
|
25062
|
-
export
|
|
25063
|
-
|
|
25064
|
-
const sid = getSessionToken();
|
|
25065
|
-
if (sid) {
|
|
25066
|
-
try {
|
|
25067
|
-
await fetch(\`\${WORKER_URL.replace(/\\/$/, '')}/auth/netsuite/logout\`, {
|
|
25068
|
-
method: 'POST',
|
|
25069
|
-
headers: { Authorization: 'Bearer ' + sid },
|
|
25070
|
-
});
|
|
25071
|
-
} catch {}
|
|
25072
|
-
}
|
|
25073
|
-
try { sessionStorage.removeItem(TOKEN_KEY); } catch {}
|
|
25074
|
-
window.location.reload();
|
|
25034
|
+
export const authClient = createAuthClient()
|
|
25035
|
+
`;
|
|
25075
25036
|
}
|
|
25037
|
+
function authServerTs() {
|
|
25038
|
+
return `import { createServerFn } from '@tanstack/react-start'
|
|
25039
|
+
import { getRequestHeaders } from '@tanstack/react-start/server'
|
|
25040
|
+
import { getAuth } from '#/lib/auth'
|
|
25076
25041
|
|
|
25077
|
-
|
|
25078
|
-
|
|
25079
|
-
|
|
25080
|
-
|
|
25081
|
-
|
|
25082
|
-
export
|
|
25083
|
-
|
|
25084
|
-
const
|
|
25085
|
-
|
|
25086
|
-
|
|
25087
|
-
|
|
25088
|
-
const headers = new Headers(init.headers ?? {});
|
|
25089
|
-
if (sid) headers.set('Authorization', 'Bearer ' + sid);
|
|
25090
|
-
return fetch(url, { ...init, headers });
|
|
25091
|
-
}
|
|
25042
|
+
export const getSession = createServerFn({ method: 'GET' }).handler(async () => {
|
|
25043
|
+
const headers = getRequestHeaders()
|
|
25044
|
+
return getAuth().api.getSession({ headers })
|
|
25045
|
+
})
|
|
25046
|
+
|
|
25047
|
+
export const ensureSession = createServerFn({ method: 'GET' }).handler(async () => {
|
|
25048
|
+
const headers = getRequestHeaders()
|
|
25049
|
+
const session = await getAuth().api.getSession({ headers })
|
|
25050
|
+
if (!session) throw new Error('Unauthorized')
|
|
25051
|
+
return session
|
|
25052
|
+
})
|
|
25092
25053
|
`;
|
|
25093
25054
|
}
|
|
25094
|
-
function
|
|
25095
|
-
return `import {
|
|
25055
|
+
function authRouteTs() {
|
|
25056
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25057
|
+
import { getAuth } from '#/lib/auth'
|
|
25096
25058
|
|
|
25097
|
-
export
|
|
25098
|
-
|
|
25099
|
-
|
|
25100
|
-
|
|
25101
|
-
|
|
25102
|
-
|
|
25103
|
-
|
|
25104
|
-
|
|
25105
|
-
);
|
|
25106
|
-
}
|
|
25059
|
+
export const Route = createFileRoute('/api/auth/$')({
|
|
25060
|
+
server: {
|
|
25061
|
+
handlers: {
|
|
25062
|
+
GET: ({ request }: { request: Request }) => getAuth().handler(request),
|
|
25063
|
+
POST: ({ request }: { request: Request }) => getAuth().handler(request),
|
|
25064
|
+
},
|
|
25065
|
+
},
|
|
25066
|
+
})
|
|
25107
25067
|
`;
|
|
25108
25068
|
}
|
|
25109
|
-
function netsuiteLoginPageTsx() {
|
|
25110
|
-
return `import { SignInWithNetSuite } from './SignInWithNetSuite';
|
|
25111
25069
|
|
|
25112
|
-
|
|
25113
|
-
|
|
25114
|
-
|
|
25115
|
-
|
|
25116
|
-
|
|
25117
|
-
|
|
25118
|
-
|
|
25119
|
-
|
|
25120
|
-
|
|
25121
|
-
|
|
25122
|
-
|
|
25123
|
-
|
|
25124
|
-
|
|
25125
|
-
|
|
25126
|
-
|
|
25127
|
-
|
|
25128
|
-
|
|
25129
|
-
|
|
25130
|
-
|
|
25131
|
-
|
|
25132
|
-
|
|
25133
|
-
|
|
25134
|
-
|
|
25135
|
-
|
|
25136
|
-
|
|
25137
|
-
|
|
25138
|
-
|
|
25139
|
-
|
|
25140
|
-
|
|
25141
|
-
|
|
25142
|
-
|
|
25143
|
-
|
|
25144
|
-
|
|
25145
|
-
|
|
25146
|
-
|
|
25147
|
-
|
|
25148
|
-
|
|
25149
|
-
|
|
25150
|
-
|
|
25070
|
+
// src/tools/setup-authentication/constants.ts
|
|
25071
|
+
var PROVIDERS = [
|
|
25072
|
+
"password",
|
|
25073
|
+
"google",
|
|
25074
|
+
"github",
|
|
25075
|
+
"discord",
|
|
25076
|
+
"microsoft"
|
|
25077
|
+
];
|
|
25078
|
+
var PROVIDER_LABELS = {
|
|
25079
|
+
google: "Google",
|
|
25080
|
+
github: "GitHub",
|
|
25081
|
+
discord: "Discord",
|
|
25082
|
+
microsoft: "Microsoft"
|
|
25083
|
+
};
|
|
25084
|
+
var BETTER_AUTH_VERSION = "^1.6.23";
|
|
25085
|
+
|
|
25086
|
+
// src/tools/setup-authentication/db-schema.ts
|
|
25087
|
+
import { readFile } from "fs/promises";
|
|
25088
|
+
import path4 from "path";
|
|
25089
|
+
var SCHEMA_MARKER = "sqliteTable('user'";
|
|
25090
|
+
function authTablesBlock() {
|
|
25091
|
+
return `
|
|
25092
|
+
export const user = sqliteTable('user', {
|
|
25093
|
+
id: text('id').primaryKey(),
|
|
25094
|
+
name: text('name').notNull(),
|
|
25095
|
+
email: text('email').notNull().unique(),
|
|
25096
|
+
emailVerified: integer('email_verified', { mode: 'boolean' }).default(false).notNull(),
|
|
25097
|
+
image: text('image'),
|
|
25098
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
25099
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25100
|
+
.notNull(),
|
|
25101
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
25102
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25103
|
+
.$onUpdate(() => new Date())
|
|
25104
|
+
.notNull(),
|
|
25105
|
+
})
|
|
25106
|
+
|
|
25107
|
+
export const session = sqliteTable('session', {
|
|
25108
|
+
id: text('id').primaryKey(),
|
|
25109
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
25110
|
+
token: text('token').notNull().unique(),
|
|
25111
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
25112
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25113
|
+
.notNull(),
|
|
25114
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
25115
|
+
.$onUpdate(() => new Date())
|
|
25116
|
+
.notNull(),
|
|
25117
|
+
ipAddress: text('ip_address'),
|
|
25118
|
+
userAgent: text('user_agent'),
|
|
25119
|
+
userId: text('user_id')
|
|
25120
|
+
.notNull()
|
|
25121
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
25122
|
+
})
|
|
25123
|
+
|
|
25124
|
+
export const account = sqliteTable('account', {
|
|
25125
|
+
id: text('id').primaryKey(),
|
|
25126
|
+
accountId: text('account_id').notNull(),
|
|
25127
|
+
providerId: text('provider_id').notNull(),
|
|
25128
|
+
userId: text('user_id')
|
|
25129
|
+
.notNull()
|
|
25130
|
+
.references(() => user.id, { onDelete: 'cascade' }),
|
|
25131
|
+
accessToken: text('access_token'),
|
|
25132
|
+
refreshToken: text('refresh_token'),
|
|
25133
|
+
idToken: text('id_token'),
|
|
25134
|
+
accessTokenExpiresAt: integer('access_token_expires_at', { mode: 'timestamp_ms' }),
|
|
25135
|
+
refreshTokenExpiresAt: integer('refresh_token_expires_at', { mode: 'timestamp_ms' }),
|
|
25136
|
+
scope: text('scope'),
|
|
25137
|
+
password: text('password'),
|
|
25138
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
25139
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25140
|
+
.notNull(),
|
|
25141
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
25142
|
+
.$onUpdate(() => new Date())
|
|
25143
|
+
.notNull(),
|
|
25144
|
+
})
|
|
25145
|
+
|
|
25146
|
+
export const verification = sqliteTable('verification', {
|
|
25147
|
+
id: text('id').primaryKey(),
|
|
25148
|
+
identifier: text('identifier').notNull(),
|
|
25149
|
+
value: text('value').notNull(),
|
|
25150
|
+
expiresAt: integer('expires_at', { mode: 'timestamp_ms' }).notNull(),
|
|
25151
|
+
createdAt: integer('created_at', { mode: 'timestamp_ms' })
|
|
25152
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25153
|
+
.notNull(),
|
|
25154
|
+
updatedAt: integer('updated_at', { mode: 'timestamp_ms' })
|
|
25155
|
+
.default(sql\`(cast(unixepoch('subsecond') * 1000 as integer))\`)
|
|
25156
|
+
.$onUpdate(() => new Date())
|
|
25157
|
+
.notNull(),
|
|
25158
|
+
})
|
|
25151
25159
|
`;
|
|
25152
25160
|
}
|
|
25153
|
-
function
|
|
25154
|
-
|
|
25155
|
-
|
|
25156
|
-
|
|
25157
|
-
|
|
25158
|
-
|
|
25159
|
-
|
|
25160
|
-
|
|
25161
|
+
async function appendAuthTables(dir) {
|
|
25162
|
+
const schemaPath = path4.join(dir, "src", "db", "schema.ts");
|
|
25163
|
+
const current = await readFile(schemaPath, "utf8").catch(() => null);
|
|
25164
|
+
if (current === null) {
|
|
25165
|
+
throw new Error("src/db/schema.ts not found -- run scaffold first.");
|
|
25166
|
+
}
|
|
25167
|
+
if (current.includes(SCHEMA_MARKER))
|
|
25168
|
+
return false;
|
|
25169
|
+
await Bun.write(schemaPath, `${current.trimEnd()}
|
|
25170
|
+
${authTablesBlock()}`);
|
|
25171
|
+
return true;
|
|
25172
|
+
}
|
|
25161
25173
|
|
|
25162
|
-
|
|
25163
|
-
|
|
25164
|
-
|
|
25165
|
-
|
|
25166
|
-
|
|
25167
|
-
|
|
25168
|
-
|
|
25169
|
-
|
|
25170
|
-
|
|
25171
|
-
|
|
25172
|
-
|
|
25173
|
-
|
|
25174
|
+
// src/tools/setup-authentication/package-json.ts
|
|
25175
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
25176
|
+
import path5 from "path";
|
|
25177
|
+
async function addAuthDependencies(dir) {
|
|
25178
|
+
const pkgPath = path5.join(dir, "package.json");
|
|
25179
|
+
const pkg = JSON.parse(await readFile2(pkgPath, "utf8"));
|
|
25180
|
+
pkg.dependencies ??= {};
|
|
25181
|
+
if (pkg.dependencies["better-auth"])
|
|
25182
|
+
return false;
|
|
25183
|
+
pkg.dependencies["better-auth"] = BETTER_AUTH_VERSION;
|
|
25184
|
+
pkg.dependencies["@better-auth/drizzle-adapter"] = BETTER_AUTH_VERSION;
|
|
25185
|
+
await Bun.write(pkgPath, `${JSON.stringify(pkg, null, 2)}
|
|
25186
|
+
`);
|
|
25187
|
+
return true;
|
|
25174
25188
|
}
|
|
25175
25189
|
|
|
25176
|
-
|
|
25177
|
-
|
|
25190
|
+
// src/tools/setup-authentication/pages.ts
|
|
25191
|
+
function socialButtonsJsx(oauth) {
|
|
25192
|
+
return oauth.map((p) => ` <Button
|
|
25193
|
+
type="button"
|
|
25194
|
+
variant="outline"
|
|
25195
|
+
className="w-full"
|
|
25196
|
+
onClick={() => authClient.signIn.social({ provider: '${p}', callbackURL: '/' })}
|
|
25197
|
+
>
|
|
25198
|
+
Continue with ${PROVIDER_LABELS[p]}
|
|
25199
|
+
</Button>`).join(`
|
|
25200
|
+
`);
|
|
25201
|
+
}
|
|
25202
|
+
function authedLayoutTsx() {
|
|
25203
|
+
return `import { createFileRoute, redirect } from '@tanstack/react-router'
|
|
25204
|
+
import { getSession } from '#/lib/auth-server'
|
|
25178
25205
|
|
|
25179
|
-
|
|
25180
|
-
|
|
25181
|
-
|
|
25182
|
-
|
|
25183
|
-
|
|
25184
|
-
|
|
25185
|
-
|
|
25206
|
+
// Pathless guard -- no URL segment of its own. Put protected pages under
|
|
25207
|
+
// src/routes/_authed/ (e.g. _authed/dashboard.tsx -> /dashboard) and they
|
|
25208
|
+
// inherit this beforeLoad. See src/routes/README.md, "Authenticated routes".
|
|
25209
|
+
export const Route = createFileRoute('/_authed')({
|
|
25210
|
+
beforeLoad: async () => {
|
|
25211
|
+
const session = await getSession()
|
|
25212
|
+
if (!session) throw redirect({ to: '/login' })
|
|
25213
|
+
return { user: session.user }
|
|
25214
|
+
},
|
|
25215
|
+
})
|
|
25216
|
+
`;
|
|
25217
|
+
}
|
|
25218
|
+
function loginPageTsx(hasPassword, oauth) {
|
|
25219
|
+
const social = oauth.length ? `
|
|
25220
|
+
${socialButtonsJsx(oauth)}
|
|
25221
|
+
` : "";
|
|
25222
|
+
const divider = oauth.length && hasPassword ? `
|
|
25223
|
+
<div className="relative">
|
|
25224
|
+
<div className="absolute inset-0 flex items-center">
|
|
25225
|
+
<span className="w-full border-t" />
|
|
25226
|
+
</div>
|
|
25227
|
+
<div className="relative flex justify-center text-xs uppercase">
|
|
25228
|
+
<span className="bg-card px-2 text-muted-foreground">Or continue with email</span>
|
|
25229
|
+
</div>
|
|
25230
|
+
</div>
|
|
25231
|
+
` : "";
|
|
25232
|
+
const passwordForm = hasPassword ? `
|
|
25233
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
25234
|
+
<div className="space-y-2">
|
|
25235
|
+
<Label htmlFor="email">Email</Label>
|
|
25236
|
+
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
|
|
25237
|
+
</div>
|
|
25238
|
+
<div className="space-y-2">
|
|
25239
|
+
<Label htmlFor="password">Password</Label>
|
|
25240
|
+
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
|
|
25241
|
+
</div>
|
|
25242
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
25243
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
25244
|
+
{loading ? 'Logging in\u2026' : 'Log in'}
|
|
25245
|
+
</Button>
|
|
25246
|
+
<p className="text-sm text-center text-muted-foreground">
|
|
25247
|
+
No account? <Link to="/signup" className="underline">Sign up</Link>
|
|
25248
|
+
</p>
|
|
25249
|
+
</form>
|
|
25250
|
+
` : "";
|
|
25251
|
+
const stateAndHandler = hasPassword ? `
|
|
25252
|
+
const router = useRouter()
|
|
25253
|
+
const [email, setEmail] = useState('')
|
|
25254
|
+
const [password, setPassword] = useState('')
|
|
25255
|
+
const [error, setError] = useState('')
|
|
25256
|
+
const [loading, setLoading] = useState(false)
|
|
25186
25257
|
|
|
25187
|
-
|
|
25188
|
-
|
|
25189
|
-
|
|
25190
|
-
|
|
25191
|
-
}
|
|
25192
|
-
|
|
25258
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
25259
|
+
e.preventDefault()
|
|
25260
|
+
setError('')
|
|
25261
|
+
setLoading(true)
|
|
25262
|
+
const { error } = await authClient.signIn.email({ email, password })
|
|
25263
|
+
setLoading(false)
|
|
25264
|
+
if (error) {
|
|
25265
|
+
setError(error.message ?? 'Something went wrong.')
|
|
25266
|
+
return
|
|
25193
25267
|
}
|
|
25194
|
-
|
|
25195
|
-
|
|
25196
|
-
|
|
25197
|
-
|
|
25198
|
-
|
|
25268
|
+
router.navigate({ to: '/' })
|
|
25269
|
+
}
|
|
25270
|
+
` : "";
|
|
25271
|
+
const imports = [
|
|
25272
|
+
hasPassword ? "import { createFileRoute, Link, useRouter } from '@tanstack/react-router'" : "import { createFileRoute } from '@tanstack/react-router'",
|
|
25273
|
+
hasPassword ? "import { useState } from 'react'" : "",
|
|
25274
|
+
"import { authClient } from '#/lib/auth-client'",
|
|
25275
|
+
"import { Button } from '#/components/ui/button'",
|
|
25276
|
+
hasPassword ? "import { Input } from '#/components/ui/input'" : "",
|
|
25277
|
+
hasPassword ? "import { Label } from '#/components/ui/label'" : "",
|
|
25278
|
+
"import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'"
|
|
25279
|
+
].filter(Boolean).join(`
|
|
25280
|
+
`);
|
|
25281
|
+
return `${imports}
|
|
25199
25282
|
|
|
25200
|
-
|
|
25201
|
-
|
|
25202
|
-
|
|
25203
|
-
session,
|
|
25204
|
-
signIn: () => signInWithNetSuite(),
|
|
25205
|
-
signOut: signOutNetSuite,
|
|
25206
|
-
refresh,
|
|
25207
|
-
};
|
|
25283
|
+
export const Route = createFileRoute('/login')({
|
|
25284
|
+
component: LoginPage,
|
|
25285
|
+
})
|
|
25208
25286
|
|
|
25287
|
+
function LoginPage() {${stateAndHandler}
|
|
25209
25288
|
return (
|
|
25210
|
-
<
|
|
25211
|
-
|
|
25212
|
-
|
|
25213
|
-
|
|
25289
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
25290
|
+
<Card className="w-full max-w-sm">
|
|
25291
|
+
<CardHeader>
|
|
25292
|
+
<CardTitle>Log in</CardTitle>
|
|
25293
|
+
</CardHeader>
|
|
25294
|
+
<CardContent className="space-y-4">${social}${divider}${passwordForm}
|
|
25295
|
+
</CardContent>
|
|
25296
|
+
</Card>
|
|
25297
|
+
</div>
|
|
25298
|
+
)
|
|
25299
|
+
}
|
|
25300
|
+
`;
|
|
25214
25301
|
}
|
|
25302
|
+
function signupPageTsx() {
|
|
25303
|
+
return `import { createFileRoute, Link, useRouter } from '@tanstack/react-router'
|
|
25304
|
+
import { useState } from 'react'
|
|
25305
|
+
import { authClient } from '#/lib/auth-client'
|
|
25306
|
+
import { Button } from '#/components/ui/button'
|
|
25307
|
+
import { Input } from '#/components/ui/input'
|
|
25308
|
+
import { Label } from '#/components/ui/label'
|
|
25309
|
+
import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
|
|
25215
25310
|
|
|
25216
|
-
export
|
|
25217
|
-
|
|
25218
|
-
|
|
25219
|
-
|
|
25311
|
+
export const Route = createFileRoute('/signup')({
|
|
25312
|
+
component: SignupPage,
|
|
25313
|
+
})
|
|
25314
|
+
|
|
25315
|
+
function SignupPage() {
|
|
25316
|
+
const router = useRouter()
|
|
25317
|
+
const [name, setName] = useState('')
|
|
25318
|
+
const [email, setEmail] = useState('')
|
|
25319
|
+
const [password, setPassword] = useState('')
|
|
25320
|
+
const [error, setError] = useState('')
|
|
25321
|
+
const [loading, setLoading] = useState(false)
|
|
25322
|
+
|
|
25323
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
25324
|
+
e.preventDefault()
|
|
25325
|
+
setError('')
|
|
25326
|
+
setLoading(true)
|
|
25327
|
+
const { error } = await authClient.signUp.email({ name, email, password })
|
|
25328
|
+
setLoading(false)
|
|
25329
|
+
if (error) {
|
|
25330
|
+
setError(error.message ?? 'Something went wrong.')
|
|
25331
|
+
return
|
|
25332
|
+
}
|
|
25333
|
+
router.navigate({ to: '/' })
|
|
25220
25334
|
}
|
|
25221
|
-
|
|
25335
|
+
|
|
25336
|
+
return (
|
|
25337
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
25338
|
+
<Card className="w-full max-w-sm">
|
|
25339
|
+
<CardHeader>
|
|
25340
|
+
<CardTitle>Sign up</CardTitle>
|
|
25341
|
+
</CardHeader>
|
|
25342
|
+
<CardContent>
|
|
25343
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
25344
|
+
<div className="space-y-2">
|
|
25345
|
+
<Label htmlFor="name">Name</Label>
|
|
25346
|
+
<Input id="name" type="text" value={name} onChange={e => setName(e.target.value)} required autoComplete="name" />
|
|
25347
|
+
</div>
|
|
25348
|
+
<div className="space-y-2">
|
|
25349
|
+
<Label htmlFor="email">Email</Label>
|
|
25350
|
+
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
|
|
25351
|
+
</div>
|
|
25352
|
+
<div className="space-y-2">
|
|
25353
|
+
<Label htmlFor="password">Password</Label>
|
|
25354
|
+
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="new-password" minLength={8} />
|
|
25355
|
+
</div>
|
|
25356
|
+
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
25357
|
+
<Button type="submit" className="w-full" disabled={loading}>
|
|
25358
|
+
{loading ? 'Signing up\u2026' : 'Sign up'}
|
|
25359
|
+
</Button>
|
|
25360
|
+
<p className="text-sm text-center text-muted-foreground">
|
|
25361
|
+
Already have an account? <Link to="/login" className="underline">Log in</Link>
|
|
25362
|
+
</p>
|
|
25363
|
+
</form>
|
|
25364
|
+
</CardContent>
|
|
25365
|
+
</Card>
|
|
25366
|
+
</div>
|
|
25367
|
+
)
|
|
25222
25368
|
}
|
|
25223
25369
|
`;
|
|
25224
25370
|
}
|
|
25225
|
-
function
|
|
25226
|
-
return `import
|
|
25227
|
-
import {
|
|
25228
|
-
import {
|
|
25371
|
+
function dashboardPageTsx() {
|
|
25372
|
+
return `import { createFileRoute, useRouter } from '@tanstack/react-router'
|
|
25373
|
+
import { authClient } from '#/lib/auth-client'
|
|
25374
|
+
import { Button } from '#/components/ui/button'
|
|
25229
25375
|
|
|
25230
|
-
|
|
25231
|
-
|
|
25232
|
-
|
|
25233
|
-
|
|
25234
|
-
|
|
25235
|
-
|
|
25236
|
-
* ...
|
|
25237
|
-
* function RootComponent() {
|
|
25238
|
-
* return (
|
|
25239
|
-
* <>
|
|
25240
|
-
* <AuthLayout>
|
|
25241
|
-
* <Outlet />
|
|
25242
|
-
* </AuthLayout>
|
|
25243
|
-
* { ...devtools... }
|
|
25244
|
-
* </>
|
|
25245
|
-
* );
|
|
25246
|
-
* }
|
|
25247
|
-
*
|
|
25248
|
-
* It provides NetSuite auth context to the whole tree and gates rendering:
|
|
25249
|
-
* while the session resolves it shows a spinner, unauthenticated users see
|
|
25250
|
-
* <LoginPage />, authenticated users see your routes. Use useNetSuiteAuth()
|
|
25251
|
-
* anywhere below it to read the session or sign out. NEVER swap this for a
|
|
25252
|
-
* password gate -- real OAuth is the only supported login.
|
|
25253
|
-
*/
|
|
25254
|
-
export function AuthLayout({ children }: { children: ReactNode }) {
|
|
25255
|
-
return (
|
|
25256
|
-
<NetSuiteAuthProvider>
|
|
25257
|
-
<Gate>{children}</Gate>
|
|
25258
|
-
</NetSuiteAuthProvider>
|
|
25259
|
-
);
|
|
25260
|
-
}
|
|
25376
|
+
// Example protected page -- _authed.tsx needs at least one child route or
|
|
25377
|
+
// it collides with index.tsx on path "/" and \`tsr generate\` fails. Replace
|
|
25378
|
+
// this, or add more pages alongside it under src/routes/_authed/.
|
|
25379
|
+
export const Route = createFileRoute('/_authed/dashboard')({
|
|
25380
|
+
component: DashboardPage,
|
|
25381
|
+
})
|
|
25261
25382
|
|
|
25262
|
-
function
|
|
25263
|
-
const
|
|
25383
|
+
function DashboardPage() {
|
|
25384
|
+
const router = useRouter()
|
|
25385
|
+
const { data: session } = authClient.useSession()
|
|
25264
25386
|
|
|
25265
|
-
|
|
25266
|
-
|
|
25267
|
-
<div className="
|
|
25268
|
-
<
|
|
25387
|
+
return (
|
|
25388
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
25389
|
+
<div className="text-center space-y-4">
|
|
25390
|
+
<p className="text-lg">Signed in as {session?.user.email}</p>
|
|
25391
|
+
<Button
|
|
25392
|
+
onClick={() =>
|
|
25393
|
+
authClient.signOut({
|
|
25394
|
+
fetchOptions: { onSuccess: () => router.navigate({ to: '/login' }) },
|
|
25395
|
+
})
|
|
25396
|
+
}
|
|
25397
|
+
>
|
|
25398
|
+
Sign out
|
|
25399
|
+
</Button>
|
|
25269
25400
|
</div>
|
|
25270
|
-
|
|
25271
|
-
|
|
25272
|
-
|
|
25273
|
-
if (!isAuthenticated) return <LoginPage />;
|
|
25274
|
-
|
|
25275
|
-
return <>{children}</>;
|
|
25401
|
+
</div>
|
|
25402
|
+
)
|
|
25276
25403
|
}
|
|
25277
25404
|
`;
|
|
25278
25405
|
}
|
|
25279
|
-
|
|
25280
|
-
|
|
25281
|
-
|
|
25282
|
-
|
|
25406
|
+
|
|
25407
|
+
// src/tools/setup-authentication/index.ts
|
|
25408
|
+
var setupAuthentication = tool({
|
|
25409
|
+
description: "Add authentication to an existing project via better-auth. Call scaffold first, " + "then this. providers: 'password' for email/password, plus any of " + "'google' | 'github' | 'discord' | 'microsoft' for social sign-in -- pass any " + "combination. Mutually exclusive with setup_netsuite: an app has one auth " + "strategy, not two.",
|
|
25410
|
+
args: {
|
|
25411
|
+
providers: exports_external2.array(exports_external2.enum(PROVIDERS)).min(1).describe("Auth methods to enable, e.g. ['password', 'google']")
|
|
25412
|
+
},
|
|
25413
|
+
async execute({ providers }, context) {
|
|
25283
25414
|
const dir = context.directory;
|
|
25284
|
-
|
|
25285
|
-
|
|
25286
|
-
|
|
25287
|
-
|
|
25288
|
-
[
|
|
25289
|
-
[
|
|
25290
|
-
[
|
|
25291
|
-
|
|
25292
|
-
authLayoutTsx()
|
|
25293
|
-
],
|
|
25415
|
+
const hasPassword = providers.includes("password");
|
|
25416
|
+
const oauth = providers.filter((p) => p !== "password");
|
|
25417
|
+
const files = [
|
|
25418
|
+
[path6.join("src", "lib", "auth.ts"), authLibTs(providers)],
|
|
25419
|
+
[path6.join("src", "lib", "auth-client.ts"), authClientTs()],
|
|
25420
|
+
[path6.join("src", "lib", "auth-server.ts"), authServerTs()],
|
|
25421
|
+
[path6.join("src", "routes", "api", "auth", "$.ts"), authRouteTs()],
|
|
25422
|
+
[path6.join("src", "routes", "_authed.tsx"), authedLayoutTsx()],
|
|
25294
25423
|
[
|
|
25295
|
-
|
|
25296
|
-
|
|
25424
|
+
path6.join("src", "routes", "_authed", "dashboard.tsx"),
|
|
25425
|
+
dashboardPageTsx()
|
|
25297
25426
|
],
|
|
25298
25427
|
[
|
|
25299
|
-
|
|
25300
|
-
|
|
25428
|
+
path6.join("src", "routes", "login.tsx"),
|
|
25429
|
+
loginPageTsx(hasPassword, oauth)
|
|
25301
25430
|
]
|
|
25302
25431
|
];
|
|
25303
|
-
|
|
25304
|
-
|
|
25432
|
+
if (hasPassword) {
|
|
25433
|
+
files.push([path6.join("src", "routes", "signup.tsx"), signupPageTsx()]);
|
|
25434
|
+
}
|
|
25435
|
+
for (const [filePath, content] of files) {
|
|
25436
|
+
await Bun.write(path6.join(dir, filePath), content);
|
|
25437
|
+
}
|
|
25438
|
+
const schemaAppended = await appendAuthTables(dir);
|
|
25439
|
+
const depsAdded = await addAuthDependencies(dir);
|
|
25440
|
+
const envLines = [
|
|
25441
|
+
" - dev (.env): BETTER_AUTH_SECRET=<random>",
|
|
25442
|
+
" - prod: `wrangler secret put BETTER_AUTH_SECRET`",
|
|
25443
|
+
" - HELLOLEO_URL is auto-injected by the platform (preview + production) -- nothing to set for baseURL"
|
|
25444
|
+
];
|
|
25445
|
+
for (const p of oauth) {
|
|
25446
|
+
const prefix = p.toUpperCase();
|
|
25447
|
+
envLines.push(` - ${PROVIDER_LABELS[p]}: ${prefix}_CLIENT_ID / ${prefix}_CLIENT_SECRET (dev: .env, prod: wrangler secret put)`);
|
|
25305
25448
|
}
|
|
25306
25449
|
return [
|
|
25307
|
-
`Wrote ${
|
|
25308
|
-
" - src/lib/
|
|
25309
|
-
" - src/lib/auth.
|
|
25310
|
-
" - src/
|
|
25311
|
-
" - src/
|
|
25312
|
-
" - src/
|
|
25450
|
+
`Wrote authentication (better-auth, providers: ${providers.join(", ")}):`,
|
|
25451
|
+
" - src/lib/auth.ts (lazy betterAuth instance -- read the comment on why it's not a module-scope export)",
|
|
25452
|
+
" - src/lib/auth-client.ts (React client -- authClient.signIn.email/social, signUp.email, signOut)",
|
|
25453
|
+
" - src/lib/auth-server.ts (getSession/ensureSession server functions, for SSR route guards)",
|
|
25454
|
+
" - src/routes/api/auth/$.ts (catch-all handler)",
|
|
25455
|
+
" - src/routes/_authed.tsx (pathless guard for protected routes)",
|
|
25456
|
+
" - src/routes/_authed/dashboard.tsx (example protected page -- _authed needs at least one child route or it collides with index.tsx on '/')",
|
|
25457
|
+
` - src/routes/login.tsx${hasPassword ? ", src/routes/signup.tsx" : ""}`,
|
|
25458
|
+
` - src/db/schema.ts ${schemaAppended ? "(appended user/session/account/verification tables)" : "(already had a `user` table, left as-is)"}`,
|
|
25459
|
+
` - package.json ${depsAdded ? "(added better-auth, @better-auth/drizzle-adapter)" : "(dependencies already present)"}`,
|
|
25313
25460
|
"",
|
|
25314
|
-
"
|
|
25315
|
-
"- AuthLayout shows LoginPage to signed-out users and renders the app's routes once they sign in with NetSuite.",
|
|
25316
|
-
"- Read the session anywhere with useNetSuiteAuth() from src/lib/auth.tsx.",
|
|
25317
|
-
"- Never replace AuthLayout with a password gate. Real OAuth is the only supported login.",
|
|
25318
|
-
"- 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).",
|
|
25461
|
+
"Run `bun install`, then `bun run db:push` (or just `bun dev`, which runs it first) to create the new tables.",
|
|
25319
25462
|
"",
|
|
25320
|
-
"
|
|
25463
|
+
"Required env vars/secrets:",
|
|
25464
|
+
...envLines,
|
|
25321
25465
|
"",
|
|
25322
|
-
"
|
|
25466
|
+
"Add more protected pages under src/routes/_authed/ (e.g. _authed/settings.tsx -> /settings); they inherit the guard.",
|
|
25323
25467
|
"",
|
|
25324
|
-
"
|
|
25325
|
-
"
|
|
25326
|
-
"
|
|
25468
|
+
"Verify before shipping: password hashing defaults to scrypt via Node's crypto, exposed through the " + "nodejs_compat flag (already on for this template) -- confirm a real sign-up/login round-trip works in `bun dev`.",
|
|
25469
|
+
"",
|
|
25470
|
+
"Mutually exclusive with setup_netsuite -- an app has one auth strategy, not two."
|
|
25327
25471
|
].join(`
|
|
25328
25472
|
`);
|
|
25329
25473
|
}
|
|
25330
25474
|
});
|
|
25331
|
-
var
|
|
25475
|
+
var SetupAuthenticationPlugin = async () => {
|
|
25332
25476
|
return {
|
|
25333
|
-
tool: {
|
|
25477
|
+
tool: { setup_authentication: setupAuthentication }
|
|
25334
25478
|
};
|
|
25335
25479
|
};
|
|
25336
|
-
// src/tools/setup-
|
|
25337
|
-
import { readFile } from "fs/promises";
|
|
25338
|
-
import
|
|
25339
|
-
|
|
25340
|
-
function usersTableBlock() {
|
|
25480
|
+
// src/tools/setup-netsuite.ts
|
|
25481
|
+
import { mkdir as mkdir2, readFile as readFile3 } from "fs/promises";
|
|
25482
|
+
import path7 from "path";
|
|
25483
|
+
function schemaAppend() {
|
|
25341
25484
|
return `
|
|
25342
|
-
|
|
25343
|
-
|
|
25344
|
-
|
|
25345
|
-
|
|
25485
|
+
// --- NetSuite OAuth (added by setup_netsuite) ---
|
|
25486
|
+
// Short-lived PKCE + return-to state between /auth/netsuite/start and its
|
|
25487
|
+
// callback. Single-use: the callback deletes the row after reading it.
|
|
25488
|
+
export const netsuiteFlows = sqliteTable('netsuite_flows', {
|
|
25489
|
+
flowId: text('flow_id').primaryKey(),
|
|
25490
|
+
codeVerifier: text('code_verifier').notNull(),
|
|
25491
|
+
returnTo: text('return_to').notNull(),
|
|
25346
25492
|
createdAt: integer('created_at')
|
|
25347
25493
|
.notNull()
|
|
25348
25494
|
.default(sql\`(unixepoch())\`),
|
|
25349
25495
|
})
|
|
25350
25496
|
|
|
25351
|
-
|
|
25497
|
+
// Opaque session id -> NetSuite tokens. Tokens stay server-side; the browser
|
|
25498
|
+
// only ever holds the sid (sent as a bearer).
|
|
25499
|
+
export const netsuiteSessions = sqliteTable('netsuite_sessions', {
|
|
25500
|
+
sid: text('sid').primaryKey(),
|
|
25501
|
+
accountId: text('account_id').notNull(),
|
|
25502
|
+
accessToken: text('access_token').notNull(),
|
|
25503
|
+
refreshToken: text('refresh_token'),
|
|
25504
|
+
expiresAt: integer('expires_at'),
|
|
25505
|
+
userId: text('user_id'),
|
|
25506
|
+
email: text('email'),
|
|
25507
|
+
createdAt: integer('created_at')
|
|
25508
|
+
.notNull()
|
|
25509
|
+
.default(sql\`(unixepoch())\`),
|
|
25510
|
+
})
|
|
25352
25511
|
`;
|
|
25353
25512
|
}
|
|
25354
|
-
function
|
|
25355
|
-
return
|
|
25356
|
-
|
|
25357
|
-
import { env } from '@helloleo/runtime'
|
|
25358
|
-
import { eq } from 'drizzle-orm'
|
|
25359
|
-
|
|
25360
|
-
/*
|
|
25361
|
-
* Password hashing, session JWTs, and user lookup. Used ONLY from
|
|
25362
|
-
* src/routes/api/auth/*.ts server routes -- never imported by a client
|
|
25363
|
-
* route file. Sessions are a stateless HMAC-signed JWT in an httpOnly
|
|
25364
|
-
* cookie, not a server-side session store: verifying a session costs one
|
|
25365
|
-
* signature check, no database or KV round-trip.
|
|
25513
|
+
function netsuiteServerTs() {
|
|
25514
|
+
return `/*
|
|
25515
|
+
* NetSuite runtime OAuth -- server side (TanStack Start server routes).
|
|
25366
25516
|
*
|
|
25367
|
-
*
|
|
25368
|
-
*
|
|
25369
|
-
*
|
|
25370
|
-
*
|
|
25371
|
-
*
|
|
25517
|
+
* Runs wherever the app runs: \`vite dev\` (workerd) in the Fly preview machine
|
|
25518
|
+
* in dev, and the published Cloudflare Worker in prod. There is no separate
|
|
25519
|
+
* backend Worker -- these helpers back the /auth/netsuite/* + /api/me routes.
|
|
25520
|
+
*
|
|
25521
|
+
* Config is read from the Cloudflare env binding via @helloleo/runtime.
|
|
25522
|
+
* Set NETSUITE_ACCOUNT_ID / _CLIENT_ID / _CLIENT_SECRET (secret optional for a
|
|
25523
|
+
* PKCE public client) and optional PUBLIC_APP_URL as HelloLeo Cloud secrets
|
|
25524
|
+
* (prod) or dev vars (preview). They never reach the browser.
|
|
25372
25525
|
*/
|
|
25526
|
+
import { eq } from 'drizzle-orm'
|
|
25527
|
+
import { env as runtimeEnv } from '@helloleo/runtime'
|
|
25528
|
+
import { db } from '#/db'
|
|
25529
|
+
import { netsuiteSessions } from '#/db/schema'
|
|
25373
25530
|
|
|
25374
|
-
|
|
25375
|
-
|
|
25376
|
-
|
|
25531
|
+
interface NsEnv {
|
|
25532
|
+
NETSUITE_ACCOUNT_ID?: string
|
|
25533
|
+
NETSUITE_CLIENT_ID?: string
|
|
25534
|
+
NETSUITE_CLIENT_SECRET?: string
|
|
25535
|
+
PUBLIC_APP_URL?: string
|
|
25536
|
+
HELLOLEO_URL?: string
|
|
25537
|
+
}
|
|
25538
|
+
const nsEnv = runtimeEnv as unknown as NsEnv
|
|
25377
25539
|
|
|
25378
|
-
|
|
25379
|
-
|
|
25380
|
-
|
|
25540
|
+
// Runtime record scope. The app signs end-users in for REST/SuiteQL.
|
|
25541
|
+
export const SCOPE = 'rest_webservices'
|
|
25542
|
+
const REFRESH_LEEWAY_SECONDS = 60
|
|
25543
|
+
|
|
25544
|
+
export function requireConfig() {
|
|
25545
|
+
const accountId = nsEnv.NETSUITE_ACCOUNT_ID
|
|
25546
|
+
const clientId = nsEnv.NETSUITE_CLIENT_ID
|
|
25547
|
+
if (!accountId || !clientId) {
|
|
25381
25548
|
throw new Error(
|
|
25382
|
-
'
|
|
25383
|
-
'\`wrangler secret put AUTH_JWT_SECRET\` (prod).',
|
|
25549
|
+
'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.',
|
|
25384
25550
|
)
|
|
25385
25551
|
}
|
|
25386
|
-
return
|
|
25552
|
+
return { accountId, clientId, clientSecret: nsEnv.NETSUITE_CLIENT_SECRET || '' }
|
|
25387
25553
|
}
|
|
25388
25554
|
|
|
25389
|
-
|
|
25390
|
-
|
|
25391
|
-
|
|
25392
|
-
.join('')
|
|
25555
|
+
// Account id -> DNS host form: lowercase, underscores to hyphens.
|
|
25556
|
+
function host(accountId: string) {
|
|
25557
|
+
return accountId.trim().toLowerCase().replace(/_/g, '-')
|
|
25393
25558
|
}
|
|
25394
|
-
|
|
25395
|
-
|
|
25396
|
-
if (a.length !== b.length) return false
|
|
25397
|
-
let diff = 0
|
|
25398
|
-
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
|
|
25399
|
-
return diff === 0
|
|
25559
|
+
export function authorizeUrl(accountId: string) {
|
|
25560
|
+
return \`https://\${host(accountId)}.app.netsuite.com/app/login/oauth2/authorize.nl\`
|
|
25400
25561
|
}
|
|
25401
|
-
|
|
25402
|
-
|
|
25403
|
-
// demo app -- swap for a WASM bcrypt/argon2 if this ever guards real data) ----
|
|
25404
|
-
|
|
25405
|
-
export function generateSalt(): string {
|
|
25406
|
-
return toHex(crypto.getRandomValues(new Uint8Array(16)))
|
|
25562
|
+
export function tokenUrl(accountId: string) {
|
|
25563
|
+
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token\`
|
|
25407
25564
|
}
|
|
25408
|
-
|
|
25409
|
-
|
|
25410
|
-
const digest = await crypto.subtle.digest('SHA-256', encoder.encode(input))
|
|
25411
|
-
return toHex(new Uint8Array(digest))
|
|
25565
|
+
export function restBase(accountId: string) {
|
|
25566
|
+
return \`https://\${host(accountId)}.suitetalk.api.netsuite.com\`
|
|
25412
25567
|
}
|
|
25413
25568
|
|
|
25414
|
-
|
|
25415
|
-
return
|
|
25569
|
+
function stripTrailingSlash(s: string) {
|
|
25570
|
+
return s.endsWith('/') ? s.slice(0, -1) : s
|
|
25416
25571
|
}
|
|
25417
25572
|
|
|
25418
|
-
|
|
25419
|
-
|
|
25420
|
-
|
|
25421
|
-
|
|
25573
|
+
// Public origin of THIS app, used to build the redirect URI. Prefer an explicit
|
|
25574
|
+
// PUBLIC_APP_URL; else derive from the request (honouring the preview proxy's
|
|
25575
|
+
// forwarded headers). Must match a redirect URI registered on the NetSuite
|
|
25576
|
+
// Integration Record exactly.
|
|
25577
|
+
export function appOrigin(request: Request) {
|
|
25578
|
+
// 1) Explicit override wins (set by the platform per environment).
|
|
25579
|
+
if (nsEnv.PUBLIC_APP_URL) return stripTrailingSlash(nsEnv.PUBLIC_APP_URL)
|
|
25580
|
+
// 2) HELLOLEO_URL is injected on every deploy (the app's canonical origin), so
|
|
25581
|
+
// it's set even when the NetSuite wizard's PUBLIC_APP_URL pin wasn't.
|
|
25582
|
+
if (nsEnv.HELLOLEO_URL) return stripTrailingSlash(nsEnv.HELLOLEO_URL)
|
|
25583
|
+
// 3) Preview/prod proxies rewrite Host, so trust the forwarded/original host
|
|
25584
|
+
// they set (HelloLeo's nginx sends X-Original-Host) \u2014 that's the public URL
|
|
25585
|
+
// the browser loaded, not an internal upstream host.
|
|
25586
|
+
const url = new URL(request.url)
|
|
25587
|
+
const proto = request.headers.get('x-forwarded-proto') || url.protocol.replace(':', '')
|
|
25588
|
+
const hostHeader =
|
|
25589
|
+
request.headers.get('x-forwarded-host') ||
|
|
25590
|
+
request.headers.get('x-original-host') ||
|
|
25591
|
+
url.host
|
|
25592
|
+
return \`\${proto}://\${hostHeader}\`
|
|
25593
|
+
}
|
|
25594
|
+
export function redirectUri(request: Request) {
|
|
25595
|
+
return \`\${appOrigin(request)}/auth/netsuite/callback\`
|
|
25422
25596
|
}
|
|
25423
25597
|
|
|
25424
|
-
//
|
|
25425
|
-
|
|
25426
|
-
|
|
25427
|
-
|
|
25428
|
-
|
|
25429
|
-
|
|
25598
|
+
// Coerce ?return_to= to our OWN origin. The callback appends the session bearer
|
|
25599
|
+
// as a #nssid=... fragment; an off-origin return_to would leak that live
|
|
25600
|
+
// credential to an attacker-controlled page (open-redirect -> token theft).
|
|
25601
|
+
export function safeReturnTo(request: Request, raw: string | null): string {
|
|
25602
|
+
const origin = appOrigin(request)
|
|
25603
|
+
if (!raw) return origin
|
|
25604
|
+
try {
|
|
25605
|
+
const u = new URL(raw, origin)
|
|
25606
|
+
return u.origin === origin ? u.toString() : origin
|
|
25607
|
+
} catch {
|
|
25608
|
+
return origin
|
|
25609
|
+
}
|
|
25430
25610
|
}
|
|
25431
25611
|
|
|
25432
|
-
|
|
25433
|
-
|
|
25434
|
-
|
|
25612
|
+
// --- PKCE (Web Crypto: works on workerd and Node) ---
|
|
25613
|
+
function b64url(bytes: Uint8Array) {
|
|
25614
|
+
let s = ''
|
|
25615
|
+
for (const b of bytes) s += String.fromCharCode(b)
|
|
25616
|
+
return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')
|
|
25617
|
+
}
|
|
25618
|
+
export async function pkce() {
|
|
25619
|
+
const verifier = b64url(crypto.getRandomValues(new Uint8Array(48)))
|
|
25620
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
25621
|
+
const challenge = b64url(new Uint8Array(digest))
|
|
25622
|
+
return { verifier, challenge }
|
|
25435
25623
|
}
|
|
25436
25624
|
|
|
25437
|
-
|
|
25438
|
-
|
|
25439
|
-
|
|
25440
|
-
|
|
25441
|
-
|
|
25442
|
-
|
|
25443
|
-
['sign'],
|
|
25444
|
-
)
|
|
25625
|
+
interface TokenResponse {
|
|
25626
|
+
access_token?: string
|
|
25627
|
+
refresh_token?: string
|
|
25628
|
+
expires_in?: number
|
|
25629
|
+
error?: string
|
|
25630
|
+
error_description?: string
|
|
25445
25631
|
}
|
|
25446
25632
|
|
|
25447
|
-
|
|
25448
|
-
|
|
25449
|
-
const
|
|
25450
|
-
|
|
25451
|
-
|
|
25452
|
-
|
|
25453
|
-
)
|
|
25454
|
-
const key = await hmacKey(getAuthSecret())
|
|
25455
|
-
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(\`\${header}.\${payload}\`))
|
|
25456
|
-
return \`\${header}.\${payload}.\${base64url(new Uint8Array(sig))}\`
|
|
25633
|
+
// Confidential client -> HTTP Basic. Public (PKCE-only) -> client_id in body.
|
|
25634
|
+
function authHeaders(clientId: string, clientSecret: string, params: Record<string, string>) {
|
|
25635
|
+
const headers: Record<string, string> = { 'Content-Type': 'application/x-www-form-urlencoded' }
|
|
25636
|
+
if (clientSecret) headers.Authorization = 'Basic ' + btoa(\`\${clientId}:\${clientSecret}\`)
|
|
25637
|
+
else params.client_id = clientId
|
|
25638
|
+
return headers
|
|
25457
25639
|
}
|
|
25458
25640
|
|
|
25459
|
-
|
|
25460
|
-
|
|
25461
|
-
|
|
25462
|
-
|
|
25463
|
-
|
|
25464
|
-
|
|
25465
|
-
|
|
25466
|
-
|
|
25467
|
-
|
|
25641
|
+
// POST the token endpoint and parse tolerantly. NetSuite returns JSON for both
|
|
25642
|
+
// success and OAuth errors, but a gateway (502/504) can return HTML -- never let
|
|
25643
|
+
// res.json() throw into the route handler (that would surface as an opaque 500
|
|
25644
|
+
// instead of a clean "token exchange failed").
|
|
25645
|
+
async function postToken(
|
|
25646
|
+
accountId: string,
|
|
25647
|
+
params: Record<string, string>,
|
|
25648
|
+
headers: Record<string, string>,
|
|
25649
|
+
): Promise<TokenResponse> {
|
|
25650
|
+
let res: Response
|
|
25468
25651
|
try {
|
|
25469
|
-
|
|
25470
|
-
|
|
25471
|
-
return
|
|
25652
|
+
res = await fetch(tokenUrl(accountId), { method: 'POST', headers, body: new URLSearchParams(params) })
|
|
25653
|
+
} catch (e) {
|
|
25654
|
+
return { error: 'network_error', error_description: String(e) }
|
|
25655
|
+
}
|
|
25656
|
+
const text = await res.text()
|
|
25657
|
+
let data: TokenResponse = {}
|
|
25658
|
+
try {
|
|
25659
|
+
data = JSON.parse(text) as TokenResponse
|
|
25472
25660
|
} catch {
|
|
25473
|
-
|
|
25661
|
+
// non-JSON body (e.g. gateway HTML) -- leave data empty, fall through
|
|
25662
|
+
}
|
|
25663
|
+
if (!res.ok && !data.error) {
|
|
25664
|
+
data = { error: 'token_request_failed', error_description: 'HTTP ' + res.status }
|
|
25474
25665
|
}
|
|
25666
|
+
return data
|
|
25475
25667
|
}
|
|
25476
25668
|
|
|
25477
|
-
export function
|
|
25478
|
-
|
|
25669
|
+
export async function exchangeCode(request: Request, code: string, verifier: string) {
|
|
25670
|
+
const { accountId, clientId, clientSecret } = requireConfig()
|
|
25671
|
+
const params: Record<string, string> = {
|
|
25672
|
+
grant_type: 'authorization_code',
|
|
25673
|
+
code,
|
|
25674
|
+
redirect_uri: redirectUri(request),
|
|
25675
|
+
code_verifier: verifier,
|
|
25676
|
+
}
|
|
25677
|
+
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
25479
25678
|
}
|
|
25480
25679
|
|
|
25481
|
-
|
|
25482
|
-
|
|
25680
|
+
async function refreshToken(refresh: string) {
|
|
25681
|
+
const { accountId, clientId, clientSecret } = requireConfig()
|
|
25682
|
+
const params: Record<string, string> = { grant_type: 'refresh_token', refresh_token: refresh }
|
|
25683
|
+
return postToken(accountId, params, authHeaders(clientId, clientSecret, params))
|
|
25483
25684
|
}
|
|
25484
25685
|
|
|
25485
|
-
export
|
|
25486
|
-
|
|
25487
|
-
|
|
25488
|
-
|
|
25489
|
-
|
|
25490
|
-
|
|
25491
|
-
|
|
25492
|
-
|
|
25686
|
+
export interface NsSession {
|
|
25687
|
+
sid: string
|
|
25688
|
+
accountId: string
|
|
25689
|
+
accessToken: string
|
|
25690
|
+
refreshToken: string | null
|
|
25691
|
+
expiresAt: number | null
|
|
25692
|
+
userId: string | null
|
|
25693
|
+
email: string | null
|
|
25493
25694
|
}
|
|
25494
25695
|
|
|
25495
|
-
|
|
25496
|
-
|
|
25497
|
-
|
|
25498
|
-
|
|
25499
|
-
|
|
25500
|
-
const
|
|
25501
|
-
|
|
25502
|
-
|
|
25503
|
-
|
|
25504
|
-
|
|
25505
|
-
|
|
25696
|
+
// Load a session by sid, transparently refreshing the access token when it's
|
|
25697
|
+
// within the leeway of expiry. Returns null if unknown or unrefreshable.
|
|
25698
|
+
export async function getSession(sid: string): Promise<NsSession | null> {
|
|
25699
|
+
if (!sid) return null
|
|
25700
|
+
const rows = await db.select().from(netsuiteSessions).where(eq(netsuiteSessions.sid, sid)).limit(1)
|
|
25701
|
+
const s = rows[0]
|
|
25702
|
+
if (!s) return null
|
|
25703
|
+
const now = Math.floor(Date.now() / 1000)
|
|
25704
|
+
// A missing expiry is treated as EXPIRED (force a refresh) rather than trusting
|
|
25705
|
+
// the token forever -- NetSuite normally sends expires_in, so this is defensive.
|
|
25706
|
+
const expired = s.expiresAt == null ? true : now + REFRESH_LEEWAY_SECONDS >= s.expiresAt
|
|
25707
|
+
if (expired) {
|
|
25708
|
+
// Token expired (or about to). It MUST refresh; if it can't, the session is
|
|
25709
|
+
// dead \u2014 delete it and return null so /api/me reports signed-out and the user
|
|
25710
|
+
// re-auths, rather than a "logged in" app whose every NetSuite call 401s.
|
|
25711
|
+
if (!s.refreshToken) { await deleteSession(sid); return null }
|
|
25712
|
+
const tok = await refreshToken(s.refreshToken)
|
|
25713
|
+
if (!tok.access_token) { await deleteSession(sid); return null }
|
|
25714
|
+
const expiresAt = now + (tok.expires_in || 3600)
|
|
25715
|
+
const refreshTok = tok.refresh_token ?? s.refreshToken
|
|
25716
|
+
await db
|
|
25717
|
+
.update(netsuiteSessions)
|
|
25718
|
+
.set({ accessToken: tok.access_token, refreshToken: refreshTok, expiresAt })
|
|
25719
|
+
.where(eq(netsuiteSessions.sid, sid))
|
|
25720
|
+
return { ...s, accessToken: tok.access_token, refreshToken: refreshTok, expiresAt }
|
|
25721
|
+
}
|
|
25722
|
+
return s
|
|
25723
|
+
}
|
|
25724
|
+
|
|
25725
|
+
export async function deleteSession(sid: string) {
|
|
25726
|
+
if (sid) await db.delete(netsuiteSessions).where(eq(netsuiteSessions.sid, sid))
|
|
25727
|
+
}
|
|
25728
|
+
|
|
25729
|
+
export function bearer(request: Request) {
|
|
25730
|
+
const h = request.headers.get('authorization') || ''
|
|
25731
|
+
return h.startsWith('Bearer ') ? h.slice(7) : ''
|
|
25732
|
+
}
|
|
25733
|
+
|
|
25734
|
+
/**
|
|
25735
|
+
* Call NetSuite REST / SuiteQL for the signed-in session. Use this from your
|
|
25736
|
+
* data routes (src/routes/api/*). \`path\` is relative to the account's
|
|
25737
|
+
* suitetalk host, e.g. '/services/rest/query/v1/suiteql'. Returns the raw
|
|
25738
|
+
* fetch Response (401 if the sid is unknown). Access token is auto-refreshed.
|
|
25739
|
+
*/
|
|
25740
|
+
export async function netsuiteFetch(sid: string, path: string, init: RequestInit = {}): Promise<Response> {
|
|
25741
|
+
const session = await getSession(sid)
|
|
25742
|
+
if (!session) return new Response(JSON.stringify({ error: 'unauthorized' }), { status: 401 })
|
|
25743
|
+
const url = path.startsWith('http') ? path : \`\${restBase(session.accountId)}\${path}\`
|
|
25744
|
+
const headers = new Headers(init.headers)
|
|
25745
|
+
headers.set('Authorization', \`Bearer \${session.accessToken}\`)
|
|
25746
|
+
if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
|
|
25747
|
+
return fetch(url, { ...init, headers })
|
|
25506
25748
|
}
|
|
25507
25749
|
`;
|
|
25508
25750
|
}
|
|
25509
|
-
function
|
|
25751
|
+
function startRouteTs() {
|
|
25510
25752
|
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25511
25753
|
import { db } from '#/db'
|
|
25512
|
-
import {
|
|
25513
|
-
import {
|
|
25514
|
-
|
|
25515
|
-
|
|
25516
|
-
|
|
25517
|
-
|
|
25754
|
+
import { netsuiteFlows } from '#/db/schema'
|
|
25755
|
+
import {
|
|
25756
|
+
authorizeUrl,
|
|
25757
|
+
pkce,
|
|
25758
|
+
redirectUri,
|
|
25759
|
+
requireConfig,
|
|
25760
|
+
safeReturnTo,
|
|
25761
|
+
SCOPE,
|
|
25762
|
+
} from '#/lib/netsuite-server'
|
|
25518
25763
|
|
|
25519
|
-
|
|
25764
|
+
// GET /auth/netsuite/start -- begin the NetSuite OAuth (PKCE) flow.
|
|
25765
|
+
export const Route = createFileRoute('/auth/netsuite/start')({
|
|
25520
25766
|
server: {
|
|
25521
25767
|
handlers: {
|
|
25522
|
-
|
|
25523
|
-
const
|
|
25524
|
-
|
|
25525
|
-
|
|
25526
|
-
|
|
25527
|
-
const email = parsed.data.email.toLowerCase()
|
|
25768
|
+
GET: async ({ request }) => {
|
|
25769
|
+
const { accountId, clientId } = requireConfig()
|
|
25770
|
+
const url = new URL(request.url)
|
|
25771
|
+
// Same-origin only: the callback returns the bearer in a URL fragment.
|
|
25772
|
+
const returnTo = safeReturnTo(request, url.searchParams.get('return_to'))
|
|
25528
25773
|
|
|
25529
|
-
const
|
|
25530
|
-
|
|
25531
|
-
|
|
25532
|
-
}
|
|
25774
|
+
const { verifier, challenge } = await pkce()
|
|
25775
|
+
const flowId = crypto.randomUUID()
|
|
25776
|
+
await db.insert(netsuiteFlows).values({ flowId, codeVerifier: verifier, returnTo })
|
|
25533
25777
|
|
|
25534
|
-
const
|
|
25535
|
-
|
|
25536
|
-
|
|
25537
|
-
|
|
25538
|
-
|
|
25539
|
-
|
|
25778
|
+
const authorize = new URL(authorizeUrl(accountId))
|
|
25779
|
+
authorize.searchParams.set('response_type', 'code')
|
|
25780
|
+
authorize.searchParams.set('client_id', clientId)
|
|
25781
|
+
authorize.searchParams.set('redirect_uri', redirectUri(request))
|
|
25782
|
+
authorize.searchParams.set('scope', SCOPE)
|
|
25783
|
+
authorize.searchParams.set('state', flowId)
|
|
25784
|
+
authorize.searchParams.set('code_challenge', challenge)
|
|
25785
|
+
authorize.searchParams.set('code_challenge_method', 'S256')
|
|
25540
25786
|
|
|
25541
|
-
|
|
25542
|
-
return Response.json(
|
|
25543
|
-
{ user },
|
|
25544
|
-
{ status: 201, headers: { 'Set-Cookie': sessionCookieHeader(token) } },
|
|
25545
|
-
)
|
|
25787
|
+
return new Response(null, { status: 302, headers: { Location: authorize.toString() } })
|
|
25546
25788
|
},
|
|
25547
25789
|
},
|
|
25548
25790
|
},
|
|
25549
25791
|
})
|
|
25550
25792
|
`;
|
|
25551
25793
|
}
|
|
25552
|
-
function
|
|
25794
|
+
function callbackRouteTs() {
|
|
25553
25795
|
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25554
|
-
import { db } from '#/db'
|
|
25555
|
-
import { users } from '#/db/schema'
|
|
25556
|
-
import { sessionCookieHeader, signSession, verifyPassword } from '#/lib/auth'
|
|
25557
25796
|
import { eq } from 'drizzle-orm'
|
|
25558
|
-
import {
|
|
25559
|
-
|
|
25560
|
-
|
|
25797
|
+
import { db } from '#/db'
|
|
25798
|
+
import { netsuiteFlows, netsuiteSessions } from '#/db/schema'
|
|
25799
|
+
import { exchangeCode, requireConfig } from '#/lib/netsuite-server'
|
|
25561
25800
|
|
|
25562
|
-
|
|
25801
|
+
// GET /auth/netsuite/callback -- exchange the code, mint a session, hand the
|
|
25802
|
+
// bearer back to the SPA via the URL fragment (#nssid=...). The fragment
|
|
25803
|
+
// survives the HelloLeo preview iframe, where third-party cookies are dropped.
|
|
25804
|
+
export const Route = createFileRoute('/auth/netsuite/callback')({
|
|
25563
25805
|
server: {
|
|
25564
25806
|
handlers: {
|
|
25565
|
-
|
|
25566
|
-
const
|
|
25567
|
-
|
|
25568
|
-
|
|
25807
|
+
GET: async ({ request }) => {
|
|
25808
|
+
const url = new URL(request.url)
|
|
25809
|
+
const err = url.searchParams.get('error')
|
|
25810
|
+
if (err) return new Response(\`NetSuite authorization failed: \${err}\`, { status: 400 })
|
|
25811
|
+
|
|
25812
|
+
const code = url.searchParams.get('code')
|
|
25813
|
+
const state = url.searchParams.get('state')
|
|
25814
|
+
if (!code || !state) return new Response('Missing code or state', { status: 400 })
|
|
25815
|
+
|
|
25816
|
+
const rows = await db
|
|
25817
|
+
.select()
|
|
25818
|
+
.from(netsuiteFlows)
|
|
25819
|
+
.where(eq(netsuiteFlows.flowId, state))
|
|
25820
|
+
.limit(1)
|
|
25821
|
+
const flow = rows[0]
|
|
25822
|
+
if (!flow) return new Response('Unknown or expired flow -- start again', { status: 400 })
|
|
25823
|
+
// Single-use: burn the flow row immediately.
|
|
25824
|
+
await db.delete(netsuiteFlows).where(eq(netsuiteFlows.flowId, state))
|
|
25825
|
+
// Reject stale flows: the PKCE verifier must not stay valid indefinitely
|
|
25826
|
+
// for an abandoned or leaked authorize URL (createdAt is unixepoch seconds).
|
|
25827
|
+
const nowSec = Math.floor(Date.now() / 1000)
|
|
25828
|
+
if (flow.createdAt && nowSec - flow.createdAt > 600) {
|
|
25829
|
+
return new Response('Flow expired -- start again', { status: 400 })
|
|
25569
25830
|
}
|
|
25570
|
-
const email = parsed.data.email.toLowerCase()
|
|
25571
25831
|
|
|
25572
|
-
const
|
|
25573
|
-
if (!
|
|
25574
|
-
return Response
|
|
25832
|
+
const tok = await exchangeCode(request, code, flow.codeVerifier)
|
|
25833
|
+
if (!tok.access_token) {
|
|
25834
|
+
return new Response(
|
|
25835
|
+
\`Token exchange failed: \${tok.error_description || tok.error || 'unknown error'}\`,
|
|
25836
|
+
{ status: 400 },
|
|
25837
|
+
)
|
|
25575
25838
|
}
|
|
25576
25839
|
|
|
25577
|
-
const
|
|
25578
|
-
|
|
25579
|
-
|
|
25580
|
-
|
|
25581
|
-
|
|
25840
|
+
const sid = crypto.randomUUID()
|
|
25841
|
+
const now = Math.floor(Date.now() / 1000)
|
|
25842
|
+
await db.insert(netsuiteSessions).values({
|
|
25843
|
+
sid,
|
|
25844
|
+
accountId: requireConfig().accountId,
|
|
25845
|
+
accessToken: tok.access_token,
|
|
25846
|
+
refreshToken: tok.refresh_token ?? null,
|
|
25847
|
+
expiresAt: now + (tok.expires_in || 3600),
|
|
25848
|
+
})
|
|
25849
|
+
|
|
25850
|
+
const dest = new URL(flow.returnTo)
|
|
25851
|
+
dest.hash = \`nssid=\${sid}\`
|
|
25852
|
+
return new Response(null, { status: 302, headers: { Location: dest.toString() } })
|
|
25582
25853
|
},
|
|
25583
25854
|
},
|
|
25584
25855
|
},
|
|
@@ -25587,13 +25858,15 @@ export const Route = createFileRoute('/api/auth/login')({
|
|
|
25587
25858
|
}
|
|
25588
25859
|
function logoutRouteTs() {
|
|
25589
25860
|
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25590
|
-
import {
|
|
25861
|
+
import { bearer, deleteSession } from '#/lib/netsuite-server'
|
|
25591
25862
|
|
|
25592
|
-
|
|
25863
|
+
// POST /auth/netsuite/logout -- destroy the current session.
|
|
25864
|
+
export const Route = createFileRoute('/auth/netsuite/logout')({
|
|
25593
25865
|
server: {
|
|
25594
25866
|
handlers: {
|
|
25595
|
-
POST: async () => {
|
|
25596
|
-
|
|
25867
|
+
POST: async ({ request }) => {
|
|
25868
|
+
await deleteSession(bearer(request))
|
|
25869
|
+
return Response.json({ ok: true })
|
|
25597
25870
|
},
|
|
25598
25871
|
},
|
|
25599
25872
|
},
|
|
@@ -25602,279 +25875,462 @@ export const Route = createFileRoute('/api/auth/logout')({
|
|
|
25602
25875
|
}
|
|
25603
25876
|
function meRouteTs() {
|
|
25604
25877
|
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25605
|
-
import {
|
|
25878
|
+
import { bearer, getSession } from '#/lib/netsuite-server'
|
|
25606
25879
|
|
|
25607
|
-
|
|
25880
|
+
// GET /api/me -- resolve the bearer sid to a session. The ONLY authoritative
|
|
25881
|
+
// auth check; the client must never trust sessionStorage on its own.
|
|
25882
|
+
export const Route = createFileRoute('/api/me')({
|
|
25608
25883
|
server: {
|
|
25609
25884
|
handlers: {
|
|
25610
25885
|
GET: async ({ request }) => {
|
|
25611
|
-
const
|
|
25612
|
-
return Response.json({
|
|
25886
|
+
const session = await getSession(bearer(request))
|
|
25887
|
+
if (!session) return Response.json({ authenticated: false })
|
|
25888
|
+
return Response.json({
|
|
25889
|
+
authenticated: true,
|
|
25890
|
+
account_id: session.accountId,
|
|
25891
|
+
})
|
|
25613
25892
|
},
|
|
25614
25893
|
},
|
|
25615
25894
|
},
|
|
25616
25895
|
})
|
|
25617
25896
|
`;
|
|
25618
25897
|
}
|
|
25619
|
-
function
|
|
25898
|
+
function configRouteTs() {
|
|
25899
|
+
return `import { createFileRoute } from '@tanstack/react-router'
|
|
25900
|
+
import { env as runtimeEnv } from '@helloleo/runtime'
|
|
25901
|
+
import { appOrigin, redirectUri } from '#/lib/netsuite-server'
|
|
25902
|
+
|
|
25903
|
+
// GET /auth/netsuite/config -- single source of truth for the redirect URI.
|
|
25904
|
+
// Whatever this returns is EXACTLY what /auth/netsuite/start sends NetSuite, so
|
|
25905
|
+
// show the user THIS value to register on their Integration Record. Never
|
|
25906
|
+
// hardcode or guess a callback URL.
|
|
25907
|
+
export const Route = createFileRoute('/auth/netsuite/config')({
|
|
25908
|
+
server: {
|
|
25909
|
+
handlers: {
|
|
25910
|
+
GET: async ({ request }) => {
|
|
25911
|
+
const e = runtimeEnv as unknown as {
|
|
25912
|
+
NETSUITE_ACCOUNT_ID?: string
|
|
25913
|
+
NETSUITE_CLIENT_ID?: string
|
|
25914
|
+
}
|
|
25915
|
+
return Response.json({
|
|
25916
|
+
redirect_uri: redirectUri(request),
|
|
25917
|
+
origin: appOrigin(request),
|
|
25918
|
+
configured: Boolean(e.NETSUITE_ACCOUNT_ID && e.NETSUITE_CLIENT_ID),
|
|
25919
|
+
})
|
|
25920
|
+
},
|
|
25921
|
+
},
|
|
25922
|
+
},
|
|
25923
|
+
})
|
|
25924
|
+
`;
|
|
25925
|
+
}
|
|
25926
|
+
function netsuiteClientTs() {
|
|
25620
25927
|
return `/*
|
|
25621
|
-
*
|
|
25622
|
-
*
|
|
25623
|
-
*
|
|
25928
|
+
* NetSuite auth -- client (same-origin). The OAuth backend is THIS app's own
|
|
25929
|
+
* server routes (src/routes/auth/netsuite/* + src/routes/api/me.ts), so every
|
|
25930
|
+
* call is relative -- no separate Worker, no VITE_WORKER_URL.
|
|
25931
|
+
*
|
|
25932
|
+
* The browser only ever holds an opaque session id (sid), handed back in the
|
|
25933
|
+
* callback's URL fragment (#nssid=...) so it survives the HelloLeo preview
|
|
25934
|
+
* iframe (third-party cookies are partitioned/dropped there). The sid rides as
|
|
25935
|
+
* Authorization: Bearer on every call; NetSuite tokens stay server-side. The
|
|
25936
|
+
* only authoritative auth check is GET /api/me.
|
|
25937
|
+
*
|
|
25938
|
+
* NEVER use a password gate. NEVER trust sessionStorage as the auth state --
|
|
25939
|
+
* it's just a cache of the bearer.
|
|
25624
25940
|
*/
|
|
25941
|
+
const TOKEN_KEY = 'nssid'
|
|
25942
|
+
|
|
25943
|
+
// Capture-and-strip #nssid=... as early as possible so URLs stay clean.
|
|
25944
|
+
;(function bootstrapTokenFromHash() {
|
|
25945
|
+
if (typeof window === 'undefined') return
|
|
25946
|
+
const hash = window.location.hash.replace(/^#/, '')
|
|
25947
|
+
if (!hash) return
|
|
25948
|
+
const params = new URLSearchParams(hash)
|
|
25949
|
+
const sid = params.get(TOKEN_KEY)
|
|
25950
|
+
if (sid) {
|
|
25951
|
+
try {
|
|
25952
|
+
sessionStorage.setItem(TOKEN_KEY, sid)
|
|
25953
|
+
} catch {}
|
|
25954
|
+
params.delete(TOKEN_KEY)
|
|
25955
|
+
const rest = params.toString()
|
|
25956
|
+
window.history.replaceState(
|
|
25957
|
+
null,
|
|
25958
|
+
'',
|
|
25959
|
+
window.location.pathname + window.location.search + (rest ? '#' + rest : ''),
|
|
25960
|
+
)
|
|
25961
|
+
}
|
|
25962
|
+
})()
|
|
25625
25963
|
|
|
25626
|
-
export
|
|
25627
|
-
|
|
25628
|
-
|
|
25964
|
+
export function getSessionToken(): string | null {
|
|
25965
|
+
try {
|
|
25966
|
+
return sessionStorage.getItem(TOKEN_KEY)
|
|
25967
|
+
} catch {
|
|
25968
|
+
return null
|
|
25969
|
+
}
|
|
25629
25970
|
}
|
|
25630
25971
|
|
|
25631
|
-
export
|
|
25632
|
-
|
|
25633
|
-
|
|
25634
|
-
|
|
25635
|
-
|
|
25636
|
-
|
|
25972
|
+
export function signInWithNetSuite(returnTo: string = window.location.href) {
|
|
25973
|
+
const url = new URL('/auth/netsuite/start', window.location.origin)
|
|
25974
|
+
url.searchParams.set('return_to', returnTo)
|
|
25975
|
+
// The app renders inside the HelloLeo preview iframe; /start 302s to
|
|
25976
|
+
// NetSuite, whose OAuth page sends X-Frame-Options: DENY. Navigate the TOP
|
|
25977
|
+
// frame so the flow runs at top level and the callback lands there to read
|
|
25978
|
+
// #nssid=. Assign .href (NOT .assign(): reading .assign off a cross-origin
|
|
25979
|
+
// window.top.location throws, dropping us into the iframe fallback ->
|
|
25980
|
+
// "refused to connect").
|
|
25981
|
+
try {
|
|
25982
|
+
if (window.top && window.top !== window) {
|
|
25983
|
+
window.top.location.href = url.toString()
|
|
25984
|
+
return
|
|
25985
|
+
}
|
|
25986
|
+
} catch {
|
|
25987
|
+
/* top window unreachable -- fall through to same-frame navigation */
|
|
25988
|
+
}
|
|
25989
|
+
window.location.href = url.toString()
|
|
25637
25990
|
}
|
|
25638
25991
|
|
|
25639
|
-
export
|
|
25640
|
-
|
|
25641
|
-
|
|
25642
|
-
|
|
25643
|
-
|
|
25644
|
-
|
|
25992
|
+
export interface NetSuiteSession {
|
|
25993
|
+
authenticated: boolean
|
|
25994
|
+
account_id?: string
|
|
25995
|
+
}
|
|
25996
|
+
|
|
25997
|
+
export async function getNetSuiteSession(): Promise<NetSuiteSession> {
|
|
25998
|
+
const sid = getSessionToken()
|
|
25999
|
+
if (!sid) return { authenticated: false }
|
|
26000
|
+
const res = await fetch('/api/me', { headers: { Authorization: 'Bearer ' + sid } })
|
|
26001
|
+
if (!res.ok) return { authenticated: false }
|
|
25645
26002
|
const data = await res.json()
|
|
25646
|
-
return
|
|
26003
|
+
return { authenticated: Boolean(data?.authenticated), ...data }
|
|
26004
|
+
}
|
|
26005
|
+
|
|
26006
|
+
export async function signOutNetSuite(): Promise<void> {
|
|
26007
|
+
const sid = getSessionToken()
|
|
26008
|
+
if (sid) {
|
|
26009
|
+
try {
|
|
26010
|
+
await fetch('/auth/netsuite/logout', {
|
|
26011
|
+
method: 'POST',
|
|
26012
|
+
headers: { Authorization: 'Bearer ' + sid },
|
|
26013
|
+
})
|
|
26014
|
+
} catch {}
|
|
26015
|
+
}
|
|
26016
|
+
try {
|
|
26017
|
+
sessionStorage.removeItem(TOKEN_KEY)
|
|
26018
|
+
} catch {}
|
|
26019
|
+
window.location.reload()
|
|
25647
26020
|
}
|
|
25648
26021
|
|
|
25649
|
-
|
|
25650
|
-
|
|
25651
|
-
|
|
25652
|
-
|
|
25653
|
-
|
|
25654
|
-
|
|
25655
|
-
const
|
|
25656
|
-
|
|
26022
|
+
/**
|
|
26023
|
+
* Fetch YOUR data routes (src/routes/api/*) with the bearer attached. Use this
|
|
26024
|
+
* instead of bare fetch for authenticated calls.
|
|
26025
|
+
*/
|
|
26026
|
+
export async function apiFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
26027
|
+
const sid = getSessionToken()
|
|
26028
|
+
const headers = new Headers(init.headers)
|
|
26029
|
+
if (sid) headers.set('Authorization', 'Bearer ' + sid)
|
|
26030
|
+
return fetch(path, { ...init, headers })
|
|
25657
26031
|
}
|
|
25658
26032
|
|
|
25659
|
-
|
|
25660
|
-
|
|
26033
|
+
/**
|
|
26034
|
+
* The exact redirect URI this app will send NetSuite, read from the server so
|
|
26035
|
+
* it can never drift from what /auth/netsuite/start actually uses. Show it to
|
|
26036
|
+
* whoever registers the Integration Record; never hardcode a callback URL.
|
|
26037
|
+
*/
|
|
26038
|
+
export async function getNetSuiteRedirectUri(): Promise<string | null> {
|
|
26039
|
+
try {
|
|
26040
|
+
const res = await fetch('/auth/netsuite/config')
|
|
26041
|
+
if (!res.ok) return null
|
|
26042
|
+
const data = await res.json()
|
|
26043
|
+
return data?.redirect_uri ?? null
|
|
26044
|
+
} catch {
|
|
26045
|
+
return null
|
|
26046
|
+
}
|
|
25661
26047
|
}
|
|
25662
26048
|
`;
|
|
25663
26049
|
}
|
|
25664
|
-
function
|
|
25665
|
-
return `import
|
|
26050
|
+
function authTsx() {
|
|
26051
|
+
return `import * as React from 'react'
|
|
26052
|
+
import {
|
|
26053
|
+
getNetSuiteSession,
|
|
26054
|
+
signInWithNetSuite,
|
|
26055
|
+
signOutNetSuite,
|
|
26056
|
+
type NetSuiteSession,
|
|
26057
|
+
} from './netsuite'
|
|
25666
26058
|
|
|
25667
|
-
|
|
25668
|
-
|
|
25669
|
-
|
|
25670
|
-
|
|
25671
|
-
|
|
25672
|
-
|
|
25673
|
-
|
|
25674
|
-
|
|
25675
|
-
|
|
25676
|
-
|
|
25677
|
-
|
|
26059
|
+
/**
|
|
26060
|
+
* NetSuite auth context. Holds the session (resolved client-side from /api/me
|
|
26061
|
+
* via getNetSuiteSession) and exposes sign-in / sign-out. /api/me is the only
|
|
26062
|
+
* authoritative check -- never trust sessionStorage directly.
|
|
26063
|
+
*/
|
|
26064
|
+
export interface NetSuiteAuthContextValue {
|
|
26065
|
+
pending: boolean
|
|
26066
|
+
isAuthenticated: boolean
|
|
26067
|
+
session: NetSuiteSession | null
|
|
26068
|
+
signIn: () => void
|
|
26069
|
+
signOut: () => Promise<void>
|
|
26070
|
+
refresh: () => Promise<void>
|
|
25678
26071
|
}
|
|
25679
|
-
function loginPageTsx() {
|
|
25680
|
-
return `import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
|
25681
|
-
import { useState } from 'react'
|
|
25682
|
-
import { login } from '#/lib/session-client'
|
|
25683
|
-
import { Button } from '#/components/ui/button'
|
|
25684
|
-
import { Input } from '#/components/ui/input'
|
|
25685
|
-
import { Label } from '#/components/ui/label'
|
|
25686
|
-
import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
|
|
25687
26072
|
|
|
25688
|
-
|
|
25689
|
-
beforeLoad: ({ context }) => {
|
|
25690
|
-
if (context.user) throw redirect({ to: '/' })
|
|
25691
|
-
},
|
|
25692
|
-
component: LoginPage,
|
|
25693
|
-
})
|
|
26073
|
+
const NetSuiteAuthContext = React.createContext<NetSuiteAuthContextValue | null>(null)
|
|
25694
26074
|
|
|
25695
|
-
function
|
|
25696
|
-
const
|
|
25697
|
-
|
|
25698
|
-
|
|
25699
|
-
|
|
25700
|
-
const [
|
|
26075
|
+
export function NetSuiteAuthProvider({ children }: { children: React.ReactNode }) {
|
|
26076
|
+
const [session, setSession] = React.useState<NetSuiteSession | null>(null)
|
|
26077
|
+
// Start pending on BOTH server and client render so SSR and first paint match
|
|
26078
|
+
// (a spinner), avoiding a hydration mismatch. The effect (client-only)
|
|
26079
|
+
// resolves it.
|
|
26080
|
+
const [pending, setPending] = React.useState(true)
|
|
25701
26081
|
|
|
25702
|
-
|
|
25703
|
-
|
|
25704
|
-
|
|
25705
|
-
|
|
25706
|
-
|
|
25707
|
-
|
|
25708
|
-
if ('error' in result) {
|
|
25709
|
-
setError(result.error)
|
|
25710
|
-
return
|
|
26082
|
+
const refresh = React.useCallback(async () => {
|
|
26083
|
+
setPending(true)
|
|
26084
|
+
try {
|
|
26085
|
+
setSession(await getNetSuiteSession())
|
|
26086
|
+
} finally {
|
|
26087
|
+
setPending(false)
|
|
25711
26088
|
}
|
|
25712
|
-
|
|
25713
|
-
|
|
26089
|
+
}, [])
|
|
26090
|
+
|
|
26091
|
+
React.useEffect(() => {
|
|
26092
|
+
refresh()
|
|
26093
|
+
}, [refresh])
|
|
26094
|
+
|
|
26095
|
+
const value: NetSuiteAuthContextValue = {
|
|
26096
|
+
pending,
|
|
26097
|
+
isAuthenticated: Boolean(session?.authenticated),
|
|
26098
|
+
session,
|
|
26099
|
+
signIn: () => signInWithNetSuite(),
|
|
26100
|
+
signOut: signOutNetSuite,
|
|
26101
|
+
refresh,
|
|
25714
26102
|
}
|
|
25715
26103
|
|
|
25716
|
-
return
|
|
25717
|
-
|
|
25718
|
-
|
|
25719
|
-
|
|
25720
|
-
|
|
25721
|
-
|
|
25722
|
-
|
|
25723
|
-
|
|
25724
|
-
|
|
25725
|
-
<Label htmlFor="email">Email</Label>
|
|
25726
|
-
<Input id="email" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoComplete="email" />
|
|
25727
|
-
</div>
|
|
25728
|
-
<div className="space-y-2">
|
|
25729
|
-
<Label htmlFor="password">Password</Label>
|
|
25730
|
-
<Input id="password" type="password" value={password} onChange={e => setPassword(e.target.value)} required autoComplete="current-password" />
|
|
25731
|
-
</div>
|
|
25732
|
-
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
25733
|
-
<Button type="submit" className="w-full" disabled={loading}>
|
|
25734
|
-
{loading ? 'Logging in\u2026' : 'Log in'}
|
|
25735
|
-
</Button>
|
|
25736
|
-
<p className="text-sm text-center text-muted-foreground">
|
|
25737
|
-
No account? <Link to="/signup" className="underline">Sign up</Link>
|
|
25738
|
-
</p>
|
|
25739
|
-
</form>
|
|
25740
|
-
</CardContent>
|
|
25741
|
-
</Card>
|
|
25742
|
-
</div>
|
|
25743
|
-
)
|
|
26104
|
+
return <NetSuiteAuthContext.Provider value={value}>{children}</NetSuiteAuthContext.Provider>
|
|
26105
|
+
}
|
|
26106
|
+
|
|
26107
|
+
export function useNetSuiteAuth(): NetSuiteAuthContextValue {
|
|
26108
|
+
const ctx = React.useContext(NetSuiteAuthContext)
|
|
26109
|
+
if (!ctx) {
|
|
26110
|
+
throw new Error('useNetSuiteAuth must be used within <NetSuiteAuthProvider>')
|
|
26111
|
+
}
|
|
26112
|
+
return ctx
|
|
25744
26113
|
}
|
|
25745
26114
|
`;
|
|
25746
26115
|
}
|
|
25747
|
-
function
|
|
25748
|
-
return `import {
|
|
25749
|
-
import {
|
|
25750
|
-
import {
|
|
25751
|
-
import { Button } from '#/components/ui/button'
|
|
25752
|
-
import { Input } from '#/components/ui/input'
|
|
25753
|
-
import { Label } from '#/components/ui/label'
|
|
25754
|
-
import { Card, CardContent, CardHeader, CardTitle } from '#/components/ui/card'
|
|
26116
|
+
function authLayoutTsx() {
|
|
26117
|
+
return `import type { ReactNode } from 'react'
|
|
26118
|
+
import { NetSuiteAuthProvider, useNetSuiteAuth } from '../../lib/auth'
|
|
26119
|
+
import { LoginPage } from './LoginPage'
|
|
25755
26120
|
|
|
25756
|
-
|
|
25757
|
-
|
|
25758
|
-
|
|
25759
|
-
|
|
25760
|
-
|
|
25761
|
-
|
|
26121
|
+
/**
|
|
26122
|
+
* NetSuite auth layout. The ONE manual wiring step: wrap the root route's
|
|
26123
|
+
* <Outlet /> with it in src/routes/__root.tsx:
|
|
26124
|
+
*
|
|
26125
|
+
* import { AuthLayout } from '../components/auth/AuthLayout'
|
|
26126
|
+
* ...
|
|
26127
|
+
* function RootComponent() {
|
|
26128
|
+
* return (
|
|
26129
|
+
* <RootDocument>
|
|
26130
|
+
* <AuthLayout>
|
|
26131
|
+
* <Outlet />
|
|
26132
|
+
* </AuthLayout>
|
|
26133
|
+
* </RootDocument>
|
|
26134
|
+
* )
|
|
26135
|
+
* }
|
|
26136
|
+
*
|
|
26137
|
+
* It provides auth context and gates rendering: a spinner while the session
|
|
26138
|
+
* resolves, <LoginPage /> for signed-out users, your routes once signed in.
|
|
26139
|
+
* NEVER swap this for a password gate -- real OAuth is the only login.
|
|
26140
|
+
*/
|
|
26141
|
+
export function AuthLayout({ children }: { children: ReactNode }) {
|
|
26142
|
+
return (
|
|
26143
|
+
<NetSuiteAuthProvider>
|
|
26144
|
+
<Gate>{children}</Gate>
|
|
26145
|
+
</NetSuiteAuthProvider>
|
|
26146
|
+
)
|
|
26147
|
+
}
|
|
25762
26148
|
|
|
25763
|
-
function
|
|
25764
|
-
const
|
|
25765
|
-
const [email, setEmail] = useState('')
|
|
25766
|
-
const [password, setPassword] = useState('')
|
|
25767
|
-
const [error, setError] = useState('')
|
|
25768
|
-
const [loading, setLoading] = useState(false)
|
|
26149
|
+
function Gate({ children }: { children: ReactNode }) {
|
|
26150
|
+
const { pending, isAuthenticated } = useNetSuiteAuth()
|
|
25769
26151
|
|
|
25770
|
-
|
|
25771
|
-
|
|
25772
|
-
|
|
25773
|
-
|
|
25774
|
-
|
|
25775
|
-
|
|
25776
|
-
if ('error' in result) {
|
|
25777
|
-
setError(result.error)
|
|
25778
|
-
return
|
|
25779
|
-
}
|
|
25780
|
-
await router.invalidate()
|
|
25781
|
-
router.navigate({ to: '/' })
|
|
26152
|
+
if (pending) {
|
|
26153
|
+
return (
|
|
26154
|
+
<div className="min-h-screen flex items-center justify-center bg-background">
|
|
26155
|
+
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
|
26156
|
+
</div>
|
|
26157
|
+
)
|
|
25782
26158
|
}
|
|
25783
26159
|
|
|
26160
|
+
if (!isAuthenticated) return <LoginPage />
|
|
26161
|
+
|
|
26162
|
+
return <>{children}</>
|
|
26163
|
+
}
|
|
26164
|
+
`;
|
|
26165
|
+
}
|
|
26166
|
+
function loginPageTsx2() {
|
|
26167
|
+
return `import { SignInWithNetSuite } from './SignInWithNetSuite'
|
|
26168
|
+
|
|
26169
|
+
/**
|
|
26170
|
+
* Default unauthenticated landing page. Replaces any password gate. Customise
|
|
26171
|
+
* the copy / imagery freely but keep <SignInWithNetSuite /> as the only
|
|
26172
|
+
* call-to-action -- it points to /auth/netsuite/start.
|
|
26173
|
+
*/
|
|
26174
|
+
export function LoginPage() {
|
|
25784
26175
|
return (
|
|
25785
|
-
<div className="min-h-screen
|
|
25786
|
-
<
|
|
25787
|
-
<
|
|
25788
|
-
|
|
25789
|
-
|
|
25790
|
-
|
|
25791
|
-
|
|
25792
|
-
|
|
25793
|
-
|
|
25794
|
-
|
|
25795
|
-
|
|
25796
|
-
|
|
25797
|
-
|
|
25798
|
-
|
|
25799
|
-
|
|
25800
|
-
|
|
25801
|
-
<
|
|
25802
|
-
|
|
25803
|
-
|
|
25804
|
-
<p className="text-sm text-center text-muted-foreground">
|
|
25805
|
-
Already have an account? <Link to="/login" className="underline">Log in</Link>
|
|
26176
|
+
<div className="min-h-screen bg-background text-foreground flex">
|
|
26177
|
+
<div className="hidden lg:flex lg:w-1/2 bg-primary text-primary-foreground p-12 flex-col justify-between">
|
|
26178
|
+
<div className="text-xl font-semibold">NetSuite App</div>
|
|
26179
|
+
<div>
|
|
26180
|
+
<h1 className="text-4xl font-bold leading-tight">Connect your NetSuite account</h1>
|
|
26181
|
+
<p className="mt-4 text-primary-foreground/80 max-w-md">
|
|
26182
|
+
Sign in with your NetSuite credentials to access your data securely. Tokens stay
|
|
26183
|
+
server-side -- they never reach the browser.
|
|
26184
|
+
</p>
|
|
26185
|
+
</div>
|
|
26186
|
+
<div className="text-xs text-primary-foreground/60">Powered by HelloLeo</div>
|
|
26187
|
+
</div>
|
|
26188
|
+
<div className="flex-1 flex items-center justify-center px-6 py-12">
|
|
26189
|
+
<div className="w-full max-w-sm space-y-6">
|
|
26190
|
+
<div>
|
|
26191
|
+
<h2 className="text-2xl font-semibold">Welcome</h2>
|
|
26192
|
+
<p className="mt-2 text-sm text-muted-foreground">
|
|
26193
|
+
Use the button below to sign in. You'll be redirected to NetSuite to authorise
|
|
26194
|
+
access, then back here.
|
|
25806
26195
|
</p>
|
|
25807
|
-
</
|
|
25808
|
-
|
|
25809
|
-
|
|
26196
|
+
</div>
|
|
26197
|
+
<SignInWithNetSuite />
|
|
26198
|
+
</div>
|
|
26199
|
+
</div>
|
|
25810
26200
|
</div>
|
|
25811
26201
|
)
|
|
25812
26202
|
}
|
|
25813
26203
|
`;
|
|
25814
26204
|
}
|
|
25815
|
-
|
|
25816
|
-
|
|
25817
|
-
|
|
25818
|
-
|
|
25819
|
-
|
|
25820
|
-
|
|
25821
|
-
|
|
25822
|
-
|
|
25823
|
-
|
|
25824
|
-
|
|
25825
|
-
|
|
26205
|
+
function signInButtonTsx() {
|
|
26206
|
+
return `import { signInWithNetSuite } from '../../lib/netsuite'
|
|
26207
|
+
|
|
26208
|
+
export function SignInWithNetSuite() {
|
|
26209
|
+
return (
|
|
26210
|
+
<button
|
|
26211
|
+
onClick={() => signInWithNetSuite()}
|
|
26212
|
+
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"
|
|
26213
|
+
>
|
|
26214
|
+
Sign in with NetSuite
|
|
26215
|
+
</button>
|
|
26216
|
+
)
|
|
25826
26217
|
}
|
|
25827
|
-
|
|
25828
|
-
|
|
26218
|
+
`;
|
|
26219
|
+
}
|
|
26220
|
+
var setupNetsuite = tool({
|
|
26221
|
+
description: "Add NetSuite runtime OAuth (end-user LOGIN) to a TanStack Start project. " + "Call scaffold first, then this. 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. " + "Emits server routes (the OAuth backend lives IN the app -- no separate " + "Worker, no VITE_WORKER_URL), the netsuite_flows + netsuite_sessions " + "drizzle tables, and the same-origin client + auth components. Config " + "(NETSUITE_ACCOUNT_ID/_CLIENT_ID/_CLIENT_SECRET) comes from the project " + ".env in dev and HelloLeo Cloud secrets in prod -- same var names, never " + "in code. 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",
|
|
25829
26222
|
args: {},
|
|
25830
26223
|
async execute(_args, context) {
|
|
25831
26224
|
const dir = context.directory;
|
|
25832
|
-
await
|
|
25833
|
-
|
|
25834
|
-
|
|
25835
|
-
|
|
25836
|
-
|
|
25837
|
-
|
|
25838
|
-
|
|
25839
|
-
|
|
25840
|
-
|
|
25841
|
-
|
|
25842
|
-
|
|
25843
|
-
|
|
26225
|
+
await mkdir2(path7.join(dir, "src", "lib"), { recursive: true });
|
|
26226
|
+
await mkdir2(path7.join(dir, "src", "components", "auth"), {
|
|
26227
|
+
recursive: true
|
|
26228
|
+
});
|
|
26229
|
+
await mkdir2(path7.join(dir, "src", "routes", "auth", "netsuite"), {
|
|
26230
|
+
recursive: true
|
|
26231
|
+
});
|
|
26232
|
+
await mkdir2(path7.join(dir, "src", "routes", "api"), { recursive: true });
|
|
26233
|
+
const schemaPath = path7.join(dir, "src", "db", "schema.ts");
|
|
26234
|
+
let schemaAppended = false;
|
|
26235
|
+
try {
|
|
26236
|
+
const current = await readFile3(schemaPath, "utf8");
|
|
26237
|
+
if (!current.includes("netsuite_flows")) {
|
|
26238
|
+
await Bun.write(schemaPath, current + schemaAppend());
|
|
26239
|
+
schemaAppended = true;
|
|
26240
|
+
}
|
|
26241
|
+
} catch {
|
|
26242
|
+
await Bun.write(schemaPath, `import { sql } from 'drizzle-orm'
|
|
26243
|
+
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'
|
|
26244
|
+
${schemaAppend()}`);
|
|
26245
|
+
schemaAppended = true;
|
|
26246
|
+
}
|
|
26247
|
+
const files = [
|
|
26248
|
+
[path7.join("src", "lib", "netsuite-server.ts"), netsuiteServerTs()],
|
|
26249
|
+
[
|
|
26250
|
+
path7.join("src", "routes", "auth", "netsuite", "start.ts"),
|
|
26251
|
+
startRouteTs()
|
|
26252
|
+
],
|
|
26253
|
+
[
|
|
26254
|
+
path7.join("src", "routes", "auth", "netsuite", "callback.ts"),
|
|
26255
|
+
callbackRouteTs()
|
|
26256
|
+
],
|
|
26257
|
+
[
|
|
26258
|
+
path7.join("src", "routes", "auth", "netsuite", "logout.ts"),
|
|
26259
|
+
logoutRouteTs()
|
|
26260
|
+
],
|
|
26261
|
+
[
|
|
26262
|
+
path7.join("src", "routes", "auth", "netsuite", "config.ts"),
|
|
26263
|
+
configRouteTs()
|
|
26264
|
+
],
|
|
26265
|
+
[path7.join("src", "routes", "api", "me.ts"), meRouteTs()],
|
|
26266
|
+
[path7.join("src", "lib", "netsuite.ts"), netsuiteClientTs()],
|
|
26267
|
+
[path7.join("src", "lib", "auth.tsx"), authTsx()],
|
|
26268
|
+
[
|
|
26269
|
+
path7.join("src", "components", "auth", "AuthLayout.tsx"),
|
|
26270
|
+
authLayoutTsx()
|
|
26271
|
+
],
|
|
26272
|
+
[path7.join("src", "components", "auth", "LoginPage.tsx"), loginPageTsx2()],
|
|
26273
|
+
[
|
|
26274
|
+
path7.join("src", "components", "auth", "SignInWithNetSuite.tsx"),
|
|
26275
|
+
signInButtonTsx()
|
|
26276
|
+
]
|
|
26277
|
+
];
|
|
26278
|
+
for (const [filePath, content] of files) {
|
|
26279
|
+
await Bun.write(path7.join(dir, filePath), content);
|
|
26280
|
+
}
|
|
25844
26281
|
return [
|
|
25845
|
-
|
|
25846
|
-
|
|
25847
|
-
" -
|
|
25848
|
-
" -
|
|
25849
|
-
"
|
|
25850
|
-
"
|
|
25851
|
-
" - src/
|
|
26282
|
+
`Wrote ${files.length} NetSuite files${schemaAppended ? " + appended src/db/schema.ts" : ""}:`,
|
|
26283
|
+
"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.",
|
|
26284
|
+
" - Record #2 (runtime): https://docs.helloleo.dev/integrations/netsuite#step-4-%E2%80%94-create-integration-record-2-runtime",
|
|
26285
|
+
" - Send runtime creds to Leo: https://docs.helloleo.dev/integrations/netsuite#step-5-%E2%80%94-send-the-runtime-credentials-to-leo",
|
|
26286
|
+
"",
|
|
26287
|
+
" server (OAuth backend lives in the app -- no separate Worker):",
|
|
26288
|
+
" - src/lib/netsuite-server.ts (env, PKCE, token exchange, session refresh, netsuiteFetch)",
|
|
26289
|
+
" - src/routes/auth/netsuite/start.ts GET begin PKCE flow",
|
|
26290
|
+
" - src/routes/auth/netsuite/callback.ts GET code -> tokens -> session",
|
|
26291
|
+
" - src/routes/auth/netsuite/logout.ts POST destroy session",
|
|
26292
|
+
" - src/routes/auth/netsuite/config.ts GET the exact redirect URI to register",
|
|
26293
|
+
" - src/routes/api/me.ts GET bearer -> session (auth check)",
|
|
26294
|
+
" - src/db/schema.ts += netsuite_flows + netsuite_sessions",
|
|
26295
|
+
" client:",
|
|
26296
|
+
" - src/lib/netsuite.ts (same-origin: signIn, session, apiFetch)",
|
|
26297
|
+
" - src/lib/auth.tsx (NetSuiteAuthProvider + useNetSuiteAuth)",
|
|
26298
|
+
" - src/components/auth/{AuthLayout,LoginPage,SignInWithNetSuite}.tsx",
|
|
25852
26299
|
"",
|
|
25853
|
-
"
|
|
26300
|
+
"WIRE IT UP (one manual step): in src/routes/__root.tsx, wrap <Outlet /> with <AuthLayout>:",
|
|
26301
|
+
"",
|
|
26302
|
+
" import { AuthLayout } from '../components/auth/AuthLayout'",
|
|
25854
26303
|
"",
|
|
25855
|
-
"
|
|
25856
|
-
"
|
|
25857
|
-
"
|
|
26304
|
+
" <AuthLayout>",
|
|
26305
|
+
" <Outlet />",
|
|
26306
|
+
" </AuthLayout>",
|
|
25858
26307
|
"",
|
|
25859
|
-
"
|
|
26308
|
+
"Then:",
|
|
26309
|
+
"- Build your dashboard inside <AuthLayout>. Read the session with useNetSuiteAuth().",
|
|
26310
|
+
"- Data routes: add src/routes/api/*.ts and call netsuiteFetch(bearer(request), '/services/rest/query/v1/suiteql', {...}) from the handler.",
|
|
25860
26311
|
"",
|
|
25861
|
-
"
|
|
25862
|
-
"
|
|
25863
|
-
"
|
|
26312
|
+
"TWO INTEGRATION RECORDS -- do not confuse them:",
|
|
26313
|
+
"- Record #1 (scope: mcp): already connected, used ONLY to build the app (schema discovery). This login does NOT use it.",
|
|
26314
|
+
"- 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).",
|
|
25864
26315
|
"",
|
|
25865
|
-
"
|
|
26316
|
+
"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:",
|
|
26317
|
+
"- 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.",
|
|
26318
|
+
"- 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.",
|
|
26319
|
+
"- PUBLIC_APP_URL / HELLOLEO_URL are set by the platform per environment; don't hardcode them.",
|
|
26320
|
+
"- 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). Register both the preview and published URLs -- NetSuite allows multiple redirect URIs.",
|
|
26321
|
+
"- Never replace <AuthLayout> with a password gate. Real OAuth is the only supported login."
|
|
25866
26322
|
].join(`
|
|
25867
26323
|
`);
|
|
25868
26324
|
}
|
|
25869
26325
|
});
|
|
25870
|
-
var
|
|
26326
|
+
var SetupNetsuitePlugin = async () => {
|
|
25871
26327
|
return {
|
|
25872
|
-
tool: {
|
|
26328
|
+
tool: { setup_netsuite: setupNetsuite }
|
|
25873
26329
|
};
|
|
25874
26330
|
};
|
|
25875
26331
|
export {
|
|
25876
|
-
SetupPasswordAuthPlugin,
|
|
25877
26332
|
SetupNetsuitePlugin,
|
|
26333
|
+
SetupAuthenticationPlugin,
|
|
25878
26334
|
ScaffoldPlugin,
|
|
25879
26335
|
RemovePlugin,
|
|
25880
26336
|
InspectHttpPlugin
|