create-nevela 0.7.1 → 0.9.1
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 +25 -7
- package/package.json +1 -1
- package/project.mjs +1 -1
- package/template/laravel/config/nevela.php +66 -3
- 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/Access/Permissions.php +36 -5
- package/template/laravel/src/Concerns/Confirmable.php +81 -0
- package/template/laravel/src/Concerns/Trashable.php +23 -0
- package/template/laravel/src/Console/AdoptCommand.php +139 -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 +38 -3
- package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
- package/template/laravel/src/Console/SeedCommand.php +3 -1
- 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 +17 -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 +356 -32
- 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 +223 -0
- 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 +294 -0
- package/template/laravel/src/Http/PublicThrottle.php +48 -0
- package/template/laravel/src/Http/ResourceRequest.php +193 -4
- package/template/laravel/src/Http/TrashController.php +19 -2
- package/template/laravel/src/Http/UploadController.php +42 -1
- package/template/laravel/src/Media/Uploads.php +111 -0
- package/template/laravel/src/Nevela.php +83 -2
- package/template/laravel/src/NevelaServiceProvider.php +27 -2
- package/template/laravel/src/Support/Action.php +168 -0
- package/template/laravel/src/Support/Adoption.php +352 -0
- package/template/laravel/src/Support/Descriptor.php +83 -4
- package/template/laravel/src/Support/Fake.php +15 -1
- package/template/laravel/src/Support/Field.php +91 -3
- package/template/laravel/src/Support/Html.php +185 -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 +208 -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 +42 -9
- package/template/web/app/confirm/page.tsx +51 -0
- package/template/web/app/dashboard/actions.ts +48 -0
- package/template/web/app/globals.css +46 -0
- package/template/web/components/access/role-form.tsx +40 -5
- package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
- package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
- package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
- package/template/web/components/dashboard/fields/file-field.tsx +9 -5
- package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
- package/template/web/components/dashboard/form-activity.tsx +53 -0
- package/template/web/components/dashboard/record-actions.tsx +33 -4
- package/template/web/components/dashboard/record-detail.tsx +10 -2
- package/template/web/components/dashboard/resource-action.tsx +281 -0
- 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 +46 -4
- package/template/web/components/dashboard/row-actions.tsx +56 -2
- package/template/web/components/dashboard/table-selection.tsx +19 -1
- 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 +175 -0
- package/template/web/lib/access.ts +3 -1
- package/template/web/lib/dashboard.ts +10 -0
- package/template/web/lib/extras.ts +105 -0
- package/template/web/lib/files.ts +14 -0
- package/template/web/lib/laravel.ts +60 -0
- package/template/web/lib/rich.ts +79 -0
- package/template/web/package.json +1 -1
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use InvalidArgumentException;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What of a resource is open to anyone, signed in or not: the descriptor's "public" section.
|
|
9
|
+
*
|
|
10
|
+
* "public": {
|
|
11
|
+
* "read": { "by": "slug", "fields": ["title", "slug", "body", "publishedAt"] },
|
|
12
|
+
* "create": { "fields": ["name", "email", "message"], "throttle": "10,1" }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* Nothing is open without it, and nothing beyond what it names: a resource with "read"
|
|
16
|
+
* alone can't be written to, and a field that isn't listed is never sent or accepted.
|
|
17
|
+
* Which records can be read is the model's to say, in its publicQuery scope.
|
|
18
|
+
*
|
|
19
|
+
* A form anyone can send is sent by bots too, so a create can say what stands in their
|
|
20
|
+
* way, and whether the address it is given has to be proved:
|
|
21
|
+
*
|
|
22
|
+
* "create": {
|
|
23
|
+
* "fields": ["email"],
|
|
24
|
+
* "protect": { "turnstile": true, "honeypot": true, "minSeconds": 3, "email": "email" },
|
|
25
|
+
* "confirm": "email"
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* See Forms\FormGuard for the checks, and Forms\Confirmation for the link.
|
|
29
|
+
*/
|
|
30
|
+
final class PublicAccess
|
|
31
|
+
{
|
|
32
|
+
/** Requests a minute, when the section doesn't say: plenty for a page, few for a form. */
|
|
33
|
+
public const READ_THROTTLE = '60,1';
|
|
34
|
+
|
|
35
|
+
public const CREATE_THROTTLE = '10,1';
|
|
36
|
+
|
|
37
|
+
/** What "protect" can turn on. */
|
|
38
|
+
public const CHECKS = ['turnstile', 'honeypot', 'minSeconds', 'email'];
|
|
39
|
+
|
|
40
|
+
/** The longest a form can be made to wait before it is sent, in seconds. */
|
|
41
|
+
public const MAX_SECONDS = 3600;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param list<string>|null $read The fields anyone may read, or null when nothing may be
|
|
45
|
+
* @param string|null $by The unique field one record is fetched by, in place of its id
|
|
46
|
+
* @param list<string>|null $create The fields anyone may send to create a record, or null when nobody may
|
|
47
|
+
* @param list<string> $middleware More middleware for these routes: the app's own spam checks
|
|
48
|
+
* @param array{turnstile: bool, honeypot: bool, minSeconds: int, email: string|null}|null $protect The checks a create passes before it is looked at, or null for none
|
|
49
|
+
* @param string|null $confirm The field holding an address its owner has to prove, by a link sent to it
|
|
50
|
+
*/
|
|
51
|
+
public function __construct(
|
|
52
|
+
public readonly ?array $read = null,
|
|
53
|
+
public readonly ?string $by = null,
|
|
54
|
+
public readonly ?array $create = null,
|
|
55
|
+
public readonly string $readThrottle = self::READ_THROTTLE,
|
|
56
|
+
public readonly string $createThrottle = self::CREATE_THROTTLE,
|
|
57
|
+
public readonly array $middleware = [],
|
|
58
|
+
public readonly ?array $protect = null,
|
|
59
|
+
public readonly ?string $confirm = null,
|
|
60
|
+
) {
|
|
61
|
+
foreach ([$readThrottle, $createThrottle] as $throttle) {
|
|
62
|
+
if (! preg_match('/^[1-9][0-9]*,[1-9][0-9]*$/', $throttle)) {
|
|
63
|
+
throw new InvalidArgumentException("\"{$throttle}\" is not a throttle. Write how many requests in how many minutes: \"10,1\".");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if ($read === null && $create === null) {
|
|
67
|
+
throw new InvalidArgumentException('A "public" section opens something: give it "read", "create" or both.');
|
|
68
|
+
}
|
|
69
|
+
foreach ($middleware as $name) {
|
|
70
|
+
if (! is_string($name) || ! preg_match('/^[A-Za-z0-9_.:,\\\\-]+$/', $name)) {
|
|
71
|
+
throw new InvalidArgumentException('Public middleware is named as in a route: "honeypot", or "throttle:5,1".');
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if ($protect !== null && ! $protect['turnstile'] && ! $protect['honeypot'] && $protect['minSeconds'] === 0 && $protect['email'] === null) {
|
|
75
|
+
throw new InvalidArgumentException('"protect" turns nothing on. Give it "turnstile", "honeypot", "minSeconds" or "email", or leave it out.');
|
|
76
|
+
}
|
|
77
|
+
if ($protect !== null && ($protect['minSeconds'] < 0 || $protect['minSeconds'] > self::MAX_SECONDS)) {
|
|
78
|
+
throw new InvalidArgumentException('"minSeconds" is how long a person takes to fill the form in: between 0 and '.self::MAX_SECONDS.'.');
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Whether a create has checks to pass. */
|
|
83
|
+
public function protected(): bool
|
|
84
|
+
{
|
|
85
|
+
return $this->protect !== null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* "protect" as it is written, made whole: every check said yes or no to.
|
|
90
|
+
*
|
|
91
|
+
* @return array{turnstile: bool, honeypot: bool, minSeconds: int, email: string|null}
|
|
92
|
+
*/
|
|
93
|
+
private static function protection(mixed $given): array
|
|
94
|
+
{
|
|
95
|
+
if (! is_array($given)) {
|
|
96
|
+
throw new InvalidArgumentException('"protect" lists the checks to make: { "turnstile": true, "honeypot": true, "minSeconds": 3, "email": "email" }.');
|
|
97
|
+
}
|
|
98
|
+
if (($unknown = array_diff(array_keys($given), self::CHECKS)) !== []) {
|
|
99
|
+
throw new InvalidArgumentException('"protect" has no check called "'.reset($unknown).'". There are: '.implode(', ', self::CHECKS).'.');
|
|
100
|
+
}
|
|
101
|
+
$seconds = $given['minSeconds'] ?? 0;
|
|
102
|
+
if (! is_int($seconds)) {
|
|
103
|
+
throw new InvalidArgumentException('"minSeconds" is a whole number of seconds.');
|
|
104
|
+
}
|
|
105
|
+
$email = $given['email'] ?? null;
|
|
106
|
+
if ($email !== null && ! is_string($email)) {
|
|
107
|
+
throw new InvalidArgumentException('"email" in "protect" names the field that holds the address.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return ['turnstile' => ($given['turnstile'] ?? false) === true, 'honeypot' => ($given['honeypot'] ?? false) === true, 'minSeconds' => $seconds, 'email' => $email];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** @param array<string, mixed> $data */
|
|
114
|
+
public static function fromArray(array $data): self
|
|
115
|
+
{
|
|
116
|
+
$read = isset($data['read']) ? (array) $data['read'] : null;
|
|
117
|
+
$create = isset($data['create']) ? (array) $data['create'] : null;
|
|
118
|
+
|
|
119
|
+
return new self(
|
|
120
|
+
$read === null ? null : array_values(array_map('strval', (array) ($read['fields'] ?? []))),
|
|
121
|
+
$read['by'] ?? null,
|
|
122
|
+
$create === null ? null : array_values(array_map('strval', (array) ($create['fields'] ?? []))),
|
|
123
|
+
(string) ($read['throttle'] ?? self::READ_THROTTLE),
|
|
124
|
+
(string) ($create['throttle'] ?? self::CREATE_THROTTLE),
|
|
125
|
+
array_values((array) ($data['middleware'] ?? [])),
|
|
126
|
+
isset($create['protect']) ? self::protection($create['protect']) : null,
|
|
127
|
+
isset($create['confirm']) ? (string) $create['confirm'] : null,
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** @return array<string, mixed> */
|
|
132
|
+
public function toArray(): array
|
|
133
|
+
{
|
|
134
|
+
return array_filter([
|
|
135
|
+
'read' => $this->read === null ? null : array_filter([
|
|
136
|
+
'by' => $this->by,
|
|
137
|
+
'fields' => $this->read,
|
|
138
|
+
'throttle' => $this->readThrottle === self::READ_THROTTLE ? null : $this->readThrottle,
|
|
139
|
+
], fn ($v) => $v !== null),
|
|
140
|
+
'create' => $this->create === null ? null : array_filter([
|
|
141
|
+
'fields' => $this->create,
|
|
142
|
+
'throttle' => $this->createThrottle === self::CREATE_THROTTLE ? null : $this->createThrottle,
|
|
143
|
+
// Only what is on, so a descriptor reads as it was written.
|
|
144
|
+
'protect' => $this->protect === null ? null : array_filter($this->protect, fn ($v) => $v !== false && $v !== 0 && $v !== null),
|
|
145
|
+
'confirm' => $this->confirm,
|
|
146
|
+
], fn ($v) => $v !== null),
|
|
147
|
+
'middleware' => $this->middleware ?: null,
|
|
148
|
+
], fn ($v) => $v !== null);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Checked against the resource's fields, which the section on its own can't know.
|
|
153
|
+
*
|
|
154
|
+
* @param array<string, Field> $fields
|
|
155
|
+
*
|
|
156
|
+
* @throws InvalidArgumentException
|
|
157
|
+
*/
|
|
158
|
+
public function check(string $resource, array $fields): void
|
|
159
|
+
{
|
|
160
|
+
foreach (['read' => $this->read, 'create' => $this->create] as $what => $listed) {
|
|
161
|
+
if ($listed === null) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if ($listed === []) {
|
|
165
|
+
throw new InvalidArgumentException("{$resource}: public {$what} names no fields, so there would be nothing to {$what}.");
|
|
166
|
+
}
|
|
167
|
+
foreach ($listed as $field) {
|
|
168
|
+
if (! isset($fields[$field])) {
|
|
169
|
+
throw new InvalidArgumentException("{$resource}: public {$what} lists \"{$field}\", which is not one of its fields.");
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
foreach ($this->read ?? [] as $field) {
|
|
174
|
+
if ($fields[$field]->private) {
|
|
175
|
+
throw new InvalidArgumentException("{$resource}: \"{$field}\" is a private file, so it can't be read by anyone who asks.");
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if ($this->by !== null) {
|
|
179
|
+
if ($this->read === null) {
|
|
180
|
+
throw new InvalidArgumentException("{$resource}: \"by\" says how one record is read, and nothing is.");
|
|
181
|
+
}
|
|
182
|
+
if (! isset($fields[$this->by]) || ! $fields[$this->by]->unique) {
|
|
183
|
+
throw new InvalidArgumentException("{$resource}: a record is fetched by \"{$this->by}\", which has to be a unique field ({$this->by}:slug!).");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if ($this->create === null && ($this->protect !== null || $this->confirm !== null)) {
|
|
187
|
+
throw new InvalidArgumentException("{$resource}: \"protect\" and \"confirm\" are about a public create, and there isn't one.");
|
|
188
|
+
}
|
|
189
|
+
foreach (['protect.email' => $this->protect['email'] ?? null, 'confirm' => $this->confirm] as $what => $named) {
|
|
190
|
+
if ($named === null) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// The address has to be something a visitor types into the form.
|
|
194
|
+
if (! in_array($named, $this->create ?? [], true) || $fields[$named]->kind !== 'string') {
|
|
195
|
+
throw new InvalidArgumentException("{$resource}: {$what} names \"{$named}\", which has to be a text field the public create lists.");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if ($this->create !== null) {
|
|
199
|
+
foreach ($fields as $field) {
|
|
200
|
+
// Left out of the form, a field needs something to be: nothing, or a default.
|
|
201
|
+
// (A yes/no column is no unless said otherwise.)
|
|
202
|
+
if ($field->required && $field->default === null && $field->kind !== 'boolean' && ! in_array($field->name, $this->create, true)) {
|
|
203
|
+
throw new InvalidArgumentException("{$resource}: \"{$field->name}\" is required and a public create can't send it. List it under create, give it a default, or make it optional.");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Nevela\Laravel\Support;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Str;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A slug nobody typed: made from another field, and made different from every one
|
|
10
|
+
* already in the table.
|
|
11
|
+
*
|
|
12
|
+
* "Opening day" is `opening-day`; the next post of that name is `opening-day-2`. Records
|
|
13
|
+
* in the trash are counted, because they still hold their value and can come back with it.
|
|
14
|
+
*/
|
|
15
|
+
final class Slug
|
|
16
|
+
{
|
|
17
|
+
/** Room left at the end of the column's 255 for the "-12" that makes one different. */
|
|
18
|
+
private const LONGEST = 240;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param array<string, true> $taken Values spoken for that the table doesn't hold yet: earlier rows of the same batch. The one returned is added.
|
|
22
|
+
*/
|
|
23
|
+
public static function free(string $table, string $column, string $from, array &$taken = []): string
|
|
24
|
+
{
|
|
25
|
+
$base = Str::limit(Str::slug($from), self::LONGEST, '');
|
|
26
|
+
// A title with nothing a slug can keep (all symbols, say) still needs an address.
|
|
27
|
+
$base = trim($base, '-') !== '' ? trim($base, '-') : 'untitled';
|
|
28
|
+
|
|
29
|
+
$held = $taken;
|
|
30
|
+
// Straight from the table, past the model's scopes: a record in the trash holds its slug too.
|
|
31
|
+
foreach (DB::table($table)->where($column, $base)->orWhere($column, 'like', $base.'-%')->pluck($column) as $existing) {
|
|
32
|
+
$held[(string) $existing] = true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$slug = $base;
|
|
36
|
+
for ($n = 2; isset($held[$slug]); $n++) {
|
|
37
|
+
$slug = "{$base}-{$n}";
|
|
38
|
+
}
|
|
39
|
+
$taken[$slug] = true;
|
|
40
|
+
|
|
41
|
+
return $slug;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -102,7 +102,9 @@ final class Trash
|
|
|
102
102
|
public static function purge(Model $record): bool
|
|
103
103
|
{
|
|
104
104
|
try {
|
|
105
|
-
|
|
105
|
+
// In a transaction of its own. Called inside another, that makes it a savepoint:
|
|
106
|
+
// on Postgres a refused delete would otherwise abort everything around it.
|
|
107
|
+
$record->getConnection()->transaction(fn () => $record->forceDelete());
|
|
106
108
|
|
|
107
109
|
return true;
|
|
108
110
|
} catch (QueryException $e) {
|
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
# Where Nevela's routes live in the Laravel app (apps/api), including the prefix.
|
|
2
2
|
NEVELA_API_URL=http://127.0.0.1:8000/api
|
|
3
|
+
|
|
4
|
+
# Cloudflare Turnstile's site key, for a public form whose descriptor asks for the robot
|
|
5
|
+
# check. Its secret key goes in apps/api/.env as TURNSTILE_SECRET_KEY.
|
|
6
|
+
# NEXT_PUBLIC_TURNSTILE_SITE_KEY=
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { NextRequest } from "next/server";
|
|
2
|
-
import { API_URL, sessionToken } from "@/lib/laravel";
|
|
2
|
+
import { API_URL, callerHeaders, sessionToken } from "@/lib/laravel";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* The browser's way to the Laravel API, for the few things a server action can't do:
|
|
6
6
|
*
|
|
7
7
|
* - reading, for the relation picker's search (`/api/categories?q=…`),
|
|
8
|
-
* - showing stored files (`/api/_nevela/files/<key>`),
|
|
8
|
+
* - showing stored files (`/api/_nevela/files/<key>`),
|
|
9
|
+
* - downloading a private one (`/api/_nevela/private/<slug>/<id>/<field>`), which Laravel
|
|
10
|
+
* hands only to someone who may view that record, and
|
|
9
11
|
* - uploading one (`PUT /api/_nevela/uploads/<Resource>/<field>`), which needs the
|
|
10
12
|
* browser's upload progress and is larger than a server action accepts.
|
|
11
13
|
*
|
|
@@ -13,6 +15,11 @@ import { API_URL, sessionToken } from "@/lib/laravel";
|
|
|
13
15
|
* as a Bearer token. Laravel still decides what the person may see or store. Everything
|
|
14
16
|
* else that changes data goes through the server actions, so nothing but an upload is
|
|
15
17
|
* let through here as a write.
|
|
18
|
+
*
|
|
19
|
+
* And for a site's visitors, who have no session: what a resource's "public" section
|
|
20
|
+
* opens (`/api/public/…`). Reading it, a form's create (`POST /api/public/<slug>`) and a
|
|
21
|
+
* file for that form (`PUT /api/public/_nevela/uploads/<Resource>/<field>`) go through
|
|
22
|
+
* with no token. Laravel has those routes only for resources whose descriptor opens them.
|
|
16
23
|
*/
|
|
17
24
|
export const dynamic = "force-dynamic";
|
|
18
25
|
|
|
@@ -23,7 +30,7 @@ const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
|
|
|
23
30
|
* What is passed back from Laravel's answer. Not its length or encoding: fetch() hands
|
|
24
31
|
* over the body already decompressed, so the length Laravel gave may no longer be true.
|
|
25
32
|
*/
|
|
26
|
-
const RESPONSE_HEADERS = ["content-type", "cache-control", "etag", "last-modified", "location", "x-content-type-options"];
|
|
33
|
+
const RESPONSE_HEADERS = ["content-type", "content-disposition", "cache-control", "etag", "last-modified", "location", "x-content-type-options"];
|
|
27
34
|
|
|
28
35
|
type Context = { params: Promise<{ path: string[] }> };
|
|
29
36
|
|
|
@@ -32,11 +39,16 @@ function target(path: string[], search: string): string | null {
|
|
|
32
39
|
return `${API_URL}/${path.map(encodeURIComponent).join("/")}${search}`;
|
|
33
40
|
}
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
/** Whether an address is under what a resource's "public" section opens. */
|
|
43
|
+
const isPublic = (path: string[]) => path[0] === "public" && path.length > 1;
|
|
44
|
+
|
|
45
|
+
async function forward(url: string, init: RequestInit, withToken: boolean, forVisitor = false): Promise<Response> {
|
|
36
46
|
const headers = new Headers(init.headers);
|
|
37
47
|
headers.set("Accept", headers.get("Accept") ?? "application/json");
|
|
38
48
|
const token = withToken ? await sessionToken() : undefined;
|
|
39
49
|
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
50
|
+
// Whose request this is, so Laravel's throttle counts the visitor and not this server.
|
|
51
|
+
if (forVisitor) for (const [name, value] of Object.entries(await callerHeaders())) headers.set(name, value);
|
|
40
52
|
|
|
41
53
|
let upstream: Response;
|
|
42
54
|
try {
|
|
@@ -68,17 +80,38 @@ export async function GET(request: NextRequest, context: Context) {
|
|
|
68
80
|
// a request with no session is told 401; asked as a page, Laravel would try to send it
|
|
69
81
|
// to a sign-in route this app doesn't have. A file is asked for as the browser wants it.
|
|
70
82
|
const accept = isFile ? (request.headers.get("accept") ?? "*/*") : "application/json";
|
|
71
|
-
|
|
83
|
+
// What is public is the same for everyone, so it is asked for as nobody.
|
|
84
|
+
return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile && !isPublic(path), isPublic(path));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* A visitor's form: a public create. Nothing else is posted this way. A signed-in
|
|
89
|
+
* person's saves go through the dashboard's server actions.
|
|
90
|
+
*/
|
|
91
|
+
export async function POST(request: NextRequest, context: Context) {
|
|
92
|
+
const { path } = await context.params;
|
|
93
|
+
const url = target(path, request.nextUrl.search);
|
|
94
|
+
if (!url || path.length !== 2 || path[0] !== "public" || path[1].startsWith("_")) {
|
|
95
|
+
return Response.json({ error: "Only a public form is sent this way. Records are saved through the dashboard." }, { status: 405 });
|
|
96
|
+
}
|
|
97
|
+
const length = Number(request.headers.get("content-length") ?? 0);
|
|
98
|
+
if (length > 1024 * 1024) return Response.json({ error: "That is too much to send." }, { status: 413 });
|
|
99
|
+
const body = await request.text();
|
|
100
|
+
if (body.length > 1024 * 1024) return Response.json({ error: "That is too much to send." }, { status: 413 });
|
|
101
|
+
return forward(url, { method: "POST", body, headers: { "Content-Type": "application/json" } }, false, true);
|
|
72
102
|
}
|
|
73
103
|
|
|
74
104
|
export async function PUT(request: NextRequest, context: Context) {
|
|
75
105
|
const { path } = await context.params;
|
|
76
106
|
const url = target(path, request.nextUrl.search);
|
|
77
|
-
|
|
107
|
+
// A visitor's file for a public form: /api/public/_nevela/uploads/<Resource>/<field>.
|
|
108
|
+
const forVisitor = path.length === 5 && path[0] === "public" && path[1] === "_nevela" && path[2] === "uploads";
|
|
109
|
+
if (!url || !(forVisitor || (path[0] === "_nevela" && path[1] === "uploads"))) {
|
|
78
110
|
return Response.json({ error: "Only uploads are sent this way. Records are saved through the dashboard." }, { status: 405 });
|
|
79
111
|
}
|
|
80
|
-
// Nothing is read until it is known who is sending it and how much there is.
|
|
81
|
-
|
|
112
|
+
// Nothing is read until it is known who is sending it and how much there is. (A visitor
|
|
113
|
+
// is nobody, by design: Laravel takes their file only for a field a public form lists.)
|
|
114
|
+
if (!forVisitor && !(await sessionToken())) return Response.json({ error: "Sign in first." }, { status: 401 });
|
|
82
115
|
const length = Number(request.headers.get("content-length"));
|
|
83
116
|
if (!Number.isFinite(length) || length <= 0) return Response.json({ error: "The upload didn't say how large it is." }, { status: 411 });
|
|
84
117
|
if (length > MAX_UPLOAD_BYTES) {
|
|
@@ -91,5 +124,5 @@ export async function PUT(request: NextRequest, context: Context) {
|
|
|
91
124
|
if (body.byteLength > MAX_UPLOAD_BYTES) {
|
|
92
125
|
return Response.json({ error: "That file is too large to upload." }, { status: 413 });
|
|
93
126
|
}
|
|
94
|
-
return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } },
|
|
127
|
+
return forward(url, { method: "PUT", body, headers: { "Content-Type": request.headers.get("content-type") ?? "application/octet-stream" } }, !forVisitor, forVisitor);
|
|
95
128
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
import { AuthShell } from "@/components/auth/auth-shell";
|
|
3
|
+
import { laravel } from "@/lib/laravel";
|
|
4
|
+
|
|
5
|
+
export const metadata = { title: "Confirm your address", robots: { index: false } };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where the link in a confirmation email leads: a list someone joined through a public
|
|
9
|
+
* form whose descriptor has "confirm". Opening it proves the address is theirs.
|
|
10
|
+
*
|
|
11
|
+
* Laravel made the link and Laravel checks it. This page passes on what the link
|
|
12
|
+
* carries and says how it went. To send people somewhere of your own, change what is
|
|
13
|
+
* shown here, or point nevela.public.confirm.path at another page that does the same.
|
|
14
|
+
*/
|
|
15
|
+
export default async function ConfirmPage({ searchParams }: { searchParams: Promise<{ list?: string; id?: string; key?: string }> }) {
|
|
16
|
+
const { list = "", id = "", key = "" } = await searchParams;
|
|
17
|
+
// Only what a link of ours looks like is passed on.
|
|
18
|
+
const wellFormed = /^[a-z0-9][a-z0-9-]{0,80}$/.test(list) && /^[A-Za-z0-9-]{1,40}$/.test(id) && /^[a-f0-9]{64}$/.test(key);
|
|
19
|
+
|
|
20
|
+
let confirmed = false;
|
|
21
|
+
let unreachable = false;
|
|
22
|
+
if (wellFormed) {
|
|
23
|
+
try {
|
|
24
|
+
const response = await laravel(`public/_nevela/confirm/${list}/${id}/${key}`, { method: "POST", token: null });
|
|
25
|
+
confirmed = response.ok;
|
|
26
|
+
unreachable = response.status >= 500 || response.status === 429;
|
|
27
|
+
} catch {
|
|
28
|
+
unreachable = true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const [title, subtitle] = confirmed
|
|
33
|
+
? ["You're confirmed", "Thank you. Your address is on the list."]
|
|
34
|
+
: unreachable
|
|
35
|
+
? ["We couldn't check that just now", "Nothing is wrong with your link. Open it again in a moment."]
|
|
36
|
+
: ["This link is no longer good", "It may have been used too late. Sign up again and we'll send a new one."];
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<AuthShell
|
|
40
|
+
title={title}
|
|
41
|
+
subtitle={subtitle}
|
|
42
|
+
footer={
|
|
43
|
+
<Link href="/" className="font-medium text-link hover:underline">
|
|
44
|
+
Back to the site
|
|
45
|
+
</Link>
|
|
46
|
+
}
|
|
47
|
+
>
|
|
48
|
+
{null}
|
|
49
|
+
</AuthShell>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -8,6 +8,7 @@ import { revalidateResource } from "@/lib/cache";
|
|
|
8
8
|
import { fileUrl } from "@/lib/files";
|
|
9
9
|
import { laravel } from "@/lib/laravel";
|
|
10
10
|
import { allResources, resourcePath, dashboardSession, dashboardStore, mayDo } from "@/lib/dashboard";
|
|
11
|
+
import { actionsOf, formOf, publishFieldOf } from "@/lib/extras";
|
|
11
12
|
|
|
12
13
|
export type ActionResult<T = unknown> =
|
|
13
14
|
| { ok: true; data: T }
|
|
@@ -36,6 +37,39 @@ export async function deleteRecordAction(resourceName: string, id: string): Prom
|
|
|
36
37
|
return result;
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Run one of a resource's own actions (the descriptor's "actions"): on one record, on
|
|
42
|
+
* the rows selected, or on the list as a whole.
|
|
43
|
+
*
|
|
44
|
+
* Laravel does it, in the class the app wrote for it, and decides everything: who may,
|
|
45
|
+
* whether it applies, whether what was filled in is valid. Its answer comes back as it
|
|
46
|
+
* is, so a problem with a field can be shown on that field.
|
|
47
|
+
*/
|
|
48
|
+
export async function runResourceAction(
|
|
49
|
+
resourceName: string,
|
|
50
|
+
key: string,
|
|
51
|
+
target: { id?: string; ids?: string[] },
|
|
52
|
+
input: Record<string, unknown> = {},
|
|
53
|
+
): Promise<ActionResult<{ message: string; done?: number; skipped?: number }>> {
|
|
54
|
+
const { allowed: inAdmin, may } = await dashboardSession();
|
|
55
|
+
const resource = allResources().find((item) => item.name === resourceName);
|
|
56
|
+
const action = actionsOf(resourceName).find((item) => item.key === key);
|
|
57
|
+
if (!inAdmin || !resource || !action || !may(action.permission)) return forbidden();
|
|
58
|
+
if (target.ids && target.ids.length > 500) return { ok: false, status: 400, error: "That's more than 500 records. Do them in smaller batches." };
|
|
59
|
+
|
|
60
|
+
const path = target.id ? `${resource.slug}/${encodeURIComponent(target.id)}/_actions/${key}` : `${resource.slug}/_actions/${key}`;
|
|
61
|
+
const response = await laravel(path, { method: "POST", body: { input, ...(target.ids ? { ids: target.ids } : {}) } });
|
|
62
|
+
const body = (response.body ?? {}) as { message?: string; done?: number; skipped?: number; error?: string; detail?: string; issues?: FieldIssue[] };
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
// `detail` is only sent while the Laravel app is in debug mode: the reason, for whoever is building it.
|
|
65
|
+
return { ok: false, status: response.status, error: body.detail ? `${body.error ?? "It didn't work."} ${body.detail}` : (body.error ?? "It didn't work."), issues: body.issues };
|
|
66
|
+
}
|
|
67
|
+
// What it changed could be anything of this resource's, so everything of it is read again.
|
|
68
|
+
revalidateResource({ resource: resourceName, action: "update", id: target.id ?? "" });
|
|
69
|
+
revalidatePath(resourcePath(resource));
|
|
70
|
+
return { ok: true, data: { message: body.message ?? `${action.label}: done.`, done: body.done, skipped: body.skipped } };
|
|
71
|
+
}
|
|
72
|
+
|
|
39
73
|
/** How many rows one export may fetch: enough for a spreadsheet. */
|
|
40
74
|
const EXPORT_LIMIT = 50_000;
|
|
41
75
|
/** Laravel's `nevela.max_per_page`. */
|
|
@@ -77,8 +111,11 @@ export async function exportRecordsAction(resourceName: string, query: string):
|
|
|
77
111
|
|
|
78
112
|
const rows: Record<string, unknown>[] = [];
|
|
79
113
|
let truncated = false;
|
|
114
|
+
// Someone who joined a list and hasn't proved their address isn't on it yet.
|
|
115
|
+
const confirmedOnly = formOf(resourceName)?.confirm !== undefined && !new URLSearchParams(query).has("filter[confirmed]");
|
|
80
116
|
for (let page = 1; rows.length < EXPORT_LIMIT; page++) {
|
|
81
117
|
const params = new URLSearchParams(query);
|
|
118
|
+
if (confirmedOnly) params.set("filter[confirmed]", "true");
|
|
82
119
|
params.set("page", String(page));
|
|
83
120
|
params.set("perPage", String(EXPORT_PAGE));
|
|
84
121
|
const result = await store.list(params);
|
|
@@ -192,6 +229,17 @@ export async function updateRecordAction(resourceName: string, id: string, input
|
|
|
192
229
|
return result;
|
|
193
230
|
}
|
|
194
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Publish a record now, or take it back to a draft: its publish date is set to this
|
|
234
|
+
* moment, or cleared. An edit like any other, so it takes the permission to edit, and
|
|
235
|
+
* Laravel's policy has the last word.
|
|
236
|
+
*/
|
|
237
|
+
export async function publishRecordAction(resourceName: string, id: string, publish: boolean): Promise<ActionResult<Record<string, unknown>>> {
|
|
238
|
+
const field = publishFieldOf(resourceName);
|
|
239
|
+
if (!field) return { ok: false, status: 404, error: "This isn't something that is published." };
|
|
240
|
+
return updateRecordAction(resourceName, id, { [field]: publish ? new Date().toISOString() : null });
|
|
241
|
+
}
|
|
242
|
+
|
|
195
243
|
/**
|
|
196
244
|
* Where the browser sends a file for a file or image field.
|
|
197
245
|
*
|
|
@@ -253,3 +253,49 @@
|
|
|
253
253
|
letter-spacing: var(--heading-tracking);
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
|
+
|
|
257
|
+
/*
|
|
258
|
+
* Rich text: what a rich text field's editor holds and what a record's page shows of it.
|
|
259
|
+
* Tailwind's reset takes the looks off headings, lists and quotes; this gives them back.
|
|
260
|
+
*/
|
|
261
|
+
.rich-text {
|
|
262
|
+
line-height: 1.6;
|
|
263
|
+
overflow-wrap: anywhere;
|
|
264
|
+
}
|
|
265
|
+
.rich-text > * + * {
|
|
266
|
+
margin-top: 0.75em;
|
|
267
|
+
}
|
|
268
|
+
.rich-text h2 {
|
|
269
|
+
font-size: 1.25em;
|
|
270
|
+
line-height: 1.3;
|
|
271
|
+
}
|
|
272
|
+
.rich-text h3 {
|
|
273
|
+
font-size: 1.1em;
|
|
274
|
+
line-height: 1.35;
|
|
275
|
+
}
|
|
276
|
+
.rich-text ul,
|
|
277
|
+
.rich-text ol {
|
|
278
|
+
padding-left: 1.5em;
|
|
279
|
+
}
|
|
280
|
+
.rich-text ul {
|
|
281
|
+
list-style: disc;
|
|
282
|
+
}
|
|
283
|
+
.rich-text ol {
|
|
284
|
+
list-style: decimal;
|
|
285
|
+
}
|
|
286
|
+
.rich-text li + li {
|
|
287
|
+
margin-top: 0.25em;
|
|
288
|
+
}
|
|
289
|
+
.rich-text blockquote {
|
|
290
|
+
border-left: 3px solid var(--border);
|
|
291
|
+
padding-left: 1em;
|
|
292
|
+
color: var(--muted-foreground);
|
|
293
|
+
}
|
|
294
|
+
.rich-text a {
|
|
295
|
+
color: var(--primary);
|
|
296
|
+
text-decoration: underline;
|
|
297
|
+
text-underline-offset: 2px;
|
|
298
|
+
}
|
|
299
|
+
.rich-text s {
|
|
300
|
+
text-decoration: line-through;
|
|
301
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import Link from "next/link";
|
|
4
4
|
import { useRouter } from "next/navigation";
|
|
5
|
-
import { useMemo, useState, useTransition } from "react";
|
|
5
|
+
import { Fragment, useMemo, useState, useTransition } from "react";
|
|
6
6
|
import { toast } from "sonner";
|
|
7
7
|
import { createRoleAction, deleteRoleAction, updateRoleAction } from "@/app/dashboard/access/actions";
|
|
8
8
|
import {
|
|
@@ -27,6 +27,12 @@ import type { ManagedRole, PermissionCatalog, PermissionFeature } from "@/lib/ac
|
|
|
27
27
|
const LIST = "/dashboard/access/roles";
|
|
28
28
|
const ACTIONS = ["create", "view", "edit", "delete"] as const;
|
|
29
29
|
const ACTION_LABELS: Record<string, string> = { create: "Create", view: "View", edit: "Edit", delete: "Delete" };
|
|
30
|
+
/**
|
|
31
|
+
* Whether a permission is one of a resource's own actions ("applications.accept") and not
|
|
32
|
+
* one of the four. Those are given by name only: a whole section, or a pattern, doesn't
|
|
33
|
+
* carry them, which is how Laravel reads a grant too.
|
|
34
|
+
*/
|
|
35
|
+
const isOwn = (key: string) => !(ACTIONS as readonly string[]).includes(key.split(".")[1] ?? "");
|
|
30
36
|
|
|
31
37
|
/**
|
|
32
38
|
* A role's name and what it allows.
|
|
@@ -84,8 +90,9 @@ export function RoleForm({
|
|
|
84
90
|
}, [kept.length]);
|
|
85
91
|
|
|
86
92
|
const holds = (key: string) => mine === null || mine.includes(key);
|
|
87
|
-
const
|
|
88
|
-
const
|
|
93
|
+
const inWhole = (key: string) => !isOwn(key) && whole.has(moduleOf.get(key.split(".")[0]!) ?? "");
|
|
94
|
+
const on = (key: string) => everything || inWhole(key) || byPattern.has(key) || picked.has(key);
|
|
95
|
+
const fixed = (key: string) => locked || inWhole(key) || byPattern.has(key) || (!picked.has(key) && !holds(key));
|
|
89
96
|
|
|
90
97
|
const total = catalog.total;
|
|
91
98
|
const count = useMemo(() => {
|
|
@@ -116,7 +123,7 @@ export function RoleForm({
|
|
|
116
123
|
// A permission already covered by a whole section doesn't need saying twice.
|
|
117
124
|
const grants = everything
|
|
118
125
|
? ["*"]
|
|
119
|
-
: [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !
|
|
126
|
+
: [...[...whole].map((section) => `@${section}.*`), ...kept, ...[...picked].filter((key) => !inWhole(key))];
|
|
120
127
|
const input = { name: name.trim(), description: description.trim() || null, grants };
|
|
121
128
|
|
|
122
129
|
startTransition(async () => {
|
|
@@ -349,8 +356,11 @@ function FeatureRows({
|
|
|
349
356
|
const keys = feature.actions.map((action) => `${feature.key}.${action}`);
|
|
350
357
|
const every = keys.every(on);
|
|
351
358
|
const some = keys.some(on);
|
|
359
|
+
// The resource's own actions: accept, send an announcement. They have no column, so they get a line.
|
|
360
|
+
const own = feature.actions.filter((action) => !(ACTIONS as readonly string[]).includes(action));
|
|
352
361
|
return (
|
|
353
|
-
<
|
|
362
|
+
<Fragment key={feature.key}>
|
|
363
|
+
<tr className={own.length > 0 ? undefined : "border-b last:border-b-0"}>
|
|
354
364
|
<th scope="row" className="px-3 py-2 text-left font-normal">
|
|
355
365
|
{feature.name}
|
|
356
366
|
</th>
|
|
@@ -382,6 +392,31 @@ function FeatureRows({
|
|
|
382
392
|
/>
|
|
383
393
|
</td>
|
|
384
394
|
</tr>
|
|
395
|
+
{own.length > 0 && (
|
|
396
|
+
<tr className="border-b last:border-b-0">
|
|
397
|
+
<td colSpan={ACTIONS.length + 2} className="px-3 pt-0 pb-2">
|
|
398
|
+
<span className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
399
|
+
<span className="text-xs">Also</span>
|
|
400
|
+
{own.map((action) => {
|
|
401
|
+
const key = `${feature.key}.${action}`;
|
|
402
|
+
return (
|
|
403
|
+
<label key={action} className="flex items-center gap-2">
|
|
404
|
+
{/* Named with its resource, as the four are: two resources can both have an "Archive". */}
|
|
405
|
+
<Checkbox
|
|
406
|
+
aria-label={`${feature.labels?.[action] ?? action} ${feature.name.toLowerCase()}`}
|
|
407
|
+
checked={on(key)}
|
|
408
|
+
disabled={fixed(key)}
|
|
409
|
+
onCheckedChange={(value) => set([key], value === true)}
|
|
410
|
+
/>
|
|
411
|
+
{feature.labels?.[action] ?? action}
|
|
412
|
+
</label>
|
|
413
|
+
);
|
|
414
|
+
})}
|
|
415
|
+
</span>
|
|
416
|
+
</td>
|
|
417
|
+
</tr>
|
|
418
|
+
)}
|
|
419
|
+
</Fragment>
|
|
385
420
|
);
|
|
386
421
|
})}
|
|
387
422
|
</>
|