@open-mercato/core 0.6.7-develop.6592.1.7f69fa2454 → 0.6.7-develop.6594.1.5581f765f4
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/.turbo/turbo-build.log +1 -1
- package/dist/generated/entities/custom_entity/index.js +2 -0
- package/dist/generated/entities/custom_entity/index.js.map +2 -2
- package/dist/generated/entity-fields-registry.js +1 -0
- package/dist/generated/entity-fields-registry.js.map +2 -2
- package/dist/modules/attachments/api/image/[id]/[[...slug]]/route.js.map +2 -2
- package/dist/modules/attachments/lib/imageSafety.js.map +2 -2
- package/dist/modules/auth/api/features.js +14 -0
- package/dist/modules/auth/api/features.js.map +2 -2
- package/dist/modules/entities/api/entities.js +20 -0
- package/dist/modules/entities/api/entities.js.map +2 -2
- package/dist/modules/entities/api/entity-settings.js +89 -0
- package/dist/modules/entities/api/entity-settings.js.map +7 -0
- package/dist/modules/entities/api/records.js +46 -25
- package/dist/modules/entities/api/records.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js +13 -4
- package/dist/modules/entities/backend/entities/user/[entityId]/page.js.map +2 -2
- package/dist/modules/entities/backend/entities/user/create/page.js +27 -2
- package/dist/modules/entities/backend/entities/user/create/page.js.map +2 -2
- package/dist/modules/entities/data/entities.js +4 -0
- package/dist/modules/entities/data/entities.js.map +2 -2
- package/dist/modules/entities/lib/entityAcl.js +17 -4
- package/dist/modules/entities/lib/entityAcl.js.map +3 -3
- package/dist/modules/entities/lib/install-from-ce.js +2 -0
- package/dist/modules/entities/lib/install-from-ce.js.map +2 -2
- package/dist/modules/entities/lib/recordFeatures.js +24 -0
- package/dist/modules/entities/lib/recordFeatures.js.map +7 -0
- package/dist/modules/entities/lib/register.js +3 -0
- package/dist/modules/entities/lib/register.js.map +2 -2
- package/dist/modules/entities/lib/restrictedEntityFeatures.js +62 -0
- package/dist/modules/entities/lib/restrictedEntityFeatures.js.map +7 -0
- package/dist/modules/entities/migrations/Migration20260716120000.js +13 -0
- package/dist/modules/entities/migrations/Migration20260716120000.js.map +7 -0
- package/generated/entities/custom_entity/index.ts +1 -0
- package/generated/entity-fields-registry.ts +1 -0
- package/package.json +8 -7
- package/src/modules/attachments/api/image/[id]/[[...slug]]/route.ts +2 -2
- package/src/modules/attachments/lib/imageSafety.ts +2 -2
- package/src/modules/auth/api/features.ts +17 -0
- package/src/modules/entities/api/entities.ts +28 -0
- package/src/modules/entities/api/entity-settings.ts +103 -0
- package/src/modules/entities/api/records.ts +70 -29
- package/src/modules/entities/backend/entities/user/[entityId]/page.tsx +12 -3
- package/src/modules/entities/backend/entities/user/create/page.tsx +33 -1
- package/src/modules/entities/data/entities.ts +6 -0
- package/src/modules/entities/i18n/de.json +3 -0
- package/src/modules/entities/i18n/en.json +3 -0
- package/src/modules/entities/i18n/es.json +3 -0
- package/src/modules/entities/i18n/pl.json +3 -0
- package/src/modules/entities/lib/entityAcl.ts +27 -5
- package/src/modules/entities/lib/install-from-ce.ts +2 -0
- package/src/modules/entities/lib/recordFeatures.ts +48 -0
- package/src/modules/entities/lib/register.ts +4 -0
- package/src/modules/entities/lib/restrictedEntityFeatures.ts +98 -0
- package/src/modules/entities/migrations/.snapshot-open-mercato.json +10 -0
- package/src/modules/entities/migrations/Migration20260716120000.ts +13 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/entities/migrations/Migration20260716120000.ts"],
|
|
4
|
+
"sourcesContent": ["import { Migration } from '@mikro-orm/migrations';\n\nexport class Migration20260716120000 extends Migration {\n\n override async up(): Promise<void> {\n this.addSql(`alter table \"custom_entities\" add column \"access_restricted\" boolean not null default false;`);\n }\n\n override async down(): Promise<void> {\n this.addSql(`alter table \"custom_entities\" drop column \"access_restricted\";`);\n }\n\n}\n"],
|
|
5
|
+
"mappings": "AAAA,SAAS,iBAAiB;AAEnB,MAAM,gCAAgC,UAAU;AAAA,EAErD,MAAe,KAAoB;AACjC,SAAK,OAAO,8FAA8F;AAAA,EAC5G;AAAA,EAEA,MAAe,OAAsB;AACnC,SAAK,OAAO,gEAAgE;AAAA,EAC9E;AAEF;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -5,6 +5,7 @@ export const description = "description";
|
|
|
5
5
|
export const label_field = "label_field";
|
|
6
6
|
export const default_editor = "default_editor";
|
|
7
7
|
export const show_in_sidebar = "show_in_sidebar";
|
|
8
|
+
export const access_restricted = "access_restricted";
|
|
8
9
|
export const organization_id = "organization_id";
|
|
9
10
|
export const tenant_id = "tenant_id";
|
|
10
11
|
export const is_active = "is_active";
|
|
@@ -503,6 +503,7 @@ export const entityFieldsRegistry: Record<string, Record<string, string>> = {
|
|
|
503
503
|
"label_field": "label_field",
|
|
504
504
|
"default_editor": "default_editor",
|
|
505
505
|
"show_in_sidebar": "show_in_sidebar",
|
|
506
|
+
"access_restricted": "access_restricted",
|
|
506
507
|
"organization_id": "organization_id",
|
|
507
508
|
"tenant_id": "tenant_id",
|
|
508
509
|
"is_active": "is_active",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6594.1.5581f765f4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -248,21 +248,22 @@
|
|
|
248
248
|
"resend": "^6.17.2",
|
|
249
249
|
"sanitize-html": "^2.17.5",
|
|
250
250
|
"semver": "^7.8.5",
|
|
251
|
+
"sharp": "^0.35.3",
|
|
251
252
|
"svix": "^1.96.1",
|
|
252
253
|
"ts-pattern": "^5.0.0",
|
|
253
254
|
"zod": "^4.4.3"
|
|
254
255
|
},
|
|
255
256
|
"peerDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
257
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6594.1.5581f765f4",
|
|
258
|
+
"@open-mercato/shared": "0.6.7-develop.6594.1.5581f765f4",
|
|
259
|
+
"@open-mercato/ui": "0.6.7-develop.6594.1.5581f765f4",
|
|
259
260
|
"react": "^19.0.0",
|
|
260
261
|
"react-dom": "^19.0.0"
|
|
261
262
|
},
|
|
262
263
|
"devDependencies": {
|
|
263
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
264
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
264
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6594.1.5581f765f4",
|
|
265
|
+
"@open-mercato/shared": "0.6.7-develop.6594.1.5581f765f4",
|
|
266
|
+
"@open-mercato/ui": "0.6.7-develop.6594.1.5581f765f4",
|
|
266
267
|
"@testing-library/dom": "^10.4.1",
|
|
267
268
|
"@testing-library/jest-dom": "^6.9.1",
|
|
268
269
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
-
import sharp from 'sharp'
|
|
2
|
+
import sharp, { type ResizeOptions } from 'sharp'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
5
5
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
@@ -109,7 +109,7 @@ export async function GET(
|
|
|
109
109
|
limitInputPixels: MAX_IMAGE_SOURCE_PIXELS,
|
|
110
110
|
})
|
|
111
111
|
if (width || height) {
|
|
112
|
-
const resizeOptions:
|
|
112
|
+
const resizeOptions: ResizeOptions = {
|
|
113
113
|
width: width || undefined,
|
|
114
114
|
height: height || undefined,
|
|
115
115
|
fit: cropType === 'contain' ? 'contain' : 'cover',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import sharp from 'sharp'
|
|
1
|
+
import sharp, { type Metadata } from 'sharp'
|
|
2
2
|
|
|
3
3
|
export const MAX_IMAGE_SOURCE_BYTES = 25 * 1024 * 1024
|
|
4
4
|
export const MAX_IMAGE_SOURCE_PIXELS = 40_000_000
|
|
@@ -92,7 +92,7 @@ export function validateImageMagicBytes(buffer: Buffer, declaredMimeType: string
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
export async function validateImageDimensions(buffer: Buffer): Promise<ImageSafetyResult> {
|
|
95
|
-
let metadata:
|
|
95
|
+
let metadata: Metadata
|
|
96
96
|
try {
|
|
97
97
|
metadata = await sharp(buffer, {
|
|
98
98
|
failOn: 'error',
|
|
@@ -3,6 +3,8 @@ import { z } from 'zod'
|
|
|
3
3
|
import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { getModules } from '@open-mercato/shared/lib/i18n/server'
|
|
6
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
|
+
import { synthesizeRestrictedEntityFeatures } from '@open-mercato/core/modules/entities/lib/restrictedEntityFeatures'
|
|
6
8
|
|
|
7
9
|
export const metadata = {
|
|
8
10
|
GET: { requireAuth: true, requireFeatures: ['auth.acl.manage'] },
|
|
@@ -44,6 +46,21 @@ export async function GET(req: Request) {
|
|
|
44
46
|
return base
|
|
45
47
|
})
|
|
46
48
|
)
|
|
49
|
+
// Append synthesized per-entity features for the tenant's restricted custom
|
|
50
|
+
// entities so they can be granted in the ACL editor. Tenant-scoped; never
|
|
51
|
+
// throws (falls back to the static catalog on any failure).
|
|
52
|
+
try {
|
|
53
|
+
const { resolve } = await createRequestContainer()
|
|
54
|
+
const em = resolve('em') as any
|
|
55
|
+
const synthesized = await synthesizeRestrictedEntityFeatures(em, auth.tenantId ?? null)
|
|
56
|
+
for (const item of synthesized) {
|
|
57
|
+
const deps = normalizeDependsOn(item.dependsOn)
|
|
58
|
+
const base: FeatureItem = { id: item.id, title: item.title, module: item.module }
|
|
59
|
+
if (deps) base.dependsOn = deps
|
|
60
|
+
items.push(base)
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
|
|
47
64
|
// Deduplicate by id (keep first occurrence)
|
|
48
65
|
const byId = new Map<string, FeatureItem>()
|
|
49
66
|
for (const it of items) if (!byId.has(it.id)) byId.set(it.id, it)
|
|
@@ -68,6 +68,7 @@ export async function GET(req: Request) {
|
|
|
68
68
|
labelField: (c as any).labelField ?? undefined,
|
|
69
69
|
defaultEditor: (c as any).defaultEditor ?? undefined,
|
|
70
70
|
showInSidebar: (c as any).showInSidebar ?? false,
|
|
71
|
+
accessRestricted: (c as any).accessRestricted ?? false,
|
|
71
72
|
updatedAt: c.updatedAt instanceof Date ? c.updatedAt.toISOString() : (c.updatedAt ?? undefined),
|
|
72
73
|
}))
|
|
73
74
|
|
|
@@ -113,6 +114,10 @@ export async function POST(req: Request) {
|
|
|
113
114
|
return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })
|
|
114
115
|
}
|
|
115
116
|
const input = parsed.data
|
|
117
|
+
// Distinguish "caller did not send the field" (apply tenant default-restricted
|
|
118
|
+
// policy on create) from "caller explicitly chose false".
|
|
119
|
+
const accessRestrictedExplicit = body != null && typeof body === 'object'
|
|
120
|
+
&& Object.prototype.hasOwnProperty.call(body, 'accessRestricted')
|
|
116
121
|
|
|
117
122
|
const container = await createRequestContainer()
|
|
118
123
|
const { resolve } = container
|
|
@@ -157,6 +162,7 @@ export async function POST(req: Request) {
|
|
|
157
162
|
})
|
|
158
163
|
if (guard.blockedResponse) return guard.blockedResponse
|
|
159
164
|
|
|
165
|
+
const isCreate = !ent
|
|
160
166
|
if (!ent) ent = em.create(CustomEntity, { ...where, createdAt: new Date() })
|
|
161
167
|
ent.label = input.label
|
|
162
168
|
ent.description = input.description ?? null
|
|
@@ -164,6 +170,27 @@ export async function POST(req: Request) {
|
|
|
164
170
|
ent.labelField = input.labelField ?? ent.labelField ?? null
|
|
165
171
|
ent.defaultEditor = input.defaultEditor ?? ent.defaultEditor ?? null
|
|
166
172
|
ent.showInSidebar = input.showInSidebar ?? ent.showInSidebar ?? false
|
|
173
|
+
// Never silently flip a security control on a partial upsert. `access_restricted`
|
|
174
|
+
// changes ONLY when the caller explicitly sends the field. On create without it,
|
|
175
|
+
// fall back to the tenant default-restricted policy; on update without it, keep
|
|
176
|
+
// the entity's current value (a metadata-only update must not un-restrict).
|
|
177
|
+
if (accessRestrictedExplicit) {
|
|
178
|
+
ent.accessRestricted = input.accessRestricted === true
|
|
179
|
+
} else if (isCreate) {
|
|
180
|
+
let policyDefault = false
|
|
181
|
+
try {
|
|
182
|
+
const moduleConfigService = resolve('moduleConfigService') as {
|
|
183
|
+
getValue: (m: string, n: string, o?: { defaultValue?: unknown; scope?: { tenantId?: string | null } }) => Promise<unknown>
|
|
184
|
+
}
|
|
185
|
+
policyDefault = (await moduleConfigService.getValue('entities', 'newEntitiesRestrictedByDefault', {
|
|
186
|
+
defaultValue: false,
|
|
187
|
+
scope: { tenantId: auth.tenantId ?? null },
|
|
188
|
+
})) === true
|
|
189
|
+
} catch {}
|
|
190
|
+
ent.accessRestricted = policyDefault
|
|
191
|
+
} else {
|
|
192
|
+
ent.accessRestricted = ent.accessRestricted ?? false
|
|
193
|
+
}
|
|
167
194
|
ent.updatedAt = new Date()
|
|
168
195
|
em.persist(ent)
|
|
169
196
|
await em.flush()
|
|
@@ -229,6 +256,7 @@ const entitySummarySchema = z.object({
|
|
|
229
256
|
labelField: z.string().optional(),
|
|
230
257
|
defaultEditor: z.string().optional(),
|
|
231
258
|
showInSidebar: z.boolean().optional(),
|
|
259
|
+
accessRestricted: z.boolean().optional(),
|
|
232
260
|
updatedAt: z.string().optional(),
|
|
233
261
|
count: z.number(),
|
|
234
262
|
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
4
|
+
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
5
|
+
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
6
|
+
|
|
7
|
+
// Tenant-scoped policy: when true, new custom entities are created with
|
|
8
|
+
// `access_restricted = true` unless the create request explicitly sets the flag.
|
|
9
|
+
const CONFIG_MODULE = 'entities'
|
|
10
|
+
const NEW_ENTITIES_RESTRICTED_KEY = 'newEntitiesRestrictedByDefault'
|
|
11
|
+
|
|
12
|
+
export const metadata = {
|
|
13
|
+
GET: { requireAuth: true, requireFeatures: ['entities.definitions.view'] },
|
|
14
|
+
PUT: { requireAuth: true, requireFeatures: ['entities.definitions.manage'] },
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ModuleConfigServiceLike = {
|
|
18
|
+
getValue: (
|
|
19
|
+
moduleId: string,
|
|
20
|
+
name: string,
|
|
21
|
+
options?: { defaultValue?: unknown; scope?: { tenantId?: string | null } },
|
|
22
|
+
) => Promise<unknown>
|
|
23
|
+
setValue: (
|
|
24
|
+
moduleId: string,
|
|
25
|
+
name: string,
|
|
26
|
+
value: unknown,
|
|
27
|
+
scope?: { tenantId?: string | null },
|
|
28
|
+
) => Promise<unknown>
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readPolicy(tenantId: string | null): Promise<boolean> {
|
|
32
|
+
try {
|
|
33
|
+
const { resolve } = await createRequestContainer()
|
|
34
|
+
const moduleConfigService = resolve('moduleConfigService') as ModuleConfigServiceLike
|
|
35
|
+
const value = await moduleConfigService.getValue(CONFIG_MODULE, NEW_ENTITIES_RESTRICTED_KEY, {
|
|
36
|
+
defaultValue: false,
|
|
37
|
+
scope: { tenantId },
|
|
38
|
+
})
|
|
39
|
+
return value === true
|
|
40
|
+
} catch {
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function GET(req: Request) {
|
|
46
|
+
const auth = await getAuthFromRequest(req)
|
|
47
|
+
if (!auth || !auth.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
48
|
+
const newEntitiesRestrictedByDefault = await readPolicy(auth.tenantId ?? null)
|
|
49
|
+
return NextResponse.json({ newEntitiesRestrictedByDefault })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const putBodySchema = z.object({
|
|
53
|
+
newEntitiesRestrictedByDefault: z.boolean(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export async function PUT(req: Request) {
|
|
57
|
+
const auth = await getAuthFromRequest(req)
|
|
58
|
+
if (!auth || !auth.tenantId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
59
|
+
let body: unknown
|
|
60
|
+
try { body = await req.json() } catch { return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) }
|
|
61
|
+
const parsed = putBodySchema.safeParse(body)
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
return NextResponse.json({ error: 'Validation failed', details: parsed.error.flatten() }, { status: 400 })
|
|
64
|
+
}
|
|
65
|
+
const { resolve } = await createRequestContainer()
|
|
66
|
+
const moduleConfigService = resolve('moduleConfigService') as ModuleConfigServiceLike
|
|
67
|
+
await moduleConfigService.setValue(
|
|
68
|
+
CONFIG_MODULE,
|
|
69
|
+
NEW_ENTITIES_RESTRICTED_KEY,
|
|
70
|
+
parsed.data.newEntitiesRestrictedByDefault,
|
|
71
|
+
{ tenantId: auth.tenantId ?? null },
|
|
72
|
+
)
|
|
73
|
+
return NextResponse.json({ ok: true, newEntitiesRestrictedByDefault: parsed.data.newEntitiesRestrictedByDefault })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const settingsResponseSchema = z.object({
|
|
77
|
+
newEntitiesRestrictedByDefault: z.boolean(),
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
export const openApi: OpenApiRouteDoc = {
|
|
81
|
+
tag: 'Entities',
|
|
82
|
+
summary: 'Custom entity workspace settings',
|
|
83
|
+
methods: {
|
|
84
|
+
GET: {
|
|
85
|
+
summary: 'Get custom entity settings',
|
|
86
|
+
description: 'Returns the tenant-scoped default-restricted policy for new custom entities.',
|
|
87
|
+
responses: [
|
|
88
|
+
{ status: 200, description: 'Current settings', schema: settingsResponseSchema },
|
|
89
|
+
{ status: 401, description: 'Missing authentication', schema: z.object({ error: z.string() }) },
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
PUT: {
|
|
93
|
+
summary: 'Update custom entity settings',
|
|
94
|
+
description: 'Sets the tenant-scoped default-restricted policy for new custom entities.',
|
|
95
|
+
requestBody: { schema: putBodySchema },
|
|
96
|
+
responses: [
|
|
97
|
+
{ status: 200, description: 'Updated settings', schema: z.object({ ok: z.boolean(), newEntitiesRestrictedByDefault: z.boolean() }) },
|
|
98
|
+
{ status: 400, description: 'Invalid payload', schema: z.object({ error: z.string() }) },
|
|
99
|
+
{ status: 401, description: 'Missing authentication', schema: z.object({ error: z.string() }) },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
}
|
|
@@ -20,29 +20,63 @@ import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
|
20
20
|
|
|
21
21
|
const logger = createLogger('entities').child({ component: 'records' })
|
|
22
22
|
|
|
23
|
-
let
|
|
24
|
-
function
|
|
25
|
-
if (
|
|
23
|
+
let declaredCustomEntityRestricted: Map<string, boolean> | null = null
|
|
24
|
+
function loadDeclaredCustomEntities(): Map<string, boolean> {
|
|
25
|
+
if (declaredCustomEntityRestricted === null) {
|
|
26
26
|
try {
|
|
27
|
-
const mods = getModules() as Array<{ customEntities?: Array<{ id?: string }> }>
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
for (const
|
|
31
|
-
|
|
32
|
-
if (spec?.id) ids.add(spec.id)
|
|
33
|
-
}
|
|
27
|
+
const mods = getModules() as Array<{ customEntities?: Array<{ id?: string; accessRestricted?: boolean }> }>
|
|
28
|
+
const map = new Map<string, boolean>()
|
|
29
|
+
for (const mod of mods ?? []) {
|
|
30
|
+
for (const spec of mod?.customEntities ?? []) {
|
|
31
|
+
if (spec?.id) map.set(spec.id, spec.accessRestricted === true)
|
|
34
32
|
}
|
|
35
|
-
declaredCustomEntityIds = ids
|
|
36
33
|
}
|
|
34
|
+
// Cache even when empty so we don't rebuild on every request (and fall back
|
|
35
|
+
// to the DB lookup unnecessarily). Only a thrown getModules() leaves it null
|
|
36
|
+
// so a genuinely-uninitialized registry is retried.
|
|
37
|
+
declaredCustomEntityRestricted = map
|
|
37
38
|
} catch {}
|
|
38
39
|
}
|
|
39
|
-
return
|
|
40
|
+
return declaredCustomEntityRestricted ?? new Map<string, boolean>()
|
|
41
|
+
}
|
|
42
|
+
function isDeclaredCustomEntity(entityId: string): boolean {
|
|
43
|
+
return loadDeclaredCustomEntities().has(entityId)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type RecordsEntityScope = { tenantId: string | null; organizationId: string | null }
|
|
47
|
+
|
|
48
|
+
// Resolve the CustomEntity registration that applies to THIS caller, most-specific
|
|
49
|
+
// first (org+tenant → tenant-global → instance-global), mirroring the overlay
|
|
50
|
+
// precedence used by the entity-definitions list. Scoping matters because the
|
|
51
|
+
// row's `access_restricted` flag is a security control: an unscoped lookup could
|
|
52
|
+
// read another tenant's row for a colliding entityId (e.g. `user:vendors`) and
|
|
53
|
+
// mis-decide the restriction. Returns null when the caller's scope has no row.
|
|
54
|
+
async function findScopedCustomEntity(em: any, CustomEntity: any, entityId: string, scope: RecordsEntityScope) {
|
|
55
|
+
const { tenantId, organizationId } = scope
|
|
56
|
+
const candidates: Array<Record<string, unknown>> = [
|
|
57
|
+
{ entityId, organizationId, tenantId },
|
|
58
|
+
{ entityId, organizationId: null, tenantId },
|
|
59
|
+
{ entityId, organizationId: null, tenantId: null },
|
|
60
|
+
]
|
|
61
|
+
const seen = new Set<string>()
|
|
62
|
+
for (const where of candidates) {
|
|
63
|
+
const key = JSON.stringify(where)
|
|
64
|
+
if (seen.has(key)) continue
|
|
65
|
+
seen.add(key)
|
|
66
|
+
const row = await em.findOne(CustomEntity as any, where)
|
|
67
|
+
if (row) return row
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
40
70
|
}
|
|
41
71
|
|
|
42
72
|
const CUSTOM_ENTITY_RECORD_RESOURCE_KIND = 'entities.record'
|
|
43
73
|
|
|
44
74
|
type RecordsEntityKind = 'system' | 'custom' | 'unknown'
|
|
45
75
|
|
|
76
|
+
// `restricted` is meaningful only when `kind === 'custom'`; it drives the
|
|
77
|
+
// per-entity ACL gate in `assertEntityAclForRequest`.
|
|
78
|
+
type RecordsEntityClassification = { kind: RecordsEntityKind; restricted: boolean }
|
|
79
|
+
|
|
46
80
|
// This surface manages doc-storage records, which exist for CUSTOM entities only.
|
|
47
81
|
// Module-declared ids backed by a registered ORM table are system entities — their
|
|
48
82
|
// records live in their own module tables/APIs, and stray doc rows for them poisoned
|
|
@@ -50,18 +84,25 @@ type RecordsEntityKind = 'system' | 'custom' | 'unknown'
|
|
|
50
84
|
// previous fallback that classified an entity by the mere presence of
|
|
51
85
|
// `custom_entities_storage` rows is gone: within the allowed set, declaration (ce.ts)
|
|
52
86
|
// or an active `custom_entities` registration is authoritative.
|
|
53
|
-
async function classifyRecordsEntity(em: any, entityId: string): Promise<
|
|
54
|
-
if (isOrmBackedSystemEntityId(em, entityId)) return 'system'
|
|
55
|
-
|
|
87
|
+
async function classifyRecordsEntity(em: any, entityId: string, scope: RecordsEntityScope): Promise<RecordsEntityClassification> {
|
|
88
|
+
if (isOrmBackedSystemEntityId(em, entityId)) return { kind: 'system', restricted: false }
|
|
89
|
+
const declared = loadDeclaredCustomEntities()
|
|
90
|
+
if (declared.has(entityId)) return { kind: 'custom', restricted: declared.get(entityId) === true }
|
|
56
91
|
try {
|
|
57
92
|
const { CustomEntity } = await import('../data/entities')
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
93
|
+
// Restriction is decided from the row that applies to THIS caller's scope so
|
|
94
|
+
// a colliding entityId in another tenant can't flip the flag.
|
|
95
|
+
const scoped = await findScopedCustomEntity(em, CustomEntity, entityId, scope)
|
|
96
|
+
if (scoped) return { kind: 'custom', restricted: (scoped as any).accessRestricted === true }
|
|
97
|
+
// No in-scope registration: preserve the historical custom-vs-unknown
|
|
98
|
+
// classification (any registration row — active or soft-deleted — proves the
|
|
99
|
+
// id is custom; records persist beyond soft delete, TC-ENTITIES-006). A row
|
|
100
|
+
// outside the caller's scope never marks the entity restricted for them, and
|
|
101
|
+
// the record query is itself tenant/org-scoped, so this cannot leak data.
|
|
102
|
+
const anyRow = await em.findOne(CustomEntity as any, { entityId })
|
|
103
|
+
if (anyRow) return { kind: 'custom', restricted: false }
|
|
63
104
|
} catch {}
|
|
64
|
-
return 'unknown'
|
|
105
|
+
return { kind: 'unknown', restricted: false }
|
|
65
106
|
}
|
|
66
107
|
|
|
67
108
|
function systemEntityRecordsRejection(entityId: string) {
|
|
@@ -171,10 +212,10 @@ export async function GET(req: Request) {
|
|
|
171
212
|
// registered in `custom_entities`, so classification checks the declared registry plus
|
|
172
213
|
// active registrations. System (table-backed) ids are rejected above; for the allowed
|
|
173
214
|
// set `isCustomEntity` drives mapRow's cf_ stripping so the edit form reads back values.
|
|
174
|
-
const entityKind = await classifyRecordsEntity(em, entityId)
|
|
215
|
+
const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
|
|
175
216
|
if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
|
|
176
217
|
const isCustomEntity = entityKind === 'custom'
|
|
177
|
-
await assertEntityAclForRequest({ auth, entityId, action: 'view', isCustomEntity, rbac })
|
|
218
|
+
await assertEntityAclForRequest({ auth, entityId, action: 'view', isCustomEntity, isRestricted, rbac })
|
|
178
219
|
if (organizationIds && organizationIds.length === 0) {
|
|
179
220
|
return NextResponse.json({ items: [], total: 0, page, pageSize, totalPages: 0 })
|
|
180
221
|
}
|
|
@@ -395,10 +436,10 @@ export async function POST(req: Request) {
|
|
|
395
436
|
const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
|
|
396
437
|
const targetOrgId = scope.selectedId ?? auth.orgId
|
|
397
438
|
if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
|
|
398
|
-
const entityKind = await classifyRecordsEntity(em, entityId)
|
|
439
|
+
const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
|
|
399
440
|
if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
|
|
400
441
|
const isCustomEntity = entityKind === 'custom'
|
|
401
|
-
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
|
|
442
|
+
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
|
|
402
443
|
// Strip reserved record/system columns the edit form echoes back from the loaded record
|
|
403
444
|
// (`id`, plus `updated_at`/`updatedAt` used for optimistic locking). They are not custom
|
|
404
445
|
// fields; without this they validate as cf_id / cf_updated_at / cf_updatedAt and are
|
|
@@ -469,10 +510,10 @@ export async function PUT(req: Request) {
|
|
|
469
510
|
const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
|
|
470
511
|
const targetOrgId = scope.selectedId ?? auth.orgId
|
|
471
512
|
if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
|
|
472
|
-
const entityKind = await classifyRecordsEntity(em, entityId)
|
|
513
|
+
const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
|
|
473
514
|
if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
|
|
474
515
|
const isCustomEntity = entityKind === 'custom'
|
|
475
|
-
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
|
|
516
|
+
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
|
|
476
517
|
// Strip reserved record/system columns the edit form echoes back from the loaded record
|
|
477
518
|
// (`id`, plus `updated_at`/`updatedAt` used for optimistic locking). They are not custom
|
|
478
519
|
// fields; without this they validate as cf_id / cf_updated_at / cf_updatedAt and are
|
|
@@ -568,10 +609,10 @@ export async function DELETE(req: Request) {
|
|
|
568
609
|
const scope = await resolveOrganizationScope({ em, rbac, auth, selectedId: getSelectedOrganizationFromRequest(req) })
|
|
569
610
|
const targetOrgId = scope.selectedId ?? auth.orgId
|
|
570
611
|
if (!targetOrgId) return NextResponse.json({ error: 'Organization context is required' }, { status: 400 })
|
|
571
|
-
const entityKind = await classifyRecordsEntity(em, entityId)
|
|
612
|
+
const { kind: entityKind, restricted: isRestricted } = await classifyRecordsEntity(em, entityId, { tenantId: auth.tenantId ?? null, organizationId: scope.selectedId ?? auth.orgId ?? null })
|
|
572
613
|
if (entityKind === 'system') return systemEntityRecordsRejection(entityId)
|
|
573
614
|
const isCustomEntity = entityKind === 'custom'
|
|
574
|
-
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, rbac })
|
|
615
|
+
await assertEntityAclForRequest({ auth, entityId, action: 'manage', isCustomEntity, isRestricted, rbac })
|
|
575
616
|
|
|
576
617
|
try {
|
|
577
618
|
const currentUpdatedAt = await readCustomEntityRecordUpdatedAt(em, {
|
|
@@ -53,7 +53,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
53
53
|
const [label, setLabel] = useState('')
|
|
54
54
|
const [entitySource, setEntitySource] = useState<EntitySource>('custom')
|
|
55
55
|
const [entityFormLoading, setEntityFormLoading] = useState(true)
|
|
56
|
-
const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; updatedAt?: string }>({})
|
|
56
|
+
const [entityInitial, setEntityInitial] = useState<{ label?: string; description?: string; labelField?: string; defaultEditor?: string; showInSidebar?: boolean; accessRestricted?: boolean; updatedAt?: string }>({})
|
|
57
57
|
const [defs, setDefs] = useState<Def[]>([])
|
|
58
58
|
const [defsVersion, setDefsVersion] = useState<string | null>(null)
|
|
59
59
|
const [orderDirty, setOrderDirty] = useState(false)
|
|
@@ -180,6 +180,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
180
180
|
const defaultEditorValue =
|
|
181
181
|
typeof record?.defaultEditor === 'string' ? record.defaultEditor : ''
|
|
182
182
|
const showInSidebarValue = record?.showInSidebar === true
|
|
183
|
+
const accessRestrictedValue = record?.accessRestricted === true
|
|
183
184
|
const updatedAtValue =
|
|
184
185
|
typeof record?.updatedAt === 'string' && record.updatedAt.length > 0 ? record.updatedAt : undefined
|
|
185
186
|
setLabel(labelValue)
|
|
@@ -190,6 +191,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
190
191
|
labelField: labelFieldValue,
|
|
191
192
|
defaultEditor: defaultEditorValue,
|
|
192
193
|
showInSidebar: showInSidebarValue,
|
|
194
|
+
accessRestricted: accessRestrictedValue,
|
|
193
195
|
updatedAt: updatedAtValue,
|
|
194
196
|
})
|
|
195
197
|
setEntityFormLoading(false)
|
|
@@ -419,6 +421,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
419
421
|
defaultEditor: z.union([z.enum(['markdown','simpleMarkdown','htmlRichText']).optional(), z.literal('')]).optional(),
|
|
420
422
|
// Include showInSidebar so CrudForm doesn't strip it on submit
|
|
421
423
|
showInSidebar: z.boolean().optional(),
|
|
424
|
+
accessRestricted: z.boolean().optional(),
|
|
422
425
|
}) as z.ZodType<Record<string, unknown>>
|
|
423
426
|
|
|
424
427
|
const metadataFieldsReadOnly = entitySource === 'code'
|
|
@@ -438,6 +441,12 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
438
441
|
],
|
|
439
442
|
} as any,
|
|
440
443
|
...(entitySource === 'custom' ? [{ id: 'showInSidebar', label: t('entities.userEntities.form.showInSidebar.label', 'Show in sidebar'), type: 'checkbox' }] : []),
|
|
444
|
+
...(entitySource === 'custom' ? [{
|
|
445
|
+
id: 'accessRestricted',
|
|
446
|
+
label: t('entities.userEntities.form.accessRestricted.label', 'Restrict record access'),
|
|
447
|
+
type: 'checkbox',
|
|
448
|
+
description: t('entities.userEntities.form.accessRestricted.help', 'Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.'),
|
|
449
|
+
}] : []),
|
|
441
450
|
]
|
|
442
451
|
const renderFieldDefinitions = React.useCallback(() => (
|
|
443
452
|
<FieldDefinitionsEditor
|
|
@@ -496,7 +505,7 @@ export default function EditDefinitionsPage({ params }: { params?: { entityId?:
|
|
|
496
505
|
const definitionsGroup: CrudFormGroup = { id: 'definitions', title: t('entities.userEntities.edit.groups.definitions', 'Field Definitions'), column: 1, component: renderFieldDefinitions }
|
|
497
506
|
|
|
498
507
|
const groups: CrudFormGroup[] = [
|
|
499
|
-
{ id: 'settings', title: t('entities.userEntities.edit.groups.settings', 'Entity Settings'), column: 1, fields: entitySource === 'custom' ? ['label','description','defaultEditor','showInSidebar'] : ['label','description','defaultEditor'] },
|
|
508
|
+
{ id: 'settings', title: t('entities.userEntities.edit.groups.settings', 'Entity Settings'), column: 1, fields: entitySource === 'custom' ? ['label','description','defaultEditor','showInSidebar','accessRestricted'] : ['label','description','defaultEditor'] },
|
|
500
509
|
definitionsGroup,
|
|
501
510
|
]
|
|
502
511
|
|
|
@@ -673,7 +682,7 @@ export function buildEntityMetadataPayload(
|
|
|
673
682
|
const partial = entitySource === 'custom'
|
|
674
683
|
? upsertCustomEntitySchema
|
|
675
684
|
.pick({ label: true, description: true, labelField: true as any, defaultEditor: true as any })
|
|
676
|
-
.extend({ showInSidebar: z.boolean().optional() }) as unknown as z.ZodTypeAny
|
|
685
|
+
.extend({ showInSidebar: z.boolean().optional(), accessRestricted: z.boolean().optional() }) as unknown as z.ZodTypeAny
|
|
677
686
|
: upsertCustomEntitySchema
|
|
678
687
|
.pick({ label: true, description: true, defaultEditor: true as any }) as unknown as z.ZodTypeAny
|
|
679
688
|
const normalized = {
|
|
@@ -9,6 +9,7 @@ import { upsertCustomEntitySchema } from '@open-mercato/core/modules/entities/da
|
|
|
9
9
|
import { useRouter } from 'next/navigation'
|
|
10
10
|
import { pushWithFlash } from '@open-mercato/ui/backend/utils/flash'
|
|
11
11
|
import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
|
|
12
|
+
import { LoadingMessage } from '@open-mercato/ui/backend/detail'
|
|
12
13
|
|
|
13
14
|
const schema = upsertCustomEntitySchema
|
|
14
15
|
|
|
@@ -17,6 +18,21 @@ import { Page, PageBody } from '@open-mercato/ui/backend/Page'
|
|
|
17
18
|
export default function CreateEntityPage() {
|
|
18
19
|
const t = useT()
|
|
19
20
|
const router = useRouter()
|
|
21
|
+
// Pre-fill the restriction toggle from the tenant default-restricted policy so
|
|
22
|
+
// the form reflects the workspace posture; null while loading.
|
|
23
|
+
const [defaultRestricted, setDefaultRestricted] = React.useState<boolean | null>(null)
|
|
24
|
+
React.useEffect(() => {
|
|
25
|
+
let active = true
|
|
26
|
+
;(async () => {
|
|
27
|
+
const data = await readApiResultOrThrow<{ newEntitiesRestrictedByDefault?: boolean }>(
|
|
28
|
+
'/api/entities/entity-settings',
|
|
29
|
+
undefined,
|
|
30
|
+
{ errorMessage: '[internal] Failed to load entity settings', fallback: { newEntitiesRestrictedByDefault: false } },
|
|
31
|
+
).catch(() => ({ newEntitiesRestrictedByDefault: false }))
|
|
32
|
+
if (active) setDefaultRestricted(data?.newEntitiesRestrictedByDefault === true)
|
|
33
|
+
})()
|
|
34
|
+
return () => { active = false }
|
|
35
|
+
}, [])
|
|
20
36
|
const fields = React.useMemo<CrudField[]>(() => ([
|
|
21
37
|
{
|
|
22
38
|
id: 'entityId',
|
|
@@ -39,8 +55,24 @@ export default function CreateEntityPage() {
|
|
|
39
55
|
],
|
|
40
56
|
} as unknown as CrudField,
|
|
41
57
|
{ id: 'showInSidebar', label: t('entities.userEntities.form.showInSidebar.label', 'Show in sidebar'), type: 'checkbox' } as CrudField,
|
|
58
|
+
{
|
|
59
|
+
id: 'accessRestricted',
|
|
60
|
+
label: t('entities.userEntities.form.accessRestricted.label', 'Restrict record access'),
|
|
61
|
+
type: 'checkbox',
|
|
62
|
+
description: t('entities.userEntities.form.accessRestricted.help', 'Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.'),
|
|
63
|
+
} as CrudField,
|
|
42
64
|
]), [t])
|
|
43
65
|
|
|
66
|
+
if (defaultRestricted === null) {
|
|
67
|
+
return (
|
|
68
|
+
<Page>
|
|
69
|
+
<PageBody>
|
|
70
|
+
<LoadingMessage label={t('entities.userEntities.form.loading', 'Loading…')} />
|
|
71
|
+
</PageBody>
|
|
72
|
+
</Page>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
return (
|
|
45
77
|
<Page>
|
|
46
78
|
<PageBody>
|
|
@@ -49,7 +81,7 @@ export default function CreateEntityPage() {
|
|
|
49
81
|
backHref="/backend/entities/user"
|
|
50
82
|
schema={schema}
|
|
51
83
|
fields={fields}
|
|
52
|
-
initialValues={{ entityId: 'user:your_entity', label: '', showInSidebar: false }}
|
|
84
|
+
initialValues={{ entityId: 'user:your_entity', label: '', showInSidebar: false, accessRestricted: defaultRestricted }}
|
|
53
85
|
submitLabel={t('entities.userEntities.form.submit', 'Create')}
|
|
54
86
|
cancelHref="/backend/entities/user"
|
|
55
87
|
onSubmit={async (vals) => {
|
|
@@ -136,6 +136,12 @@ export class CustomEntity {
|
|
|
136
136
|
@Property({ name: 'show_in_sidebar', type: 'boolean', default: false })
|
|
137
137
|
showInSidebar: boolean = false
|
|
138
138
|
|
|
139
|
+
// When true, records require an explicit per-entity ACL grant
|
|
140
|
+
// (entities.records.<entity_id>.view/.manage) beyond the coarse
|
|
141
|
+
// entities.records.* feature. Defaults to unrestricted for backward compat.
|
|
142
|
+
@Property({ name: 'access_restricted', type: 'boolean', default: false })
|
|
143
|
+
accessRestricted: boolean = false
|
|
144
|
+
|
|
139
145
|
// Note: Per-field UI preferences (list visibility, filter visibility, form editability)
|
|
140
146
|
// are stored in CustomFieldDef.configJson, not at entity level.
|
|
141
147
|
|
|
@@ -221,6 +221,8 @@
|
|
|
221
221
|
"entities.userEntities.errors.entityIdRequired": "Entitäts-ID ist erforderlich",
|
|
222
222
|
"entities.userEntities.errors.loadFailed": "Entitäten konnten nicht geladen werden",
|
|
223
223
|
"entities.userEntities.flash.created": "Entität erstellt",
|
|
224
|
+
"entities.userEntities.form.accessRestricted.help": "Erfordert eine explizite entitätsspezifische Berechtigung zum Anzeigen oder Verwalten der Datensätze dieser Entität. Deaktiviert lassen, um allen Benutzern mit der allgemeinen Datensatzberechtigung Zugriff zu gewähren.",
|
|
225
|
+
"entities.userEntities.form.accessRestricted.label": "Datensatzzugriff einschränken",
|
|
224
226
|
"entities.userEntities.form.defaultEditor.label": "Standardeditor (mehrzeilig)",
|
|
225
227
|
"entities.userEntities.form.defaultEditor.options.default": "Standard (Markdown)",
|
|
226
228
|
"entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML-Rich-Text",
|
|
@@ -230,6 +232,7 @@
|
|
|
230
232
|
"entities.userEntities.form.entityId.label": "Entitäts-ID",
|
|
231
233
|
"entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
|
|
232
234
|
"entities.userEntities.form.label.label": "Bezeichnung",
|
|
235
|
+
"entities.userEntities.form.loading": "Wird geladen…",
|
|
233
236
|
"entities.userEntities.form.showInSidebar.label": "In der Seitenleiste anzeigen",
|
|
234
237
|
"entities.userEntities.form.submit": "Erstellen",
|
|
235
238
|
"entities.userEntities.form.title": "Entität erstellen",
|
|
@@ -221,6 +221,8 @@
|
|
|
221
221
|
"entities.userEntities.errors.entityIdRequired": "Entity ID is required",
|
|
222
222
|
"entities.userEntities.errors.loadFailed": "Failed to load entities",
|
|
223
223
|
"entities.userEntities.flash.created": "Entity created",
|
|
224
|
+
"entities.userEntities.form.accessRestricted.help": "Require an explicit per-entity permission to view or manage this entity’s records. Leave off to allow anyone with the general records permission.",
|
|
225
|
+
"entities.userEntities.form.accessRestricted.label": "Restrict record access",
|
|
224
226
|
"entities.userEntities.form.defaultEditor.label": "Default Editor (multiline)",
|
|
225
227
|
"entities.userEntities.form.defaultEditor.options.default": "Default (Markdown)",
|
|
226
228
|
"entities.userEntities.form.defaultEditor.options.htmlRichText": "HTML Rich Text",
|
|
@@ -230,6 +232,7 @@
|
|
|
230
232
|
"entities.userEntities.form.entityId.label": "Entity ID",
|
|
231
233
|
"entities.userEntities.form.entityId.placeholder": "module_name:entity_id",
|
|
232
234
|
"entities.userEntities.form.label.label": "Label",
|
|
235
|
+
"entities.userEntities.form.loading": "Loading…",
|
|
233
236
|
"entities.userEntities.form.showInSidebar.label": "Show in sidebar",
|
|
234
237
|
"entities.userEntities.form.submit": "Create",
|
|
235
238
|
"entities.userEntities.form.title": "Create Entity",
|