spine-framework 0.3.61 → 0.3.62

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.
@@ -301,6 +301,8 @@ interface RegistrationConfig {
301
301
  enabled: boolean
302
302
  default_role: string
303
303
  redirect_path: string
304
+ account_strategy?: 'existing' | 'new' | 'choice'
305
+ target_account?: string
304
306
  }
305
307
 
306
308
  interface SpineConfig {
@@ -313,6 +315,8 @@ interface SpineConfig {
313
315
  default_role: string
314
316
  path: string
315
317
  enabled: boolean
318
+ account_strategy: 'existing' | 'new' | 'choice'
319
+ target_account?: string
316
320
  }>
317
321
  }
318
322
  }
@@ -360,12 +364,14 @@ function updateRegistrationConfig(appSlug: string, regConfig: RegistrationConfig
360
364
  console.log(` ⚠️ Could not load roles from seed data, using default role only`)
361
365
  }
362
366
 
363
- // Add/update this app's registration entry with multi-role support
367
+ // Add/update this app's registration entry with multi-role and account strategy support
364
368
  config.registration.apps[appSlug] = {
365
369
  roles: availableRoles,
366
370
  default_role: regConfig.default_role,
367
371
  path: regConfig.redirect_path,
368
372
  enabled: regConfig.enabled,
373
+ account_strategy: regConfig.account_strategy || 'new',
374
+ target_account: regConfig.target_account,
369
375
  }
370
376
 
371
377
  // If this is the first registration-enabled app, make it the default
@@ -213,12 +213,79 @@ export const validateToken = createHandler(async (_ctx, _body) => {
213
213
  * (invite path is handled entirely in the DB trigger)
214
214
  */
215
215
  export const completeRegistration = createHandler(async (ctx, body) => {
216
- const { path, account_name, email, role_slug } = body
216
+ const { path, account_name, email, role_slug, target_account } = body
217
217
 
218
218
  if (!path || !email) {
219
219
  throw new Error('path and email are required')
220
220
  }
221
221
 
222
+ if (path === 'existing_account') {
223
+ if (!target_account) throw new Error('target_account is required for existing_account path')
224
+
225
+ // Resolve the target account
226
+ const { data: targetAccount } = await adminDb
227
+ .from('accounts')
228
+ .select('id, slug')
229
+ .eq('slug', target_account)
230
+ .single()
231
+
232
+ if (!targetAccount) {
233
+ throw new Error(`Target account '${target_account}' not found`)
234
+ }
235
+
236
+ // Resolve person type_id
237
+ const { data: personType } = await adminDb
238
+ .from('types')
239
+ .select('id, design_schema, validation_schema')
240
+ .eq('kind', 'person')
241
+ .eq('slug', 'person')
242
+ .single()
243
+
244
+ // Resolve role from role_slug param
245
+ const targetRoleSlug = role_slug || 'member'
246
+
247
+ let { data: roles } = await adminDb
248
+ .from('roles')
249
+ .select('id, slug')
250
+ .eq('slug', targetRoleSlug)
251
+ .limit(1)
252
+
253
+ let role = roles?.[0]
254
+
255
+ // If specific role not found, try member_admin or member as fallback
256
+ if (!role) {
257
+ const { data: fallbackRoles } = await adminDb
258
+ .from('roles')
259
+ .select('id, slug')
260
+ .in('slug', ['member_admin', 'member'])
261
+ .order('slug')
262
+ .limit(1)
263
+ role = fallbackRoles?.[0]
264
+ }
265
+
266
+ // Update the walk-up shell to be an active user on the target account
267
+ const { error: updateErr } = await adminDb
268
+ .from('people')
269
+ .update({
270
+ account_id: targetAccount.id,
271
+ role_id: role?.id ?? null,
272
+ status: 'active',
273
+ type_id: personType.id,
274
+ data: {},
275
+ updated_at: new Date().toISOString(),
276
+ })
277
+ .eq('email', email)
278
+ .eq('status', 'active')
279
+ .is('role_id', null)
280
+
281
+ if (updateErr) throw new Error('Failed to assign user to existing account: ' + updateErr.message)
282
+
283
+ return {
284
+ status: 'active',
285
+ account_slug: targetAccount.slug
286
+ }
287
+ }
288
+
222
289
  if (path === 'new_account') {
223
290
  if (!account_name) throw new Error('account_name is required for new_account path')
224
291
 
@@ -57,6 +57,8 @@ interface RegistrationConfig {
57
57
  default_role: string
58
58
  path: string
59
59
  enabled: boolean
60
+ account_strategy: 'existing' | 'new' | 'choice'
61
+ target_account?: string
60
62
  }>
61
63
  }
62
64
 
@@ -153,6 +155,9 @@ export function RegisterPage() {
153
155
  const [domainMatchChoice, setDomainMatchChoice] = useState<DomainMatchChoice>(null)
154
156
  const [domainCheckLoading, setDomainCheckLoading] = useState(false)
155
157
 
158
+ // Account strategy state
159
+ const [accountStrategy, setAccountStrategy] = useState<'existing' | 'new'>('new')
160
+
156
161
  const [error, setError] = useState('')
157
162
  const [isLoading, setIsLoading] = useState(false)
158
163
 
@@ -235,26 +240,40 @@ export function RegisterPage() {
235
240
  // 4. Walk-up paths — call complete-registration
236
241
  const resolvedEmail = email
237
242
 
238
- // Determine role from URL params or config
243
+ // Determine role and account strategy from URL params or config
239
244
  const urlRole = searchParams.get('role')
240
245
  const urlApp = searchParams.get('app')
241
246
 
242
247
  let effectiveRole = regConfig?.default_role || 'member'
248
+ let effectiveAccountStrategy: 'existing' | 'new' | 'choice' = 'new'
249
+ let targetAccount: string | undefined
243
250
 
244
- // If app is specified, use app's default role
251
+ // If app is specified, use app's configuration
245
252
  if (urlApp && regConfig?.apps[urlApp]) {
246
253
  const appConfig = regConfig.apps[urlApp]
247
254
 
248
- // If URL role is provided, validate it against app's available roles
255
+ // Determine role
249
256
  if (urlRole && appConfig.roles.includes(urlRole)) {
250
257
  effectiveRole = urlRole
251
258
  } else {
252
259
  effectiveRole = appConfig.default_role
253
260
  }
261
+
262
+ // Determine account strategy
263
+ effectiveAccountStrategy = appConfig.account_strategy
264
+ targetAccount = appConfig.target_account
254
265
  }
255
- // If only URL role is specified (no app), use it directly
266
+ // If only URL role is specified (no app), use it directly with default strategy
256
267
  else if (urlRole) {
257
268
  effectiveRole = urlRole
269
+ effectiveAccountStrategy = 'new'
270
+ }
271
+
272
+ // For 'existing' strategy, always use the target account
273
+ if (effectiveAccountStrategy === 'existing') {
274
+ setAccountStrategy('existing')
275
+ } else {
276
+ setAccountStrategy('new')
258
277
  }
259
278
 
260
279
  if (domainMatchChoice === 'join') {
@@ -273,17 +292,29 @@ export function RegisterPage() {
273
292
  return
274
293
  }
275
294
 
276
- // New account (personal or company create_new)
277
- const accountName = useType === 'personal' ? fullName : companyName
278
- const newResult = await callInvitesApi('complete-registration', {
279
- path: 'new_account',
280
- email: resolvedEmail,
281
- account_name: accountName,
282
- role_slug: effectiveRole,
283
- }, 'POST')
284
-
285
- if (newResult.error) {
286
- setError(newResult.error)
295
+ // Handle account creation vs existing account assignment
296
+ let registrationResult
297
+ if (accountStrategy === 'existing' && targetAccount) {
298
+ // Assign to existing account
299
+ registrationResult = await callInvitesApi('complete-registration', {
300
+ path: 'existing_account',
301
+ email: resolvedEmail,
302
+ target_account: targetAccount,
303
+ role_slug: effectiveRole,
304
+ }, 'POST')
305
+ } else {
306
+ // Create new account (personal or company)
307
+ const accountName = useType === 'personal' ? fullName : companyName
308
+ registrationResult = await callInvitesApi('complete-registration', {
309
+ path: 'new_account',
310
+ email: resolvedEmail,
311
+ account_name: accountName,
312
+ role_slug: effectiveRole,
313
+ }, 'POST')
314
+ }
315
+
316
+ if (registrationResult.error) {
317
+ setError(registrationResult.error)
287
318
  return
288
319
  }
289
320
 
@@ -325,7 +356,7 @@ export function RegisterPage() {
325
356
  const isInvite = !!inviteToken && !!invitePreview
326
357
  const showDomainMatchPrompt = domainMatchAccountId && domainMatchChoice === null && useType === 'company' && !isFirstUser
327
358
  const effectiveEmail = isInvite ? invitePreview.email : email
328
- const showUseTypeToggle = !isInvite && !isFirstUser
359
+ const showUseTypeToggle = !isInvite && !isFirstUser && accountStrategy === 'new'
329
360
 
330
361
  // Header text based on scenario
331
362
  let headerTitle = 'Create your account'
@@ -478,8 +509,8 @@ export function RegisterPage() {
478
509
  </div>
479
510
  )}
480
511
 
481
- {/* Company name — shown for company type when not joining existing */}
482
- {!isInvite && useType === 'company' && domainMatchChoice !== 'join' && !isFirstUser && (
512
+ {/* Company name — shown for company type when not joining existing and using new account strategy */}
513
+ {!isInvite && useType === 'company' && domainMatchChoice !== 'join' && !isFirstUser && accountStrategy === 'new' && (
483
514
  <div>
484
515
  <label htmlFor="companyName" className="block text-sm font-medium text-slate-700">
485
516
  Company name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-framework",
3
- "version": "0.3.61",
3
+ "version": "0.3.62",
4
4
  "description": "Multi-tenant, modular application platform for modern SaaS systems",
5
5
  "type": "module",
6
6
  "bin": {
package/spine.config.json CHANGED
@@ -9,13 +9,16 @@
9
9
  "roles": ["member"],
10
10
  "default_role": "member",
11
11
  "path": "/portal",
12
- "enabled": true
12
+ "enabled": true,
13
+ "account_strategy": "new"
13
14
  },
14
15
  "cortex": {
15
16
  "roles": ["support", "support_admin"],
16
17
  "default_role": "support",
17
18
  "path": "/cortex",
18
- "enabled": true
19
+ "enabled": true,
20
+ "account_strategy": "existing",
21
+ "target_account": "spine-system"
19
22
  }
20
23
  }
21
24
  }