tina4-nodejs 3.13.148 → 3.13.150

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 (47) hide show
  1. package/CLAUDE.md +4 -3
  2. package/package.json +1 -1
  3. package/packages/cli/dist/bin.js +10898 -9844
  4. package/packages/cli/src/commands/generate.ts +184 -25
  5. package/packages/core/dist/index.js +10864 -9810
  6. package/packages/core/public/bundled-component-provenance.json +2 -2
  7. package/packages/core/public/js/tina4-dev-admin-split.html +202 -0
  8. package/packages/core/public/js/tina4-dev-admin.min.js +147 -138
  9. package/packages/core/src/devAdmin.ts +105 -1
  10. package/packages/core/src/index.ts +3 -0
  11. package/packages/core/src/messenger.ts +57 -1
  12. package/packages/core/src/metrics.ts +5 -1
  13. package/packages/core/src/plan.ts +1 -1
  14. package/packages/core/src/router.ts +11 -1
  15. package/packages/core/src/server.ts +5 -2
  16. package/packages/core/templates/crud/form.twig +26 -0
  17. package/packages/core/templates/crud/modals.twig +31 -0
  18. package/packages/core/templates/crud/page.twig +401 -0
  19. package/packages/core/templates/crud/table.twig +69 -0
  20. package/packages/frond/dist/index.js +9 -1
  21. package/packages/frond/src/engine.ts +9 -0
  22. package/packages/frond/src/index.ts +1 -1
  23. package/packages/orm/dist/index.js +1283 -219
  24. package/packages/orm/src/adapters/mongodb.ts +209 -34
  25. package/packages/orm/src/autoCrud.ts +9 -0
  26. package/packages/orm/src/crud.ts +273 -0
  27. package/packages/orm/src/crudHelpers.ts +194 -0
  28. package/packages/orm/src/crudRender.ts +202 -0
  29. package/packages/orm/src/crudTable.ts +171 -0
  30. package/packages/orm/src/index.ts +7 -1
  31. package/packages/orm/src/migration.ts +167 -4
  32. package/packages/orm/src/query.ts +99 -40
  33. package/packages/orm/src/sqlTranslator.ts +8 -1
  34. package/packages/orm/src/types.ts +19 -0
  35. package/types/core/src/devAdmin.d.ts +23 -0
  36. package/types/core/src/messenger.d.ts +4 -0
  37. package/types/core/src/router.d.ts +0 -5
  38. package/types/frond/src/engine.d.ts +6 -0
  39. package/types/frond/src/index.d.ts +1 -1
  40. package/types/orm/src/crud.d.ts +82 -0
  41. package/types/orm/src/crudHelpers.d.ts +63 -0
  42. package/types/orm/src/crudRender.d.ts +38 -0
  43. package/types/orm/src/crudTable.d.ts +45 -0
  44. package/types/orm/src/index.d.ts +3 -1
  45. package/types/orm/src/migration.d.ts +0 -18
  46. package/types/orm/src/query.d.ts +0 -12
  47. package/types/orm/src/types.d.ts +19 -0
@@ -30,6 +30,7 @@ import { fullAnalysis, fileDetail, MetricsEngineError, metricsScanRoot } from ".
30
30
  import { registerFeedbackRoutes } from "./feedback.js";
31
31
  import { getDefaultDevServer, mcpEnabled, isRequestAllowed, isLoopback } from "./mcp.js";
32
32
  import { timingSafeEqual } from "node:crypto";
33
+ import net from "node:net";
33
34
  // VERSION-DEC-01 (feature 130): the dashboard reads the SAME resolved version
34
35
  // as server.ts's banner/health and mcp.ts's default dev server -- one shared
35
36
  // walk-up resolver in ./version.ts, not this file's own two-fixed-path reader
@@ -55,6 +56,11 @@ export const DEV_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
55
56
  */
56
57
  const DEV_MCP_PREFIXES = ["/__dev/api/mcp", "/__dev/mcp"];
57
58
 
59
+ // Static dev-toolbar assets that carry no secrets and expose no actions, so the
60
+ // peer gate does not cover them: wherever the toolbar is injected its stylesheet
61
+ // and script must load (issue #279). Host + same-origin still apply.
62
+ const DEV_PUBLIC_ASSETS = new Set(["/__dev/toolbar.css", "/__dev/toolbar.js"]);
63
+
58
64
  /** Private-key / credential basenames the file endpoints must never serve. */
59
65
  const DEV_SECRET_BASENAMES = new Set([
60
66
  ".env", ".envrc", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519",
@@ -139,6 +145,65 @@ export function devHostAllowed(hostHeader: string | undefined | null): boolean {
139
145
  return allowed.has(name);
140
146
  }
141
147
 
148
+ /**
149
+ * True when raw peer `ip` falls inside `entry` (a bare IP or a CIDR). IPv4 and
150
+ * IPv6 via node:net's BlockList; an IPv4-mapped IPv6 peer (::ffff:a.b.c.d) is
151
+ * matched as IPv4. `ip` is the real socket peer, `entry` is operator config -
152
+ * never a forwarded header.
153
+ */
154
+ export function ipInCidr(ip: string, entry: string): boolean {
155
+ try {
156
+ ip = (ip ?? "").trim();
157
+ if (ip.toLowerCase().startsWith("::ffff:")) ip = ip.slice(7); // mapped -> IPv4
158
+ const ipFam = net.isIP(ip);
159
+ if (!ipFam) return false;
160
+ entry = (entry ?? "").trim();
161
+ const bl = new net.BlockList();
162
+ if (entry.includes("/")) {
163
+ const [addr, bitsStr] = entry.split("/");
164
+ const bits = Number(bitsStr);
165
+ const fam = net.isIP(addr);
166
+ if (!fam || !Number.isInteger(bits)) return false;
167
+ bl.addSubnet(addr, bits, fam === 4 ? "ipv4" : "ipv6");
168
+ } else {
169
+ const fam = net.isIP(entry);
170
+ if (!fam) return false;
171
+ bl.addAddress(entry, fam === 4 ? "ipv4" : "ipv6");
172
+ }
173
+ return bl.check(ip, ipFam === 4 ? "ipv4" : "ipv6");
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * True when the RAW socket peer may reach /__dev: loopback always, plus any
181
+ * IP/CIDR in TINA4_DEV_ALLOWED_PEERS (comma-separated, opt-in, default none).
182
+ * Reads the real socket peer ONLY, never a forwarded header, so it cannot be
183
+ * spoofed by X-Forwarded-For. The documented way to reach the dev dashboard from
184
+ * a Docker dev box, where requests arrive from the container-network gateway
185
+ * rather than loopback (issue #279).
186
+ */
187
+ export function devPeerAllowed(remoteIp: string | undefined | null): boolean {
188
+ const ip = (remoteIp ?? "").trim();
189
+ if (isLoopback(ip)) return true;
190
+ const configured = (process.env.TINA4_DEV_ALLOWED_PEERS ?? "").trim();
191
+ if (!configured) return false;
192
+ return configured.split(",").some((entry) => entry.trim() !== "" && ipInCidr(ip, entry));
193
+ }
194
+
195
+ /**
196
+ * True when the toolbar may be injected for this viewer - the Host allow-list
197
+ * and the raw-peer gate both pass. A viewer the /__dev gate would refuse gets NO
198
+ * toolbar markup, so its stylesheet and script are never requested only to 403
199
+ * (issue #279).
200
+ */
201
+ export function devToolbarAllowed(req: Tina4Request): boolean {
202
+ if (!devHostAllowed(req.header("host"))) return false;
203
+ const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
204
+ return devPeerAllowed(peer);
205
+ }
206
+
142
207
  /**
143
208
  * Return `{status, error}` to REFUSE any /__dev request (read or write), or
144
209
  * `null` to allow (ADR-0082): Host allow-list, then same-origin, then the
@@ -155,9 +220,15 @@ export function devRequestDenial(req: Tina4Request): { status: number; error: st
155
220
  if (!devSameOriginOk(req)) {
156
221
  return { status: 403, error: "dev-admin: refused (cross-origin request)" };
157
222
  }
223
+ // The static toolbar assets are exempt from the peer gate (#279): they carry
224
+ // no secrets and the toolbar needs them wherever it is injected. Host +
225
+ // same-origin above still apply.
226
+ if (DEV_PUBLIC_ASSETS.has(path)) {
227
+ return null;
228
+ }
158
229
  if (!isMcp) {
159
230
  const peer = (req as unknown as { socket?: { remoteAddress?: string } }).socket?.remoteAddress ?? "";
160
- if (!(isLoopback(peer) || mcpTokenOk(req, true))) {
231
+ if (!(devPeerAllowed(peer) || mcpTokenOk(req, true))) {
161
232
  return { status: 403, error: "dev-admin: refused (non-loopback peer)" };
162
233
  }
163
234
  }
@@ -759,6 +830,7 @@ export class DevAdmin {
759
830
  { method: "GET", pattern: "/__dev/api/docs/.well-known.json", handler: handleDocsWellKnown },
760
831
  // JS asset
761
832
  { method: "GET", pattern: "/__dev/js/tina4-dev-admin.min.js", handler: handleDevAdminJs },
833
+ { method: "GET", pattern: "/__dev/split", handler: handleDevAdminSplit },
762
834
  // Dev-toolbar assets — served as external CSS/JS so the injected toolbar is
763
835
  // CSP-clean (no inline style=, onclick=, or <script>) under a strict
764
836
  // `default-src 'self'`. Parity with PHP's /__dev/toolbar.css + toolbar.js.
@@ -3068,6 +3140,38 @@ const handleDevAdminJs: RouteHandler = async (_req, res) => {
3068
3140
  res.raw.end("tina4-dev-admin.min.js not found");
3069
3141
  };
3070
3142
 
3143
+ // ---------------------------------------------------------------------------
3144
+ // Dev Admin split view — the shared asset (same file python/php/ruby serve)
3145
+ // ---------------------------------------------------------------------------
3146
+
3147
+ const handleDevAdminSplit: RouteHandler = async (_req, res) => {
3148
+ const { readFileSync, existsSync } = await import("node:fs");
3149
+ const { dirname, join, resolve } = await import("node:path");
3150
+ const { fileURLToPath } = await import("node:url");
3151
+ const dir = dirname(fileURLToPath(import.meta.url));
3152
+
3153
+ const candidates = [
3154
+ join(dir, "..", "public", "js", "tina4-dev-admin-split.html"),
3155
+ join(dir, "..", "..", "public", "js", "tina4-dev-admin-split.html"),
3156
+ resolve(process.cwd(), "node_modules", "tina4-nodejs", "packages", "core", "public", "js", "tina4-dev-admin-split.html"),
3157
+ resolve(process.cwd(), "public", "js", "tina4-dev-admin-split.html"),
3158
+ ];
3159
+
3160
+ for (const path of candidates) {
3161
+ if (existsSync(path)) {
3162
+ try {
3163
+ const content = readFileSync(path, "utf-8");
3164
+ res.raw.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
3165
+ res.raw.end(content);
3166
+ return;
3167
+ } catch { /* try next */ }
3168
+ }
3169
+ }
3170
+
3171
+ res.raw.writeHead(404, { "Content-Type": "text/plain" });
3172
+ res.raw.end("tina4-dev-admin-split.html not found");
3173
+ };
3174
+
3071
3175
  // ---------------------------------------------------------------------------
3072
3176
  // Overlay script — floating Tina4 button
3073
3177
  // ---------------------------------------------------------------------------
@@ -113,6 +113,9 @@ export { DevMailbox } from "./devMailbox.js";
113
113
  export { WSDLService, WSDLOperation } from "./wsdl.js";
114
114
  export type { WSDLOperationMeta } from "./wsdl.js";
115
115
  export { HtmlElement, htmlElement, addHtmlHelpers, Raw, SafeString } from "./htmlElement.js";
116
+ // Crud (ADR-0094) lives in @tina4/orm, not core: it is an opt-in feature, not on
117
+ // every request path, so it must not enter core's eager import graph
118
+ // (lazyFeatureLoading). Import it from "tina4-nodejs/orm".
116
119
  export { renderErrorOverlay, isDebugMode } from "./errorOverlay.js";
117
120
  export {
118
121
  cspNonce,
@@ -70,6 +70,52 @@ type SmtpTransport = "implicit_tls" | "starttls" | "plain";
70
70
  const SMTP_SEND_TIMEOUT_MS = 30_000;
71
71
  const SMTP_CHECK_TIMEOUT_MS = 10_000;
72
72
 
73
+ /** Raw TINA4_MAIL_TIMEOUT values already warned about, so a bad one is said once. */
74
+ const _timeoutWarned = new Set<string>();
75
+
76
+ /**
77
+ * Resolve the SMTP send timeout in MILLISECONDS (issue #278). Priority:
78
+ * constructor (seconds) > TINA4_MAIL_TIMEOUT (seconds) > 30. Whole seconds, at
79
+ * least 1. An explicit constructor value is the caller's own instruction, so one
80
+ * that is not a whole second >= 1 is a programming error and THROWS. A bad env
81
+ * value is a misconfiguration: warn once and use the default. Zero and negative
82
+ * are garbage, not an opt-out. Mirrors the PHP master (Messenger::resolveTimeout,
83
+ * tina4-php#286); the SMTP_TIMEOUT fallback was dropped for parity.
84
+ */
85
+ const DEFAULT_TIMEOUT_SECONDS = 30;
86
+
87
+ function resolveSendTimeoutMs(optionTimeout?: number): number {
88
+ if (optionTimeout === undefined || optionTimeout === null) {
89
+ return resolveEnvTimeoutSeconds(process.env.TINA4_MAIL_TIMEOUT) * 1000;
90
+ }
91
+ if (!Number.isInteger(optionTimeout) || optionTimeout < 1) {
92
+ throw new Error(
93
+ `Messenger timeout must be a whole number of seconds and at least 1, got ${optionTimeout}.`,
94
+ );
95
+ }
96
+ return optionTimeout * 1000;
97
+ }
98
+
99
+ /** TINA4_MAIL_TIMEOUT in whole seconds; a blank value, or garbage/< 1 (warned once), is the default. */
100
+ function resolveEnvTimeoutSeconds(raw?: string): number {
101
+ if (!raw || raw.trim() === "") return DEFAULT_TIMEOUT_SECONDS;
102
+ const trimmed = raw.trim();
103
+ const seconds = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
104
+ if (Number.isInteger(seconds) && seconds >= 1) return seconds;
105
+ warnBadMailTimeout(raw);
106
+ return DEFAULT_TIMEOUT_SECONDS;
107
+ }
108
+
109
+ /** Warn once per distinct bad TINA4_MAIL_TIMEOUT value. */
110
+ function warnBadMailTimeout(raw: string): void {
111
+ if (_timeoutWarned.has(raw)) return;
112
+ _timeoutWarned.add(raw);
113
+ Log.warning(
114
+ `TINA4_MAIL_TIMEOUT must be a whole number of seconds and at least 1, got ${JSON.stringify(raw)} ` +
115
+ `- using the default of ${DEFAULT_TIMEOUT_SECONDS} seconds`,
116
+ );
117
+ }
118
+
73
119
  /**
74
120
  * Give a socket an idle timeout that fails it with a clear error. Without one a
75
121
  * client that waits for a greeting the server never sends (a plaintext client
@@ -194,6 +240,8 @@ interface MessengerOptions {
194
240
  imapPass?: string;
195
241
  /** IMAP transport security: "tls" (default), "starttls", or "none". */
196
242
  imapEncryption?: string;
243
+ /** SMTP send timeout in whole SECONDS, at least 1 (default 30; env TINA4_MAIL_TIMEOUT). */
244
+ timeout?: number;
197
245
  }
198
246
 
199
247
  export interface ImapMessage {
@@ -466,6 +514,8 @@ export class Messenger {
466
514
  private imapUser: string;
467
515
  private imapPass: string;
468
516
  private imapEncryption: string;
517
+ /** Resolved SMTP send timeout, in milliseconds (see the constructor). */
518
+ public readonly timeoutMs: number;
469
519
 
470
520
  constructor(options?: MessengerOptions) {
471
521
  // Priority: constructor > TINA4_MAIL_* > sensible default.
@@ -527,6 +577,12 @@ export class Messenger {
527
577
  this.imapEncryption = normaliseEncryption(options?.imapEncryption
528
578
  ?? process.env.TINA4_MAIL_IMAP_ENCRYPTION
529
579
  ?? "tls", "IMAP");
580
+
581
+ // SMTP send timeout (whole seconds, at least 1): constructor >
582
+ // TINA4_MAIL_TIMEOUT > 30. An explicit sub-second value throws; a bad env
583
+ // value warns once and uses the default. Stored as ms, the unit
584
+ // openSmtpSession/failOnIdle want. Parity with the PHP/Python/Ruby master.
585
+ this.timeoutMs = resolveSendTimeoutMs(options?.timeout);
530
586
  }
531
587
 
532
588
  /**
@@ -671,7 +727,7 @@ export class Messenger {
671
727
  private async sendSmtp(options: SendOptions, recipients: MailRecipients, messageId: string): Promise<SendResult> {
672
728
  let socket: SmtpSocket | null = null;
673
729
  try {
674
- socket = await this.openSmtpSession();
730
+ socket = await this.openSmtpSession(this.timeoutMs);
675
731
  await this.authenticateSmtp(socket);
676
732
  await this.sendSmtpEnvelope(socket, recipients.allRecipients);
677
733
  await this.sendSmtpMessage(socket, options, recipients, messageId);
@@ -69,7 +69,11 @@ export function enginePath(): string | null {
69
69
  function runEngine(target: string): Record<string, any> {
70
70
  const binary = enginePath();
71
71
  if (!binary) throw new MetricsEngineError(`tina4 not found on PATH - ${INSTALL_HINT}`);
72
- const processResult = spawnSync(binary, ["metrics", "--path", target, "--json"], {
72
+ // --no-history: a dashboard READ must never write .tina4-metrics.json.
73
+ // Without it every scan records history, the file watcher sees the write and
74
+ // reloads the page, which re-scans - a self-inflicted ~2-second reload loop
75
+ // that makes the dashboard unusable.
76
+ const processResult = spawnSync(binary, ["metrics", "--path", target, "--json", "--no-history"], {
73
77
  encoding: "utf8",
74
78
  timeout: 60_000,
75
79
  maxBuffer: 64 * 1024 * 1024,
@@ -533,7 +533,7 @@ export const Plan = {
533
533
  const title = currentPlan.title || target;
534
534
  const goal = currentPlan.goal || "";
535
535
 
536
- const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11437/api/chat";
536
+ const aiUrl = process.env.TINA4_AI_URL || "http://localhost:11435/api/chat";
537
537
  const aiModel = process.env.TINA4_AI_MODEL || "qwen2.5-coder:14b";
538
538
  const prompts = fleshPrompt(title, goal, existing, prompt);
539
539
  const fetched = await fetchFleshReply(aiUrl, aiModel, prompts);
@@ -956,8 +956,18 @@ export async function runRouteMiddlewares(
956
956
  * Default global router instance.
957
957
  * Top-level get(), post(), etc. register routes here.
958
958
  * The server merges these routes on startup.
959
+ *
960
+ * Anchored on globalThis so it is a true single instance across bundles. The
961
+ * published package ships each workspace as its own esbuild bundle, so
962
+ * @tina4/orm carries an inlined copy of this module; without this, orm code
963
+ * (e.g. Crud.registerBackend, ADR-0094) would add AutoCrud routes to a second,
964
+ * private Router that the core server never serves. One globalThis slot makes
965
+ * every copy resolve to the same instance.
959
966
  */
960
- export const defaultRouter = new Router();
967
+ const TINA4_DEFAULT_ROUTER = Symbol.for("tina4.defaultRouter");
968
+ const globalRouterHost = globalThis as unknown as { [TINA4_DEFAULT_ROUTER]?: Router };
969
+ export const defaultRouter: Router =
970
+ globalRouterHost[TINA4_DEFAULT_ROUTER] ?? (globalRouterHost[TINA4_DEFAULT_ROUTER] = new Router());
961
971
 
962
972
  /**
963
973
  * Top-level route registration functions — mirrors Python's decorator pattern.
@@ -46,7 +46,7 @@ import { generateNonce, runWithCspNonce, currentCspNonce } from "./csp.js";
46
46
  import { createHealthRoutes } from "./health.js";
47
47
  import { rateLimiter } from "./rateLimiter.js";
48
48
  import { Log } from "./logger.js";
49
- import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed } from "./devAdmin.js";
49
+ import { DevAdmin, RequestInspector, WsTracker, devRequestDenial, devHostAllowed, devToolbarAllowed } from "./devAdmin.js";
50
50
  import { CLOSE_GOING_AWAY, devReloadWs, serveWebSocketRoute, wsRouteManager } from "./websocket.js";
51
51
  import { feedbackEnabled, injectFeedbackWidget } from "./feedback.js";
52
52
  import { I18n } from "./i18n.js";
@@ -1311,7 +1311,10 @@ function isInjectableHtml(res: Tina4Response): boolean {
1311
1311
  * so calling it unconditionally is cheap when it no-ops.
1312
1312
  */
1313
1313
  function injectIntoHtml(ctx: ResponseWrapContext, devToolbar: boolean, html: string): string {
1314
- if (!devToolbar) return injectFeedbackWidget(ctx.req, html);
1314
+ // Do not inject the toolbar for a viewer the /__dev gate would refuse (#279):
1315
+ // otherwise the page carries toolbar markup whose stylesheet and script 403.
1316
+ // The feedback widget still runs (it re-checks its own gate).
1317
+ if (!devToolbar || !devToolbarAllowed(ctx.req)) return injectFeedbackWidget(ctx.req, html);
1315
1318
 
1316
1319
  const toolbarCtx: DevToolbarContext = {
1317
1320
  version: TINA4_VERSION,
@@ -0,0 +1,26 @@
1
+ {#
2
+ crud/form.twig — the create/edit field inputs (and the public generateForm).
3
+
4
+ Override: drop your own src/templates/crud/form.twig to change how inputs render.
5
+
6
+ fields - one per input: { id, name, label, value, placeholder, required_attr,
7
+ and exactly ONE control flag: input (with type) | textarea | checkbox
8
+ (checked_attr) | select (options_html) }.
9
+ wrap - render the <form> wrapper; form_id_attr/action/form_method set it
10
+ edit - add the hidden primary-key input (edit modal)
11
+ modal_footer- render the Cancel/Save modal buttons; submit_button - a plain Submit
12
+
13
+ No inline styles or on*= handlers: the modal buttons carry data-crud-action and
14
+ are wired by the page script's delegated listener (ADR-0088).
15
+ #}
16
+ {% if wrap %}<form{{ form_id_attr|raw }} action="{{ action }}" method="{{ form_method }}" class="needs-validation" data-crud-form="1">{% endif %}
17
+ {% if method_override %}<input type="hidden" name="_method" value="{{ method_override }}">{% endif %}
18
+ {% if edit %}<input type="hidden" id="{{ mode }}-pk-value" name="{{ pk }}">{% endif %}
19
+ {% if modal_footer %}<div class="crud-errors" data-crud-errors hidden></div>{% endif %}
20
+ {% for field in fields %}<div class="mb-3">
21
+ <label for="{{ field.id }}" class="form-label">{{ field.label }}</label>
22
+ {% if field.textarea %}<textarea class="form-control" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.value }}</textarea>{% endif %}{% if field.checkbox %}<div class="form-check"><input class="form-check-input" type="checkbox" id="{{ field.id }}" name="{{ field.name }}"{{ field.checked_attr|raw }}></div>{% endif %}{% if field.select %}<select class="form-select" id="{{ field.id }}" name="{{ field.name }}"{{ field.required_attr|raw }}>{{ field.options_html|raw }}</select>{% endif %}{% if field.input %}<input type="{{ field.type }}" class="form-control" id="{{ field.id }}" name="{{ field.name }}" value="{{ field.value }}" placeholder="{{ field.placeholder }}"{{ field.required_attr|raw }}>{% endif %}
23
+ </div>
24
+ {% endfor %}
25
+ {% if modal_footer %}<div class="modal-footer"><button type="button" class="btn btn-secondary" data-crud-action="close" data-crud-modal="{{ mode }}">Cancel</button><button type="button" class="btn btn-primary" data-crud-action="save" data-crud-mode="{{ mode }}">Save</button></div>{% endif %}{% if submit_button %}<button type="submit" class="btn btn-primary">Submit</button>{% endif %}
26
+ {% if wrap %}</form>{% endif %}
@@ -0,0 +1,31 @@
1
+ {#
2
+ crud/modals.twig — the create, edit and delete-confirm dialogs.
3
+
4
+ Override: drop your own src/templates/crud/modals.twig to restyle the dialogs.
5
+ create_form / edit_form are the crud/form.twig renders injected here. Buttons
6
+ carry data-crud-action (close / confirm-delete) and are wired by the page
7
+ script — no inline on*= handlers (ADR-0088).
8
+ #}
9
+ <div class="modal-overlay" id="modal-create">
10
+ <div class="modal-box">
11
+ <h3>Create New Record</h3>
12
+ {{ create_form|raw }}
13
+ </div>
14
+ </div>
15
+ <div class="modal-overlay" id="modal-edit">
16
+ <div class="modal-box">
17
+ <h3>Edit Record</h3>
18
+ {{ edit_form|raw }}
19
+ </div>
20
+ </div>
21
+ <div class="modal-overlay" id="modal-delete">
22
+ <div class="modal-box">
23
+ <h3>Confirm Delete</h3>
24
+ <p>Are you sure you want to delete this record? This action cannot be undone.</p>
25
+ <input type="hidden" id="delete-pk-value">
26
+ <div class="modal-footer">
27
+ <button class="btn btn-secondary" data-crud-action="close" data-crud-modal="delete">Cancel</button>
28
+ <button class="btn btn-danger" data-crud-action="confirm-delete">Delete</button>
29
+ </div>
30
+ </div>
31
+ </div>