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.
- package/index.mjs +24 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +41 -0
- package/template/laravel/database/migrations/2026_10_12_000000_create_nevela_form_tables.php +60 -0
- package/template/laravel/resources/disposable-domains.txt +9880 -0
- package/template/laravel/src/Access/ClosedAccounts.php +2 -1
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Console/BlogCommand.php +254 -0
- package/template/laravel/src/Console/DevCommand.php +9 -0
- package/template/laravel/src/Console/DigestCommand.php +28 -0
- package/template/laravel/src/Console/DisposableCommand.php +49 -0
- package/template/laravel/src/Console/GenerateCommand.php +25 -0
- package/template/laravel/src/Console/SetupCommand.php +15 -2
- package/template/laravel/src/Console/StatusCommand.php +14 -0
- package/template/laravel/src/Console/TrashCommand.php +9 -0
- package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
- package/template/laravel/src/Forms/Confirmation.php +88 -0
- package/template/laravel/src/Forms/Disposable.php +74 -0
- package/template/laravel/src/Forms/FormBlocks.php +76 -0
- package/template/laravel/src/Forms/FormGuard.php +359 -0
- package/template/laravel/src/Forms/OwnerDigest.php +63 -0
- package/template/laravel/src/Forms/PublicMail.php +217 -0
- package/template/laravel/src/Forms/Refusal.php +50 -0
- package/template/laravel/src/Generator/BlogPages.php +409 -0
- package/template/laravel/src/Generator/ResourceGenerator.php +133 -7
- package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
- package/template/laravel/src/Http/Access/RolesController.php +2 -1
- package/template/laravel/src/Http/Access/UsersController.php +2 -1
- package/template/laravel/src/Http/ActionController.php +2 -1
- package/template/laravel/src/Http/Auth/AccountController.php +6 -3
- package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
- package/template/laravel/src/Http/FormsController.php +45 -0
- package/template/laravel/src/Http/PublicController.php +89 -4
- package/template/laravel/src/Http/ResourceRequest.php +43 -3
- package/template/laravel/src/Http/TrashController.php +2 -1
- package/template/laravel/src/Http/UploadController.php +2 -2
- package/template/laravel/src/Nevela.php +30 -1
- package/template/laravel/src/NevelaServiceProvider.php +11 -2
- package/template/laravel/src/Support/Descriptor.php +24 -0
- package/template/laravel/src/Support/Field.php +17 -0
- package/template/laravel/src/Support/Key.php +57 -0
- package/template/laravel/src/Support/ListQuery.php +29 -1
- package/template/laravel/src/Support/PublicAccess.php +75 -0
- package/template/laravel/src/Support/Slug.php +43 -0
- package/template/laravel/src/Trash/Trash.php +3 -1
- package/template/web/.env.example +4 -0
- package/template/web/app/api/[...path]/route.ts +6 -3
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +15 -1
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +29 -5
- package/template/web/components/dashboard/record-detail.tsx +2 -1
- package/template/web/components/dashboard/resource-form.tsx +19 -2
- package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
- package/template/web/components/dashboard/resource-table.tsx +32 -3
- package/template/web/components/dashboard/row-actions.tsx +33 -3
- package/template/web/components/marketing/site-header.tsx +9 -2
- package/template/web/components/public-form-guard.tsx +125 -0
- package/template/web/hooks/use-public-form.ts +263 -0
- package/template/web/lib/extras.ts +39 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
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
|
+
};
|