@endora-commerce/mod-admin-users 0.100.0
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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/admin/index.d.ts +47 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +44 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/admin/pages/AdminRolesPage.d.ts +3 -0
- package/dist/admin/pages/AdminRolesPage.d.ts.map +1 -0
- package/dist/admin/pages/AdminRolesPage.js +180 -0
- package/dist/admin/pages/AdminRolesPage.js.map +1 -0
- package/dist/admin/pages/AdminUsersPage.d.ts +3 -0
- package/dist/admin/pages/AdminUsersPage.d.ts.map +1 -0
- package/dist/admin/pages/AdminUsersPage.js +151 -0
- package/dist/admin/pages/AdminUsersPage.js.map +1 -0
- package/dist/admin/permission-label.d.ts +26 -0
- package/dist/admin/permission-label.d.ts.map +1 -0
- package/dist/admin/permission-label.js +67 -0
- package/dist/admin/permission-label.js.map +1 -0
- package/dist/backend/cli/create-admin.d.ts +16 -0
- package/dist/backend/cli/create-admin.d.ts.map +1 -0
- package/dist/backend/cli/create-admin.js +101 -0
- package/dist/backend/cli/create-admin.js.map +1 -0
- package/dist/backend/demo/reset.d.ts +15 -0
- package/dist/backend/demo/reset.d.ts.map +1 -0
- package/dist/backend/demo/reset.js +13 -0
- package/dist/backend/demo/reset.js.map +1 -0
- package/dist/backend/demo/rows.d.ts +50 -0
- package/dist/backend/demo/rows.d.ts.map +1 -0
- package/dist/backend/demo/rows.js +73 -0
- package/dist/backend/demo/rows.js.map +1 -0
- package/dist/backend/demo/seed.d.ts +24 -0
- package/dist/backend/demo/seed.d.ts.map +1 -0
- package/dist/backend/demo/seed.js +43 -0
- package/dist/backend/demo/seed.js.map +1 -0
- package/dist/backend/entities/admin-user.entity.d.ts +44 -0
- package/dist/backend/entities/admin-user.entity.d.ts.map +1 -0
- package/dist/backend/entities/admin-user.entity.js +115 -0
- package/dist/backend/entities/admin-user.entity.js.map +1 -0
- package/dist/backend/index.d.ts +77 -0
- package/dist/backend/index.d.ts.map +1 -0
- package/dist/backend/index.js +111 -0
- package/dist/backend/index.js.map +1 -0
- package/dist/backend/plugin.d.ts +70 -0
- package/dist/backend/plugin.d.ts.map +1 -0
- package/dist/backend/plugin.js +50 -0
- package/dist/backend/plugin.js.map +1 -0
- package/dist/backend/routes.admin.d.ts +32 -0
- package/dist/backend/routes.admin.d.ts.map +1 -0
- package/dist/backend/routes.admin.js +192 -0
- package/dist/backend/routes.admin.js.map +1 -0
- package/dist/backend/routes.impersonation.d.ts +25 -0
- package/dist/backend/routes.impersonation.d.ts.map +1 -0
- package/dist/backend/routes.impersonation.js +108 -0
- package/dist/backend/routes.impersonation.js.map +1 -0
- package/dist/backend/routes.public.d.ts +20 -0
- package/dist/backend/routes.public.d.ts.map +1 -0
- package/dist/backend/routes.public.js +75 -0
- package/dist/backend/routes.public.js.map +1 -0
- package/dist/backend/services/admin-auth-service.d.ts +47 -0
- package/dist/backend/services/admin-auth-service.d.ts.map +1 -0
- package/dist/backend/services/admin-auth-service.js +93 -0
- package/dist/backend/services/admin-auth-service.js.map +1 -0
- package/dist/backend/services/admin-user-ports.d.ts +75 -0
- package/dist/backend/services/admin-user-ports.d.ts.map +1 -0
- package/dist/backend/services/admin-user-ports.js +160 -0
- package/dist/backend/services/admin-user-ports.js.map +1 -0
- package/dist/backend/services/admin-user-service.d.ts +134 -0
- package/dist/backend/services/admin-user-service.d.ts.map +1 -0
- package/dist/backend/services/admin-user-service.js +241 -0
- package/dist/backend/services/admin-user-service.js.map +1 -0
- package/dist/backend/services/impersonation-service.d.ts +65 -0
- package/dist/backend/services/impersonation-service.d.ts.map +1 -0
- package/dist/backend/services/impersonation-service.js +103 -0
- package/dist/backend/services/impersonation-service.js.map +1 -0
- package/dist/backend/services/two-factor-enrolments.d.ts +19 -0
- package/dist/backend/services/two-factor-enrolments.d.ts.map +1 -0
- package/dist/backend/services/two-factor-enrolments.js +2 -0
- package/dist/backend/services/two-factor-enrolments.js.map +1 -0
- package/dist/manifest.d.ts +182 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +208 -0
- package/dist/manifest.js.map +1 -0
- package/dist/migrations/20260425T053028_admin_users_init.d.ts +11 -0
- package/dist/migrations/20260425T053028_admin_users_init.d.ts.map +1 -0
- package/dist/migrations/20260425T053028_admin_users_init.js +51 -0
- package/dist/migrations/20260425T053028_admin_users_init.js.map +1 -0
- package/dist/migrations/20260819T155150_admin_users_fold_email_case.d.ts +55 -0
- package/dist/migrations/20260819T155150_admin_users_fold_email_case.d.ts.map +1 -0
- package/dist/migrations/20260819T155150_admin_users_fold_email_case.js +106 -0
- package/dist/migrations/20260819T155150_admin_users_fold_email_case.js.map +1 -0
- package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.d.ts +25 -0
- package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.d.ts.map +1 -0
- package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.js +29 -0
- package/dist/migrations/20260825T124801_admin_users_drop_legacy_two_factor_secret.js.map +1 -0
- package/dist/migrations/index.d.ts +29 -0
- package/dist/migrations/index.d.ts.map +1 -0
- package/dist/migrations/index.js +33 -0
- package/dist/migrations/index.js.map +1 -0
- package/docs/admin_users.md +53 -0
- package/i18n/en.json +5 -0
- package/i18n/pl.json +5 -0
- package/package.json +100 -0
- package/tailwind.css +14 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { type ModuleCliCommand } from '@endora-commerce/contracts';
|
|
2
|
+
import type { ModuleContext } from '@endora-commerce/platform/kernel';
|
|
3
|
+
/**
|
|
4
|
+
* Admin Users module — manifest backfill (Module Lifecycle, feature 018).
|
|
5
|
+
*
|
|
6
|
+
* Predates the lifecycle system; this manifest is the static record
|
|
7
|
+
* required so the module participates in the registry. No install /
|
|
8
|
+
* uninstall hook today — the module's schema is owned by earlier
|
|
9
|
+
* platform-wide migrations.
|
|
10
|
+
*/
|
|
11
|
+
export declare const manifest: {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string;
|
|
14
|
+
version: string;
|
|
15
|
+
dependencies: string[];
|
|
16
|
+
description?: string | undefined;
|
|
17
|
+
acknowledgedDependencies?: {
|
|
18
|
+
moduleId: string;
|
|
19
|
+
port: string;
|
|
20
|
+
reason: string;
|
|
21
|
+
}[] | undefined;
|
|
22
|
+
nonBindingDependencies?: {
|
|
23
|
+
moduleId: string;
|
|
24
|
+
name: string;
|
|
25
|
+
kind: "contributes-to" | "degrades-without" | "refuses-without";
|
|
26
|
+
reason: string;
|
|
27
|
+
whenAbsent?: string | undefined;
|
|
28
|
+
}[] | undefined;
|
|
29
|
+
activation?: {
|
|
30
|
+
settingCode: string;
|
|
31
|
+
default: boolean;
|
|
32
|
+
} | {
|
|
33
|
+
nonDeactivatable: true;
|
|
34
|
+
reason: string;
|
|
35
|
+
} | undefined;
|
|
36
|
+
settings?: {
|
|
37
|
+
moduleCode: string;
|
|
38
|
+
groups: {
|
|
39
|
+
code: string;
|
|
40
|
+
name: string;
|
|
41
|
+
salesChannelCodes?: string[] | undefined;
|
|
42
|
+
isSystemProtected?: boolean | undefined;
|
|
43
|
+
}[];
|
|
44
|
+
settings: {
|
|
45
|
+
code: string;
|
|
46
|
+
name: string;
|
|
47
|
+
valueType: "string" | "number" | "boolean" | "json" | "string_list" | "secret" | "credential_ref";
|
|
48
|
+
defaultValue: unknown;
|
|
49
|
+
description?: string | undefined;
|
|
50
|
+
groupCode?: string | undefined;
|
|
51
|
+
previousDefaultValues?: unknown[] | undefined;
|
|
52
|
+
salesChannelCodes?: string[] | undefined;
|
|
53
|
+
enumOptions?: string[] | undefined;
|
|
54
|
+
configurationType?: string | undefined;
|
|
55
|
+
hidden?: boolean | undefined;
|
|
56
|
+
}[];
|
|
57
|
+
} | undefined;
|
|
58
|
+
i18n?: {
|
|
59
|
+
bundlesDir: string;
|
|
60
|
+
} | undefined;
|
|
61
|
+
docs?: false | {
|
|
62
|
+
dir: string;
|
|
63
|
+
} | undefined;
|
|
64
|
+
demo?: false | {
|
|
65
|
+
summary: string;
|
|
66
|
+
seed: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoSeedResult>;
|
|
67
|
+
reset: (context: import("@endora-commerce/contracts").ModuleDemoContext<never>) => Promise<import("@endora-commerce/contracts").DemoResetResult>;
|
|
68
|
+
after?: readonly string[] | undefined;
|
|
69
|
+
package?: string | undefined;
|
|
70
|
+
} | undefined;
|
|
71
|
+
actions?: {
|
|
72
|
+
id: string;
|
|
73
|
+
labelKey: string;
|
|
74
|
+
icon: "Plus" | "Sparkles" | "Settings" | "Search" | "Boxes" | "Layers" | "Menu" | "PlusCircle" | "PlusSquare" | "FilePlus" | "FolderPlus" | "Upload" | "FileUp" | "CloudUpload" | "Download" | "FileDown" | "FileText" | "BookOpen" | "Rss" | "Package" | "Tag" | "ShoppingCart" | "Receipt" | "CreditCard" | "Users" | "UserPlus" | "Inbox" | "ListChecks" | "ClipboardList" | "Image" | "Video" | "LayoutDashboard" | "PanelLeft" | "KeyRound" | "ShieldCheck" | "Edit" | "Archive" | "Box" | "Truck" | "CircleDollarSign" | "Activity" | "LineChart" | "Smartphone" | "Webhook" | "Scale" | "PlugZap" | "PercentDiamond" | "Newspaper" | "Languages" | "Eraser" | "Warehouse" | "TrendingDown" | "Bell" | "PackageOpen" | "Building2" | "Store" | "ClipboardCheck";
|
|
75
|
+
targetRoute: string;
|
|
76
|
+
keywords: string[];
|
|
77
|
+
weight: number;
|
|
78
|
+
descriptionKey?: string | undefined;
|
|
79
|
+
requiredPermission?: string | undefined;
|
|
80
|
+
}[] | undefined;
|
|
81
|
+
permissions?: {
|
|
82
|
+
code: string;
|
|
83
|
+
label: string;
|
|
84
|
+
module?: string | undefined;
|
|
85
|
+
description?: string | undefined;
|
|
86
|
+
requires?: string[] | undefined;
|
|
87
|
+
}[] | undefined;
|
|
88
|
+
transactionalEmails?: {
|
|
89
|
+
code: string;
|
|
90
|
+
name: string;
|
|
91
|
+
variables: {
|
|
92
|
+
key: string;
|
|
93
|
+
label: string;
|
|
94
|
+
sampleValue?: string | undefined;
|
|
95
|
+
description?: string | undefined;
|
|
96
|
+
}[];
|
|
97
|
+
description?: string | undefined;
|
|
98
|
+
group?: string | undefined;
|
|
99
|
+
}[] | undefined;
|
|
100
|
+
capabilities?: string[] | undefined;
|
|
101
|
+
exclusiveCapabilities?: {
|
|
102
|
+
key: string;
|
|
103
|
+
errorCode: string;
|
|
104
|
+
}[] | undefined;
|
|
105
|
+
errorCodes?: {
|
|
106
|
+
code: string;
|
|
107
|
+
tokens?: string[] | undefined;
|
|
108
|
+
}[] | undefined;
|
|
109
|
+
blocks?: {
|
|
110
|
+
name: string;
|
|
111
|
+
labelKey: string;
|
|
112
|
+
category: string;
|
|
113
|
+
contexts: ("email" | "invoice" | "cms" | "newsletter")[];
|
|
114
|
+
fields: Record<string, {
|
|
115
|
+
type: "number" | "object" | "array" | "uuid" | "text" | "textarea" | "select" | "radio" | "external" | "richtext";
|
|
116
|
+
label?: string | undefined;
|
|
117
|
+
required?: boolean | undefined;
|
|
118
|
+
options?: {
|
|
119
|
+
label: string;
|
|
120
|
+
value: string | number;
|
|
121
|
+
}[] | undefined;
|
|
122
|
+
refKind?: string | undefined;
|
|
123
|
+
}>;
|
|
124
|
+
descriptionKey?: string | undefined;
|
|
125
|
+
defaultProps?: Record<string, unknown> | undefined;
|
|
126
|
+
responsiveFields?: string[] | undefined;
|
|
127
|
+
previewIcon?: string | undefined;
|
|
128
|
+
weight?: number | undefined;
|
|
129
|
+
}[] | undefined;
|
|
130
|
+
blockCategories?: {
|
|
131
|
+
key: string;
|
|
132
|
+
titleKey: string;
|
|
133
|
+
contexts: ("email" | "invoice" | "cms" | "newsletter")[];
|
|
134
|
+
weight?: number | undefined;
|
|
135
|
+
visible?: boolean | undefined;
|
|
136
|
+
}[] | undefined;
|
|
137
|
+
env?: {
|
|
138
|
+
name: string;
|
|
139
|
+
describes: {
|
|
140
|
+
en: string;
|
|
141
|
+
pl: string;
|
|
142
|
+
};
|
|
143
|
+
requirement: {
|
|
144
|
+
kind: "required";
|
|
145
|
+
} | {
|
|
146
|
+
kind: "requiredWhen";
|
|
147
|
+
input: string;
|
|
148
|
+
equals: string;
|
|
149
|
+
} | {
|
|
150
|
+
kind: "optional";
|
|
151
|
+
without: {
|
|
152
|
+
en: string;
|
|
153
|
+
pl: string;
|
|
154
|
+
};
|
|
155
|
+
};
|
|
156
|
+
secret: boolean;
|
|
157
|
+
generable: boolean;
|
|
158
|
+
owner: {
|
|
159
|
+
kind: "platform";
|
|
160
|
+
} | {
|
|
161
|
+
kind: "application";
|
|
162
|
+
application: "admin" | "backend" | "storefront";
|
|
163
|
+
} | {
|
|
164
|
+
kind: "module";
|
|
165
|
+
moduleId: string;
|
|
166
|
+
};
|
|
167
|
+
consumers: ("admin" | "backend" | "storefront")[];
|
|
168
|
+
addressOf: "admin" | "backend" | "storefront" | null;
|
|
169
|
+
}[] | undefined;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* The operator command this module declares — feature 080, T042b / D-160.9.
|
|
173
|
+
*
|
|
174
|
+
* It was `scripts/create-admin.ts`, whose one `check:module-boundary` key was an
|
|
175
|
+
* `AdminRole` **entity** import: with no container there was nothing to resolve
|
|
176
|
+
* `adminRolePort` from. Composing reaches it, and D-157.5 is why that is safe on
|
|
177
|
+
* a database with no administrator in it — `loadModulePresence` runs before the
|
|
178
|
+
* first module registers and its reconciler is what puts the presence rows
|
|
179
|
+
* there, so a composed bootstrap resolves a working port on a virgin schema.
|
|
180
|
+
*/
|
|
181
|
+
export declare const cliCommands: ReadonlyArray<ModuleCliCommand<ModuleContext>>;
|
|
182
|
+
//# sourceMappingURL=manifest.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAEtB,MAAM,4BAA4B,CAAC;AACpC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kCAAkC,CAAC;AA8BtE;;;;;;;GAOG;AACH,eAAO,MAAM,QAAQ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuJnB,CAAC;AAEH;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,EAAE,aAAa,CAAC,gBAAgB,CAAC,aAAa,CAAC,CAetE,CAAC"}
|
package/dist/manifest.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { defineModuleManifest, } from '@endora-commerce/contracts';
|
|
2
|
+
/**
|
|
3
|
+
* The demo data this module owns (feature 113, T222 — contract §1.3).
|
|
4
|
+
*
|
|
5
|
+
* A typed `const` rather than an inline object literal: declared inline the
|
|
6
|
+
* parameter infers from the schema and is `never`, so the author loses
|
|
7
|
+
* `context.ctx: ModuleContext`.
|
|
8
|
+
*
|
|
9
|
+
* Both bodies are reached by a **relative `await import()`** (§1.4), in
|
|
10
|
+
* `cliCommands.run`'s shape and for `cliCommands`' reason: a manifest is loaded
|
|
11
|
+
* by every process that composes the platform and by the check scripts that
|
|
12
|
+
* import the generated index, so a demo body imported at the top of this file
|
|
13
|
+
* would be a service graph pulled into all of them. It needs no `exports`
|
|
14
|
+
* subpath and no `files` entry (§1.5).
|
|
15
|
+
*
|
|
16
|
+
* **The role each account holds is not here.** An `admin_users` row carrying an
|
|
17
|
+
* `admin_roles` id is two modules' rows in one statement, so the assignment is a
|
|
18
|
+
* composition step (§5.1) and belongs to whoever owns the instance. The column
|
|
19
|
+
* is nullable, which is what makes that split available at all.
|
|
20
|
+
*
|
|
21
|
+
* The sign-in details come back as `DemoSeedResult.credentials`, so the runner
|
|
22
|
+
* formats them once (§3.7) instead of the seed printing them itself.
|
|
23
|
+
*/
|
|
24
|
+
const demo = {
|
|
25
|
+
summary: 'Three administrator accounts to sign in to the Admin UI with.',
|
|
26
|
+
seed: async (context) => (await import('./backend/demo/seed.js')).seedDemo(context),
|
|
27
|
+
reset: async (context) => (await import('./backend/demo/reset.js')).resetDemo(context),
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* Admin Users module — manifest backfill (Module Lifecycle, feature 018).
|
|
31
|
+
*
|
|
32
|
+
* Predates the lifecycle system; this manifest is the static record
|
|
33
|
+
* required so the module participates in the registry. No install /
|
|
34
|
+
* uninstall hook today — the module's schema is owned by earlier
|
|
35
|
+
* platform-wide migrations.
|
|
36
|
+
*/
|
|
37
|
+
export const manifest = defineModuleManifest({
|
|
38
|
+
id: 'admin_users',
|
|
39
|
+
name: 'Admin Users',
|
|
40
|
+
description: 'Admin user accounts, sessions, and impersonation flows.',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
dependencies: ['admin_roles', 'auth'],
|
|
43
|
+
/**
|
|
44
|
+
* Feature 075, Phase C — impersonation resolves its target through
|
|
45
|
+
* `customerAccountReadPort` instead of querying `customer_accounts`' entity.
|
|
46
|
+
*
|
|
47
|
+
* It is acknowledged rather than declared, because the ordinary declaration
|
|
48
|
+
* closes a cycle `module-graph.test.ts` and the composer generator both refuse:
|
|
49
|
+
* `customer_accounts` → `price_lists` → `catalog` → `admin_users`. The same
|
|
50
|
+
* shape `auth` records against `customer_accounts` and `admin_roles` against
|
|
51
|
+
* this module.
|
|
52
|
+
*
|
|
53
|
+
* The seam fails closed — the port is gated, and an impersonation that cannot
|
|
54
|
+
* identify its target must refuse rather than mint a session against a
|
|
55
|
+
* customer nobody looked up. That is what the edge costs, stated as a
|
|
56
|
+
* behaviour rather than as a claim about which flips are refused: the locked
|
|
57
|
+
* set is re-derived on every run, and a sentence here is a copy nothing
|
|
58
|
+
* refreshes (D-100).
|
|
59
|
+
*/
|
|
60
|
+
acknowledgedDependencies: [
|
|
61
|
+
{
|
|
62
|
+
moduleId: 'customer_accounts',
|
|
63
|
+
port: 'customerAccountReadPort',
|
|
64
|
+
reason: 'Starting an impersonation reads the target customer account — its organisation ' +
|
|
65
|
+
'membership and whether it is still live — and that row belongs to customer_accounts, ' +
|
|
66
|
+
'which reaches this module transitively through price_lists and catalog. Declaring it ' +
|
|
67
|
+
'here closes a cycle. With customer_accounts absent the seam fails closed: an ' +
|
|
68
|
+
'impersonation that cannot identify its target refuses rather than minting a session ' +
|
|
69
|
+
'against a customer nobody looked up.',
|
|
70
|
+
},
|
|
71
|
+
],
|
|
72
|
+
/**
|
|
73
|
+
* D-96 — `mfaLoginPort`, the second factor on admin login.
|
|
74
|
+
*
|
|
75
|
+
* Real to the container, binding on no operator. `mfa` declares this module
|
|
76
|
+
* in its own `dependencies`, so the ordinary declaration closes a cycle; and
|
|
77
|
+
* an acknowledged edge would put admin login among the dependents that refuse
|
|
78
|
+
* the flip, which is the wrong way round — two-factor authentication is a
|
|
79
|
+
* client security policy, not a platform floor, and feature 074 gave the
|
|
80
|
+
* operator a switch for exactly that reason.
|
|
81
|
+
*/
|
|
82
|
+
nonBindingDependencies: [
|
|
83
|
+
{
|
|
84
|
+
moduleId: 'mfa',
|
|
85
|
+
name: 'mfaLoginPort',
|
|
86
|
+
kind: 'degrades-without',
|
|
87
|
+
whenAbsent: 'Admin sign-in stops asking for a second factor and offers no Google/Microsoft button. ' +
|
|
88
|
+
'Every admin has a password a peer admin can reset, so no admin is locked out.',
|
|
89
|
+
reason: 'AdminAuthService verifies the password first and then asks the second factor what to ' +
|
|
90
|
+
'do. With `mfa` absent it asks nobody: `backend.ts` probes ' +
|
|
91
|
+
'`effectiveState.isPresent("mfa")` and passes `undefined`, which selects the ' +
|
|
92
|
+
'password-only branch feature 042 FR-033 requires and this service has always had. ' +
|
|
93
|
+
'Nothing catches `ModuleDisabledError` — the decision is taken before the port is ' +
|
|
94
|
+
'resolved, so the degrade is declared rather than laundered out of a closed gate. ' +
|
|
95
|
+
'Enrolled secrets, recovery codes and every policy value stay in the database and ' +
|
|
96
|
+
'apply again on the way back, which is what the activation control promises. Admin ' +
|
|
97
|
+
'accounts are never auto-created and always carry a human-chosen password a peer ' +
|
|
98
|
+
'admin can reset, so no admin becomes unreachable while the module is off.',
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
moduleId: 'mfa',
|
|
102
|
+
name: 'mfaEnrolmentStatePort',
|
|
103
|
+
kind: 'degrades-without',
|
|
104
|
+
whenAbsent: 'Reads "no second factor" for every admin on /admin-users, on /admin/me and on the ' +
|
|
105
|
+
'login response. True while MFA is off — admin sign-in asks for none — and not a ' +
|
|
106
|
+
'claim that nobody is enrolled.',
|
|
107
|
+
reason: '`twoFactorEnabled` on every response carrying an admin user is the live `mfa` ' +
|
|
108
|
+
'enrolment, read in one batch through `mfaEnrolmentStatePort`. It was ' +
|
|
109
|
+
'`!!u.twoFactorConfirmedAt` until 2026-08-28 — a column on this module\'s own table ' +
|
|
110
|
+
'that has never had a writer — so the screen whose job is to tell an operator who is ' +
|
|
111
|
+
'protected answered "nobody" while people were protected. Non-binding for the reason ' +
|
|
112
|
+
'the login edge beside it is: `mfa` declares this module in its own `dependencies`, ' +
|
|
113
|
+
'so an ordinary declaration closes a cycle, and an acknowledged edge would make a ' +
|
|
114
|
+
'client security policy unswitchable over a column on a list screen. The degrade is ' +
|
|
115
|
+
'taken by **not resolving** — `backend.ts` probes presence first and answers an empty ' +
|
|
116
|
+
'set — so nothing catches `ModuleDisabledError`.',
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
// D-173 — `customers:impersonate` is a **shared** gate, in the shape issue
|
|
120
|
+
// #213 gave `integrations:manage`: it guards this module's
|
|
121
|
+
// `/admin/organizations/:id/impersonate` and `/admin/impersonation/end`
|
|
122
|
+
// endpoints and `customers`' own impersonation start, and the core
|
|
123
|
+
// `PERMISSION_CATALOGUE` row that carries its label names `customers` alone.
|
|
124
|
+
// Declaring it here makes this module a second *owner*, so the presence
|
|
125
|
+
// filter on `/admin-roles` keeps it grantable while either surface is on.
|
|
126
|
+
// Without this line, switching `customers` off would take the code off the
|
|
127
|
+
// role editor while these routes — owned by a module that declares itself
|
|
128
|
+
// non-deactivatable — went on enforcing it: a gate nobody can be granted.
|
|
129
|
+
//
|
|
130
|
+
// The label decides which repair applies: *"Impersonate customers"* reads as
|
|
131
|
+
// a sentence about this module's own screen, so the code is shared rather
|
|
132
|
+
// than replaced by one of this module's own.
|
|
133
|
+
/**
|
|
134
|
+
* This module's first i18n bundle and its first command-palette action —
|
|
135
|
+
* feature 091, Phase 4, batch four.
|
|
136
|
+
*
|
|
137
|
+
* The bundle exists because the sidebar entry moved into this package with
|
|
138
|
+
* the two screens and a nav declaration's `labelKey` is **module-relative**
|
|
139
|
+
* (R8): `appShell.nav.users` was one of `_i18n`'s and is
|
|
140
|
+
* `nav.adminUsers.label` here. It is a **flat** `{"a.b.c": "text"}` map at
|
|
141
|
+
* the **package root**, for the reason `admin_roles`' bundle records.
|
|
142
|
+
*
|
|
143
|
+
* The action pays one of the fifteen entries `specs/deferred-defects.md`
|
|
144
|
+
* still holds under *"Sixteen modules with an admin screen declare no
|
|
145
|
+
* command-palette action"*. `admin_users:manage` is the code every endpoint
|
|
146
|
+
* under `/api/v1/admin/admin-users` enforces, which is what
|
|
147
|
+
* `check:action-route-permissions` holds this declaration to.
|
|
148
|
+
*
|
|
149
|
+
* **There is exactly one action and it points at `/admin-users`, not at
|
|
150
|
+
* `/admin-roles`.** This module declares the roles editor's *route* — the
|
|
151
|
+
* screen's API is its own — but the roles *advertisement* is `admin_roles`',
|
|
152
|
+
* which ships the sidebar entry and the palette action for it. See
|
|
153
|
+
* `src/admin/index.ts` for the split and for why it is inert while both
|
|
154
|
+
* modules are locked.
|
|
155
|
+
*/
|
|
156
|
+
i18n: { bundlesDir: 'i18n' },
|
|
157
|
+
docs: { dir: 'docs' },
|
|
158
|
+
actions: [
|
|
159
|
+
{
|
|
160
|
+
id: 'open-admin-users',
|
|
161
|
+
labelKey: 'actions.openAdminUsers.label',
|
|
162
|
+
descriptionKey: 'actions.openAdminUsers.description',
|
|
163
|
+
icon: 'Users',
|
|
164
|
+
targetRoute: '/admin-users',
|
|
165
|
+
requiredPermission: 'admin_users:manage',
|
|
166
|
+
keywords: ['admin', 'users', 'uzytkownicy', 'operators', 'operatorzy', 'staff', 'konta'],
|
|
167
|
+
weight: 200,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
permissions: [{ code: 'customers:impersonate', label: 'Impersonate customers' }],
|
|
171
|
+
demo,
|
|
172
|
+
// Feature 072/073 (Constitution XVII) — this module owns the admin login
|
|
173
|
+
// route, the admin session and the impersonation flow. Switched off, nobody
|
|
174
|
+
// can sign in to the Admin UI, including to switch it back on: the one
|
|
175
|
+
// control that would undo the change is behind the door it just locked.
|
|
176
|
+
activation: {
|
|
177
|
+
nonDeactivatable: true,
|
|
178
|
+
reason: 'Owns admin login, sessions and impersonation; switched off, no operator could sign in ' +
|
|
179
|
+
'to the Admin UI at all — including to switch it back on.',
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
/**
|
|
183
|
+
* The operator command this module declares — feature 080, T042b / D-160.9.
|
|
184
|
+
*
|
|
185
|
+
* It was `scripts/create-admin.ts`, whose one `check:module-boundary` key was an
|
|
186
|
+
* `AdminRole` **entity** import: with no container there was nothing to resolve
|
|
187
|
+
* `adminRolePort` from. Composing reaches it, and D-157.5 is why that is safe on
|
|
188
|
+
* a database with no administrator in it — `loadModulePresence` runs before the
|
|
189
|
+
* first module registers and its reconciler is what puts the presence rows
|
|
190
|
+
* there, so a composed bootstrap resolves a working port on a virgin schema.
|
|
191
|
+
*/
|
|
192
|
+
export const cliCommands = [
|
|
193
|
+
{
|
|
194
|
+
name: 'create',
|
|
195
|
+
summary: 'Create or update an admin user, bootstrapping the platform_admin role.',
|
|
196
|
+
help: `usage: admin_users create --email=<e> --password=<p> --first-name=<f> --last-name=<l>
|
|
197
|
+
[--role=<code>] [--skip-role-bootstrap]
|
|
198
|
+
|
|
199
|
+
Idempotent: re-running with the same email updates the password and the role
|
|
200
|
+
assignment. The first admin created gets the \`platform_admin\` role with the
|
|
201
|
+
wildcard \`*\` permission; narrower roles are defined from the Admin UI.
|
|
202
|
+
|
|
203
|
+
--role=<code> an existing role code (default: platform_admin)
|
|
204
|
+
--skip-role-bootstrap do not create platform_admin when it is missing`,
|
|
205
|
+
run: async (context) => (await import('./backend/cli/create-admin.js')).createAdmin(context),
|
|
206
|
+
},
|
|
207
|
+
];
|
|
208
|
+
//# sourceMappingURL=manifest.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"manifest.js","sourceRoot":"","sources":["../src/manifest.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Admin module — Phase 6 (T184). Creates admin_roles + admin_users tables.
|
|
4
|
+
* admin_users.admin_role_id is RESTRICT-deleted to keep audit references
|
|
5
|
+
* meaningful even after a Role is deactivated.
|
|
6
|
+
*/
|
|
7
|
+
export declare class Migration20260425T053028AdminUsersInit extends Migration {
|
|
8
|
+
up(): Promise<void>;
|
|
9
|
+
down(): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=20260425T053028_admin_users_init.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260425T053028_admin_users_init.d.ts","sourceRoot":"","sources":["../../src/migrations/20260425T053028_admin_users_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;GAIG;AACH,qBAAa,sCAAuC,SAAQ,SAAS;IACpD,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IAwCnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAIrC"}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Admin module — Phase 6 (T184). Creates admin_roles + admin_users tables.
|
|
4
|
+
* admin_users.admin_role_id is RESTRICT-deleted to keep audit references
|
|
5
|
+
* meaningful even after a Role is deactivated.
|
|
6
|
+
*/
|
|
7
|
+
export class Migration20260425T053028AdminUsersInit extends Migration {
|
|
8
|
+
async up() {
|
|
9
|
+
this.addSql(`
|
|
10
|
+
create table "admin_roles" (
|
|
11
|
+
"id" uuid not null,
|
|
12
|
+
"code" varchar(64) not null,
|
|
13
|
+
"name" varchar(160) not null,
|
|
14
|
+
"permissions" jsonb not null default '[]'::jsonb,
|
|
15
|
+
"requires_two_factor" boolean not null default false,
|
|
16
|
+
"created_at" timestamptz not null,
|
|
17
|
+
"updated_at" timestamptz not null,
|
|
18
|
+
constraint "admin_roles_pkey" primary key ("id"),
|
|
19
|
+
constraint "admin_roles_code_unique" unique ("code")
|
|
20
|
+
);
|
|
21
|
+
`);
|
|
22
|
+
this.addSql(`
|
|
23
|
+
create table "admin_users" (
|
|
24
|
+
"id" uuid not null,
|
|
25
|
+
"email" varchar(320) not null,
|
|
26
|
+
"password_hash" varchar(512) not null,
|
|
27
|
+
"first_name" varchar(120) not null,
|
|
28
|
+
"last_name" varchar(120) not null,
|
|
29
|
+
"admin_role_id" uuid null,
|
|
30
|
+
"status" varchar(16) not null default 'active',
|
|
31
|
+
"two_factor_secret" varchar(64) null,
|
|
32
|
+
"two_factor_confirmed_at" timestamptz null,
|
|
33
|
+
"last_login_at" timestamptz null,
|
|
34
|
+
"created_at" timestamptz not null,
|
|
35
|
+
"updated_at" timestamptz not null,
|
|
36
|
+
"deleted_at" timestamptz null,
|
|
37
|
+
constraint "admin_users_pkey" primary key ("id"),
|
|
38
|
+
constraint "admin_users_email_unique" unique ("email"),
|
|
39
|
+
constraint "admin_users_admin_role_fk" foreign key ("admin_role_id")
|
|
40
|
+
references "admin_roles" ("id") on delete restrict
|
|
41
|
+
);
|
|
42
|
+
`);
|
|
43
|
+
this.addSql('create index "admin_users_admin_role_id_index" on "admin_users" ("admin_role_id");');
|
|
44
|
+
this.addSql('create index "admin_users_status_index" on "admin_users" ("status");');
|
|
45
|
+
}
|
|
46
|
+
async down() {
|
|
47
|
+
this.addSql('drop table if exists "admin_users" cascade;');
|
|
48
|
+
this.addSql('drop table if exists "admin_roles" cascade;');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=20260425T053028_admin_users_init.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260425T053028_admin_users_init.js","sourceRoot":"","sources":["../../src/migrations/20260425T053028_admin_users_init.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;GAIG;AACH,MAAM,OAAO,sCAAuC,SAAQ,SAAS;IAC1D,KAAK,CAAC,EAAE;QACf,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;KAYX,CAAC,CAAC;QAEH,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;KAoBX,CAAC,CAAC;QACH,IAAI,CAAC,MAAM,CAAC,oFAAoF,CAAC,CAAC;QAClG,IAAI,CAAC,MAAM,CAAC,sEAAsE,CAAC,CAAC;IACtF,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,IAAI,CAAC,MAAM,CAAC,6CAA6C,CAAC,CAAC;QAC3D,IAAI,CAAC,MAAM,CAAC,6CAA6C,CAAC,CAAC;IAC7D,CAAC;CACF"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Fold `admin_users.email` to the form the code now stores and compares.
|
|
4
|
+
*
|
|
5
|
+
* `AdminUserService.create` folded the address and the login read compared it
|
|
6
|
+
* verbatim, so an operator created as `Operator.Mixed@example.com` had
|
|
7
|
+
* `operator.mixed@example.com` on record and could never sign in with the address
|
|
8
|
+
* they were handed: Postgres' `=` on `text` is case-sensitive. The code side of
|
|
9
|
+
* the repair makes every read and every write go through
|
|
10
|
+
* `normalizeEmailAddress`; this migration brings the rows written before it to
|
|
11
|
+
* the same form, so an address stored mixed-case stays reachable.
|
|
12
|
+
*
|
|
13
|
+
* The policy is the one the buyer-side twin of this defect settled on
|
|
14
|
+
* (`20260819T142837_customer_accounts_fold_email_case.ts`), and it is repeated
|
|
15
|
+
* rather than re-decided.
|
|
16
|
+
*
|
|
17
|
+
* ## What a collision means, and why it does not fail the deploy
|
|
18
|
+
*
|
|
19
|
+
* Two rows differing only in case are two operators that the fold turns into
|
|
20
|
+
* one address, and `admin_users_email_unique` will not hold both. The migration
|
|
21
|
+
* therefore **folds one row per address and leaves the rest exactly as they
|
|
22
|
+
* are** — it drops nothing, merges nothing and refuses nothing:
|
|
23
|
+
*
|
|
24
|
+
* - a row that **already holds the folded address** wins it, because it is the
|
|
25
|
+
* one every audit row, every assignment and every session has been resolving
|
|
26
|
+
* to by that address all along;
|
|
27
|
+
* - otherwise the **earliest-created** row wins it, since it is the operator
|
|
28
|
+
* with the longer history behind it;
|
|
29
|
+
* - every other row in the group keeps its stored spelling and is counted by
|
|
30
|
+
* the warning below.
|
|
31
|
+
*
|
|
32
|
+
* A stranded row is not data loss but it is not a working operator either: the
|
|
33
|
+
* login read folds, so nothing will match its spelling until an operator
|
|
34
|
+
* renames it. Which admin account somebody's audit trail belongs to is not a
|
|
35
|
+
* decision a migration running unattended may take, and failing the deploy
|
|
36
|
+
* would stop every other repair in the same release over a pair of rows a human
|
|
37
|
+
* has to look at anyway.
|
|
38
|
+
*
|
|
39
|
+
* The admin side has one aggravating circumstance the buyer side does not: it
|
|
40
|
+
* ships no e-mail-keyed password reset, so a stranded operator has no
|
|
41
|
+
* self-service way back in at all. That is what the warning is for — it names a
|
|
42
|
+
* row somebody has to go and fix.
|
|
43
|
+
*
|
|
44
|
+
* ## Measured before it was written
|
|
45
|
+
*
|
|
46
|
+
* Zero collisions and zero mixed-case rows across both developer databases
|
|
47
|
+
* (`b2b`: 3 admin users; `b2b_test`: 4). The rules above are therefore written
|
|
48
|
+
* for the environments this will meet later, not for a pair of rows anybody has
|
|
49
|
+
* today.
|
|
50
|
+
*/
|
|
51
|
+
export declare class Migration20260819T155150AdminUsersFoldEmailCase extends Migration {
|
|
52
|
+
up(): Promise<void>;
|
|
53
|
+
down(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=20260819T155150_admin_users_fold_email_case.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260819T155150_admin_users_fold_email_case.d.ts","sourceRoot":"","sources":["../../src/migrations/20260819T155150_admin_users_fold_email_case.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,qBAAa,+CAAgD,SAAQ,SAAS;IAC7D,EAAE,IAAI,OAAO,CAAC,IAAI,CAAC;IA+CnB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC;CAQrC"}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { Migration } from '@mikro-orm/migrations';
|
|
2
|
+
/**
|
|
3
|
+
* Fold `admin_users.email` to the form the code now stores and compares.
|
|
4
|
+
*
|
|
5
|
+
* `AdminUserService.create` folded the address and the login read compared it
|
|
6
|
+
* verbatim, so an operator created as `Operator.Mixed@example.com` had
|
|
7
|
+
* `operator.mixed@example.com` on record and could never sign in with the address
|
|
8
|
+
* they were handed: Postgres' `=` on `text` is case-sensitive. The code side of
|
|
9
|
+
* the repair makes every read and every write go through
|
|
10
|
+
* `normalizeEmailAddress`; this migration brings the rows written before it to
|
|
11
|
+
* the same form, so an address stored mixed-case stays reachable.
|
|
12
|
+
*
|
|
13
|
+
* The policy is the one the buyer-side twin of this defect settled on
|
|
14
|
+
* (`20260819T142837_customer_accounts_fold_email_case.ts`), and it is repeated
|
|
15
|
+
* rather than re-decided.
|
|
16
|
+
*
|
|
17
|
+
* ## What a collision means, and why it does not fail the deploy
|
|
18
|
+
*
|
|
19
|
+
* Two rows differing only in case are two operators that the fold turns into
|
|
20
|
+
* one address, and `admin_users_email_unique` will not hold both. The migration
|
|
21
|
+
* therefore **folds one row per address and leaves the rest exactly as they
|
|
22
|
+
* are** — it drops nothing, merges nothing and refuses nothing:
|
|
23
|
+
*
|
|
24
|
+
* - a row that **already holds the folded address** wins it, because it is the
|
|
25
|
+
* one every audit row, every assignment and every session has been resolving
|
|
26
|
+
* to by that address all along;
|
|
27
|
+
* - otherwise the **earliest-created** row wins it, since it is the operator
|
|
28
|
+
* with the longer history behind it;
|
|
29
|
+
* - every other row in the group keeps its stored spelling and is counted by
|
|
30
|
+
* the warning below.
|
|
31
|
+
*
|
|
32
|
+
* A stranded row is not data loss but it is not a working operator either: the
|
|
33
|
+
* login read folds, so nothing will match its spelling until an operator
|
|
34
|
+
* renames it. Which admin account somebody's audit trail belongs to is not a
|
|
35
|
+
* decision a migration running unattended may take, and failing the deploy
|
|
36
|
+
* would stop every other repair in the same release over a pair of rows a human
|
|
37
|
+
* has to look at anyway.
|
|
38
|
+
*
|
|
39
|
+
* The admin side has one aggravating circumstance the buyer side does not: it
|
|
40
|
+
* ships no e-mail-keyed password reset, so a stranded operator has no
|
|
41
|
+
* self-service way back in at all. That is what the warning is for — it names a
|
|
42
|
+
* row somebody has to go and fix.
|
|
43
|
+
*
|
|
44
|
+
* ## Measured before it was written
|
|
45
|
+
*
|
|
46
|
+
* Zero collisions and zero mixed-case rows across both developer databases
|
|
47
|
+
* (`b2b`: 3 admin users; `b2b_test`: 4). The rules above are therefore written
|
|
48
|
+
* for the environments this will meet later, not for a pair of rows anybody has
|
|
49
|
+
* today.
|
|
50
|
+
*/
|
|
51
|
+
export class Migration20260819T155150AdminUsersFoldEmailCase extends Migration {
|
|
52
|
+
async up() {
|
|
53
|
+
// One winner per folded address: a row that already holds it first
|
|
54
|
+
// (`email = folded` sorts true-first under `desc`), then the earliest
|
|
55
|
+
// created, then the smallest id so the choice is deterministic. The winner
|
|
56
|
+
// is only updated when it does not already hold the folded value, which is
|
|
57
|
+
// also why this statement cannot violate the unique index: an update
|
|
58
|
+
// happens only for a group in which no row holds the target address.
|
|
59
|
+
this.addSql(`
|
|
60
|
+
with ranked as (
|
|
61
|
+
select "id",
|
|
62
|
+
lower(btrim("email")) as folded,
|
|
63
|
+
row_number() over (
|
|
64
|
+
partition by lower(btrim("email"))
|
|
65
|
+
order by ("email" = lower(btrim("email"))) desc,
|
|
66
|
+
"created_at" asc,
|
|
67
|
+
"id" asc
|
|
68
|
+
) as rank
|
|
69
|
+
from "admin_users"
|
|
70
|
+
)
|
|
71
|
+
update "admin_users" au
|
|
72
|
+
set "email" = r.folded
|
|
73
|
+
from ranked r
|
|
74
|
+
where r."id" = au."id"
|
|
75
|
+
and r.rank = 1
|
|
76
|
+
and au."email" <> r.folded;
|
|
77
|
+
`);
|
|
78
|
+
// Say out loud what was left behind. A migration that quietly leaves an
|
|
79
|
+
// operator nobody can sign in as is the same class of defect as the one
|
|
80
|
+
// being repaired here.
|
|
81
|
+
this.addSql(`
|
|
82
|
+
do $$
|
|
83
|
+
declare
|
|
84
|
+
stranded integer;
|
|
85
|
+
begin
|
|
86
|
+
select count(*) into stranded
|
|
87
|
+
from "admin_users"
|
|
88
|
+
where "email" <> lower(btrim("email"));
|
|
89
|
+
if stranded > 0 then
|
|
90
|
+
raise warning
|
|
91
|
+
'admin_users: % row(s) kept a mixed-case e-mail because another admin user already holds the folded address. They cannot be signed into until an operator renames them.',
|
|
92
|
+
stranded;
|
|
93
|
+
end if;
|
|
94
|
+
end $$;
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
async down() {
|
|
98
|
+
// Deliberately empty. The original capitalisation is not recorded anywhere
|
|
99
|
+
// — this migration overwrites the only copy of it — so there is nothing to
|
|
100
|
+
// restore, and an `up` that folded a handful of rows must not be paired
|
|
101
|
+
// with a `down` that pretends otherwise. Rolling this back means rolling
|
|
102
|
+
// back the code that reads the folded form; the rows are then already in
|
|
103
|
+
// the shape the old code wrote, which is what it was folding to anyway.
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
//# sourceMappingURL=20260819T155150_admin_users_fold_email_case.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"20260819T155150_admin_users_fold_email_case.js","sourceRoot":"","sources":["../../src/migrations/20260819T155150_admin_users_fold_email_case.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAElD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgDG;AACH,MAAM,OAAO,+CAAgD,SAAQ,SAAS;IACnE,KAAK,CAAC,EAAE;QACf,mEAAmE;QACnE,sEAAsE;QACtE,2EAA2E;QAC3E,2EAA2E;QAC3E,qEAAqE;QACrE,qEAAqE;QACrE,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;;;;;KAkBX,CAAC,CAAC;QAEH,wEAAwE;QACxE,wEAAwE;QACxE,uBAAuB;QACvB,IAAI,CAAC,MAAM,CAAC;;;;;;;;;;;;;;KAcX,CAAC,CAAC;IACL,CAAC;IAEQ,KAAK,CAAC,IAAI;QACjB,2EAA2E;QAC3E,2EAA2E;QAC3E,wEAAwE;QACxE,yEAAyE;QACzE,yEAAyE;QACzE,wEAAwE;IAC1E,CAAC;CACF"}
|