@nextsparkjs/core 0.1.0-beta.176 → 0.1.0-beta.178

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.
Files changed (72) hide show
  1. package/dist/components/devtools/MarkdownViewer.d.ts.map +1 -1
  2. package/dist/components/devtools/MarkdownViewer.js +5 -0
  3. package/dist/components/devtools/api-explorer/ApiDocsModal.d.ts.map +1 -1
  4. package/dist/components/devtools/api-explorer/ApiDocsModal.js +0 -0
  5. package/dist/components/devtools/api-explorer/ApiEndpointsSidebar.d.ts.map +1 -1
  6. package/dist/components/devtools/api-explorer/ApiEndpointsSidebar.js +16 -5
  7. package/dist/components/devtools/api-explorer/ApiExplorer.d.ts.map +1 -1
  8. package/dist/components/devtools/api-explorer/ApiExplorer.js +63 -21
  9. package/dist/components/devtools/api-explorer/ApiRequestPanel.d.ts +3 -1
  10. package/dist/components/devtools/api-explorer/ApiRequestPanel.d.ts.map +1 -1
  11. package/dist/components/devtools/api-explorer/ApiRequestPanel.js +5 -1
  12. package/dist/components/devtools/api-explorer/explorer-storage.d.ts +12 -0
  13. package/dist/components/devtools/api-explorer/explorer-storage.d.ts.map +1 -0
  14. package/dist/components/devtools/api-explorer/explorer-storage.js +25 -0
  15. package/dist/components/devtools/api-tester/AuthSelector.d.ts +4 -1
  16. package/dist/components/devtools/api-tester/AuthSelector.d.ts.map +1 -1
  17. package/dist/components/devtools/api-tester/AuthSelector.js +24 -2
  18. package/dist/components/docs/docs-content.d.ts.map +1 -1
  19. package/dist/components/docs/docs-content.js +17 -0
  20. package/dist/components/docs/mermaid-diagram.d.ts +11 -0
  21. package/dist/components/docs/mermaid-diagram.d.ts.map +1 -0
  22. package/dist/components/docs/mermaid-diagram.js +20 -0
  23. package/dist/components/permissions/PermissionsMatrix.d.ts.map +1 -1
  24. package/dist/components/permissions/PermissionsMatrix.js +5 -4
  25. package/dist/components/teams/InviteMemberDialog.js +1 -1
  26. package/dist/components/teams/TeamMembersList.js +1 -1
  27. package/dist/hooks/useApiExplorerNavigation.d.ts.map +1 -1
  28. package/dist/hooks/useApiExplorerNavigation.js +5 -1
  29. package/dist/lib/actions/team.actions.d.ts.map +1 -1
  30. package/dist/lib/actions/team.actions.js +7 -5
  31. package/dist/lib/api/auth/dual-auth.d.ts +14 -0
  32. package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
  33. package/dist/lib/api/auth/dual-auth.js +43 -2
  34. package/dist/lib/auth.d.ts.map +1 -1
  35. package/dist/lib/auth.js +3 -3
  36. package/dist/lib/config/app.config.js +60 -60
  37. package/dist/lib/config/config-sync.d.ts.map +1 -1
  38. package/dist/lib/config/config-sync.js +14 -9
  39. package/dist/lib/config/roles-merge.d.ts +0 -77
  40. package/dist/lib/config/roles-merge.d.ts.map +1 -1
  41. package/dist/lib/config/roles-merge.js +1 -98
  42. package/dist/lib/config/types.d.ts +6 -2
  43. package/dist/lib/config/types.d.ts.map +1 -1
  44. package/dist/lib/docs/mermaid.d.ts +15 -0
  45. package/dist/lib/docs/mermaid.d.ts.map +1 -0
  46. package/dist/lib/docs/mermaid.js +42 -0
  47. package/dist/lib/docs/parser.d.ts.map +1 -1
  48. package/dist/lib/docs/parser.js +2 -1
  49. package/dist/lib/docs/remark-mermaid.d.ts +15 -0
  50. package/dist/lib/docs/remark-mermaid.d.ts.map +1 -0
  51. package/dist/lib/docs/remark-mermaid.js +26 -0
  52. package/dist/lib/services/membership.service.js +2 -2
  53. package/dist/lib/services/team-member.service.d.ts +2 -2
  54. package/dist/lib/services/team-member.service.d.ts.map +1 -1
  55. package/dist/lib/services/team-member.service.js +4 -3
  56. package/dist/lib/teams/types.d.ts +2 -1
  57. package/dist/lib/teams/types.d.ts.map +1 -1
  58. package/dist/lib/teams/types.js +4 -4
  59. package/dist/styles/classes.json +3 -2
  60. package/dist/styles/ui.css +1 -1
  61. package/dist/templates/app/(auth)/login/page.tsx +2 -0
  62. package/dist/templates/app/(auth)/signup/page.tsx +4 -0
  63. package/dist/templates/app/superadmin/team-roles/page.tsx +2 -2
  64. package/package.json +3 -2
  65. package/scripts/build/registry/__tests__/team-roles.test.mjs +192 -0
  66. package/scripts/build/registry/discovery/permissions.mjs +163 -1
  67. package/scripts/build/registry/generators/permissions-registry.mjs +95 -35
  68. package/styles/docs.css +15 -0
  69. package/templates/app/(auth)/login/page.tsx +2 -0
  70. package/templates/app/(auth)/signup/page.tsx +4 -0
  71. package/templates/app/superadmin/team-roles/page.tsx +2 -2
  72. package/tests/jest/__mocks__/@nextsparkjs/registries/permissions-registry.ts +1 -0
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
2
2
  import { LoginForm } from '@nextsparkjs/core/components/auth/forms/LoginForm'
3
3
  import { getTemplateOrDefault, getMetadataOrDefault } from '@nextsparkjs/core/lib/template-resolver'
4
4
 
5
+ export const dynamic = 'force-dynamic'
6
+
5
7
  const defaultMetadata: Metadata = {
6
8
  title: 'Sign In',
7
9
  description: 'Sign in to your account to access the platform',
@@ -6,6 +6,10 @@ import { getTemplateOrDefault, getMetadataOrDefault } from '@nextsparkjs/core/li
6
6
  import { AUTH_CONFIG } from '@nextsparkjs/core/lib/config'
7
7
  import { TeamService } from '@nextsparkjs/core/lib/services'
8
8
 
9
+ // This page reads DB state (TeamService.hasGlobal) and redirect()s during render,
10
+ // so it must render per-request and never be statically prerendered at build time.
11
+ export const dynamic = 'force-dynamic'
12
+
9
13
  const defaultMetadata: Metadata = {
10
14
  title: 'Create Account',
11
15
  description: 'Create your account to start using our platform',
@@ -40,7 +40,7 @@ import { getTemplateOrDefaultClient } from "@nextsparkjs/registries/template-reg
40
40
  * Both use pre-computed data for O(1) runtime lookups.
41
41
  */
42
42
  function TeamRolesPage() {
43
- const teamRolesConfig = APP_CONFIG_MERGED.teamRoles;
43
+ const teamRolesConfig = APP_CONFIG_MERGED.teams.roles;
44
44
  const roles = teamRolesConfig.availableTeamRoles;
45
45
  const hierarchy = teamRolesConfig.hierarchy;
46
46
 
@@ -258,7 +258,7 @@ function TeamRolesPage() {
258
258
  <p>
259
259
  <strong>Roles config:</strong>{" "}
260
260
  <code className="bg-background px-1 rounded">
261
- APP_CONFIG_MERGED.teamRoles
261
+ APP_CONFIG_MERGED.teams.roles
262
262
  </code>
263
263
  </p>
264
264
  <p>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nextsparkjs/core",
3
- "version": "0.1.0-beta.176",
3
+ "version": "0.1.0-beta.178",
4
4
  "description": "NextSpark - The complete SaaS framework for Next.js",
5
5
  "license": "MIT",
6
6
  "author": "NextSpark <hello@nextspark.dev>",
@@ -449,6 +449,7 @@
449
449
  "fs-extra": "^11.2.0",
450
450
  "gray-matter": "^4.0.3",
451
451
  "lucide-react": "^0.539.0",
452
+ "mermaid": "^11.0.0",
452
453
  "next-intl": "^4.9.1",
453
454
  "next-themes": "^0.4.6",
454
455
  "ora": "^8.1.1",
@@ -469,7 +470,7 @@
469
470
  "tailwind-merge": "^3.3.1",
470
471
  "uuid": "^13.0.0",
471
472
  "zod": "^4.1.5",
472
- "@nextsparkjs/testing": "0.1.0-beta.176"
473
+ "@nextsparkjs/testing": "0.1.0-beta.178"
473
474
  },
474
475
  "scripts": {
475
476
  "postinstall": "node scripts/postinstall.mjs || true",
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Tests for the declarative team-role model at the build layer.
3
+ *
4
+ * Model: `teams.roles` (app.config) is the AUTHORITATIVE, declarative set of NON-OWNER
5
+ * roles. When present it REPLACES the defaults; when absent the set is base roles +
6
+ * theme custom roles (permissions.config). 'owner' is ALWAYS force-included.
7
+ *
8
+ * Covers the real production code paths:
9
+ * - computeTeamRoleConfig() : the pure build-time role-set computation
10
+ * - parseTeamRolesFromAppConfig() : the app.config.ts teams.roles parser
11
+ * - generatePermissionsRegistry() : asserts the EMITTED literals
12
+ *
13
+ * Run: node --test packages/core/scripts/build/registry/__tests__/team-roles.test.mjs
14
+ */
15
+
16
+ import { test } from 'node:test'
17
+ import assert from 'node:assert/strict'
18
+
19
+ import { computeTeamRoleConfig, generatePermissionsRegistry } from '../generators/permissions-registry.mjs'
20
+ import { parseTeamRolesFromAppConfig } from '../discovery/permissions.mjs'
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // computeTeamRoleConfig — the pure build-time computation
24
+ // ---------------------------------------------------------------------------
25
+
26
+ test('no config → default = base non-owner roles + owner (no regression)', () => {
27
+ const r = computeTeamRoleConfig({})
28
+ assert.deepEqual(r.availableRoles, ['owner', 'admin', 'member', 'viewer'])
29
+ assert.equal(r.defaultTeamRole, 'member')
30
+ assert.equal(r.customRolesCount, 0)
31
+ assert.deepEqual(r.coreTeamRoles, ['owner'])
32
+ assert.equal(r.hierarchy.owner, 100)
33
+ })
34
+
35
+ test('no teamRoles override → base + permissions.config custom roles (default theme shape)', () => {
36
+ const r = computeTeamRoleConfig({
37
+ rolesConfig: { additionalRoles: ['editor'], hierarchy: { editor: 40 } },
38
+ })
39
+ assert.deepEqual(r.availableRoles, ['owner', 'admin', 'member', 'viewer', 'editor'])
40
+ assert.equal(r.hierarchy.editor, 40)
41
+ assert.equal(r.customRolesCount, 1)
42
+ assert.equal(r.defaultTeamRole, 'member')
43
+ })
44
+
45
+ test('teamRoles REPLACES the whole set (owner forced, everything else dropped)', () => {
46
+ const r = computeTeamRoleConfig({
47
+ rolesConfig: { additionalRoles: ['editor'] }, // defined but NOT selected → excluded
48
+ teamRolesConfig: { teamRoles: ['test1', 'test2'] },
49
+ })
50
+ assert.deepEqual(r.availableRoles, ['owner', 'test1', 'test2'])
51
+ assert.equal(r.availableRoles.includes('admin'), false)
52
+ assert.equal(r.availableRoles.includes('member'), false)
53
+ assert.equal(r.availableRoles.includes('viewer'), false)
54
+ assert.equal(r.availableRoles.includes('editor'), false)
55
+ // default falls back to first non-owner since 'member' is not in the set
56
+ assert.equal(r.defaultTeamRole, 'test1')
57
+ })
58
+
59
+ test("'owner' is ALWAYS present and force-deduped", () => {
60
+ assert.deepEqual(computeTeamRoleConfig({ teamRolesConfig: { teamRoles: ['admin'] } }).availableRoles, ['owner', 'admin'])
61
+ assert.deepEqual(computeTeamRoleConfig({ teamRolesConfig: { teamRoles: ['owner', 'admin'] } }).availableRoles, ['owner', 'admin'])
62
+ })
63
+
64
+ test('empty teamRoles → owner-only team', () => {
65
+ const r = computeTeamRoleConfig({ teamRolesConfig: { teamRoles: [] } })
66
+ assert.deepEqual(r.availableRoles, ['owner'])
67
+ assert.equal(r.defaultTeamRole, 'owner')
68
+ })
69
+
70
+ test('selected custom role keeps its permissions.config metadata', () => {
71
+ const r = computeTeamRoleConfig({
72
+ rolesConfig: { additionalRoles: ['editor'], hierarchy: { editor: 40 }, displayNames: { editor: 'common.teamRoles.editor' } },
73
+ teamRolesConfig: { teamRoles: ['admin', 'editor'] },
74
+ })
75
+ assert.deepEqual(r.availableRoles, ['owner', 'admin', 'editor'])
76
+ assert.equal(r.hierarchy.editor, 40)
77
+ assert.equal(r.displayNames.editor, 'common.teamRoles.editor')
78
+ })
79
+
80
+ test('Campus-style: select admin + teacher + coach (drop member/viewer)', () => {
81
+ const r = computeTeamRoleConfig({
82
+ rolesConfig: { additionalRoles: ['teacher', 'coach'], hierarchy: { teacher: 30, coach: 20 } },
83
+ teamRolesConfig: { teamRoles: ['admin', 'teacher', 'coach'], defaultTeamRole: 'coach' },
84
+ })
85
+ assert.deepEqual(r.availableRoles, ['owner', 'admin', 'teacher', 'coach'])
86
+ assert.equal(r.defaultTeamRole, 'coach')
87
+ assert.equal(r.hierarchy.teacher, 30)
88
+ })
89
+
90
+ test('explicit defaultTeamRole outside the set falls back to a valid role', () => {
91
+ const r = computeTeamRoleConfig({ teamRolesConfig: { teamRoles: ['admin', 'lead'], defaultTeamRole: 'ghost' } })
92
+ assert.equal(r.availableRoles.includes('ghost'), false)
93
+ assert.ok(r.availableRoles.includes(r.defaultTeamRole))
94
+ })
95
+
96
+ test('non-owner hierarchy is capped below 100; owner pinned at 100', () => {
97
+ const r = computeTeamRoleConfig({
98
+ rolesConfig: { additionalRoles: ['big'], hierarchy: { big: 250 } },
99
+ teamRolesConfig: { teamRoles: ['big'] },
100
+ })
101
+ assert.equal(r.hierarchy.big, 99)
102
+ assert.equal(r.hierarchy.owner, 100)
103
+ })
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // parseTeamRolesFromAppConfig — the app.config.ts parser
107
+ // ---------------------------------------------------------------------------
108
+
109
+ test('teams.roles array form → authoritative teamRoles set', () => {
110
+ const parsed = parseTeamRolesFromAppConfig(`teams: { mode: 'multi-tenant', roles: ['admin', 'editor'] }`)
111
+ assert.deepEqual(parsed, { teamRoles: ['admin', 'editor'] })
112
+ })
113
+
114
+ test('teams.roles array form strips owner (always forced by the generator)', () => {
115
+ const parsed = parseTeamRolesFromAppConfig(`teams: { roles: ['owner', 'admin'] }`)
116
+ assert.deepEqual(parsed, { teamRoles: ['admin'] })
117
+ })
118
+
119
+ test('teams.roles array form + sibling defaultTeamRole (Campus shape, options nested)', () => {
120
+ const content = `
121
+ teams: {
122
+ mode: 'multi-tenant' as const,
123
+ options: {
124
+ maxMembersPerTeam: 100000,
125
+ allowLeaveTeam: false,
126
+ allowCreateTeams: false,
127
+ },
128
+ roles: ['admin', 'teacher', 'coach', 'student'],
129
+ defaultTeamRole: 'student',
130
+ },`
131
+ const parsed = parseTeamRolesFromAppConfig(content)
132
+ assert.deepEqual(parsed.teamRoles, ['admin', 'teacher', 'coach', 'student'])
133
+ assert.equal(parsed.defaultTeamRole, 'student')
134
+ })
135
+
136
+ test('teams without a roles key → null (generator uses the default set)', () => {
137
+ const content = `
138
+ teams: {
139
+ mode: 'multi-tenant' as const,
140
+ options: { maxMembersPerTeam: 50 },
141
+ },`
142
+ assert.equal(parseTeamRolesFromAppConfig(content), null)
143
+ })
144
+
145
+ test('a teams.availableTeamRoles sibling (not teams.roles) does NOT match (case-sensitive)', () => {
146
+ const content = `
147
+ teams: {
148
+ mode: 'multi-tenant',
149
+ availableTeamRoles: ['owner', 'admin', 'teacher'],
150
+ },`
151
+ assert.equal(parseTeamRolesFromAppConfig(content), null)
152
+ })
153
+
154
+ test('teams.roles object form parses the set + defaultTeamRole', () => {
155
+ const content = `
156
+ teams: {
157
+ mode: 'multi-tenant',
158
+ roles: {
159
+ availableTeamRoles: ['admin', 'editor'],
160
+ defaultTeamRole: 'editor',
161
+ },
162
+ },`
163
+ const parsed = parseTeamRolesFromAppConfig(content)
164
+ assert.deepEqual(parsed.teamRoles, ['admin', 'editor'])
165
+ assert.equal(parsed.defaultTeamRole, 'editor')
166
+ })
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // generatePermissionsRegistry — assert the EMITTED literals (the real output)
170
+ // ---------------------------------------------------------------------------
171
+
172
+ const readRoles = (out) => JSON.parse(out.match(/export const AVAILABLE_ROLES: readonly string\[\] = (\[[^\]]*\])/)[1])
173
+
174
+ test('generated registry: teamRoles REPLACES the set', async () => {
175
+ const out = await generatePermissionsRegistry(
176
+ { themeName: 'test', importPath: 'x', entities: {}, teams: [], features: [],
177
+ roles: { additionalRoles: ['editor'] }, teamRoles: { teamRoles: ['test1', 'test2'] } },
178
+ [], { outputDir: '/tmp/nextspark-test', isNpmMode: false },
179
+ )
180
+ assert.deepEqual(readRoles(out), ['owner', 'test1', 'test2'])
181
+ assert.match(out, /export const DEFAULT_TEAM_ROLE: string = "test1"/)
182
+ })
183
+
184
+ test('generated registry: no teamRoles → base + custom (no regression)', async () => {
185
+ const out = await generatePermissionsRegistry(
186
+ { themeName: 'test', importPath: 'x', entities: {}, teams: [], features: [],
187
+ roles: { additionalRoles: ['editor'] }, teamRoles: null },
188
+ [], { outputDir: '/tmp/nextspark-test', isNpmMode: false },
189
+ )
190
+ assert.deepEqual(readRoles(out), ['owner', 'admin', 'member', 'viewer', 'editor'])
191
+ assert.match(out, /export const DEFAULT_TEAM_ROLE: string = "member"/)
192
+ })
@@ -223,6 +223,154 @@ function parseActionArrayFromConfig(content, sectionName) {
223
223
  return results
224
224
  }
225
225
 
226
+ function parseStringArrayFromBlock(content, key) {
227
+ const match = content.match(new RegExp(`${key}:\\s*\\[([^\\]]*)\\]`))
228
+ if (!match) return null
229
+
230
+ return match[1]
231
+ .split(',')
232
+ .map((value) => value.trim().replace(/['"]/g, '').replace(/\s+as\s+const/g, ''))
233
+ .filter(Boolean)
234
+ }
235
+
236
+ function parseNumberMapFromBlock(content, key) {
237
+ const match = content.match(new RegExp(`${key}:\\s*\\{([^}]*)\\}`))
238
+ if (!match) return null
239
+
240
+ const result = {}
241
+ const pairs = match[1].match(/['"]?([\w-]+)['"]?:\s*(\d+)/g)
242
+ if (!pairs) return null
243
+
244
+ for (const pair of pairs) {
245
+ const pairMatch = pair.match(/['"]?([\w-]+)['"]?:\s*(\d+)/)
246
+ if (!pairMatch) continue
247
+ result[pairMatch[1]] = parseInt(pairMatch[2], 10)
248
+ }
249
+
250
+ return Object.keys(result).length > 0 ? result : null
251
+ }
252
+
253
+ function parseStringMapFromBlock(content, key) {
254
+ const match = content.match(new RegExp(`${key}:\\s*\\{([^}]*)\\}`))
255
+ if (!match) return null
256
+
257
+ const result = {}
258
+ const pairs = match[1].match(/['"]?([\w-]+)['"]?:\s*['"]([^'"]+)['"]/g)
259
+ if (!pairs) return null
260
+
261
+ for (const pair of pairs) {
262
+ const pairMatch = pair.match(/['"]?([\w-]+)['"]?:\s*['"]([^'"]+)['"]/)
263
+ if (!pairMatch) continue
264
+ result[pairMatch[1]] = pairMatch[2]
265
+ }
266
+
267
+ return Object.keys(result).length > 0 ? result : null
268
+ }
269
+
270
+ /**
271
+ * Extract the inner text of a brace-delimited object for a given key.
272
+ * Brace-matches so nested objects (e.g. `options: { ... }`) are handled correctly.
273
+ *
274
+ * @param {string} content - Source text
275
+ * @param {string} key - Object key to locate (e.g. 'teams', 'roles')
276
+ * @returns {string|null} The text between the matching braces, or null if not found
277
+ */
278
+ function extractObjectBlock(content, key) {
279
+ const keyIndex = content.search(new RegExp(`\\b${key}:`))
280
+ if (keyIndex === -1) return null
281
+
282
+ const objectStart = content.indexOf('{', keyIndex)
283
+ if (objectStart === -1) return null
284
+
285
+ let depth = 0
286
+ for (let i = objectStart; i < content.length; i++) {
287
+ const ch = content[i]
288
+ if (ch === '{') depth += 1
289
+ else if (ch === '}') {
290
+ depth -= 1
291
+ if (depth === 0) return content.slice(objectStart + 1, i)
292
+ }
293
+ }
294
+
295
+ return null
296
+ }
297
+
298
+ /**
299
+ * Parse team roles from app.config.ts.
300
+ *
301
+ * Team roles are declared INSIDE the `teams` block (REPLACE model). `teams.roles`
302
+ * is the AUTHORITATIVE, declarative set of NON-OWNER team roles. 'owner' is always
303
+ * force-included by the generator and is stripped here if listed.
304
+ *
305
+ * Array form (preferred) — exactly these roles (+ owner):
306
+ * teams: {
307
+ * mode: 'multi-tenant',
308
+ * roles: ['admin', 'member', 'viewer'],
309
+ * defaultTeamRole?: 'member',
310
+ * }
311
+ *
312
+ * Object form (optional inline metadata):
313
+ * teams: {
314
+ * roles: {
315
+ * roles: [...] | availableTeamRoles: [...], // the authoritative non-owner set
316
+ * defaultTeamRole?: string,
317
+ * hierarchy?: Record<string, number>,
318
+ * displayNames?: Record<string, string>,
319
+ * descriptions?: Record<string, string>,
320
+ * },
321
+ * }
322
+ *
323
+ * When `teams.roles` is ABSENT, the generator uses the default set: base roles
324
+ * (owner/admin/member/viewer) + theme custom roles from permissions.config.
325
+ *
326
+ * @param {string} content - app.config.ts content
327
+ * @returns {Object|null} Parsed team roles config ({ teamRoles?, defaultTeamRole?, ... })
328
+ */
329
+ export function parseTeamRolesFromAppConfig(content) {
330
+ const teamsBlock = extractObjectBlock(content, 'teams')
331
+ if (!teamsBlock) return null
332
+
333
+ const result = {}
334
+
335
+ // Array form: teams.roles is the authoritative non-owner set ('owner' is forced).
336
+ const arrayRoles = parseStringArrayFromBlock(teamsBlock, 'roles')
337
+ if (arrayRoles) {
338
+ result.teamRoles = Array.from(
339
+ new Set(arrayRoles.filter((role) => role && role !== 'owner'))
340
+ )
341
+ const defaultMatch = teamsBlock.match(/defaultTeamRole:\s*['"]([^'"]+)['"]/)
342
+ if (defaultMatch) result.defaultTeamRole = defaultMatch[1]
343
+ return Object.keys(result).length > 0 ? result : null
344
+ }
345
+
346
+ // Object form: teams.roles = { roles | availableTeamRoles: [...], defaultTeamRole?, ... }
347
+ const rolesBlock = extractObjectBlock(teamsBlock, 'roles')
348
+ if (rolesBlock) {
349
+ const roleSet =
350
+ parseStringArrayFromBlock(rolesBlock, 'roles') ??
351
+ parseStringArrayFromBlock(rolesBlock, 'availableTeamRoles')
352
+ if (roleSet) {
353
+ result.teamRoles = Array.from(
354
+ new Set(roleSet.filter((role) => role && role !== 'owner'))
355
+ )
356
+ }
357
+
358
+ const defaultMatch = rolesBlock.match(/defaultTeamRole:\s*['"]([^'"]+)['"]/)
359
+ if (defaultMatch) result.defaultTeamRole = defaultMatch[1]
360
+
361
+ const hierarchy = parseNumberMapFromBlock(rolesBlock, 'hierarchy')
362
+ if (hierarchy) result.hierarchy = hierarchy
363
+ const displayNames = parseStringMapFromBlock(rolesBlock, 'displayNames')
364
+ if (displayNames) result.displayNames = displayNames
365
+ const descriptions = parseStringMapFromBlock(rolesBlock, 'descriptions')
366
+ if (descriptions) result.descriptions = descriptions
367
+
368
+ return Object.keys(result).length > 0 ? result : null
369
+ }
370
+
371
+ return null
372
+ }
373
+
226
374
  /**
227
375
  * Discover permissions configuration from active theme
228
376
  * Returns null if theme doesn't have a permissions.config.ts
@@ -250,6 +398,7 @@ export async function discoverPermissionsConfig(config = DEFAULT_CONFIG) {
250
398
  let roles = null
251
399
  let teams = []
252
400
  let features = []
401
+ let teamRoles = null
253
402
  try {
254
403
  const content = readFileSync(permissionsPath, 'utf8')
255
404
  entities = parseEntitiesFromConfig(content)
@@ -271,6 +420,18 @@ export async function discoverPermissionsConfig(config = DEFAULT_CONFIG) {
271
420
  if (roles && roles.additionalRoles) {
272
421
  log(` 👥 Parsed ${roles.additionalRoles.length} custom roles: ${roles.additionalRoles.join(', ')}`, 'info')
273
422
  }
423
+
424
+ const appConfigPath = join(config.themesDir, themeName, 'config', 'app.config.ts')
425
+ if (existsSync(appConfigPath)) {
426
+ const appConfigContent = readFileSync(appConfigPath, 'utf8')
427
+ teamRoles = parseTeamRolesFromAppConfig(appConfigContent)
428
+
429
+ if (teamRoles) {
430
+ const declared = teamRoles.teamRoles?.length
431
+ const detail = typeof declared === 'number' ? ` (${declared} roles declared)` : ''
432
+ log(` 🧩 Parsed teamRoles from app.config.ts${detail}`, 'info')
433
+ }
434
+ }
274
435
  } catch (err) {
275
436
  log(`Error parsing permissions.config.ts: ${err.message}`, 'warning')
276
437
  }
@@ -282,6 +443,7 @@ export async function discoverPermissionsConfig(config = DEFAULT_CONFIG) {
282
443
  entities,
283
444
  roles,
284
445
  teams,
285
- features
446
+ features,
447
+ teamRoles,
286
448
  }
287
449
  }
@@ -19,6 +19,83 @@ const rootDir = join(__dirname, '../../../../../..')
19
19
 
20
20
  import { convertCorePath } from '../config.mjs'
21
21
 
22
+ const BASE_TEAM_ROLES = ['owner', 'admin', 'member', 'viewer']
23
+ const NON_REMOVABLE_TEAM_ROLES = ['owner'] // The only core team role; never removable
24
+ const BASE_TEAM_ROLE_HIERARCHY = { owner: 100, admin: 50, member: 10, viewer: 1 }
25
+ const BASE_TEAM_ROLE_DISPLAY_NAMES = {
26
+ owner: 'common.teamRoles.owner',
27
+ admin: 'common.teamRoles.admin',
28
+ member: 'common.teamRoles.member',
29
+ viewer: 'common.teamRoles.viewer',
30
+ }
31
+ const BASE_TEAM_ROLE_DESCRIPTIONS = {
32
+ owner: 'Full team control, cannot be removed',
33
+ admin: 'Manage team members and settings',
34
+ member: 'Standard team access',
35
+ viewer: 'Read-only access to team resources',
36
+ }
37
+
38
+ /**
39
+ * Compute the effective team-role configuration at build time.
40
+ *
41
+ * Pure (no I/O) so it can be unit-tested directly and emitted as literals.
42
+ * Doctrine: `teamRoles` (app.config) is the AUTHORITATIVE, declarative set of
43
+ * non-owner roles. When provided it REPLACES the defaults; when absent the set is
44
+ * the base roles + theme custom roles (permissions.config). 'owner' is ALWAYS
45
+ * force-included and can never be removed. Hierarchy/displayNames/descriptions are
46
+ * merged (base <- permissions.config <- app.config) and restricted to the effective
47
+ * set; the default role can never resolve to a role outside the set.
48
+ *
49
+ * @param {{ rolesConfig?: object|null, teamRolesConfig?: object|null }} opts
50
+ * @returns {{ coreTeamRoles: string[], availableRoles: string[], hierarchy: Record<string,number>, displayNames: Record<string,string>, descriptions: Record<string,string>, defaultTeamRole: string, customRolesCount: number }}
51
+ */
52
+ export function computeTeamRoleConfig({ rolesConfig = null, teamRolesConfig = null } = {}) {
53
+ const legacyAdditionalRoles = Array.isArray(rolesConfig?.additionalRoles) ? rolesConfig.additionalRoles : []
54
+ // `teamRoles` (app.config) is the AUTHORITATIVE, declarative non-owner set when present.
55
+ // When absent, the default set is the base non-owner roles + theme custom roles
56
+ // (permissions.config.additionalRoles). Either way 'owner' is always force-included.
57
+ const declaredSet = Array.isArray(teamRolesConfig?.teamRoles) ? teamRolesConfig.teamRoles : null
58
+ const source = declaredSet ?? [...BASE_TEAM_ROLES.filter((role) => role !== 'owner'), ...legacyAdditionalRoles]
59
+
60
+ const nonOwner = []
61
+ for (const role of source) {
62
+ if (role && role !== 'owner' && !nonOwner.includes(role)) nonOwner.push(role)
63
+ }
64
+ const availableRoles = ['owner', ...nonOwner]
65
+
66
+ // hierarchy: base <- permissions.config <- app.config teamRoles; owner pinned at 100
67
+ const hierarchy = {
68
+ ...BASE_TEAM_ROLE_HIERARCHY,
69
+ ...(rolesConfig?.hierarchy ?? {}),
70
+ ...(teamRolesConfig?.hierarchy ?? {}),
71
+ }
72
+ hierarchy.owner = 100
73
+ for (const role of availableRoles) {
74
+ if (!(role in hierarchy)) hierarchy[role] = 1
75
+ if (role !== 'owner' && hierarchy[role] >= 100) hierarchy[role] = 99
76
+ }
77
+
78
+ const displayNames = { ...BASE_TEAM_ROLE_DISPLAY_NAMES, ...(rolesConfig?.displayNames ?? {}), ...(teamRolesConfig?.displayNames ?? {}) }
79
+ const descriptions = { ...BASE_TEAM_ROLE_DESCRIPTIONS, ...(rolesConfig?.descriptions ?? {}), ...(teamRolesConfig?.descriptions ?? {}) }
80
+
81
+ const onlyAvailable = (obj) => Object.fromEntries(Object.entries(obj).filter(([role]) => availableRoles.includes(role)))
82
+
83
+ const configuredDefault = teamRolesConfig?.defaultTeamRole
84
+ const defaultTeamRole = (typeof configuredDefault === 'string' && availableRoles.includes(configuredDefault))
85
+ ? configuredDefault
86
+ : (availableRoles.includes('member') ? 'member' : (availableRoles.find((role) => role !== 'owner') ?? 'owner'))
87
+
88
+ return {
89
+ coreTeamRoles: [...NON_REMOVABLE_TEAM_ROLES],
90
+ availableRoles,
91
+ hierarchy: onlyAvailable(hierarchy),
92
+ displayNames: onlyAvailable(displayNames),
93
+ descriptions: onlyAvailable(descriptions),
94
+ defaultTeamRole,
95
+ customRolesCount: availableRoles.filter((role) => !BASE_TEAM_ROLES.includes(role)).length,
96
+ }
97
+ }
98
+
22
99
  /**
23
100
  * Generate pre-computed permissions registry
24
101
  * Merges core permissions + theme permissions + entity permissions at build time
@@ -187,13 +264,16 @@ export async function generatePermissionsRegistry(permissionsConfig, entities, c
187
264
 
188
265
  // Extract roles configuration
189
266
  const rolesConfig = hasThemeConfig && permissionsConfig.roles ? permissionsConfig.roles : null
267
+ const teamRolesConfig = hasThemeConfig && permissionsConfig.teamRoles ? permissionsConfig.teamRoles : null
268
+
269
+ // Effective team-role set, computed once at build time (base + extras − removals; owner kept)
270
+ const teamRoleConfig = computeTeamRoleConfig({ rolesConfig, teamRolesConfig })
190
271
 
191
272
  // Build imports (convert @/core/ paths based on NPM mode)
192
273
  const outputFilePath = join(config.outputDir, 'permissions-registry.ts')
193
274
 
194
275
  const imports = [
195
276
  `import { CORE_PERMISSIONS_CONFIG } from '${convertCorePath('@/core/lib/permissions/system', outputFilePath, config)}'`,
196
- `import { APP_CONFIG_MERGED } from '${convertCorePath('@/core/lib/config/config-sync', outputFilePath, config)}'`
197
277
  ]
198
278
 
199
279
  if (hasThemeConfig) {
@@ -242,6 +322,9 @@ const FEATURE_PERMISSIONS = ${JSON.stringify(featurePermissions, null, 2)} as un
242
322
  /** Custom roles defined in permissions.config.ts */
243
323
  export const CUSTOM_ROLES: RolesConfig = ${rolesConfig ? JSON.stringify(rolesConfig, null, 2) : '{}'}
244
324
 
325
+ /** Default role assigned to new team members (never resolves to a removed role) */
326
+ export const DEFAULT_TEAM_ROLE: string = ${JSON.stringify(teamRoleConfig.defaultTeamRole)}
327
+
245
328
  // ============================================================================
246
329
  // TEAM PERMISSIONS EXPORTS
247
330
  // ============================================================================
@@ -262,44 +345,21 @@ export const TEAM_PERMISSIONS_BY_ROLE: Record<string, string[]> = (() => {
262
345
  })()
263
346
 
264
347
  // ============================================================================
265
- // RUNTIME MERGED PERMISSIONS (computed once on module load)
348
+ // TEAM ROLES (effective set, computed at build time)
349
+ // base roles + theme extras − theme removals; 'owner' is the only non-removable core role
266
350
  // ============================================================================
267
351
 
268
- // Core team roles
269
- const CORE_TEAM_ROLES = ['owner', 'admin', 'member', 'viewer'] as const
352
+ /** Effective team roles (base + extras − removals; 'owner' always present) */
353
+ export const AVAILABLE_ROLES: readonly string[] = ${JSON.stringify(teamRoleConfig.availableRoles)}
270
354
 
271
- // Get available roles: core + custom from permissions.config.ts
272
- export const AVAILABLE_ROLES: readonly string[] = [
273
- ...CORE_TEAM_ROLES,
274
- ...(CUSTOM_ROLES.additionalRoles ?? [])
275
- ]
276
-
277
- // Role hierarchy (merged)
278
- export const ROLE_HIERARCHY: Record<string, number> = {
279
- owner: 100,
280
- admin: 50,
281
- member: 10,
282
- viewer: 1,
283
- ...(CUSTOM_ROLES.hierarchy ?? {})
284
- }
355
+ /** Team role hierarchy (restricted to AVAILABLE_ROLES) */
356
+ export const ROLE_HIERARCHY: Record<string, number> = ${JSON.stringify(teamRoleConfig.hierarchy, null, 2)}
285
357
 
286
- // Role display names (merged)
287
- export const ROLE_DISPLAY_NAMES: Record<string, string> = {
288
- owner: 'common.teamRoles.owner',
289
- admin: 'common.teamRoles.admin',
290
- member: 'common.teamRoles.member',
291
- viewer: 'common.teamRoles.viewer',
292
- ...(CUSTOM_ROLES.displayNames ?? {})
293
- }
358
+ /** Team role display names (restricted to AVAILABLE_ROLES) */
359
+ export const ROLE_DISPLAY_NAMES: Record<string, string> = ${JSON.stringify(teamRoleConfig.displayNames, null, 2)}
294
360
 
295
- // Role descriptions (merged)
296
- export const ROLE_DESCRIPTIONS: Record<string, string> = {
297
- owner: 'Full team control, cannot be removed',
298
- admin: 'Manage team members and settings',
299
- member: 'Standard team access',
300
- viewer: 'Read-only access to team resources',
301
- ...(CUSTOM_ROLES.descriptions ?? {})
302
- }
361
+ /** Team role descriptions (restricted to AVAILABLE_ROLES) */
362
+ export const ROLE_DESCRIPTIONS: Record<string, string> = ${JSON.stringify(teamRoleConfig.descriptions, null, 2)}
303
363
 
304
364
  // Merge all permissions: core + teams + features + entities
305
365
  function buildAllPermissions(): ResolvedPermission[] {
@@ -438,7 +498,7 @@ export const PERMISSIONS_METADATA = {
438
498
  teamPermissions: TEAM_PERMISSIONS.length,
439
499
  featurePermissions: FEATURE_PERMISSIONS.length,
440
500
  entityPermissions: ENTITY_PERMISSIONS.length,
441
- customRoles: CUSTOM_ROLES.additionalRoles?.length ?? 0,
501
+ customRoles: ${teamRoleConfig.customRolesCount},
442
502
  availableRoles: AVAILABLE_ROLES.length,
443
503
  categories: Object.keys(PERMISSIONS_BY_CATEGORY).length,
444
504
  generatedAt: '${new Date().toISOString()}',
package/styles/docs.css CHANGED
@@ -80,3 +80,18 @@
80
80
  .docs-content hr {
81
81
  @apply my-8 border-border;
82
82
  }
83
+
84
+ /* Mermaid diagrams: rendered client-side from a <pre class="mermaid"> marker */
85
+ .docs-content pre.mermaid {
86
+ @apply my-6 p-0 bg-transparent border-0 text-center overflow-x-auto;
87
+ line-height: normal;
88
+ }
89
+
90
+ .docs-content pre.mermaid svg {
91
+ @apply max-w-full h-auto;
92
+ }
93
+
94
+ /* Raw source shown briefly before the client render replaces it */
95
+ .docs-content pre.mermaid:not([data-processed="true"]) {
96
+ @apply text-sm text-muted-foreground;
97
+ }
@@ -2,6 +2,8 @@ import type { Metadata } from 'next'
2
2
  import { LoginForm } from '@nextsparkjs/core/components/auth/forms/LoginForm'
3
3
  import { getTemplateOrDefault, getMetadataOrDefault } from '@nextsparkjs/core/lib/template-resolver'
4
4
 
5
+ export const dynamic = 'force-dynamic'
6
+
5
7
  const defaultMetadata: Metadata = {
6
8
  title: 'Sign In',
7
9
  description: 'Sign in to your account to access the platform',
@@ -6,6 +6,10 @@ import { getTemplateOrDefault, getMetadataOrDefault } from '@nextsparkjs/core/li
6
6
  import { AUTH_CONFIG } from '@nextsparkjs/core/lib/config'
7
7
  import { TeamService } from '@nextsparkjs/core/lib/services'
8
8
 
9
+ // This page reads DB state (TeamService.hasGlobal) and redirect()s during render,
10
+ // so it must render per-request and never be statically prerendered at build time.
11
+ export const dynamic = 'force-dynamic'
12
+
9
13
  const defaultMetadata: Metadata = {
10
14
  title: 'Create Account',
11
15
  description: 'Create your account to start using our platform',