create-nevela 0.8.0 → 0.9.2

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 (62) hide show
  1. package/index.mjs +24 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +41 -0
  5. package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
  6. package/template/laravel/resources/disposable-domains.txt +9880 -0
  7. package/template/laravel/src/Access/ClosedAccounts.php +2 -1
  8. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  9. package/template/laravel/src/Console/BlogCommand.php +254 -0
  10. package/template/laravel/src/Console/DevCommand.php +9 -0
  11. package/template/laravel/src/Console/DigestCommand.php +28 -0
  12. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  13. package/template/laravel/src/Console/GenerateCommand.php +25 -0
  14. package/template/laravel/src/Console/SetupCommand.php +15 -2
  15. package/template/laravel/src/Console/StatusCommand.php +14 -0
  16. package/template/laravel/src/Console/TrashCommand.php +9 -0
  17. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  18. package/template/laravel/src/Forms/Confirmation.php +88 -0
  19. package/template/laravel/src/Forms/Disposable.php +74 -0
  20. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  21. package/template/laravel/src/Forms/FormGuard.php +359 -0
  22. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  23. package/template/laravel/src/Forms/PublicMail.php +217 -0
  24. package/template/laravel/src/Forms/Refusal.php +50 -0
  25. package/template/laravel/src/Generator/BlogPages.php +409 -0
  26. package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
  27. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  28. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  29. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  30. package/template/laravel/src/Http/ActionController.php +2 -1
  31. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  32. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  33. package/template/laravel/src/Http/FormsController.php +45 -0
  34. package/template/laravel/src/Http/PublicController.php +89 -4
  35. package/template/laravel/src/Http/ResourceRequest.php +43 -3
  36. package/template/laravel/src/Http/TrashController.php +2 -1
  37. package/template/laravel/src/Http/UploadController.php +2 -2
  38. package/template/laravel/src/Nevela.php +30 -1
  39. package/template/laravel/src/NevelaServiceProvider.php +11 -2
  40. package/template/laravel/src/Support/Descriptor.php +24 -0
  41. package/template/laravel/src/Support/Field.php +17 -0
  42. package/template/laravel/src/Support/Key.php +57 -0
  43. package/template/laravel/src/Support/ListQuery.php +29 -1
  44. package/template/laravel/src/Support/PublicAccess.php +75 -0
  45. package/template/laravel/src/Support/Slug.php +43 -0
  46. package/template/laravel/src/Trash/Trash.php +3 -1
  47. package/template/web/.env.example +4 -0
  48. package/template/web/app/api/[...path]/route.ts +6 -3
  49. package/template/web/app/confirm/page.tsx +51 -0
  50. package/template/web/app/dashboard/actions.ts +15 -1
  51. package/template/web/components/dashboard/form-activity.tsx +53 -0
  52. package/template/web/components/dashboard/record-actions.tsx +29 -5
  53. package/template/web/components/dashboard/record-detail.tsx +2 -1
  54. package/template/web/components/dashboard/resource-form.tsx +19 -2
  55. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  56. package/template/web/components/dashboard/resource-table.tsx +32 -3
  57. package/template/web/components/dashboard/row-actions.tsx +33 -3
  58. package/template/web/components/marketing/site-header.tsx +9 -2
  59. package/template/web/components/public-form-guard.tsx +125 -0
  60. package/template/web/hooks/use-public-form.ts +263 -0
  61. package/template/web/lib/extras.ts +39 -0
  62. package/template/web/package.json +1 -1
package/index.mjs CHANGED
@@ -76,13 +76,15 @@ const HELP = ` Create a new Nevela app: a Laravel API and a Next.js dashboard.
76
76
  nevela status Check versions, migrations, users and the dashboard
77
77
  nevela resource <Name> --fields="…" [--seed]
78
78
  nevela adopt <table> Make a resource of a table you already have
79
- nevela seed | user | generate | migrate | trash | artisan <command>
79
+ nevela blog Give the site a blog, if it was created without one
80
+ nevela seed | user | generate | migrate | trash | disposable | artisan <command>
80
81
 
81
82
  ${bold('Options')}
82
83
  --pm <pnpm|npm|yarn|bun> Package manager for the dashboard. Default: the one you ran this with.
83
84
  --no-install Don't install the dashboard's dependencies.
84
85
  --no-user Don't create the starter admin account, or the sample users.
85
86
  --no-sample-users Create the admin only, without the ten sample users.
87
+ --no-blog Leave out the blog: the Post resource, three sample posts and /blog.
86
88
  --fast Leave out PHPUnit, Pint and Laravel's other development packages.
87
89
  About a third quicker. Add them later: cd apps/api && composer install
88
90
  --no-git Don't run git init.
@@ -194,7 +196,7 @@ async function step(label, work) {
194
196
  }
195
197
 
196
198
  function parseArgs(argv) {
197
- const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, git: true, yes: false, bundledPackage: false, devTools: true };
199
+ const options = { name: undefined, pm: undefined, install: true, user: true, samples: true, blog: true, git: true, yes: false, bundledPackage: false, devTools: true };
198
200
  for (let i = 0; i < argv.length; i++) {
199
201
  const arg = argv[i];
200
202
  if (arg === '-h' || arg === '--help') {
@@ -208,6 +210,7 @@ function parseArgs(argv) {
208
210
  else if (arg === '--no-install') options.install = false;
209
211
  else if (arg === '--no-user') options.user = false;
210
212
  else if (arg === '--no-sample-users') options.samples = false;
213
+ else if (arg === '--no-blog') options.blog = false;
211
214
  else if (arg === '--no-git') options.git = false;
212
215
  else if (arg === '--no-dev-tools' || arg === '--fast') options.devTools = false;
213
216
  else if (arg === '--bundled-package') options.bundledPackage = true;
@@ -286,7 +289,7 @@ function write(file, contents) {
286
289
  fs.writeFileSync(file, contents);
287
290
  }
288
291
 
289
- function writeRootFiles(root, { name, title, pm, admin, samples }) {
292
+ function writeRootFiles(root, { name, title, pm, admin, samples, blog }) {
290
293
  write(path.join(root, 'package.json'), `${JSON.stringify({
291
294
  name,
292
295
  version: '0.1.0',
@@ -338,7 +341,11 @@ php nevela upgrade # bring this app to the latest Nevela
338
341
  php nevela # everything it can do
339
342
  \`\`\`
340
343
 
341
- ## Add a resource
344
+ ${blog ? `## The blog
345
+
346
+ The site has a blog at http://localhost:3000/blog. Posts are written under **Posts** in the dashboard, and a post is on the site once it is published. Its pages are in \`apps/web/app/blog\` and its settings in \`apps/web/lib/blog.ts\`: both are yours to change.
347
+
348
+ ` : ''}## Add a resource
342
349
 
343
350
  \`\`\`sh
344
351
  php nevela resource Product --fields="name:string, sku:string!, price:money, notes:text?"
@@ -682,7 +689,7 @@ async function main() {
682
689
  // `nevela upgrade` compares against these to tell the files you changed from the ones
683
690
  // you didn't, so an update never overwrites your work.
684
691
  write(path.join(web, '.nevela.json'), `${JSON.stringify(templateRecord(VERSION), null, 4)}\n`);
685
- writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples });
692
+ writeRootFiles(root, { name, title, pm, admin: options.user ? ADMIN : null, samples: options.user && options.samples, blog: options.blog });
686
693
  const packages = options.install ? start(pm, ['install'], { cwd: web }) : null;
687
694
 
688
695
  const tokens = await step('Creating the Laravel app', async (progress) => {
@@ -739,15 +746,20 @@ async function main() {
739
746
 
740
747
  let admin = false;
741
748
  let samples = false;
749
+ let blog = false;
750
+ let blogMissing = false;
742
751
  await step('Setting up the database', () => {
743
752
  // One artisan process for the app key, Sanctum's migration, the generated files, the
744
753
  // migrations, the starter account and the sample users: each separate call would
745
754
  // start Laravel again.
746
755
  const account = options.user && tokens ? [`--name=${ADMIN.name}`, `--email=${ADMIN.email}`, `--password=${ADMIN.password}`, ...(options.samples ? [] : ['--no-sample-users'])] : [];
747
- const setup = run('php', ['artisan', 'nevela:setup', ...account], { cwd: api, allowFailure: true });
756
+ const setup = run('php', ['artisan', 'nevela:setup', ...account, ...(options.blog ? [] : ['--no-blog'])], { cwd: api, allowFailure: true });
748
757
  if (setup.ok) {
749
758
  admin = account.length > 0;
750
759
  samples = admin && options.samples;
760
+ blog = options.blog && fs.existsSync(path.join(web, 'app', 'blog', 'page.tsx'));
761
+ // Asked for, and not there: the setup carried on without it, and the person is told.
762
+ blogMissing = options.blog && !blog;
751
763
  return;
752
764
  }
753
765
  // 2: the app is set up and only the starter account could not be created.
@@ -798,7 +810,12 @@ async function main() {
798
810
  console.log(` ${dim(`Ten sample users are there too, with the same password: 2 editors and 8 users. See them under Users, and what each may do under Roles.`)}`);
799
811
  }
800
812
  }
801
- console.log(`\n Add your first resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
813
+ if (blog) {
814
+ console.log(`\n The site has a blog at ${indigo('http://localhost:3000/blog')}, with three posts to start. Write more under Posts in the dashboard.`);
815
+ } else if (blogMissing) {
816
+ console.log(`\n The blog couldn't be set up, so this app has none yet. Everything else is in place. Add it later: ${dim(`${nevela} blog`)}`);
817
+ }
818
+ console.log(`\n Add ${blog ? 'a' : 'your first'} resource: ${dim(`${nevela} resource Product --fields="name:string, price:money"`)}`);
802
819
  console.log(` Check the app: ${dim(`${nevela} status`)} Upgrade it later: ${dim(`${nevela} upgrade`)} Everything: ${dim(nevela)}`);
803
820
  if (nevela !== 'php nevela' && nevela !== 'nevela') {
804
821
  console.log(`\n ${dim(`In this shell "php" isn't a command (your PHP is php.bat), so use ${nevela}. In PowerShell, php nevela works too.`)}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-nevela",
3
- "version": "0.8.0",
3
+ "version": "0.9.2",
4
4
  "description": "Create a new Nevela app: a Laravel API and a Next.js dashboard from one resource description.",
5
5
  "type": "module",
6
6
  "bin": {
package/project.mjs CHANGED
@@ -11,7 +11,7 @@ import { isNewer, satisfiesCaret } from './packagist.mjs';
11
11
  import { spawnCommand } from './shell.mjs';
12
12
 
13
13
  /** What `nevela <name>` runs: these go to `php artisan nevela:<name>`. */
14
- const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', 'generate', 'seed', 'user', 'trash'];
14
+ const NEVELA = ['upgrade', 'dev', 'status', 'version', 'resource', 'adopt', 'blog', 'generate', 'seed', 'user', 'trash', 'digest', 'disposable'];
15
15
  /** These go to the artisan command of the same name. */
16
16
  const ARTISAN = ['migrate', 'tinker', 'test', 'serve'];
17
17
 
@@ -131,6 +131,47 @@ return [
131
131
  'days' => 30,
132
132
  ],
133
133
 
134
+ /*
135
+ | Forms anyone can send: a resource's "public" create, with "protect" or "confirm".
136
+ |
137
+ | turnstile Cloudflare's robot check. The secret key is this app's alone; the site key
138
+ | goes in the web app (NEXT_PUBLIC_TURNSTILE_SITE_KEY). With no secret the
139
+ | check is skipped in development, and in production a form that asks for
140
+ | it answers 503: it is never quietly left open. A check counts only if it
141
+ | was solved on one of `hostnames` (a name stands for its subdomains), and
142
+ | never on localhost in production.
143
+ | form_lifetime How long a form can sit open before it must be loaded again, in seconds.
144
+ | mail The most one address is emailed because someone typed it into a form
145
+ | (PublicMail::toVisitor), across every form. Past it the email is dropped
146
+ | and the record is still saved. This is what stops a form being used to
147
+ | mail strangers.
148
+ | notify Who is told a form was sent (PublicMail::toOwner), and how often: past
149
+ | max_per_hour the rest wait for one email at the top of the hour.
150
+ | confirm How long an address has to follow its link ("confirm"), after which the
151
+ | record is removed, and the page of the web app the link opens.
152
+ */
153
+ 'public' => [
154
+ 'turnstile' => [
155
+ 'secret' => env('TURNSTILE_SECRET_KEY'),
156
+ /* The sites a check may be solved on, with commas and no spaces between: example.com,shop.example.com. The web app's own (NEVELA_WEB_URL), unless set. */
157
+ 'hostnames' => env('TURNSTILE_HOSTNAMES'),
158
+ 'verify_url' => 'https://challenges.cloudflare.com/turnstile/v0/siteverify',
159
+ ],
160
+ 'form_lifetime' => 2 * 60 * 60,
161
+ 'mail' => [
162
+ 'visitor_per_hour' => 1,
163
+ 'visitor_per_day' => 3,
164
+ ],
165
+ 'notify' => [
166
+ 'to' => env('NEVELA_NOTIFY_TO'),
167
+ 'max_per_hour' => 10,
168
+ ],
169
+ 'confirm' => [
170
+ 'hours' => 48,
171
+ 'path' => '/confirm',
172
+ ],
173
+ ],
174
+
134
175
  /*
135
176
  | Rich text fields (body:richtext). Whatever is sent is parsed and written out again
136
177
  | with only these tags and attributes: nothing the editor, a script or a visitor sends
@@ -0,0 +1,60 @@
1
+ <?php
2
+
3
+ use Illuminate\Database\Migrations\Migration;
4
+ use Illuminate\Database\Schema\Blueprint;
5
+ use Illuminate\Support\Facades\Schema;
6
+
7
+ // Public forms: what was refused, and the mail a form causes.
8
+ //
9
+ // A form anyone can send is sent by bots too. What is turned away is counted, so that
10
+ // protection which works can be told from a form nobody uses. And because every send can
11
+ // cause mail, to the address that was typed and to the owner, each of those is counted
12
+ // too: that is what keeps the form from being a way to mail strangers, and the owner's
13
+ // inbox from being buried.
14
+ return new class extends Migration
15
+ {
16
+ public function up(): void
17
+ {
18
+ if (! Schema::hasTable('nevela_form_blocks')) {
19
+ Schema::create('nevela_form_blocks', function (Blueprint $table) {
20
+ $table->uuid('id')->primary();
21
+ $table->string('resource');
22
+ // honeypot, forged, too_fast, expired, robot, disposable, undeliverable, throttled, unconfigured, unreachable
23
+ $table->string('reason', 32);
24
+ $table->date('day');
25
+ $table->unsignedInteger('count')->default(0);
26
+ $table->unique(['resource', 'reason', 'day']);
27
+ });
28
+ }
29
+
30
+ if (! Schema::hasTable('nevela_public_mail')) {
31
+ Schema::create('nevela_public_mail', function (Blueprint $table) {
32
+ $table->uuid('id')->primary();
33
+ // "visitor": to an address a form was given. "owner": a notice that a form was sent.
34
+ $table->string('kind', 16);
35
+ // A visitor's address is kept as a fingerprint: enough to count by, not to read back.
36
+ $table->string('recipient', 64);
37
+ $table->timestamp('sent_at')->index();
38
+ $table->index(['kind', 'recipient', 'sent_at']);
39
+ });
40
+ }
41
+
42
+ if (! Schema::hasTable('nevela_owner_digest')) {
43
+ // Notices to the owner past the hour's limit, held for one email that names them all.
44
+ Schema::create('nevela_owner_digest', function (Blueprint $table) {
45
+ $table->uuid('id')->primary();
46
+ $table->string('subject');
47
+ $table->string('line', 500)->nullable();
48
+ $table->string('url', 2048)->nullable();
49
+ $table->timestamp('held_at')->index();
50
+ });
51
+ }
52
+ }
53
+
54
+ public function down(): void
55
+ {
56
+ Schema::dropIfExists('nevela_owner_digest');
57
+ Schema::dropIfExists('nevela_public_mail');
58
+ Schema::dropIfExists('nevela_form_blocks');
59
+ }
60
+ };