create-bw-app 0.6.0 → 0.8.0
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/README.md
CHANGED
|
@@ -1,22 +1,26 @@
|
|
|
1
1
|
# create-bw-app
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Scaffold a new BrightWeb app from either the `platform` or `site` starter.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Workspace usage
|
|
6
6
|
|
|
7
7
|
From the BrightWeb platform repo root:
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
10
|
pnpm create:client
|
|
11
|
+
pnpm create:client -- --help
|
|
11
12
|
pnpm create:client -- --template site
|
|
12
13
|
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
The workspace wrapper delegates to this package with `workspace:*` dependency wiring and BrightWeb-specific output rules.
|
|
16
|
+
|
|
17
|
+
## Published usage
|
|
15
18
|
|
|
16
19
|
Once this package is published to npm:
|
|
17
20
|
|
|
18
21
|
```bash
|
|
19
22
|
pnpm dlx create-bw-app
|
|
23
|
+
pnpm dlx create-bw-app --template site
|
|
20
24
|
npm create bw-app@latest
|
|
21
25
|
```
|
|
22
26
|
|
|
@@ -24,11 +28,30 @@ npm create bw-app@latest
|
|
|
24
28
|
|
|
25
29
|
- prompts for app type: `platform` or `site`
|
|
26
30
|
- prompts for project name
|
|
27
|
-
- prompts for optional modules
|
|
31
|
+
- prompts for optional platform modules: `admin`, `crm`, and `projects`
|
|
28
32
|
- prompts to install dependencies immediately
|
|
29
|
-
- copies a clean Next.js starter template
|
|
30
|
-
- platform apps include BrightWeb auth, shell, and optional
|
|
31
|
-
- site apps include Next.js, Tailwind CSS, and local
|
|
33
|
+
- copies a clean Next.js App Router starter template
|
|
34
|
+
- platform apps include BrightWeb auth, shell wiring, and optional module starter surfaces
|
|
35
|
+
- site apps include Next.js, Tailwind CSS v4, and local component primitives
|
|
32
36
|
- writes `package.json`, `next.config.ts`, `.gitignore`, and `README.md` for both templates
|
|
33
|
-
- platform apps also write `.env.example
|
|
37
|
+
- platform apps also write `.env.example`, generated config files, and module feature flags
|
|
34
38
|
- supports repo-local `workspace:*` wiring and future published dependency wiring
|
|
39
|
+
|
|
40
|
+
## Workspace mode extras
|
|
41
|
+
|
|
42
|
+
When this package runs in BrightWeb workspace mode, it can:
|
|
43
|
+
|
|
44
|
+
- write the new app under `apps/<slug>`
|
|
45
|
+
- keep internal dependencies on `workspace:*`
|
|
46
|
+
- create `supabase/clients/<slug>/stack.json`
|
|
47
|
+
- create a client-only migrations folder so database planning stays aligned with scaffolded modules
|
|
48
|
+
|
|
49
|
+
Platform mode always resolves to the `Core + Admin` database baseline. Selecting `admin` affects the Admin starter UI and package wiring, not whether the Admin database layer exists.
|
|
50
|
+
|
|
51
|
+
## Related references
|
|
52
|
+
|
|
53
|
+
- `packages/create-bw-app/src/generator.mjs`
|
|
54
|
+
- `packages/create-bw-app/src/constants.mjs`
|
|
55
|
+
- `packages/create-bw-app/template/base`
|
|
56
|
+
- `packages/create-bw-app/template/site/base`
|
|
57
|
+
- `packages/create-bw-app/template/modules`
|
package/package.json
CHANGED
package/src/generator.mjs
CHANGED
|
@@ -19,6 +19,14 @@ import {
|
|
|
19
19
|
const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
20
20
|
const TEMPLATE_ROOT = path.join(PACKAGE_ROOT, "template");
|
|
21
21
|
const TEMPLATE_KEY_SET = new Set(TEMPLATE_OPTIONS.map((templateOption) => templateOption.key));
|
|
22
|
+
const DEFAULT_DB_MODULE_REGISTRY = {
|
|
23
|
+
modules: {
|
|
24
|
+
core: { label: "Core", dependsOn: [] },
|
|
25
|
+
admin: { label: "Admin", dependsOn: ["core"] },
|
|
26
|
+
crm: { label: "CRM", dependsOn: ["core", "admin"] },
|
|
27
|
+
projects: { label: "Projects", dependsOn: ["core", "admin", "crm"] },
|
|
28
|
+
},
|
|
29
|
+
};
|
|
22
30
|
|
|
23
31
|
function slugify(value) {
|
|
24
32
|
return value
|
|
@@ -103,6 +111,113 @@ async function readJsonIfPresent(filePath) {
|
|
|
103
111
|
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
104
112
|
}
|
|
105
113
|
|
|
114
|
+
async function getDbModuleRegistry(workspaceRoot) {
|
|
115
|
+
if (!workspaceRoot) {
|
|
116
|
+
return DEFAULT_DB_MODULE_REGISTRY;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const registryPath = path.join(workspaceRoot, "supabase", "module-registry.json");
|
|
120
|
+
return (await readJsonIfPresent(registryPath)) || DEFAULT_DB_MODULE_REGISTRY;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function resolveModuleOrder(registry, enabledModules) {
|
|
124
|
+
const resolved = [];
|
|
125
|
+
const visited = new Set();
|
|
126
|
+
const visiting = new Set();
|
|
127
|
+
|
|
128
|
+
function visit(moduleKey) {
|
|
129
|
+
if (visited.has(moduleKey)) return;
|
|
130
|
+
if (visiting.has(moduleKey)) {
|
|
131
|
+
throw new Error(`Circular module dependency detected at "${moduleKey}".`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const moduleConfig = registry.modules?.[moduleKey];
|
|
135
|
+
if (!moduleConfig) {
|
|
136
|
+
throw new Error(`Unknown database module "${moduleKey}".`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
visiting.add(moduleKey);
|
|
140
|
+
for (const dependency of moduleConfig.dependsOn || []) {
|
|
141
|
+
visit(dependency);
|
|
142
|
+
}
|
|
143
|
+
visiting.delete(moduleKey);
|
|
144
|
+
visited.add(moduleKey);
|
|
145
|
+
resolved.push(moduleKey);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
for (const moduleKey of enabledModules) {
|
|
149
|
+
visit(moduleKey);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return resolved;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function getSelectedModuleLabels(selectedModules) {
|
|
156
|
+
return selectedModules.map((moduleKey) => {
|
|
157
|
+
return getModuleLabel(moduleKey);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function getModuleLabel(moduleKey) {
|
|
162
|
+
const moduleDefinition = SELECTABLE_MODULES.find((candidate) => candidate.key === moduleKey);
|
|
163
|
+
if (moduleDefinition) {
|
|
164
|
+
return moduleDefinition.label;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (moduleKey === "core") {
|
|
168
|
+
return "Core";
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return titleizeSlug(moduleKey);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function createDbInstallPlan({ selectedModules, workspaceMode, registry }) {
|
|
175
|
+
if (!workspaceMode) {
|
|
176
|
+
return {
|
|
177
|
+
selectedLabels: getSelectedModuleLabels(selectedModules),
|
|
178
|
+
resolvedOrder: [],
|
|
179
|
+
notes: [],
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const requestedModules = Array.from(new Set(["core", ...selectedModules]));
|
|
184
|
+
if (!requestedModules.includes("admin")) {
|
|
185
|
+
requestedModules.push("admin");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const resolvedOrder = resolveModuleOrder(registry, requestedModules);
|
|
189
|
+
const notes = [];
|
|
190
|
+
|
|
191
|
+
if (!selectedModules.includes("admin") && resolvedOrder.includes("admin")) {
|
|
192
|
+
notes.push("Platform always resolves to the Core + Admin database baseline; selecting Admin only controls whether the Admin starter UI and package wiring are scaffolded.");
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
for (const moduleKey of resolvedOrder) {
|
|
196
|
+
if (moduleKey === "core" || selectedModules.includes(moduleKey) || moduleKey === "admin") {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const dependents = resolvedOrder.filter((candidateKey) => {
|
|
201
|
+
const dependencyList = registry.modules?.[candidateKey]?.dependsOn || [];
|
|
202
|
+
return dependencyList.includes(moduleKey);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
if (dependents.length === 0) continue;
|
|
206
|
+
|
|
207
|
+
const dependentLabels = dependents
|
|
208
|
+
.map((candidateKey) => registry.modules?.[candidateKey]?.label || getModuleLabel(candidateKey))
|
|
209
|
+
.join(", ");
|
|
210
|
+
const moduleLabel = registry.modules?.[moduleKey]?.label || getModuleLabel(moduleKey);
|
|
211
|
+
notes.push(`${moduleLabel} is included because ${dependentLabels} depends on it.`);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
selectedLabels: getSelectedModuleLabels(selectedModules),
|
|
216
|
+
resolvedOrder,
|
|
217
|
+
notes,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
106
221
|
async function getVersionMap(workspaceRoot) {
|
|
107
222
|
const versionMap = {
|
|
108
223
|
...APP_DEPENDENCY_DEFAULTS,
|
|
@@ -237,20 +352,23 @@ function createPlatformReadme({
|
|
|
237
352
|
selectedModules,
|
|
238
353
|
workspaceMode,
|
|
239
354
|
packageManager,
|
|
355
|
+
dbInstallPlan,
|
|
240
356
|
}) {
|
|
241
357
|
const moduleLines = SELECTABLE_MODULES.map((moduleDefinition) => {
|
|
242
358
|
const enabled = selectedModules.includes(moduleDefinition.key);
|
|
243
359
|
return `- ${moduleDefinition.key}: ${enabled ? "enabled" : "disabled"}`;
|
|
244
360
|
});
|
|
361
|
+
const resolvedDbStackLines = dbInstallPlan.resolvedOrder.map((moduleKey) => `- ${getModuleLabel(moduleKey)}`);
|
|
362
|
+
const dependencyNotes = dbInstallPlan.notes.map((note) => `- ${note}`);
|
|
245
363
|
|
|
246
364
|
const localSteps = workspaceMode
|
|
247
365
|
? [
|
|
248
|
-
"1. Review `.env.example`, then
|
|
366
|
+
"1. Review `.env.example`, then copy it to `.env.local` and fill in real service credentials.",
|
|
249
367
|
"2. Run `pnpm install` from the BrightWeb workspace root.",
|
|
250
368
|
`3. Run \`pnpm --filter ${slug} dev\`.`,
|
|
251
369
|
]
|
|
252
370
|
: [
|
|
253
|
-
"1. Review `.env.example`, then
|
|
371
|
+
"1. Review `.env.example`, then copy it to `.env.local` and fill in real service credentials.",
|
|
254
372
|
`2. Run \`${packageManager} install\`.`,
|
|
255
373
|
`3. Run \`${packageManager} dev\`.`,
|
|
256
374
|
];
|
|
@@ -268,6 +386,22 @@ function createPlatformReadme({
|
|
|
268
386
|
"",
|
|
269
387
|
...moduleLines,
|
|
270
388
|
"",
|
|
389
|
+
...(workspaceMode
|
|
390
|
+
? [
|
|
391
|
+
"## Resolved database stack",
|
|
392
|
+
"",
|
|
393
|
+
...resolvedDbStackLines,
|
|
394
|
+
"",
|
|
395
|
+
...(dependencyNotes.length > 0
|
|
396
|
+
? [
|
|
397
|
+
"## Dependency notes",
|
|
398
|
+
"",
|
|
399
|
+
...dependencyNotes,
|
|
400
|
+
"",
|
|
401
|
+
]
|
|
402
|
+
: []),
|
|
403
|
+
]
|
|
404
|
+
: []),
|
|
271
405
|
"## Starter routes",
|
|
272
406
|
"",
|
|
273
407
|
"- `/`",
|
|
@@ -548,6 +682,50 @@ async function ensureDirectory(targetDir) {
|
|
|
548
682
|
await fs.mkdir(targetDir, { recursive: true });
|
|
549
683
|
}
|
|
550
684
|
|
|
685
|
+
async function writeWorkspaceClientStack(workspaceRoot, slug, selectedModules) {
|
|
686
|
+
const clientDir = path.join(workspaceRoot, "supabase", "clients", slug);
|
|
687
|
+
const stackPath = path.join(clientDir, "stack.json");
|
|
688
|
+
const migrationsDir = path.join(clientDir, "migrations");
|
|
689
|
+
const registry = await getDbModuleRegistry(workspaceRoot);
|
|
690
|
+
const dbInstallPlan = createDbInstallPlan({
|
|
691
|
+
selectedModules,
|
|
692
|
+
workspaceMode: true,
|
|
693
|
+
registry,
|
|
694
|
+
});
|
|
695
|
+
const enabledModules = dbInstallPlan.resolvedOrder;
|
|
696
|
+
|
|
697
|
+
if (await pathExists(stackPath)) {
|
|
698
|
+
throw new Error(`Client stack already exists: ${stackPath}`);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
await ensureDirectory(migrationsDir);
|
|
702
|
+
await fs.writeFile(path.join(migrationsDir, ".gitkeep"), "", "utf8");
|
|
703
|
+
await fs.writeFile(
|
|
704
|
+
stackPath,
|
|
705
|
+
`${JSON.stringify(
|
|
706
|
+
{
|
|
707
|
+
client: {
|
|
708
|
+
slug,
|
|
709
|
+
label: titleizeSlug(slug),
|
|
710
|
+
},
|
|
711
|
+
historyMode: "greenfield-modular",
|
|
712
|
+
futureMode: "forward-only-modular",
|
|
713
|
+
enabledModules,
|
|
714
|
+
clientMigrationPath: `supabase/clients/${slug}/migrations`,
|
|
715
|
+
notes: [
|
|
716
|
+
"Generated by create-bw-app in workspace mode.",
|
|
717
|
+
`Selected app modules: ${dbInstallPlan.selectedLabels.length > 0 ? dbInstallPlan.selectedLabels.join(", ") : "none"}.`,
|
|
718
|
+
`Resolved database stack: ${enabledModules.map((moduleKey) => getModuleLabel(moduleKey)).join(" -> ")}.`,
|
|
719
|
+
"Platform always resolves to the Core + Admin database baseline; selecting Admin only controls whether the Admin starter UI and package wiring are scaffolded.",
|
|
720
|
+
"The database install order is resolved from supabase/module-registry.json.",
|
|
721
|
+
],
|
|
722
|
+
},
|
|
723
|
+
null,
|
|
724
|
+
2,
|
|
725
|
+
)}\n`,
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
551
729
|
async function runInstall(command, cwd) {
|
|
552
730
|
return new Promise((resolve, reject) => {
|
|
553
731
|
const child = spawn(command, ["install"], {
|
|
@@ -568,15 +746,36 @@ async function runInstall(command, cwd) {
|
|
|
568
746
|
});
|
|
569
747
|
}
|
|
570
748
|
|
|
571
|
-
function renderPlanSummary({
|
|
749
|
+
function renderPlanSummary({
|
|
750
|
+
targetDir,
|
|
751
|
+
dependencyMode,
|
|
752
|
+
selectedModules,
|
|
753
|
+
packageManager,
|
|
754
|
+
workspaceMode,
|
|
755
|
+
install,
|
|
756
|
+
template,
|
|
757
|
+
dbInstallPlan,
|
|
758
|
+
}) {
|
|
572
759
|
const installLocation = workspaceMode ? "workspace root" : "project directory";
|
|
573
760
|
const templateLabel = TEMPLATE_OPTIONS.find((templateOption) => templateOption.key === template)?.label || template;
|
|
761
|
+
const selectedModuleSummary =
|
|
762
|
+
template === "platform"
|
|
763
|
+
? dbInstallPlan.selectedLabels.length > 0
|
|
764
|
+
? dbInstallPlan.selectedLabels.join(", ")
|
|
765
|
+
: "none"
|
|
766
|
+
: "n/a";
|
|
574
767
|
|
|
575
768
|
return [
|
|
576
769
|
`Template: ${templateLabel}`,
|
|
577
770
|
`Target directory: ${targetDir}`,
|
|
578
771
|
`Dependency mode: ${dependencyMode}`,
|
|
579
|
-
`Selected modules: ${
|
|
772
|
+
`Selected modules: ${selectedModuleSummary}`,
|
|
773
|
+
...(template === "platform" && workspaceMode
|
|
774
|
+
? [
|
|
775
|
+
`Resolved database stack: ${dbInstallPlan.resolvedOrder.map((moduleKey) => getModuleLabel(moduleKey)).join(" -> ")}`,
|
|
776
|
+
...dbInstallPlan.notes.map((note) => `- ${note}`),
|
|
777
|
+
]
|
|
778
|
+
: []),
|
|
580
779
|
`Install dependencies: ${install ? `yes (${packageManager} in ${installLocation})` : "no"}`,
|
|
581
780
|
].join("\n");
|
|
582
781
|
}
|
|
@@ -668,7 +867,9 @@ async function scaffoldPlatformProject({
|
|
|
668
867
|
dependencyMode,
|
|
669
868
|
packageManager,
|
|
670
869
|
workspaceMode,
|
|
870
|
+
workspaceRoot,
|
|
671
871
|
answers,
|
|
872
|
+
dbInstallPlan,
|
|
672
873
|
}) {
|
|
673
874
|
const moduleFlags = createModuleFlags(selectedModules);
|
|
674
875
|
const brandValues = createDerivedBrandValues(answers.slug);
|
|
@@ -703,7 +904,6 @@ async function scaffoldPlatformProject({
|
|
|
703
904
|
const envFileContent = createEnvFileContent({ slug: answers.slug, brandValues, moduleFlags });
|
|
704
905
|
|
|
705
906
|
await fs.writeFile(path.join(targetDir, ".env.example"), envFileContent);
|
|
706
|
-
await fs.writeFile(path.join(targetDir, ".env.local"), envFileContent);
|
|
707
907
|
await fs.writeFile(path.join(targetDir, ".gitignore"), createGitignore());
|
|
708
908
|
await fs.writeFile(
|
|
709
909
|
path.join(targetDir, "README.md"),
|
|
@@ -712,8 +912,13 @@ async function scaffoldPlatformProject({
|
|
|
712
912
|
selectedModules,
|
|
713
913
|
workspaceMode,
|
|
714
914
|
packageManager,
|
|
915
|
+
dbInstallPlan,
|
|
715
916
|
}),
|
|
716
917
|
);
|
|
918
|
+
|
|
919
|
+
if (workspaceMode) {
|
|
920
|
+
await writeWorkspaceClientStack(workspaceRoot, answers.slug, selectedModules);
|
|
921
|
+
}
|
|
717
922
|
}
|
|
718
923
|
|
|
719
924
|
async function scaffoldSiteProject({
|
|
@@ -799,6 +1004,12 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
799
1004
|
}
|
|
800
1005
|
|
|
801
1006
|
const versionMap = await getVersionMap(workspaceRoot);
|
|
1007
|
+
const dbModuleRegistry = await getDbModuleRegistry(workspaceRoot);
|
|
1008
|
+
const dbInstallPlan = createDbInstallPlan({
|
|
1009
|
+
selectedModules: answers.selectedModules,
|
|
1010
|
+
workspaceMode,
|
|
1011
|
+
registry: dbModuleRegistry,
|
|
1012
|
+
});
|
|
802
1013
|
const install = answers.install && !argvOptions.dryRun;
|
|
803
1014
|
|
|
804
1015
|
if (argvOptions.dryRun) {
|
|
@@ -810,6 +1021,7 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
810
1021
|
workspaceMode,
|
|
811
1022
|
install: answers.install,
|
|
812
1023
|
template: answers.template,
|
|
1024
|
+
dbInstallPlan,
|
|
813
1025
|
})}\n\n`);
|
|
814
1026
|
return {
|
|
815
1027
|
answers,
|
|
@@ -821,6 +1033,17 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
821
1033
|
};
|
|
822
1034
|
}
|
|
823
1035
|
|
|
1036
|
+
output.write(`${renderPlanSummary({
|
|
1037
|
+
targetDir,
|
|
1038
|
+
dependencyMode,
|
|
1039
|
+
selectedModules: answers.selectedModules,
|
|
1040
|
+
packageManager,
|
|
1041
|
+
workspaceMode,
|
|
1042
|
+
install: answers.install,
|
|
1043
|
+
template: answers.template,
|
|
1044
|
+
dbInstallPlan,
|
|
1045
|
+
})}\n\n`);
|
|
1046
|
+
|
|
824
1047
|
if (answers.template === "site") {
|
|
825
1048
|
await scaffoldSiteProject({
|
|
826
1049
|
targetDir,
|
|
@@ -838,7 +1061,9 @@ export async function createBrightwebClientApp(argvOptions, runtimeOptions = {})
|
|
|
838
1061
|
dependencyMode,
|
|
839
1062
|
packageManager,
|
|
840
1063
|
workspaceMode,
|
|
1064
|
+
workspaceRoot,
|
|
841
1065
|
answers,
|
|
1066
|
+
dbInstallPlan,
|
|
842
1067
|
});
|
|
843
1068
|
}
|
|
844
1069
|
|
|
@@ -115,7 +115,7 @@ export default function HomePage() {
|
|
|
115
115
|
<li>`config/brand.ts` for client identity and contact details.</li>
|
|
116
116
|
<li>`config/modules.ts` for enabled platform modules.</li>
|
|
117
117
|
<li>`config/env.ts` for infra requirements and readiness checks.</li>
|
|
118
|
-
<li>`.env.
|
|
118
|
+
<li>`.env.example` for starter defaults; copy it to `.env.local` for per-client secrets and flags.</li>
|
|
119
119
|
</ul>
|
|
120
120
|
</div>
|
|
121
121
|
</article>
|
|
@@ -69,7 +69,7 @@ export function getStarterBootstrapChecklist() {
|
|
|
69
69
|
{
|
|
70
70
|
label: "Create per-client environment variables",
|
|
71
71
|
done: config.envReadiness.allReady,
|
|
72
|
-
detail: "Copy `.env.example`
|
|
72
|
+
detail: "Copy `.env.example` to `.env.local` and fill the real values.",
|
|
73
73
|
},
|
|
74
74
|
],
|
|
75
75
|
};
|
|
@@ -20,7 +20,7 @@ export default async function AdminPlaygroundPage() {
|
|
|
20
20
|
<p className="eyebrow">Admin Module</p>
|
|
21
21
|
<h1>Admin users and roles playground</h1>
|
|
22
22
|
<p className="muted">
|
|
23
|
-
This route previews the shared admin governance module without relying on
|
|
23
|
+
This route previews the shared admin governance module without relying on any client-specific app.
|
|
24
24
|
</p>
|
|
25
25
|
</div>
|
|
26
26
|
</article>
|