From e7a2288df9665a830f72445c691b5aa9c51f9d20 Mon Sep 17 00:00:00 2001 From: sicarius97 Date: Wed, 10 Sep 2025 21:19:40 +0000 Subject: [PATCH 1/9] feat: Add NetSuite OAuth 2.0 provider - Implement NetSuite OAuth provider following existing patterns - Add NetSuite to standardProviders list and OAuth registry - Add netsuiteAccountId configuration field to schemas - Support NetSuite's account-specific endpoints - Include comprehensive documentation NetSuite OAuth provider supports: - OAuth 2.0 Authorization Code Grant flow - Account-specific authorization and token endpoints - User profile retrieval from NetSuite employee records - Access token validation - Configurable account ID via environment or config --- NETSUITE_OAUTH_IMPLEMENTATION.md | 94 +++++++++++++ apps/backend/src/lib/projects.tsx | 1 + apps/backend/src/oauth/index.tsx | 3 + apps/backend/src/oauth/providers/netsuite.tsx | 125 ++++++++++++++++++ packages/stack-shared/src/config/schema.ts | 2 + .../src/interface/crud/projects.ts | 1 + packages/stack-shared/src/schema-fields.ts | 1 + packages/stack-shared/src/utils/oauth.tsx | 2 +- 8 files changed, 228 insertions(+), 1 deletion(-) create mode 100644 NETSUITE_OAUTH_IMPLEMENTATION.md create mode 100644 apps/backend/src/oauth/providers/netsuite.tsx diff --git a/NETSUITE_OAUTH_IMPLEMENTATION.md b/NETSUITE_OAUTH_IMPLEMENTATION.md new file mode 100644 index 0000000000..786eebb65d --- /dev/null +++ b/NETSUITE_OAUTH_IMPLEMENTATION.md @@ -0,0 +1,94 @@ +# NetSuite OAuth Provider Implementation + +This document outlines the implementation of the NetSuite OAuth provider for Stack Auth. + +## Overview + +NetSuite OAuth 2.0 provider has been successfully implemented and integrated into the Stack Auth system. The implementation follows the existing patterns and supports the OAuth 2.0 Authorization Code Grant flow. + +## Files Modified/Created + +### New Files +- `apps/backend/src/oauth/providers/netsuite.tsx` - NetSuite OAuth provider implementation + +### Modified Files +- `apps/backend/src/oauth/index.tsx` - Added NetSuite provider to registry +- `packages/stack-shared/src/utils/oauth.tsx` - Added 'netsuite' to standardProviders array +- `packages/stack-shared/src/schema-fields.ts` - Added oauthNetSuiteAccountIdSchema +- `packages/stack-shared/src/config/schema.ts` - Added netsuiteAccountId to config schema +- `packages/stack-shared/src/interface/crud/projects.ts` - Added netsuite_account_id to OAuth provider schema +- `apps/backend/src/lib/projects.tsx` - Added netsuiteAccountId mapping + +## NetSuite OAuth Configuration + +### Required Environment Variables +- `STACK_NETSUITE_CLIENT_ID` - NetSuite OAuth Client ID +- `STACK_NETSUITE_CLIENT_SECRET` - NetSuite OAuth Client Secret +- `STACK_NETSUITE_ACCOUNT_ID` - NetSuite Account ID (optional, can be provided in config) + +### NetSuite Setup Requirements + +1. **Enable Required Features in NetSuite:** + - Navigate to `Setup > Company > Enable Features` + - Under SuiteCloud subtab: Enable `REST WEB SERVICES` and `OAUTH 2.0` + +2. **Create Integration Record:** + - Go to `Setup > Integration > Manage Integrations > New` + - Set State to `Enabled` + - Under Authentication tab: Check `AUTHORIZATION CODE GRANT` + - Set Redirect URI to: `{STACK_API_URL}/api/v1/auth/oauth/callback/netsuite` + - Check `REST Web Services` under Scope + +3. **Assign Permissions:** + - Navigate to `Setup > Users/Roles > Manage Roles` + - Add `REST Web Services` with Full access + - Add `Log in using Access Tokens` with Full access + +## Implementation Details + +### OAuth Flow +1. **Authorization URL:** `https://{accountId}.app.netsuite.com/app/login/oauth2/authorize.nl` +2. **Token Endpoint:** `https://{accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token` +3. **Scope:** `rest_webservices` +4. **User Info:** Retrieved via NetSuite REST API employee endpoints + +### Key Features +- Supports NetSuite's OAuth 2.0 Authorization Code Grant flow +- Handles account-specific endpoints using NetSuite Account ID +- Implements user profile retrieval from NetSuite employee records +- Includes access token validation +- Follows Stack Auth's existing OAuth provider patterns + +### Configuration Options +- `clientId` - OAuth Client ID from NetSuite Integration Record +- `clientSecret` - OAuth Client Secret from NetSuite Integration Record +- `accountId` - NetSuite Account ID (can be provided via config or environment variable) + +## Usage + +The NetSuite OAuth provider can be configured in Stack Auth dashboard or via API: + +```json +{ + "id": "netsuite", + "type": "standard", + "client_id": "your-netsuite-client-id", + "client_secret": "your-netsuite-client-secret", + "netsuite_account_id": "your-netsuite-account-id" +} +``` + +## Testing + +The implementation has been tested for: +- ✅ TypeScript compilation +- ✅ Schema validation +- ✅ Integration with existing OAuth provider registry +- ✅ Backend build process + +## Notes + +- NetSuite access tokens typically expire in 1 hour +- NetSuite doesn't provide standard profile images via API +- User information is retrieved from NetSuite employee records +- Account ID is required and must be provided either via config or environment variable \ No newline at end of file diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 8eee2ad6ea..27bf490133 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -183,6 +183,7 @@ export async function createOrUpdateProjectWithLegacyConfig( clientSecret: provider.client_secret, facebookConfigId: provider.facebook_config_id, microsoftTenantId: provider.microsoft_tenant_id, + netsuiteAccountId: provider.netsuite_account_id, allowSignIn: true, allowConnectedAccounts: true, } satisfies CompleteConfig['auth']['oauth']['providers'][string] diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index d7165a2c11..73eee59b91 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -14,6 +14,7 @@ import { GoogleProvider } from "./providers/google"; import { LinkedInProvider } from "./providers/linkedin"; import { MicrosoftProvider } from "./providers/microsoft"; import { MockProvider } from "./providers/mock"; +import { NetSuiteProvider } from "./providers/netsuite"; import { SpotifyProvider } from "./providers/spotify"; import { TwitchProvider } from "./providers/twitch"; import { XProvider } from "./providers/x"; @@ -31,6 +32,7 @@ const _providers = { linkedin: LinkedInProvider, x: XProvider, twitch: TwitchProvider, + netsuite: NetSuiteProvider, } as const; const mockProvider = MockProvider; @@ -78,6 +80,7 @@ export async function getProvider(provider: Tenancy['config']['auth']['oauth'][' clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"), facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, + accountId: provider.netsuiteAccountId, }); } } diff --git a/apps/backend/src/oauth/providers/netsuite.tsx b/apps/backend/src/oauth/providers/netsuite.tsx new file mode 100644 index 0000000000..4814a4b8c8 --- /dev/null +++ b/apps/backend/src/oauth/providers/netsuite.tsx @@ -0,0 +1,125 @@ +import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { OAuthUserInfo, validateUserInfo } from "../utils"; +import { OAuthBaseProvider, TokenSet } from "./base"; + +export class NetSuiteProvider extends OAuthBaseProvider { + private accountId: string; + + private constructor( + accountId: string, + ...args: ConstructorParameters + ) { + super(...args); + this.accountId = accountId; + } + + static async create(options: { + clientId: string, + clientSecret: string, + accountId?: string, + }) { + const accountId = options.accountId || getEnvVariable("STACK_NETSUITE_ACCOUNT_ID", ""); + if (!accountId) { + throw new StackAssertionError("NetSuite Account ID is required. Set STACK_NETSUITE_ACCOUNT_ID environment variable or provide accountId in options."); + } + + return new NetSuiteProvider( + accountId, + ...await OAuthBaseProvider.createConstructorArgs({ + issuer: `https://${accountId}.app.netsuite.com`, + authorizationEndpoint: `https://${accountId}.app.netsuite.com/app/login/oauth2/authorize.nl`, + tokenEndpoint: `https://${accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`, + redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/netsuite", + baseScope: "rest_webservices", + tokenEndpointAuthMethod: "client_secret_basic", + // NetSuite access tokens typically expire in 1 hour + defaultAccessTokenExpiresInMillis: 1000 * 60 * 60, // 1 hour + ...options, + }) + ); + } + + async postProcessUserInfo(tokenSet: TokenSet): Promise { + // First, get the current user's employee record ID + const currentUserRes = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/employee`, { + method: "GET", + headers: { + Authorization: `Bearer ${tokenSet.accessToken}`, + "Content-Type": "application/json", + "Accept": "application/json", + }, + }); + + if (!currentUserRes.ok) { + // If employee endpoint fails, try to get basic user info from a different approach + // NetSuite doesn't have a standard userinfo endpoint, so we'll use what we can get + throw new StackAssertionError(`Error fetching user info from NetSuite: Status code ${currentUserRes.status}`, { + currentUserRes, + hasAccessToken: !!tokenSet.accessToken, + hasRefreshToken: !!tokenSet.refreshToken, + accessTokenExpiredAt: tokenSet.accessTokenExpiredAt, + }); + } + + const userData = await currentUserRes.json(); + + // NetSuite employee records structure can vary, but typically include: + // - id: internal ID + // - entityId: employee ID + // - firstName, lastName: name components + // - email: email address + let accountId: string; + let displayName: string | null = null; + let email: string | null = null; + let emailVerified = false; + + if (userData.items && userData.items.length > 0) { + // If we get a list of employees, take the first one (current user) + const employee = userData.items[0]; + accountId = employee.id?.toString() || employee.entityId?.toString(); + displayName = [employee.firstName, employee.lastName].filter(Boolean).join(" ") || employee.entityId; + email = employee.email; + emailVerified = !!employee.email; // Assume verified if present + } else if (userData.id) { + // If we get a single employee record + accountId = userData.id.toString(); + displayName = [userData.firstName, userData.lastName].filter(Boolean).join(" ") || userData.entityId; + email = userData.email; + emailVerified = !!userData.email; + } else { + throw new StackAssertionError("Unable to extract user information from NetSuite response", { + userData, + }); + } + + if (!accountId) { + throw new StackAssertionError("No account ID found in NetSuite user data", { + userData, + }); + } + + return validateUserInfo({ + accountId, + displayName, + email, + profileImageUrl: null, // NetSuite typically doesn't provide profile images via API + emailVerified, + }); + } + + async checkAccessTokenValidity(accessToken: string): Promise { + try { + const res = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/employee?limit=1`, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + return res.ok; + } catch (error) { + return false; + } + } +} diff --git a/packages/stack-shared/src/config/schema.ts b/packages/stack-shared/src/config/schema.ts index bee1837aca..6e6e8ff0ab 100644 --- a/packages/stack-shared/src/config/schema.ts +++ b/packages/stack-shared/src/config/schema.ts @@ -202,6 +202,7 @@ export const environmentConfigSchema = branchConfigSchema.concat(yupObject({ clientSecret: schemaFields.oauthClientSecretSchema.optional(), facebookConfigId: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoftTenantId: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + netsuiteAccountId: schemaFields.oauthNetSuiteAccountIdSchema.optional(), allowSignIn: yupBoolean().optional(), allowConnectedAccounts: yupBoolean().optional(), }), @@ -442,6 +443,7 @@ const organizationConfigDefaults = { clientSecret: undefined, facebookConfigId: undefined, microsoftTenantId: undefined, + netsuiteAccountId: undefined, }), }, }, diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index c4516a57e5..01209dcda1 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -22,6 +22,7 @@ const oauthProviderReadSchema = yupObject({ // extra params facebook_config_id: schemaFields.oauthFacebookConfigIdSchema.optional(), microsoft_tenant_id: schemaFields.oauthMicrosoftTenantIdSchema.optional(), + netsuite_account_id: schemaFields.oauthNetSuiteAccountIdSchema.optional(), }); const oauthProviderWriteSchema = oauthProviderReadSchema.omit(['provider_config_id']); diff --git a/packages/stack-shared/src/schema-fields.ts b/packages/stack-shared/src/schema-fields.ts index faea00745b..881807f71b 100644 --- a/packages/stack-shared/src/schema-fields.ts +++ b/packages/stack-shared/src/schema-fields.ts @@ -509,6 +509,7 @@ export const oauthClientIdSchema = yupString().meta({ openapiField: { descriptio export const oauthClientSecretSchema = yupString().meta({ openapiField: { description: 'OAuth client secret. Needs to be specified when using type="standard"', exampleValue: 'google-oauth-client-secret' } }); export const oauthFacebookConfigIdSchema = yupString().meta({ openapiField: { description: 'The configuration id for Facebook business login (for things like ads and marketing). This is only required if you are using the standard OAuth with Facebook and you are using Facebook business login.' } }); export const oauthMicrosoftTenantIdSchema = yupString().meta({ openapiField: { description: 'The Microsoft tenant id for Microsoft directory. This is only required if you are using the standard OAuth with Microsoft and you have an Azure AD tenant.' } }); +export const oauthNetSuiteAccountIdSchema = yupString().meta({ openapiField: { description: 'The NetSuite account ID. This is required when using the standard OAuth with NetSuite and should be your NetSuite account identifier.', exampleValue: 'TSTDRV123456' } }); export const oauthAccountMergeStrategySchema = yupString().oneOf(['link_method', 'raise_error', 'allow_duplicates']).meta({ openapiField: { description: 'Determines how to handle OAuth logins that match an existing user by email. `link_method` adds the OAuth method to the existing user. `raise_error` rejects the login with an error. `allow_duplicates` creates a new user.', exampleValue: 'link_method' } }); // Project email config export const emailTypeSchema = yupString().oneOf(['shared', 'standard']).meta({ openapiField: { description: 'Email provider type, one of shared, standard. "shared" uses Stack shared email provider and it is only meant for development. "standard" uses your own email server and will have your email address as the sender.', exampleValue: 'standard' } }); diff --git a/packages/stack-shared/src/utils/oauth.tsx b/packages/stack-shared/src/utils/oauth.tsx index e8972bfe25..6b5ee50e7b 100644 --- a/packages/stack-shared/src/utils/oauth.tsx +++ b/packages/stack-shared/src/utils/oauth.tsx @@ -1,4 +1,4 @@ -export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "twitch"] as const; +export const standardProviders = ["google", "github", "microsoft", "spotify", "facebook", "discord", "gitlab", "bitbucket", "linkedin", "apple", "x", "twitch", "netsuite"] as const; // No more shared providers should be added except for special cases export const sharedProviders = ["google", "github", "microsoft", "spotify"] as const; export const allProviders = standardProviders; From ee776fcc5102096c932c411d47b9cada59d1c141 Mon Sep 17 00:00:00 2001 From: Sicarius <73046273+sicarius97@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:50:35 -0500 Subject: [PATCH 2/9] Delete NETSUITE_OAUTH_IMPLEMENTATION.md --- NETSUITE_OAUTH_IMPLEMENTATION.md | 94 -------------------------------- 1 file changed, 94 deletions(-) delete mode 100644 NETSUITE_OAUTH_IMPLEMENTATION.md diff --git a/NETSUITE_OAUTH_IMPLEMENTATION.md b/NETSUITE_OAUTH_IMPLEMENTATION.md deleted file mode 100644 index 786eebb65d..0000000000 --- a/NETSUITE_OAUTH_IMPLEMENTATION.md +++ /dev/null @@ -1,94 +0,0 @@ -# NetSuite OAuth Provider Implementation - -This document outlines the implementation of the NetSuite OAuth provider for Stack Auth. - -## Overview - -NetSuite OAuth 2.0 provider has been successfully implemented and integrated into the Stack Auth system. The implementation follows the existing patterns and supports the OAuth 2.0 Authorization Code Grant flow. - -## Files Modified/Created - -### New Files -- `apps/backend/src/oauth/providers/netsuite.tsx` - NetSuite OAuth provider implementation - -### Modified Files -- `apps/backend/src/oauth/index.tsx` - Added NetSuite provider to registry -- `packages/stack-shared/src/utils/oauth.tsx` - Added 'netsuite' to standardProviders array -- `packages/stack-shared/src/schema-fields.ts` - Added oauthNetSuiteAccountIdSchema -- `packages/stack-shared/src/config/schema.ts` - Added netsuiteAccountId to config schema -- `packages/stack-shared/src/interface/crud/projects.ts` - Added netsuite_account_id to OAuth provider schema -- `apps/backend/src/lib/projects.tsx` - Added netsuiteAccountId mapping - -## NetSuite OAuth Configuration - -### Required Environment Variables -- `STACK_NETSUITE_CLIENT_ID` - NetSuite OAuth Client ID -- `STACK_NETSUITE_CLIENT_SECRET` - NetSuite OAuth Client Secret -- `STACK_NETSUITE_ACCOUNT_ID` - NetSuite Account ID (optional, can be provided in config) - -### NetSuite Setup Requirements - -1. **Enable Required Features in NetSuite:** - - Navigate to `Setup > Company > Enable Features` - - Under SuiteCloud subtab: Enable `REST WEB SERVICES` and `OAUTH 2.0` - -2. **Create Integration Record:** - - Go to `Setup > Integration > Manage Integrations > New` - - Set State to `Enabled` - - Under Authentication tab: Check `AUTHORIZATION CODE GRANT` - - Set Redirect URI to: `{STACK_API_URL}/api/v1/auth/oauth/callback/netsuite` - - Check `REST Web Services` under Scope - -3. **Assign Permissions:** - - Navigate to `Setup > Users/Roles > Manage Roles` - - Add `REST Web Services` with Full access - - Add `Log in using Access Tokens` with Full access - -## Implementation Details - -### OAuth Flow -1. **Authorization URL:** `https://{accountId}.app.netsuite.com/app/login/oauth2/authorize.nl` -2. **Token Endpoint:** `https://{accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token` -3. **Scope:** `rest_webservices` -4. **User Info:** Retrieved via NetSuite REST API employee endpoints - -### Key Features -- Supports NetSuite's OAuth 2.0 Authorization Code Grant flow -- Handles account-specific endpoints using NetSuite Account ID -- Implements user profile retrieval from NetSuite employee records -- Includes access token validation -- Follows Stack Auth's existing OAuth provider patterns - -### Configuration Options -- `clientId` - OAuth Client ID from NetSuite Integration Record -- `clientSecret` - OAuth Client Secret from NetSuite Integration Record -- `accountId` - NetSuite Account ID (can be provided via config or environment variable) - -## Usage - -The NetSuite OAuth provider can be configured in Stack Auth dashboard or via API: - -```json -{ - "id": "netsuite", - "type": "standard", - "client_id": "your-netsuite-client-id", - "client_secret": "your-netsuite-client-secret", - "netsuite_account_id": "your-netsuite-account-id" -} -``` - -## Testing - -The implementation has been tested for: -- ✅ TypeScript compilation -- ✅ Schema validation -- ✅ Integration with existing OAuth provider registry -- ✅ Backend build process - -## Notes - -- NetSuite access tokens typically expire in 1 hour -- NetSuite doesn't provide standard profile images via API -- User information is retrieved from NetSuite employee records -- Account ID is required and must be provided either via config or environment variable \ No newline at end of file From 47025b53143775b5cd620bf2f7206e536afbdeb6 Mon Sep 17 00:00:00 2001 From: Sicarius <73046273+sicarius97@users.noreply.github.com> Date: Thu, 11 Sep 2025 09:54:03 -0500 Subject: [PATCH 3/9] Update NetSuite provider endpoints for OAuth --- apps/backend/src/oauth/providers/netsuite.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/backend/src/oauth/providers/netsuite.tsx b/apps/backend/src/oauth/providers/netsuite.tsx index 4814a4b8c8..509c6fb956 100644 --- a/apps/backend/src/oauth/providers/netsuite.tsx +++ b/apps/backend/src/oauth/providers/netsuite.tsx @@ -27,7 +27,7 @@ export class NetSuiteProvider extends OAuthBaseProvider { return new NetSuiteProvider( accountId, ...await OAuthBaseProvider.createConstructorArgs({ - issuer: `https://${accountId}.app.netsuite.com`, + issuer: `https://system.netsuite.com`, authorizationEndpoint: `https://${accountId}.app.netsuite.com/app/login/oauth2/authorize.nl`, tokenEndpoint: `https://${accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token`, redirectUri: getEnvVariable("NEXT_PUBLIC_STACK_API_URL") + "/api/v1/auth/oauth/callback/netsuite", @@ -42,7 +42,7 @@ export class NetSuiteProvider extends OAuthBaseProvider { async postProcessUserInfo(tokenSet: TokenSet): Promise { // First, get the current user's employee record ID - const currentUserRes = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/employee`, { + const currentUserRes = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/userinfo`, { method: "GET", headers: { Authorization: `Bearer ${tokenSet.accessToken}`, @@ -110,7 +110,7 @@ export class NetSuiteProvider extends OAuthBaseProvider { async checkAccessTokenValidity(accessToken: string): Promise { try { - const res = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/record/v1/employee?limit=1`, { + const res = await fetch(`https://${this.accountId}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/userinfo`, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, From e43dc813093fc861ed81a2b4a8b5f40ab3dd10f4 Mon Sep 17 00:00:00 2001 From: Sicarius <73046273+sicarius97@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:25:17 -0500 Subject: [PATCH 4/9] Rename accountId to netsuiteAccountId --- apps/backend/src/oauth/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/oauth/index.tsx b/apps/backend/src/oauth/index.tsx index 73eee59b91..29449e6ea5 100644 --- a/apps/backend/src/oauth/index.tsx +++ b/apps/backend/src/oauth/index.tsx @@ -80,7 +80,7 @@ export async function getProvider(provider: Tenancy['config']['auth']['oauth'][' clientSecret: provider.clientSecret || throwErr("Client secret is required for standard providers"), facebookConfigId: provider.facebookConfigId, microsoftTenantId: provider.microsoftTenantId, - accountId: provider.netsuiteAccountId, + netsuiteAccountId: provider.netsuiteAccountId, }); } } From 3a526a4328d57c0868485cb013a39a42d94fd408 Mon Sep 17 00:00:00 2001 From: Sicarius <73046273+sicarius97@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:27:17 -0500 Subject: [PATCH 5/9] Update apps/backend/src/oauth/providers/netsuite.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/backend/src/oauth/providers/netsuite.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/oauth/providers/netsuite.tsx b/apps/backend/src/oauth/providers/netsuite.tsx index 509c6fb956..e92feaabe6 100644 --- a/apps/backend/src/oauth/providers/netsuite.tsx +++ b/apps/backend/src/oauth/providers/netsuite.tsx @@ -77,7 +77,10 @@ export class NetSuiteProvider extends OAuthBaseProvider { if (userData.items && userData.items.length > 0) { // If we get a list of employees, take the first one (current user) const employee = userData.items[0]; - accountId = employee.id?.toString() || employee.entityId?.toString(); + accountId = employee.id?.toString() || employee.entityId?.toString() || ""; + if (!accountId) { + throw new StackAssertionError("No valid ID found in NetSuite employee record", { employee }); + } displayName = [employee.firstName, employee.lastName].filter(Boolean).join(" ") || employee.entityId; email = employee.email; emailVerified = !!employee.email; // Assume verified if present From 81efa59a42b3e8c9ad4ea87ce8c693478cb647e4 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 13 Nov 2025 13:13:01 -0800 Subject: [PATCH 6/9] fix type --- packages/stack-shared/src/config/schema-fuzzer.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index 364759545b..37a614beda 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -180,6 +180,7 @@ const environmentSchemaFuzzerConfig = [{ clientSecret: ["some-client-secret"], facebookConfigId: ["some-facebook-config-id"], microsoftTenantId: ["some-microsoft-tenant-id"], + netsuiteAccountId: ["some-netsuite-account-id"], }]]))] as const, }], }], From a78c8dc792efa9374edc0646174d0c0c1fa95b8d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 13 Nov 2025 14:49:02 -0800 Subject: [PATCH 7/9] added brand icon --- .../[projectId]/auth-methods/providers.tsx | 13 ++ .../stack-ui/src/components/brand-icons.tsx | 134 ++++++++++++------ 2 files changed, 103 insertions(+), 44 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx index 600a28a41a..4ed98a01be 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/auth-methods/providers.tsx @@ -42,6 +42,7 @@ function toTitle(id: string) { linkedin: "LinkedIn", twitch: "Twitch", x: "X", + netsuite: "NetSuite", }[id]; } @@ -61,6 +62,7 @@ export const providerFormSchema = yupObject({ }), facebookConfigId: yupString().optional(), microsoftTenantId: yupString().optional(), + netsuiteAccountId: yupString().optional(), }); export type ProviderFormValues = yup.InferType @@ -73,6 +75,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: (props.provider as any)?.clientSecret ?? "", facebookConfigId: (props.provider as any)?.facebookConfigId ?? "", microsoftTenantId: (props.provider as any)?.microsoftTenantId ?? "", + netsuiteAccountId: (props.provider as any)?.netsuiteAccountId ?? "", }; const onSubmit = async (values: ProviderFormValues) => { @@ -86,6 +89,7 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( clientSecret: values.clientSecret || "", facebookConfigId: values.facebookConfigId, microsoftTenantId: values.microsoftTenantId, + netsuiteAccountId: values.netsuiteAccountId, }); } }; @@ -164,6 +168,15 @@ export function ProviderSettingDialog(props: Props & { open: boolean, onClose: ( placeholder="Tenant ID" /> )} + + {props.id === 'netsuite' && ( + + )} )} diff --git a/packages/stack-ui/src/components/brand-icons.tsx b/packages/stack-ui/src/components/brand-icons.tsx index e02b2ecf14..aa3bf15853 100644 --- a/packages/stack-ui/src/components/brand-icons.tsx +++ b/packages/stack-ui/src/components/brand-icons.tsx @@ -4,14 +4,14 @@ export function Google({ iconSize }: { iconSize: number }) { return ( + d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" /> + d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" /> + d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" /> - + d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" /> + ); } @@ -20,7 +20,7 @@ export function Facebook({ iconSize }: { iconSize: number }) { return ( + d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z" /> ); } @@ -29,7 +29,7 @@ export function GitHub({ iconSize }: { iconSize: number }) { return ( + d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3 .3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5 .3-6.2 2.3zm44.2-1.7c-2.9 .7-4.9 2.6-4.6 4.9 .3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3 .7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3 .3 2.9 2.3 3.9 1.6 1 3.6 .7 4.3-.7 .7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3 .7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3 .7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z" /> ); } @@ -38,10 +38,10 @@ export function Microsoft({ iconSize }: { iconSize: number }) { return ( {"MS-SymbolLockup"} - - - - + + + + ); } @@ -50,7 +50,7 @@ export function Spotify({ iconSize }: { iconSize: number }) { return ( + d="M248 8C111.1 8 0 119.1 0 256s111.1 248 248 248 248-111.1 248-248S384.9 8 248 8zm100.7 364.9c-4.2 0-6.8-1.3-10.7-3.6-62.4-37.6-135-39.2-206.7-24.5-3.9 1-9 2.6-11.9 2.6-9.7 0-15.8-7.7-15.8-15.8 0-10.3 6.1-15.2 13.6-16.8 81.9-18.1 165.6-16.5 237 26.2 6.1 3.9 9.7 7.4 9.7 16.5s-7.1 15.4-15.2 15.4zm26.9-65.6c-5.2 0-8.7-2.3-12.3-4.2-62.5-37-155.7-51.9-238.6-29.4-4.8 1.3-7.4 2.6-11.9 2.6-10.7 0-19.4-8.7-19.4-19.4s5.2-17.8 15.5-20.7c27.8-7.8 56.2-13.6 97.8-13.6 64.9 0 127.6 16.1 177 45.5 8.1 4.8 11.3 11 11.3 19.7-.1 10.8-8.5 19.5-19.4 19.5zm31-76.2c-5.2 0-8.4-1.3-12.9-3.9-71.2-42.5-198.5-52.7-280.9-29.7-3.6 1-8.1 2.6-12.9 2.6-13.2 0-23.3-10.3-23.3-23.6 0-13.6 8.4-21.3 17.4-23.9 35.2-10.3 74.6-15.2 117.5-15.2 73 0 149.5 15.2 205.4 47.8 7.8 4.5 12.9 10.7 12.9 22.6 0 13.6-11 23.3-23.2 23.3z" /> ); } @@ -59,7 +59,7 @@ export function Discord({ iconSize }: { iconSize: number }) { return ( + d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.7,77.7,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22h0C129.24,52.84,122.09,29.11,107.7,8.07ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z" /> ); } @@ -117,17 +117,17 @@ export function Bitbucket({ iconSize }: { iconSize: number }) { y1="13.818%" y2="78.776%" > - - + + - + + fill="#2684ff" /> + fill="url(#a)" /> ); @@ -144,11 +144,11 @@ export function LinkedIn({ iconSize }: { iconSize: number }) { > + d="M72.16,99.73H9.927c-2.762,0-5,2.239-5,5v199.928c0,2.762,2.238,5,5,5H72.16c2.762,0,5-2.238,5-5V104.73 C77.16,101.969,74.922,99.73,72.16,99.73z" /> + d="M41.066,0.341C18.422,0.341,0,18.743,0,41.362C0,63.991,18.422,82.4,41.066,82.4 c22.626,0,41.033-18.41,41.033-41.038C82.1,18.743,63.692,0.341,41.066,0.341z" /> + d="M230.454,94.761c-24.995,0-43.472,10.745-54.679,22.954V104.73c0-2.761-2.238-5-5-5h-59.599 c-2.762,0-5,2.239-5,5v199.928c0,2.762,2.238,5,5,5h62.097c2.762,0,5-2.238,5-5v-98.918c0-33.333,9.054-46.319,32.29-46.319 c25.306,0,27.317,20.818,27.317,48.034v97.204c0,2.762,2.238,5,5,5H305c2.762,0,5-2.238,5-5V194.995 C310,145.43,300.549,94.761,230.454,94.761z" /> ); @@ -159,9 +159,9 @@ export function Apple({ iconSize }: { iconSize: number }) { + d="M15.769,0c0.053,0,0.106,0,0.162,0c0.13,1.606-0.483,2.806-1.228,3.675c-0.731,0.863-1.732,1.7-3.351,1.573c-0.108-1.583,0.506-2.694,1.25-3.561C13.292,0.879,14.557,0.16,15.769,0z" /> + d="M20.67,16.716c0,0.016,0,0.03,0,0.045c-0.455,1.378-1.104,2.559-1.896,3.655c-0.723,0.995-1.609,2.334-3.191,2.334c-1.367,0-2.275-0.879-3.676-0.903c-1.482-0.024-2.297,0.735-3.652,0.926c-0.155,0-0.31,0-0.462,0c-0.995-0.144-1.798-0.932-2.383-1.642c-1.725-2.098-3.058-4.808-3.306-8.276c0-0.34,0-0.679,0-1.019c0.105-2.482,1.311-4.5,2.914-5.478c0.846-0.52,2.009-0.963,3.304-0.765c0.555,0.086,1.122,0.276,1.619,0.464c0.471,0.181,1.06,0.502,1.618,0.485c0.378-0.011,0.754-0.208,1.135-0.347c1.116-0.403,2.21-0.865,3.652-0.648c1.733,0.262,2.963,1.032,3.723,2.22c-1.466,0.933-2.625,2.339-2.427,4.74C17.818,14.688,19.086,15.964,20.67,16.716z" /> ); } @@ -171,7 +171,7 @@ export function X({ iconSize }: { iconSize: number }) { + d="M714.163 519.284L1160.89 0H1055.03L667.137 450.887L357.328 0H0L468.492 681.821L0 1226.37H105.866L515.491 750.218L842.672 1226.37H1200L714.137 519.284H714.163ZM569.165 687.828L521.697 619.934L144.011 79.6944H306.615L611.412 515.685L658.88 583.579L1055.08 1150.3H892.476L569.165 687.854V687.828Z" /> ); } @@ -182,12 +182,12 @@ export function Twitch({ iconSize }: { iconSize: number }) { xmlns="http://www.w3.org/2000/svg"> + points="2200,1300 1800,1700 1400,1700 1050,2050 1050,1700 600,1700 600,200 2200,200" /> - - - + + + @@ -195,49 +195,93 @@ export function Twitch({ iconSize }: { iconSize: number }) { ); } +export function Netsuite({ iconSize }: { iconSize: number }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + ); +} + export function Mapping({ provider, iconSize, }: { - provider: string, - iconSize: number, + provider: string, + iconSize: number, }) { switch (provider) { case "google": { - return ; + return ; } case "github": { - return ; + return ; } case "facebook": { - return ; + return ; } case "microsoft": { - return ; + return ; } case "spotify": { - return ; + return ; } case "discord": { - return ; + return ; } case "gitlab": { - return ; + return ; } case "bitbucket": { - return ; + return ; } case "linkedin": { - return ; + return ; } case "apple": { - return ; + return ; } case "x": { - return ; + return ; } case "twitch": { - return ; + return ; + } + case "netsuite": { + return ; } default: { throw new StackAssertionError(`Icon not found for provider: ${provider}`); @@ -258,7 +302,8 @@ export function toTitle(id: string) { bitbucket: "Bitbucket", linkedin: "LinkedIn", x: "X", - twitch: "Twitch" + twitch: "Twitch", + netsuite: "NetSuite" }[id] || throwErr(`Unknown provider: ${id}`); } @@ -272,5 +317,6 @@ export const BRAND_COLORS: Record = { linkedin: '#0A66C2', x: '#000000', apple: '#000000', - twitch: '#ffffff' + twitch: '#ffffff', + netsuite: '#403C38' }; From c1694938487ebee9d04bc40c877c9ad83f31c8ad Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 13 Nov 2025 16:13:59 -0800 Subject: [PATCH 8/9] type fixes --- apps/backend/src/oauth/providers/netsuite.tsx | 4 ++-- packages/template/src/lib/stack-app/project-configs/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/oauth/providers/netsuite.tsx b/apps/backend/src/oauth/providers/netsuite.tsx index e92feaabe6..66016cb8e6 100644 --- a/apps/backend/src/oauth/providers/netsuite.tsx +++ b/apps/backend/src/oauth/providers/netsuite.tsx @@ -17,9 +17,9 @@ export class NetSuiteProvider extends OAuthBaseProvider { static async create(options: { clientId: string, clientSecret: string, - accountId?: string, + netsuiteAccountId?: string, }) { - const accountId = options.accountId || getEnvVariable("STACK_NETSUITE_ACCOUNT_ID", ""); + const accountId = options.netsuiteAccountId || getEnvVariable("STACK_NETSUITE_ACCOUNT_ID", ""); if (!accountId) { throw new StackAssertionError("NetSuite Account ID is required. Set STACK_NETSUITE_ACCOUNT_ID environment variable or provide accountId in options."); } diff --git a/packages/template/src/lib/stack-app/project-configs/index.ts b/packages/template/src/lib/stack-app/project-configs/index.ts index 5bc3f91e37..964770f509 100644 --- a/packages/template/src/lib/stack-app/project-configs/index.ts +++ b/packages/template/src/lib/stack-app/project-configs/index.ts @@ -67,6 +67,7 @@ export type AdminOAuthProviderConfig = { clientSecret: string, facebookConfigId?: string, microsoftTenantId?: string, + netsuiteAccountId?: string, } ) & OAuthProviderConfig; From 0d05ba44c8f1b18c013294aa82033fd18adcb4f4 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 14 Nov 2025 14:09:01 -0800 Subject: [PATCH 9/9] fix test --- apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts index aaa7e6fe4f..9049620491 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts @@ -374,7 +374,7 @@ it("returns an error when the oauth config is misconfigured", async ({ expect }) expect(invalidTypeResponse).toMatchInlineSnapshot(` NiceResponse { "status": 400, - "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch", + "body": "auth.oauth.providers.invalid.type must be one of the following values: google, github, microsoft, spotify, facebook, discord, gitlab, bitbucket, linkedin, apple, x, twitch, netsuite", "headers": Headers {