millas 0.2.12-beta-1 → 0.2.12-beta-2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/package.json +1 -1
  2. package/src/admin/ActivityLog.js +153 -52
  3. package/src/admin/Admin.js +400 -167
  4. package/src/admin/AdminAuth.js +213 -98
  5. package/src/admin/FormGenerator.js +372 -0
  6. package/src/admin/HookRegistry.js +256 -0
  7. package/src/admin/QueryEngine.js +263 -0
  8. package/src/admin/ViewContext.js +309 -0
  9. package/src/admin/WidgetRegistry.js +406 -0
  10. package/src/admin/index.js +17 -0
  11. package/src/admin/resources/AdminResource.js +383 -97
  12. package/src/admin/static/admin.css +1341 -0
  13. package/src/admin/static/date-picker.css +157 -0
  14. package/src/admin/static/date-picker.js +316 -0
  15. package/src/admin/static/json-editor.css +649 -0
  16. package/src/admin/static/json-editor.js +1429 -0
  17. package/src/admin/static/ui.js +1044 -0
  18. package/src/admin/views/layouts/base.njk +65 -1013
  19. package/src/admin/views/pages/detail.njk +40 -16
  20. package/src/admin/views/pages/form.njk +47 -599
  21. package/src/admin/views/pages/list.njk +145 -62
  22. package/src/admin/views/partials/form-field.njk +53 -0
  23. package/src/admin/views/partials/form-footer.njk +28 -0
  24. package/src/admin/views/partials/form-readonly.njk +114 -0
  25. package/src/admin/views/partials/form-scripts.njk +476 -0
  26. package/src/admin/views/partials/form-widget.njk +296 -0
  27. package/src/admin/views/partials/json-dialog.njk +80 -0
  28. package/src/admin/views/partials/json-editor.njk +37 -0
  29. package/src/admin.zip +0 -0
  30. package/src/auth/Auth.js +18 -2
  31. package/src/auth/AuthUser.js +65 -44
  32. package/src/cli.js +3 -1
  33. package/src/commands/createsuperuser.js +254 -0
  34. package/src/commands/lang.js +589 -0
  35. package/src/commands/migrate.js +154 -81
  36. package/src/commands/serve.js +1 -0
  37. package/src/container/AppInitializer.js +65 -8
  38. package/src/container/MillasApp.js +10 -3
  39. package/src/container/MillasConfig.js +35 -6
  40. package/src/core/admin.js +5 -0
  41. package/src/core/db.js +2 -1
  42. package/src/core/foundation.js +1 -9
  43. package/src/core/lang.js +1 -0
  44. package/src/i18n/I18nServiceProvider.js +91 -0
  45. package/src/i18n/Translator.js +635 -0
  46. package/src/i18n/defaults.js +122 -0
  47. package/src/i18n/index.js +164 -0
  48. package/src/i18n/locales/en.js +55 -0
  49. package/src/i18n/locales/sw.js +48 -0
  50. package/src/logger/formatters/PrettyFormatter.js +101 -65
  51. package/src/migrations/system/0001_users.js +21 -0
  52. package/src/migrations/system/0002_admin_log.js +25 -0
  53. package/src/migrations/system/0003_sessions.js +23 -0
  54. package/src/orm/fields/index.js +210 -188
  55. package/src/orm/migration/DefaultValueParser.js +325 -0
  56. package/src/orm/migration/InteractiveResolver.js +191 -0
  57. package/src/orm/migration/Makemigrations.js +312 -0
  58. package/src/orm/migration/MigrationGraph.js +227 -0
  59. package/src/orm/migration/MigrationRunner.js +202 -108
  60. package/src/orm/migration/MigrationWriter.js +463 -0
  61. package/src/orm/migration/ModelInspector.js +143 -74
  62. package/src/orm/migration/ModelScanner.js +225 -0
  63. package/src/orm/migration/ProjectState.js +213 -0
  64. package/src/orm/migration/RenameDetector.js +175 -0
  65. package/src/orm/migration/SchemaBuilder.js +8 -81
  66. package/src/orm/migration/operations/base.js +57 -0
  67. package/src/orm/migration/operations/column.js +191 -0
  68. package/src/orm/migration/operations/fields.js +252 -0
  69. package/src/orm/migration/operations/index.js +55 -0
  70. package/src/orm/migration/operations/models.js +152 -0
  71. package/src/orm/migration/operations/registry.js +131 -0
  72. package/src/orm/migration/operations/special.js +51 -0
  73. package/src/orm/migration/utils.js +208 -0
  74. package/src/orm/model/Model.js +81 -13
  75. package/src/providers/AdminServiceProvider.js +66 -9
  76. package/src/providers/AuthServiceProvider.js +40 -5
  77. package/src/providers/CacheStorageServiceProvider.js +2 -2
  78. package/src/providers/DatabaseServiceProvider.js +3 -2
  79. package/src/providers/LogServiceProvider.js +4 -1
  80. package/src/providers/MailServiceProvider.js +1 -1
  81. package/src/providers/QueueServiceProvider.js +1 -1
  82. package/src/scaffold/templates.js +77 -21
@@ -0,0 +1,296 @@
1
+ {#
2
+ partials/form-widget.njk
3
+ All widget HTML — one block per field type.
4
+ Expects in scope: field, val, hasError, isEdit
5
+ #}
6
+
7
+ {# ── Select ── #}
8
+ {% if field.type == 'select' and field.options %}
9
+ <select id="field-{{ field.name }}" name="{{ field.name }}"
10
+ class="form-control{% if hasError %} error{% endif %}"
11
+ {% if not field.nullable %}data-required="true"{% endif %}>
12
+ <option value="">— Select —</option>
13
+ {% for opt in field.options %}
14
+ {% set optVal = opt.value if opt.value is defined else opt %}
15
+ {% set optLabel = opt.label if opt.label is defined else opt %}
16
+ <option value="{{ optVal }}" {% if val == optVal %}selected{% endif %}>{{ optLabel }}</option>
17
+ {% endfor %}
18
+ </select>
19
+
20
+ {# ── FK — async paginated dropdown ── #}
21
+ {% elif field.type == 'fk' %}
22
+ <div class="fk-widget" id="fkw-{{ field.name }}"
23
+ data-name="{{ field.name }}"
24
+ data-resource="{{ field.fkResource }}"
25
+ data-nullable="{{ 'true' if field.nullable else 'false' }}"
26
+ data-required="{{ 'true' if not field.nullable else 'false' }}"
27
+ data-current-id="{{ val }}"
28
+ data-current-label="">
29
+ <input type="hidden"
30
+ id="field-{{ field.name }}"
31
+ name="{{ field.name }}"
32
+ value="{{ val }}"
33
+ {% if not field.nullable %}data-required="true"{% endif %}>
34
+ <button type="button"
35
+ class="fk-trigger form-control{% if hasError %} error{% endif %}"
36
+ id="fktrig-{{ field.name }}"
37
+ aria-haspopup="listbox"
38
+ aria-expanded="false"
39
+ aria-controls="fkpanel-{{ field.name }}">
40
+ <span class="fk-trigger-label">
41
+ {% if val %}
42
+ <span class="fk-id-chip">#{{ val }}</span>
43
+ <span class="fk-loading-label">Loading…</span>
44
+ {% else %}
45
+ <span class="fk-placeholder">— Select —</span>
46
+ {% endif %}
47
+ </span>
48
+ <svg class="fk-chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
49
+ </button>
50
+ <div class="fk-panel" id="fkpanel-{{ field.name }}" role="listbox" style="display:none">
51
+ <div class="fk-search-wrap">
52
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
53
+ <input type="text" class="fk-search" id="fksearch-{{ field.name }}"
54
+ placeholder="Search…" autocomplete="off" spellcheck="false">
55
+ <button type="button" class="fk-search-clear" hidden aria-label="Clear search">
56
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
57
+ </button>
58
+ </div>
59
+ <div class="fk-list" id="fklist-{{ field.name }}" role="listbox">
60
+ <div class="fk-spinner-row">
61
+ <svg class="fk-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 11-6.219-8.56"/></svg>
62
+ Loading…
63
+ </div>
64
+ </div>
65
+ <div class="fk-footer" id="fkfoot-{{ field.name }}" hidden>
66
+ <span class="fk-count" id="fkcount-{{ field.name }}"></span>
67
+ <div class="fk-pages">
68
+ <button type="button" class="fk-page-btn" id="fkprev-{{ field.name }}" aria-label="Previous page" disabled>
69
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
70
+ </button>
71
+ <span class="fk-page-info" id="fkpageinfo-{{ field.name }}"></span>
72
+ <button type="button" class="fk-page-btn" id="fknext-{{ field.name }}" aria-label="Next page">
73
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
74
+ </button>
75
+ </div>
76
+ </div>
77
+ {% if field.nullable %}
78
+ <div class="fk-clear-row">
79
+ <button type="button" class="fk-clear-btn" id="fkclear-{{ field.name }}">
80
+ <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
81
+ Clear selection
82
+ </button>
83
+ </div>
84
+ {% endif %}
85
+ </div>
86
+ </div>
87
+
88
+ {# ── M2M — dual list ── #}
89
+ {% elif field.type == 'm2m' %}
90
+ <div class="m2m-widget" id="m2m-{{ field.name }}">
91
+ <div style="display:flex;gap:8px;align-items:flex-start">
92
+ <div style="flex:1">
93
+ <div class="form-label" style="font-size:11px;color:var(--text-soft)">Available</div>
94
+ <select class="form-control" size="6" id="m2m-available-{{ field.name }}" style="width:100%" multiple></select>
95
+ </div>
96
+ <div style="display:flex;flex-direction:column;gap:6px;padding-top:24px">
97
+ <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','available','chosen')">→</button>
98
+ <button type="button" class="btn btn-ghost btn-sm" onclick="m2mMove('{{ field.name }}','chosen','available')">←</button>
99
+ </div>
100
+ <div style="flex:1">
101
+ <div class="form-label" style="font-size:11px;color:var(--text-soft)">Chosen</div>
102
+ <select class="form-control" name="{{ field.name }}[]" size="6"
103
+ id="m2m-chosen-{{ field.name }}" style="width:100%" multiple>
104
+ {% if val %}{% for v in val %}<option value="{{ v }}" selected>{{ v }}</option>{% endfor %}{% endif %}
105
+ </select>
106
+ </div>
107
+ </div>
108
+ </div>
109
+
110
+ {# ── Boolean — toggle switch ── #}
111
+ {% elif field.type == 'checkbox' %}
112
+ <div class="toggle-field">
113
+ <label class="toggle-wrap" for="field-{{ field.name }}">
114
+ <input type="checkbox"
115
+ id="field-{{ field.name }}"
116
+ name="{{ field.name }}"
117
+ class="toggle-input"
118
+ value="1"{{ ' checked' if val else '' }}>
119
+ <span class="toggle-track"><span class="toggle-thumb"></span></span>
120
+ <span class="toggle-label">{{ field.label }}</span>
121
+ </label>
122
+ {% if field.help %}
123
+ <div class="form-help" style="margin-top:4px;margin-left:52px">{{ field.help }}</div>
124
+ {% endif %}
125
+ </div>
126
+
127
+ {# ── Textarea ── #}
128
+ {% elif field.type == 'textarea' %}
129
+ <textarea id="field-{{ field.name }}" name="{{ field.name }}"
130
+ class="form-control{% if hasError %} error{% endif %}"
131
+ placeholder="{{ field.placeholder or '' }}"
132
+ {% if not field.nullable %}data-required="true"{% endif %}
133
+ rows="4">{{ val }}</textarea>
134
+
135
+ {# ── JSON — dialog editor ── #}
136
+ {% elif field.type == 'json' %}
137
+ {% include "partials/json-editor.njk" %}
138
+
139
+ {# ── Richtext ── #}
140
+ {% elif field.type == 'richtext' %}
141
+ <div class="richtext-wrap">
142
+ <div class="richtext-toolbar">
143
+ <button type="button" onclick="rtCmd('bold')" title="Bold"><b>B</b></button>
144
+ <button type="button" onclick="rtCmd('italic')" title="Italic"><i>I</i></button>
145
+ <button type="button" onclick="rtCmd('underline')" title="Underline"><u>U</u></button>
146
+ <span class="rt-sep"></span>
147
+ <button type="button" onclick="rtCmd('insertUnorderedList')" title="Bullet list">≡</button>
148
+ <button type="button" onclick="rtCmd('insertOrderedList')" title="Numbered list">1.</button>
149
+ <span class="rt-sep"></span>
150
+ <button type="button" onclick="rtLink()" title="Link">🔗</button>
151
+ </div>
152
+ <div id="rt-{{ field.name }}" class="richtext-editor form-control{% if hasError %} error{% endif %}"
153
+ contenteditable="true"
154
+ style="min-height:120px;height:auto">{{ val | safe }}</div>
155
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}" value="{{ val }}">
156
+ </div>
157
+
158
+ {# ── Password ── #}
159
+ {% elif field.type == 'password' %}
160
+ <div style="position:relative">
161
+ <input type="password" id="field-{{ field.name }}" name="{{ field.name }}"
162
+ class="form-control{% if hasError %} error{% endif %}"
163
+ placeholder="{{ 'Leave blank to keep current' if isEdit else '' }}"
164
+ {% if not field.nullable and not isEdit %}data-required="true"{% endif %}
165
+ style="padding-right:40px">
166
+ <button type="button" class="pw-toggle"
167
+ onclick="this.previousElementSibling.type=this.previousElementSibling.type==='password'?'text':'password'">
168
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
169
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
170
+ </svg>
171
+ </button>
172
+ </div>
173
+
174
+ {# ── Email ── #}
175
+ {% elif field.type == 'email' %}
176
+ <input type="email" id="field-{{ field.name }}" name="{{ field.name }}"
177
+ value="{{ val }}"
178
+ class="form-control{% if hasError %} error{% endif %}"
179
+ placeholder="{{ field.placeholder or 'name@example.com' }}"
180
+ data-validate="email"
181
+ {% if not field.nullable %}data-required="true"{% endif %}>
182
+
183
+ {# ── URL ── #}
184
+ {% elif field.type == 'url' %}
185
+ <input type="url" id="field-{{ field.name }}" name="{{ field.name }}"
186
+ value="{{ val }}"
187
+ class="form-control{% if hasError %} error{% endif %}"
188
+ placeholder="{{ field.placeholder or 'https://' }}"
189
+ data-validate="url"
190
+ {% if not field.nullable %}data-required="true"{% endif %}>
191
+
192
+ {# ── Phone ── #}
193
+ {% elif field.type == 'phone' %}
194
+ <input type="tel" id="field-{{ field.name }}" name="{{ field.name }}"
195
+ value="{{ val }}"
196
+ class="form-control{% if hasError %} error{% endif %}"
197
+ placeholder="{{ field.placeholder or '+1 555 000 0000' }}"
198
+ {% if not field.nullable %}data-required="true"{% endif %}>
199
+
200
+ {# ── Color ── #}
201
+ {% elif field.type == 'color' %}
202
+ <div class="flex items-center gap-2">
203
+ <input type="color" id="field-{{ field.name }}-picker"
204
+ value="{{ val or '#000000' }}"
205
+ style="width:40px;height:36px;border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;padding:2px"
206
+ oninput="document.getElementById('field-{{ field.name }}').value=this.value">
207
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
208
+ value="{{ val }}"
209
+ class="form-control{% if hasError %} error{% endif %}"
210
+ placeholder="#000000"
211
+ style="width:120px"
212
+ oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))document.getElementById('field-{{ field.name }}-picker').value=this.value">
213
+ </div>
214
+
215
+ {# ── Number ── #}
216
+ {% elif field.type == 'number' %}
217
+ <input type="number" id="field-{{ field.name }}" name="{{ field.name }}"
218
+ value="{{ val }}"
219
+ class="form-control{% if hasError %} error{% endif %}"
220
+ placeholder="{{ field.placeholder or '0' }}"
221
+ {% if field.min is not none %}min="{{ field.min }}" data-min="{{ field.min }}"{% endif %}
222
+ {% if field.max is not none %}max="{{ field.max }}" data-max="{{ field.max }}"{% endif %}
223
+ {% if not field.nullable %}data-required="true"{% endif %}>
224
+
225
+ {# ── Date — custom picker ── #}
226
+ {% elif field.type == 'date' %}
227
+ <div class="dp-wrap">
228
+ {# Hidden input carries the real ISO value on form submit #}
229
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}"
230
+ value="{{ val }}"
231
+ {% if not field.nullable %}data-required="true"{% endif %}>
232
+ {# Visible trigger — readonly, opens the calendar via DatePicker #}
233
+ <input type="text"
234
+ class="form-control dp-trigger{% if hasError %} error{% endif %}"
235
+ id="dpt-{{ field.name }}"
236
+ placeholder="Select date…"
237
+ autocomplete="off"
238
+ data-target="field-{{ field.name }}"
239
+ data-mode="date">
240
+ <span class="dp-icon">
241
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
242
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
243
+ </svg>
244
+ </span>
245
+ </div>
246
+
247
+ {# ── Datetime — custom picker with time ── #}
248
+ {% elif field.type == 'datetime' or field.type == 'datetime-local' %}
249
+ <div class="dp-wrap">
250
+ <input type="hidden" id="field-{{ field.name }}" name="{{ field.name }}"
251
+ value="{{ val }}"
252
+ {% if not field.nullable %}data-required="true"{% endif %}>
253
+ <input type="text"
254
+ class="form-control dp-trigger{% if hasError %} error{% endif %}"
255
+ id="dpt-{{ field.name }}"
256
+ placeholder="Select date and time…"
257
+ autocomplete="off"
258
+ data-target="field-{{ field.name }}"
259
+ data-mode="datetime">
260
+ <span class="dp-icon">
261
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
262
+ <rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>
263
+ </svg>
264
+ </span>
265
+ </div>
266
+
267
+ {# ── Badge — display only, no input ── #}
268
+ {% elif field.type == 'badge' %}
269
+ {% set _bc = field.colors[val] if (field.colors and val and field.colors[val]) else 'gray' %}
270
+ <div class="readonly-value">
271
+ {% if val %}<span class="badge badge-{{ _bc }}">{{ val }}</span>{% else %}<span class="cell-muted">—</span>{% endif %}
272
+ </div>
273
+
274
+ {# ── Image — display URL as text + preview ── #}
275
+ {% elif field.type == 'image' %}
276
+ <div>
277
+ {% if val %}
278
+ <img src="{{ val }}" style="width:64px;height:64px;object-fit:cover;border-radius:var(--radius-sm);border:1px solid var(--border);display:block;margin-bottom:8px" alt="">
279
+ {% endif %}
280
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
281
+ value="{{ val }}"
282
+ class="form-control{% if hasError %} error{% endif %}"
283
+ placeholder="https://example.com/image.jpg"
284
+ {% if not field.nullable %}data-required="true"{% endif %}>
285
+ </div>
286
+
287
+ {# ── Default text ── #}
288
+ {% else %}
289
+ <input type="text" id="field-{{ field.name }}" name="{{ field.name }}"
290
+ value="{{ val }}"
291
+ class="form-control{% if hasError %} error{% endif %}"
292
+ placeholder="{{ field.placeholder or '' }}"
293
+ {% if field.max is not none %}maxlength="{{ field.max }}"{% endif %}
294
+ {% if not field.nullable %}data-required="true"{% endif %}
295
+ {% if field.prepopulate %}data-prepopulate="{{ field.prepopulate }}"{% endif %}>
296
+ {% endif %}
@@ -0,0 +1,80 @@
1
+ {#
2
+ partials/json-dialog.njk — Phase 6
3
+ Adds undo/redo buttons in the toolbar.
4
+ #}
5
+ <div id="je-dialog-content" style="display:none">
6
+
7
+ {# Toolbar #}
8
+ <div class="je-toolbar">
9
+ <div style="display:flex;align-items:center;gap:12px;flex:1;min-width:0">
10
+ <span class="je-toolbar-title" id="je-title">JSON</span>
11
+ <div id="je-root-type-wrap" class="je-root-type-wrap"></div>
12
+ </div>
13
+ <div class="je-toolbar-right">
14
+
15
+ {# Phase 6: undo / redo buttons #}
16
+ <div class="je-undo-wrap">
17
+ <button type="button" class="je-btn-icon" id="je-undo-btn"
18
+ onclick="JsonEditor._undo()" title="Undo (Ctrl+Z)" disabled>
19
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
20
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
21
+ <polyline points="9 14 4 9 9 4"/>
22
+ <path d="M20 20v-7a4 4 0 00-4-4H4"/>
23
+ </svg>
24
+ </button>
25
+ <button type="button" class="je-btn-icon" id="je-redo-btn"
26
+ onclick="JsonEditor._redo()" title="Redo (Ctrl+Y)" disabled>
27
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
28
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
29
+ <polyline points="15 14 20 9 15 4"/>
30
+ <path d="M4 20v-7a4 4 0 014-4h12"/>
31
+ </svg>
32
+ </button>
33
+ </div>
34
+
35
+ <div class="je-view-tabs">
36
+ <button type="button" class="je-view-tab active" id="je-tab-pretty"
37
+ onclick="JsonEditor._setView('pretty')">Pretty</button>
38
+ <button type="button" class="je-view-tab" id="je-tab-raw"
39
+ onclick="JsonEditor._setView('raw')">Raw</button>
40
+ </div>
41
+ <button type="button" class="je-btn-icon" id="je-format-btn"
42
+ onclick="JsonEditor._format()" title="Format / beautify" style="display:none">
43
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
44
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
45
+ <line x1="21" y1="6" x2="3" y2="6"/>
46
+ <line x1="21" y1="10" x2="7" y2="10"/>
47
+ <line x1="21" y1="14" x2="3" y2="14"/>
48
+ <line x1="21" y1="18" x2="13" y2="18"/>
49
+ </svg>
50
+ </button>
51
+ </div>
52
+ </div>
53
+
54
+ {# Pretty view #}
55
+ <div id="je-pretty-view" class="je-pretty-view">
56
+ <div class="je-tree" id="je-tree"></div>
57
+ </div>
58
+
59
+ {# Raw view #}
60
+ <div id="je-raw-view" class="je-raw-view" style="display:none">
61
+ <textarea id="je-raw-ta" class="je-raw-ta" spellcheck="false"
62
+ placeholder='{"key": "value"}'></textarea>
63
+ <div class="je-raw-hint" id="je-raw-hint"></div>
64
+ </div>
65
+
66
+ {# Footer #}
67
+ <div class="je-footer">
68
+ <span class="je-count" id="je-count"></span>
69
+ <div style="display:flex;gap:8px;align-items:center">
70
+ <button type="button" class="btn btn-ghost btn-sm" onclick="JsonEditor._cancel()">Cancel</button>
71
+ <button type="button" class="btn btn-primary btn-sm" onclick="JsonEditor._apply()">
72
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
73
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
74
+ <polyline points="20 6 9 17 4 12"/>
75
+ </svg>
76
+ Apply
77
+ </button>
78
+ </div>
79
+ </div>
80
+ </div>
@@ -0,0 +1,37 @@
1
+ {#
2
+ partials/json-editor.njk
3
+ Replaces the JSON textarea in the form with a compact trigger + preview.
4
+ Clicking opens the full Postman-style dialog.
5
+
6
+ Expects in scope: field, val, hasError, record
7
+ #}
8
+ {% set _jv = val | dump if (val is not none and val != '') else '{}' %}
9
+
10
+ <input type="hidden"
11
+ id="field-{{ field.name }}"
12
+ name="{{ field.name }}"
13
+ value="{{ _jv | escape }}"
14
+ {% if not field.nullable %}data-required="true"{% endif %}>
15
+
16
+ <div class="je-trigger {% if hasError %}error{% endif %}"
17
+ id="jet-{{ field.name }}"
18
+ tabindex="0"
19
+ role="button"
20
+ aria-label="Edit {{ field.label }} JSON"
21
+ data-field="{{ field.name }}"
22
+ onclick="JsonEditor.open('{{ field.name }}')"
23
+ onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();JsonEditor.open('{{ field.name }}');}">
24
+
25
+ <div class="je-preview-wrap" id="jep-{{ field.name }}">
26
+ <span class="je-preview-placeholder">Click to edit…</span>
27
+ </div>
28
+
29
+ <button type="button" class="je-open-btn" tabindex="-1"
30
+ onclick="event.stopPropagation();JsonEditor.open('{{ field.name }}')">
31
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
32
+ <path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
33
+ <path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
34
+ </svg>
35
+ Edit JSON
36
+ </button>
37
+ </div>
package/src/admin.zip ADDED
Binary file
package/src/auth/Auth.js CHANGED
@@ -80,7 +80,9 @@ class Auth {
80
80
  if (existing) throw new HttpError(422, 'Email already in use');
81
81
 
82
82
  const hashed = await Hasher.make(data.password);
83
- return this._UserModel.create({ ...data, password: hashed });
83
+ // is_active defaults true set explicitly so it is always present
84
+ // regardless of whether the model field has a DB default.
85
+ return this._UserModel.create({ is_active: true, ...data, password: hashed });
84
86
  }
85
87
 
86
88
  /**
@@ -98,8 +100,22 @@ class Auth {
98
100
  const user = await this._UserModel.findBy('email', email);
99
101
  if (!user) throw new HttpError(401, 'Invalid credentials');
100
102
 
103
+ // Check password before is_active — avoids leaking whether the account exists
101
104
  const ok = await Hasher.check(password, user.password);
102
- if (!ok) throw new HttpError(401, 'Invalid credentials');
105
+ if (!ok) throw new HttpError(401, 'Invalid credentials');
106
+
107
+ // Django: 'Please enter the correct email and password for a staff account.
108
+ // Note that both fields may be case-sensitive.'
109
+ // Millas matches this — inactive check is after password so error message
110
+ // doesn't reveal which condition failed to a brute-force attacker.
111
+ if (user.is_active === false || user.is_active === 0) {
112
+ throw new HttpError(401, 'This account is inactive.');
113
+ }
114
+
115
+ // Record last login (fire-and-forget — never block the login response)
116
+ try {
117
+ await this._UserModel.where('id', user.id).update({ last_login: new Date().toISOString() });
118
+ } catch { /* non-fatal — table may not have last_login yet */ }
103
119
 
104
120
  const payload = this._buildTokenPayload(user);
105
121
  const token = this._jwt.sign(payload);
@@ -7,85 +7,90 @@ const fields = require('../orm/fields/index').fields;
7
7
  * AuthUser
8
8
  *
9
9
  * Base model for authentication. Ships with Millas.
10
- * Covers the exact contract that Auth, AuthMiddleware, and AuthController expect:
11
- * - email (unique, required for login)
12
- * - password (hashed by Auth.register / Auth.hashPassword)
13
- * - role (read by RoleMiddleware)
10
+ * Covers the exact contract that Auth, AuthMiddleware, and AuthController expect.
11
+ *
12
+ * ── Fields ───────────────────────────────────────────────────────────────────
13
+ *
14
+ * Core:
15
+ * email — unique login identifier
16
+ * password — bcrypt hash (set by Auth.register / Auth.hashPassword)
17
+ * name — display name
18
+ * role — API-level role enum, read by RoleMiddleware ('admin'|'user')
19
+ *
20
+ * Django-style admin flags:
21
+ * is_active — false blocks login entirely (like Django's is_active)
22
+ * is_staff — true allows entry to the admin panel (like Django's is_staff)
23
+ * is_superuser — true bypasses all admin permission checks (like Django's is_superuser)
24
+ * last_login — updated by Auth.login() on each successful login
14
25
  *
15
26
  * ── Extending ────────────────────────────────────────────────────────────────
16
27
  *
17
- * Extend this instead of writing a User model from scratch.
18
- * Add your own fields on top all Auth behaviour is inherited.
28
+ * Because AuthUser is marked 'static abstract = true', its fields are
29
+ * automatically merged into any subclassno spread needed.
19
30
  *
20
31
  * class User extends AuthUser {
21
32
  * static table = 'users';
22
33
  * static fields = {
23
- * ...AuthUser.fields,
34
+ * // just declare what's new or overridden
24
35
  * phone: fields.string({ nullable: true }),
25
36
  * avatar_url: fields.string({ nullable: true }),
26
- * bio: fields.text({ nullable: true }),
37
+ * role: fields.enum(['tenant', 'landlord'], { default: 'tenant' }),
27
38
  * };
39
+ * // User.fields → id, name, email, password, is_active, is_staff,
40
+ * // is_superuser, last_login, created_at, updated_at,
41
+ * // phone, avatar_url, role (merged automatically)
28
42
  * }
29
43
  *
30
44
  * ── Customising the token payload ─────────────────────────────────────────
31
45
  *
32
- * Override tokenPayload() to add custom claims to the JWT:
33
- *
34
46
  * class User extends AuthUser {
35
47
  * tokenPayload() {
36
- * return {
37
- * ...super.tokenPayload(),
38
- * plan: this.plan,
39
- * orgId: this.org_id,
40
- * };
41
- * }
42
- * }
43
- *
44
- * ── Customising register / login hooks ────────────────────────────────────
45
- *
46
- * Override static hooks to run logic around auth operations:
47
- *
48
- * class User extends AuthUser {
49
- * static async afterCreate(instance) {
50
- * await emit('user.registered', { user: instance });
48
+ * return { ...super.tokenPayload(), plan: this.plan };
51
49
  * }
52
50
  * }
53
51
  */
54
52
  class AuthUser extends Model {
55
- static table = 'users';
53
+ // Abstract — no table. The app's User model owns the table.
54
+ // Equivalent to Django's AbstractUser with class Meta: abstract = True.
55
+ static abstract = true;
56
56
 
57
57
  static fields = {
58
- id: fields.id(),
59
- name: fields.string({ max: 100 }),
60
- email: fields.string({ unique: true }),
61
- password: fields.string(),
62
- role: fields.enum(['admin', 'user'], { default: 'user' }),
63
- created_at: fields.timestamp(),
64
- updated_at: fields.timestamp(),
58
+ id: fields.id(),
59
+ name: fields.string({ max: 100, nullable: true }),
60
+ email: fields.string({ unique: true }),
61
+ password: fields.string(),
62
+ role: fields.enum(['admin', 'user'], { default: 'user' }),
63
+
64
+ // ── Django-style admin access flags ──────────────────────────────────
65
+ is_active: fields.boolean({ default: true }),
66
+ is_staff: fields.boolean({ default: false }),
67
+ is_superuser: fields.boolean({ default: false }),
68
+
69
+ last_login: fields.timestamp(),
70
+ created_at: fields.timestamp(),
71
+ updated_at: fields.timestamp(),
65
72
  };
66
73
 
67
74
  // ── Auth contract helpers ──────────────────────────────────────────────────
68
75
 
69
76
  /**
70
- * The fields to include in the JWT payload.
77
+ * Fields included in the JWT payload.
71
78
  * Override to add custom claims.
72
- *
73
- * @returns {object}
74
79
  */
75
80
  tokenPayload() {
76
81
  return {
77
- id: this.id,
78
- sub: this.id,
79
- email: this.email,
80
- role: this.role || null,
82
+ id: this.id,
83
+ sub: this.id,
84
+ email: this.email,
85
+ role: this.role || null,
86
+ is_staff: this.is_staff ?? false,
87
+ is_superuser: this.is_superuser ?? false,
81
88
  };
82
89
  }
83
90
 
84
91
  /**
85
- * Safe representation — strips sensitive fields.
86
- * Used by AuthController._safeUser().
87
- *
88
- * @returns {object}
92
+ * Safe public representation — strips password and internal flags.
93
+ * Used by AuthController._safeUser() and API responses.
89
94
  */
90
95
  toSafeObject() {
91
96
  const data = { ...this };
@@ -93,6 +98,22 @@ class AuthUser extends Model {
93
98
  delete data.remember_token;
94
99
  return data;
95
100
  }
101
+
102
+ /**
103
+ * Returns true if this user can access the admin panel.
104
+ * Mirrors Django's User.has_module_perms / is_staff check.
105
+ */
106
+ get canAccessAdmin() {
107
+ return !!(this.is_active && this.is_staff);
108
+ }
109
+
110
+ /**
111
+ * Returns true if this user bypasses all permission checks.
112
+ * Mirrors Django's User.is_superuser.
113
+ */
114
+ get isSuperuser() {
115
+ return !!(this.is_active && this.is_superuser);
116
+ }
96
117
  }
97
118
 
98
119
  module.exports = AuthUser;
package/src/cli.js CHANGED
@@ -16,6 +16,8 @@ require('./commands/make')(program);
16
16
  require('./commands/migrate')(program);
17
17
  require('./commands/route')(program);
18
18
  require('./commands/queue')(program);
19
+ require('./commands/createsuperuser')(program);
20
+ require('./commands/lang')(program);
19
21
 
20
22
  // Unknown command handler
21
23
  program.on('command:*', ([cmd]) => {
@@ -24,4 +26,4 @@ program.on('command:*', ([cmd]) => {
24
26
  process.exit(1);
25
27
  });
26
28
 
27
- module.exports = { program };
29
+ module.exports = { program };