millas 0.2.12-beta → 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.
- package/package.json +3 -16
- package/src/admin/ActivityLog.js +153 -52
- package/src/admin/Admin.js +400 -167
- package/src/admin/AdminAuth.js +213 -98
- package/src/admin/FormGenerator.js +372 -0
- package/src/admin/HookRegistry.js +256 -0
- package/src/admin/QueryEngine.js +263 -0
- package/src/admin/ViewContext.js +309 -0
- package/src/admin/WidgetRegistry.js +406 -0
- package/src/admin/index.js +17 -0
- package/src/admin/resources/AdminResource.js +383 -97
- package/src/admin/static/admin.css +1341 -0
- package/src/admin/static/date-picker.css +157 -0
- package/src/admin/static/date-picker.js +316 -0
- package/src/admin/static/json-editor.css +649 -0
- package/src/admin/static/json-editor.js +1429 -0
- package/src/admin/static/ui.js +1044 -0
- package/src/admin/views/layouts/base.njk +65 -1013
- package/src/admin/views/pages/detail.njk +40 -16
- package/src/admin/views/pages/form.njk +47 -599
- package/src/admin/views/pages/list.njk +145 -62
- package/src/admin/views/partials/form-field.njk +53 -0
- package/src/admin/views/partials/form-footer.njk +28 -0
- package/src/admin/views/partials/form-readonly.njk +114 -0
- package/src/admin/views/partials/form-scripts.njk +476 -0
- package/src/admin/views/partials/form-widget.njk +296 -0
- package/src/admin/views/partials/json-dialog.njk +80 -0
- package/src/admin/views/partials/json-editor.njk +37 -0
- package/src/admin.zip +0 -0
- package/src/auth/Auth.js +31 -10
- package/src/auth/AuthController.js +3 -1
- package/src/auth/AuthUser.js +119 -0
- package/src/cli.js +4 -2
- package/src/commands/createsuperuser.js +254 -0
- package/src/commands/lang.js +589 -0
- package/src/commands/migrate.js +154 -81
- package/src/commands/serve.js +82 -110
- package/src/container/AppInitializer.js +215 -0
- package/src/container/Application.js +278 -253
- package/src/container/HttpServer.js +156 -0
- package/src/container/MillasApp.js +29 -279
- package/src/container/MillasConfig.js +192 -0
- package/src/core/admin.js +5 -0
- package/src/core/auth.js +9 -0
- package/src/core/db.js +9 -0
- package/src/core/foundation.js +59 -0
- package/src/core/http.js +11 -0
- package/src/core/lang.js +1 -0
- package/src/core/mail.js +6 -0
- package/src/core/queue.js +7 -0
- package/src/core/validation.js +29 -0
- package/src/facades/Admin.js +1 -1
- package/src/facades/Auth.js +22 -39
- package/src/facades/Cache.js +21 -10
- package/src/facades/Database.js +1 -1
- package/src/facades/Events.js +18 -17
- package/src/facades/Facade.js +197 -0
- package/src/facades/Http.js +42 -45
- package/src/facades/Log.js +25 -49
- package/src/facades/Mail.js +27 -32
- package/src/facades/Queue.js +22 -15
- package/src/facades/Storage.js +18 -10
- package/src/facades/Url.js +53 -0
- package/src/http/HttpClient.js +673 -0
- package/src/http/ResponseDispatcher.js +18 -111
- package/src/http/UrlGenerator.js +375 -0
- package/src/http/WelcomePage.js +273 -0
- package/src/http/adapters/ExpressAdapter.js +315 -0
- package/src/http/adapters/HttpAdapter.js +168 -0
- package/src/http/adapters/index.js +9 -0
- package/src/i18n/I18nServiceProvider.js +91 -0
- package/src/i18n/Translator.js +635 -0
- package/src/i18n/defaults.js +122 -0
- package/src/i18n/index.js +164 -0
- package/src/i18n/locales/en.js +55 -0
- package/src/i18n/locales/sw.js +48 -0
- package/src/index.js +5 -144
- package/src/logger/formatters/PrettyFormatter.js +103 -57
- package/src/logger/internal.js +2 -2
- package/src/logger/patchConsole.js +91 -81
- package/src/middleware/MiddlewareRegistry.js +62 -82
- package/src/migrations/system/0001_users.js +21 -0
- package/src/migrations/system/0002_admin_log.js +25 -0
- package/src/migrations/system/0003_sessions.js +23 -0
- package/src/orm/fields/index.js +210 -188
- package/src/orm/migration/DefaultValueParser.js +325 -0
- package/src/orm/migration/InteractiveResolver.js +191 -0
- package/src/orm/migration/Makemigrations.js +312 -0
- package/src/orm/migration/MigrationGraph.js +227 -0
- package/src/orm/migration/MigrationRunner.js +202 -108
- package/src/orm/migration/MigrationWriter.js +463 -0
- package/src/orm/migration/ModelInspector.js +412 -344
- package/src/orm/migration/ModelScanner.js +225 -0
- package/src/orm/migration/ProjectState.js +213 -0
- package/src/orm/migration/RenameDetector.js +175 -0
- package/src/orm/migration/SchemaBuilder.js +8 -81
- package/src/orm/migration/operations/base.js +57 -0
- package/src/orm/migration/operations/column.js +191 -0
- package/src/orm/migration/operations/fields.js +252 -0
- package/src/orm/migration/operations/index.js +55 -0
- package/src/orm/migration/operations/models.js +152 -0
- package/src/orm/migration/operations/registry.js +131 -0
- package/src/orm/migration/operations/special.js +51 -0
- package/src/orm/migration/utils.js +208 -0
- package/src/orm/model/Model.js +81 -13
- package/src/providers/AdminServiceProvider.js +66 -9
- package/src/providers/AuthServiceProvider.js +46 -7
- package/src/providers/CacheStorageServiceProvider.js +5 -3
- package/src/providers/DatabaseServiceProvider.js +3 -2
- package/src/providers/EventServiceProvider.js +2 -1
- package/src/providers/LogServiceProvider.js +7 -3
- package/src/providers/MailServiceProvider.js +4 -3
- package/src/providers/QueueServiceProvider.js +4 -3
- package/src/router/Router.js +119 -152
- package/src/scaffold/templates.js +83 -26
- package/src/facades/Validation.js +0 -69
|
@@ -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
|
-
|
|
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)
|
|
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);
|
|
@@ -230,13 +246,18 @@ class Auth {
|
|
|
230
246
|
}
|
|
231
247
|
|
|
232
248
|
_buildTokenPayload(user) {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
249
|
+
// If the model defines tokenPayload(), use it — allows custom JWT claims.
|
|
250
|
+
// Otherwise fall back to the standard shape.
|
|
251
|
+
const base = typeof user.tokenPayload === 'function'
|
|
252
|
+
? user.tokenPayload()
|
|
253
|
+
: {
|
|
254
|
+
id: user.id,
|
|
255
|
+
sub: user.id,
|
|
256
|
+
email: user.email,
|
|
257
|
+
role: user.role || null,
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
return { ...base, iat: Math.floor(Date.now() / 1000) };
|
|
240
261
|
}
|
|
241
262
|
|
|
242
263
|
_requireUserModel() {
|
|
@@ -251,4 +272,4 @@ class Auth {
|
|
|
251
272
|
|
|
252
273
|
// Singleton facade
|
|
253
274
|
module.exports = new Auth();
|
|
254
|
-
module.exports.Auth = Auth;
|
|
275
|
+
module.exports.Auth = Auth;
|
|
@@ -87,6 +87,8 @@ class AuthController extends Controller {
|
|
|
87
87
|
|
|
88
88
|
_safeUser(user) {
|
|
89
89
|
if (!user) return null;
|
|
90
|
+
// Use the model's toSafeObject() if defined (AuthUser and subclasses provide this)
|
|
91
|
+
if (typeof user.toSafeObject === 'function') return user.toSafeObject();
|
|
90
92
|
const data = user.toJSON ? user.toJSON() : { ...user };
|
|
91
93
|
delete data.password;
|
|
92
94
|
delete data.remember_token;
|
|
@@ -94,4 +96,4 @@ class AuthController extends Controller {
|
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
|
|
97
|
-
module.exports = AuthController;
|
|
99
|
+
module.exports = AuthController;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const Model = require('../orm/model/Model');
|
|
4
|
+
const fields = require('../orm/fields/index').fields;
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* AuthUser
|
|
8
|
+
*
|
|
9
|
+
* Base model for authentication. Ships with Millas.
|
|
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
|
|
25
|
+
*
|
|
26
|
+
* ── Extending ────────────────────────────────────────────────────────────────
|
|
27
|
+
*
|
|
28
|
+
* Because AuthUser is marked 'static abstract = true', its fields are
|
|
29
|
+
* automatically merged into any subclass — no spread needed.
|
|
30
|
+
*
|
|
31
|
+
* class User extends AuthUser {
|
|
32
|
+
* static table = 'users';
|
|
33
|
+
* static fields = {
|
|
34
|
+
* // just declare what's new or overridden
|
|
35
|
+
* phone: fields.string({ nullable: true }),
|
|
36
|
+
* avatar_url: fields.string({ nullable: true }),
|
|
37
|
+
* role: fields.enum(['tenant', 'landlord'], { default: 'tenant' }),
|
|
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)
|
|
42
|
+
* }
|
|
43
|
+
*
|
|
44
|
+
* ── Customising the token payload ─────────────────────────────────────────
|
|
45
|
+
*
|
|
46
|
+
* class User extends AuthUser {
|
|
47
|
+
* tokenPayload() {
|
|
48
|
+
* return { ...super.tokenPayload(), plan: this.plan };
|
|
49
|
+
* }
|
|
50
|
+
* }
|
|
51
|
+
*/
|
|
52
|
+
class AuthUser extends Model {
|
|
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
|
+
|
|
57
|
+
static fields = {
|
|
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(),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// ── Auth contract helpers ──────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fields included in the JWT payload.
|
|
78
|
+
* Override to add custom claims.
|
|
79
|
+
*/
|
|
80
|
+
tokenPayload() {
|
|
81
|
+
return {
|
|
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,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Safe public representation — strips password and internal flags.
|
|
93
|
+
* Used by AuthController._safeUser() and API responses.
|
|
94
|
+
*/
|
|
95
|
+
toSafeObject() {
|
|
96
|
+
const data = { ...this };
|
|
97
|
+
delete data.password;
|
|
98
|
+
delete data.remember_token;
|
|
99
|
+
return data;
|
|
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
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
module.exports = AuthUser;
|
package/src/cli.js
CHANGED
|
@@ -7,7 +7,7 @@ const program = new Command();
|
|
|
7
7
|
program
|
|
8
8
|
.name('millas')
|
|
9
9
|
.description(chalk.cyan('⚡ Millas — A modern batteries-included Node.js framework'))
|
|
10
|
-
.version('0.1
|
|
10
|
+
.version('0.2.12-beta-1');
|
|
11
11
|
|
|
12
12
|
// Load all command modules
|
|
13
13
|
require('./commands/new')(program);
|
|
@@ -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 };
|