* feat(schema): add auth, people, and financial tables Add users, organizations, teams, groups, and project members tables. Extend customers/vendors with netsuite fields. Add netsuite schema for invoices, bills, payments, and credit memos. Include all migrations, seeds, new UI primitives, and config updates. * feat(auth): add WorkOS authentication system Add login, signup, password reset, email verification, and invitation flows via WorkOS AuthKit. Includes auth middleware, permission helpers, dev mode fallbacks, and auth page components. * feat(people): add people management system Add user, team, group, and organization management with CRUD actions, dashboard pages, invite dialog, user drawer, and role-based filtering. Includes WorkOS invitation integration. * feat(netsuite): add NetSuite integration and financials Add bidirectional NetSuite REST API integration with OAuth 2.0, rate limiting, sync engine, and conflict resolution. Includes invoices, vendor bills, payments, credit memos CRUD, customer/vendor management pages, and financial dashboard with tabbed views. * ci: retrigger build * fix: add mobile-list-card dependency for people-table --------- Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
149 lines
3.7 KiB
TypeScript
Executable File
149 lines
3.7 KiB
TypeScript
Executable File
import { eq } from "drizzle-orm"
|
|
import type { NetSuiteConfig } from "../config"
|
|
import { encrypt, decrypt } from "./crypto"
|
|
import {
|
|
refreshAccessToken,
|
|
type OAuthTokens,
|
|
} from "./oauth-client"
|
|
import { netsuiteAuth } from "@/db/schema-netsuite"
|
|
import type { DrizzleD1Database } from "drizzle-orm/d1"
|
|
|
|
// refresh at 80% of token lifetime to avoid edge-case expiry
|
|
const REFRESH_THRESHOLD = 0.8
|
|
|
|
export class TokenManager {
|
|
private config: NetSuiteConfig
|
|
private db: DrizzleD1Database
|
|
private cachedTokens: OAuthTokens | null = null
|
|
|
|
constructor(config: NetSuiteConfig, db: DrizzleD1Database) {
|
|
this.config = config
|
|
this.db = db
|
|
}
|
|
|
|
async getAccessToken(): Promise<string> {
|
|
const tokens = await this.loadTokens()
|
|
if (!tokens) {
|
|
throw new Error(
|
|
"No NetSuite tokens found. Complete OAuth setup first."
|
|
)
|
|
}
|
|
|
|
if (this.shouldRefresh(tokens)) {
|
|
const refreshed = await this.refresh(tokens.refreshToken)
|
|
return refreshed.accessToken
|
|
}
|
|
|
|
return tokens.accessToken
|
|
}
|
|
|
|
async storeTokens(tokens: OAuthTokens): Promise<void> {
|
|
const encryptedAccess = await encrypt(
|
|
tokens.accessToken,
|
|
this.config.tokenEncryptionKey
|
|
)
|
|
const encryptedRefresh = await encrypt(
|
|
tokens.refreshToken,
|
|
this.config.tokenEncryptionKey
|
|
)
|
|
|
|
const existing = await this.db
|
|
.select()
|
|
.from(netsuiteAuth)
|
|
.where(eq(netsuiteAuth.accountId, this.config.accountId))
|
|
.limit(1)
|
|
|
|
const now = new Date().toISOString()
|
|
const values = {
|
|
accountId: this.config.accountId,
|
|
accessTokenEncrypted: encryptedAccess,
|
|
refreshTokenEncrypted: encryptedRefresh,
|
|
expiresIn: tokens.expiresIn,
|
|
tokenType: tokens.tokenType,
|
|
issuedAt: tokens.issuedAt,
|
|
updatedAt: now,
|
|
}
|
|
|
|
if (existing.length > 0) {
|
|
await this.db
|
|
.update(netsuiteAuth)
|
|
.set(values)
|
|
.where(eq(netsuiteAuth.accountId, this.config.accountId))
|
|
} else {
|
|
await this.db.insert(netsuiteAuth).values({
|
|
id: crypto.randomUUID(),
|
|
...values,
|
|
createdAt: now,
|
|
})
|
|
}
|
|
|
|
this.cachedTokens = tokens
|
|
}
|
|
|
|
async hasTokens(): Promise<boolean> {
|
|
const row = await this.db
|
|
.select({ id: netsuiteAuth.id })
|
|
.from(netsuiteAuth)
|
|
.where(eq(netsuiteAuth.accountId, this.config.accountId))
|
|
.limit(1)
|
|
return row.length > 0
|
|
}
|
|
|
|
async clearTokens(): Promise<void> {
|
|
await this.db
|
|
.delete(netsuiteAuth)
|
|
.where(eq(netsuiteAuth.accountId, this.config.accountId))
|
|
this.cachedTokens = null
|
|
}
|
|
|
|
private shouldRefresh(tokens: OAuthTokens): boolean {
|
|
const elapsed = Date.now() - tokens.issuedAt
|
|
const threshold = tokens.expiresIn * 1000 * REFRESH_THRESHOLD
|
|
return elapsed >= threshold
|
|
}
|
|
|
|
private async refresh(
|
|
refreshToken: string
|
|
): Promise<OAuthTokens> {
|
|
const tokens = await refreshAccessToken(
|
|
this.config,
|
|
refreshToken
|
|
)
|
|
await this.storeTokens(tokens)
|
|
return tokens
|
|
}
|
|
|
|
private async loadTokens(): Promise<OAuthTokens | null> {
|
|
if (this.cachedTokens) return this.cachedTokens
|
|
|
|
const rows = await this.db
|
|
.select()
|
|
.from(netsuiteAuth)
|
|
.where(eq(netsuiteAuth.accountId, this.config.accountId))
|
|
.limit(1)
|
|
|
|
if (rows.length === 0) return null
|
|
|
|
const row = rows[0]
|
|
|
|
const accessToken = await decrypt(
|
|
row.accessTokenEncrypted,
|
|
this.config.tokenEncryptionKey
|
|
)
|
|
const refreshToken = await decrypt(
|
|
row.refreshTokenEncrypted,
|
|
this.config.tokenEncryptionKey
|
|
)
|
|
|
|
this.cachedTokens = {
|
|
accessToken,
|
|
refreshToken,
|
|
expiresIn: row.expiresIn,
|
|
tokenType: row.tokenType,
|
|
issuedAt: row.issuedAt,
|
|
}
|
|
|
|
return this.cachedTokens
|
|
}
|
|
}
|