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.
Files changed (87) hide show
  1. package/index.mjs +25 -7
  2. package/package.json +1 -1
  3. package/project.mjs +1 -1
  4. package/template/laravel/config/nevela.php +66 -3
  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/Access/Permissions.php +36 -5
  9. package/template/laravel/src/Concerns/Confirmable.php +81 -0
  10. package/template/laravel/src/Concerns/Trashable.php +23 -0
  11. package/template/laravel/src/Console/AdoptCommand.php +139 -0
  12. package/template/laravel/src/Console/BlogCommand.php +254 -0
  13. package/template/laravel/src/Console/DevCommand.php +9 -0
  14. package/template/laravel/src/Console/DigestCommand.php +28 -0
  15. package/template/laravel/src/Console/DisposableCommand.php +49 -0
  16. package/template/laravel/src/Console/GenerateCommand.php +38 -3
  17. package/template/laravel/src/Console/MakeResourceCommand.php +2 -0
  18. package/template/laravel/src/Console/SeedCommand.php +3 -1
  19. package/template/laravel/src/Console/SetupCommand.php +15 -2
  20. package/template/laravel/src/Console/StatusCommand.php +14 -0
  21. package/template/laravel/src/Console/TrashCommand.php +17 -0
  22. package/template/laravel/src/Forms/ConfirmAddress.php +33 -0
  23. package/template/laravel/src/Forms/Confirmation.php +88 -0
  24. package/template/laravel/src/Forms/Disposable.php +74 -0
  25. package/template/laravel/src/Forms/FormBlocks.php +76 -0
  26. package/template/laravel/src/Forms/FormGuard.php +359 -0
  27. package/template/laravel/src/Forms/OwnerDigest.php +63 -0
  28. package/template/laravel/src/Forms/PublicMail.php +217 -0
  29. package/template/laravel/src/Forms/Refusal.php +50 -0
  30. package/template/laravel/src/Generator/BlogPages.php +409 -0
  31. package/template/laravel/src/Generator/ResourceGenerator.php +356 -32
  32. package/template/laravel/src/Http/Access/DeletedAccountsController.php +2 -1
  33. package/template/laravel/src/Http/Access/RolesController.php +2 -1
  34. package/template/laravel/src/Http/Access/UsersController.php +2 -1
  35. package/template/laravel/src/Http/ActionController.php +223 -0
  36. package/template/laravel/src/Http/Auth/AccountController.php +6 -3
  37. package/template/laravel/src/Http/Auth/SecurityController.php +8 -1
  38. package/template/laravel/src/Http/FormsController.php +45 -0
  39. package/template/laravel/src/Http/PublicController.php +294 -0
  40. package/template/laravel/src/Http/PublicThrottle.php +48 -0
  41. package/template/laravel/src/Http/ResourceRequest.php +193 -4
  42. package/template/laravel/src/Http/TrashController.php +19 -2
  43. package/template/laravel/src/Http/UploadController.php +42 -1
  44. package/template/laravel/src/Media/Uploads.php +111 -0
  45. package/template/laravel/src/Nevela.php +83 -2
  46. package/template/laravel/src/NevelaServiceProvider.php +27 -2
  47. package/template/laravel/src/Support/Action.php +168 -0
  48. package/template/laravel/src/Support/Adoption.php +352 -0
  49. package/template/laravel/src/Support/Descriptor.php +83 -4
  50. package/template/laravel/src/Support/Fake.php +15 -1
  51. package/template/laravel/src/Support/Field.php +91 -3
  52. package/template/laravel/src/Support/Html.php +185 -0
  53. package/template/laravel/src/Support/Key.php +57 -0
  54. package/template/laravel/src/Support/ListQuery.php +29 -1
  55. package/template/laravel/src/Support/PublicAccess.php +208 -0
  56. package/template/laravel/src/Support/Slug.php +43 -0
  57. package/template/laravel/src/Trash/Trash.php +3 -1
  58. package/template/web/.env.example +4 -0
  59. package/template/web/app/api/[...path]/route.ts +42 -9
  60. package/template/web/app/confirm/page.tsx +51 -0
  61. package/template/web/app/dashboard/actions.ts +48 -0
  62. package/template/web/app/globals.css +46 -0
  63. package/template/web/components/access/role-form.tsx +40 -5
  64. package/template/web/components/dashboard/bulk-create-grid.tsx +5 -2
  65. package/template/web/components/dashboard/fields/field-widget.tsx +5 -0
  66. package/template/web/components/dashboard/fields/file-cell.tsx +24 -3
  67. package/template/web/components/dashboard/fields/file-field.tsx +9 -5
  68. package/template/web/components/dashboard/fields/rich-text-field.tsx +261 -0
  69. package/template/web/components/dashboard/form-activity.tsx +53 -0
  70. package/template/web/components/dashboard/record-actions.tsx +33 -4
  71. package/template/web/components/dashboard/record-detail.tsx +10 -2
  72. package/template/web/components/dashboard/resource-action.tsx +281 -0
  73. package/template/web/components/dashboard/resource-form.tsx +19 -2
  74. package/template/web/components/dashboard/resource-table-toolbar.tsx +20 -2
  75. package/template/web/components/dashboard/resource-table.tsx +46 -4
  76. package/template/web/components/dashboard/row-actions.tsx +56 -2
  77. package/template/web/components/dashboard/table-selection.tsx +19 -1
  78. package/template/web/components/marketing/site-header.tsx +9 -2
  79. package/template/web/components/public-form-guard.tsx +125 -0
  80. package/template/web/hooks/use-public-form.ts +175 -0
  81. package/template/web/lib/access.ts +3 -1
  82. package/template/web/lib/dashboard.ts +10 -0
  83. package/template/web/lib/extras.ts +105 -0
  84. package/template/web/lib/files.ts +14 -0
  85. package/template/web/lib/laravel.ts +60 -0
  86. package/template/web/lib/rich.ts +79 -0
  87. 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
- $record->forceDelete();
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>`), and
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
- async function forward(url: string, init: RequestInit, withToken: boolean): Promise<Response> {
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
- return forward(url, { method: "GET", headers: { Accept: accept, ...conditional } }, !isFile);
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
- if (!url || path[0] !== "_nevela" || path[1] !== "uploads") {
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
- if (!(await sessionToken())) return Response.json({ error: "Sign in first." }, { status: 401 });
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" } }, true);
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 on = (key: string) => everything || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || picked.has(key);
88
- const fixed = (key: string) => locked || whole.has(moduleOf.get(key.split(".")[0]!) ?? "") || byPattern.has(key) || (!picked.has(key) && !holds(key));
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) => !whole.has(moduleOf.get(key.split(".")[0]!) ?? ""))];
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
- <tr key={feature.key} className="border-b last:border-b-0">
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
  </>