@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.
- package/dist/components/devtools/MarkdownViewer.d.ts.map +1 -1
- package/dist/components/devtools/MarkdownViewer.js +5 -0
- package/dist/components/devtools/api-explorer/ApiDocsModal.d.ts.map +1 -1
- package/dist/components/devtools/api-explorer/ApiDocsModal.js +0 -0
- package/dist/components/devtools/api-explorer/ApiEndpointsSidebar.d.ts.map +1 -1
- package/dist/components/devtools/api-explorer/ApiEndpointsSidebar.js +16 -5
- package/dist/components/devtools/api-explorer/ApiExplorer.d.ts.map +1 -1
- package/dist/components/devtools/api-explorer/ApiExplorer.js +63 -21
- package/dist/components/devtools/api-explorer/ApiRequestPanel.d.ts +3 -1
- package/dist/components/devtools/api-explorer/ApiRequestPanel.d.ts.map +1 -1
- package/dist/components/devtools/api-explorer/ApiRequestPanel.js +5 -1
- package/dist/components/devtools/api-explorer/explorer-storage.d.ts +12 -0
- package/dist/components/devtools/api-explorer/explorer-storage.d.ts.map +1 -0
- package/dist/components/devtools/api-explorer/explorer-storage.js +25 -0
- package/dist/components/devtools/api-tester/AuthSelector.d.ts +4 -1
- package/dist/components/devtools/api-tester/AuthSelector.d.ts.map +1 -1
- package/dist/components/devtools/api-tester/AuthSelector.js +24 -2
- package/dist/components/docs/docs-content.d.ts.map +1 -1
- package/dist/components/docs/docs-content.js +17 -0
- package/dist/components/docs/mermaid-diagram.d.ts +11 -0
- package/dist/components/docs/mermaid-diagram.d.ts.map +1 -0
- package/dist/components/docs/mermaid-diagram.js +20 -0
- package/dist/components/permissions/PermissionsMatrix.d.ts.map +1 -1
- package/dist/components/permissions/PermissionsMatrix.js +5 -4
- package/dist/components/teams/InviteMemberDialog.js +1 -1
- package/dist/components/teams/TeamMembersList.js +1 -1
- package/dist/hooks/useApiExplorerNavigation.d.ts.map +1 -1
- package/dist/hooks/useApiExplorerNavigation.js +5 -1
- package/dist/lib/actions/team.actions.d.ts.map +1 -1
- package/dist/lib/actions/team.actions.js +7 -5
- package/dist/lib/api/auth/dual-auth.d.ts +14 -0
- package/dist/lib/api/auth/dual-auth.d.ts.map +1 -1
- package/dist/lib/api/auth/dual-auth.js +43 -2
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +3 -3
- package/dist/lib/config/app.config.js +60 -60
- package/dist/lib/config/config-sync.d.ts.map +1 -1
- package/dist/lib/config/config-sync.js +14 -9
- package/dist/lib/config/roles-merge.d.ts +0 -77
- package/dist/lib/config/roles-merge.d.ts.map +1 -1
- package/dist/lib/config/roles-merge.js +1 -98
- package/dist/lib/config/types.d.ts +6 -2
- package/dist/lib/config/types.d.ts.map +1 -1
- package/dist/lib/docs/mermaid.d.ts +15 -0
- package/dist/lib/docs/mermaid.d.ts.map +1 -0
- package/dist/lib/docs/mermaid.js +42 -0
- package/dist/lib/docs/parser.d.ts.map +1 -1
- package/dist/lib/docs/parser.js +2 -1
- package/dist/lib/docs/remark-mermaid.d.ts +15 -0
- package/dist/lib/docs/remark-mermaid.d.ts.map +1 -0
- package/dist/lib/docs/remark-mermaid.js +26 -0
- package/dist/lib/services/membership.service.js +2 -2
- package/dist/lib/services/team-member.service.d.ts +2 -2
- package/dist/lib/services/team-member.service.d.ts.map +1 -1
- package/dist/lib/services/team-member.service.js +4 -3
- package/dist/lib/teams/types.d.ts +2 -1
- package/dist/lib/teams/types.d.ts.map +1 -1
- package/dist/lib/teams/types.js +4 -4
- package/dist/styles/classes.json +3 -2
- package/dist/styles/ui.css +1 -1
- package/dist/templates/app/(auth)/login/page.tsx +2 -0
- package/dist/templates/app/(auth)/signup/page.tsx +4 -0
- package/dist/templates/app/superadmin/team-roles/page.tsx +2 -2
- package/package.json +3 -2
- package/scripts/build/registry/__tests__/team-roles.test.mjs +192 -0
- package/scripts/build/registry/discovery/permissions.mjs +163 -1
- package/scripts/build/registry/generators/permissions-registry.mjs +95 -35
- package/styles/docs.css +15 -0
- package/templates/app/(auth)/login/page.tsx +2 -0
- package/templates/app/(auth)/signup/page.tsx +4 -0
- package/templates/app/superadmin/team-roles/page.tsx +2 -2
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
269
|
-
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
|
-
|
|
272
|
-
export const
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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',
|