nuxt-auto-crud 1.19.1 → 1.21.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/README.md CHANGED
@@ -321,6 +321,31 @@ export default defineNuxtConfig({
321
321
  })
322
322
  ```
323
323
 
324
+
325
+ ## 👤 Owner-based Permissions (RBAC)
326
+
327
+ In addition to standard `create`, `read`, `update`, and `delete` permissions, you can assign **Ownership Permissions**:
328
+
329
+ - `list`: Allows a user to view a list of active records (status='active').
330
+ - `list_all`: Allows a user to view **all** records, including inactive ones (e.g., status='inactive', 'draft').
331
+ - `update_own`: Allows a user to update a record **only if they created it**.
332
+ - `delete_own`: Allows a user to delete a record **only if they created it**.
333
+
334
+ **How it works:**
335
+ The module checks for ownership using the following logic:
336
+ 1. **Standard Tables:** Checks if the record has a `createdBy` (or `userId`) column that matches the logged-in user's ID.
337
+ 2. **Users Table:** Checks if the record being accessed is the user's own profile (`id` matches).
338
+
339
+ **Prerequisites:**
340
+ Ensure your schema includes a `createdBy` field for resources where you want this behavior:
341
+
342
+ ```typescript
343
+ export const posts = sqliteTable('posts', {
344
+ // ...
345
+ createdBy: integer('created_by'), // Recommended
346
+ })
347
+ ```
348
+
324
349
  ## ⚠️ Known Issues
325
350
 
326
351
  - **Automatic Relation Expansion:** The module tries to automatically expand foreign keys (e.g., `user_id` -> `user: { name: ... }`). However, this relies on the foreign key column name matching the target table name (e.g., `user_id` for `users`).
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "nuxt-auto-crud",
3
3
  "configKey": "autoCrud",
4
- "version": "1.19.1",
4
+ "version": "1.21.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
@@ -9,4 +9,5 @@ export declare const useRelationDisplay: (schema: {
9
9
  fetchRelations: () => Promise<void>;
10
10
  getDisplayValue: (key: string, value: unknown) => unknown;
11
11
  relationsMap: import("vue").Ref<Record<string, Record<string, string>>, Record<string, Record<string, string>>>;
12
+ forbiddenRelations: import("vue").Ref<Set<string> & Omit<Set<string>, keyof Set<any>>, Set<string> | (Set<string> & Omit<Set<string>, keyof Set<any>>)>;
12
13
  };
@@ -4,6 +4,7 @@ export const useRelationDisplay = (schema) => {
4
4
  const relationsMap = ref({});
5
5
  const displayValues = ref({});
6
6
  const headers = useRequestHeaders(["cookie"]);
7
+ const forbiddenRelations = ref(/* @__PURE__ */ new Set());
7
8
  const fetchRelations = async () => {
8
9
  const { data: relations } = await useFetch("/api/_relations");
9
10
  if (relations.value) {
@@ -30,6 +31,10 @@ export const useRelationDisplay = (schema) => {
30
31
  }
31
32
  } catch (error) {
32
33
  console.error(`Failed to fetch relation data for ${targetTable}:`, error);
34
+ const err = error;
35
+ if (err?.statusCode === 403) {
36
+ forbiddenRelations.value.add(fieldName);
37
+ }
33
38
  }
34
39
  })
35
40
  );
@@ -43,6 +48,7 @@ export const useRelationDisplay = (schema) => {
43
48
  return {
44
49
  fetchRelations,
45
50
  getDisplayValue,
46
- relationsMap
51
+ relationsMap,
52
+ forbiddenRelations
47
53
  };
48
54
  };
@@ -5,7 +5,7 @@ import { db } from "hub:db";
5
5
  import { ensureResourceAccess, formatResourceResult } from "../../utils/handler.js";
6
6
  export default eventHandler(async (event) => {
7
7
  const { model, id } = getRouterParams(event);
8
- const isAdmin = await ensureResourceAccess(event, model, "delete");
8
+ const isAdmin = await ensureResourceAccess(event, model, "delete", { id });
9
9
  const table = getTableForModel(model);
10
10
  const singularName = getModelSingularName(model);
11
11
  const deletedRecord = await db.delete(table).where(eq(table.id, Number(id))).returning().get();
@@ -1,4 +1,5 @@
1
1
  import { eventHandler, getRouterParams, readBody, createError } from "h3";
2
+ import { getUserSession } from "#imports";
2
3
  import { eq } from "drizzle-orm";
3
4
  import { getTableForModel, filterUpdatableFields } from "../../utils/modelMapper.js";
4
5
  import { db } from "hub:db";
@@ -13,6 +14,15 @@ export default eventHandler(async (event) => {
13
14
  if ("updatedAt" in table) {
14
15
  payload.updatedAt = /* @__PURE__ */ new Date();
15
16
  }
17
+ try {
18
+ const session = await getUserSession(event);
19
+ if (session?.user?.id) {
20
+ if ("updatedBy" in table) {
21
+ payload.updatedBy = session.user.id;
22
+ }
23
+ }
24
+ } catch {
25
+ }
16
26
  const updatedRecord = await db.update(table).set(payload).where(eq(table.id, Number(id))).returning().get();
17
27
  if (!updatedRecord) {
18
28
  throw createError({
@@ -1,4 +1,5 @@
1
1
  import { eventHandler, getRouterParams, readBody } from "h3";
2
+ import { getUserSession } from "#imports";
2
3
  import { getTableForModel, filterUpdatableFields } from "../../utils/modelMapper.js";
3
4
  import { db } from "hub:db";
4
5
  import { ensureResourceAccess, formatResourceResult, hashPayloadFields } from "../../utils/handler.js";
@@ -9,6 +10,18 @@ export default eventHandler(async (event) => {
9
10
  const body = await readBody(event);
10
11
  const payload = filterUpdatableFields(model, body);
11
12
  await hashPayloadFields(payload);
13
+ try {
14
+ const session = await getUserSession(event);
15
+ if (session?.user?.id) {
16
+ if ("createdBy" in table) {
17
+ payload.createdBy = session.user.id;
18
+ }
19
+ if ("updatedBy" in table) {
20
+ payload.updatedBy = session.user.id;
21
+ }
22
+ }
23
+ } catch {
24
+ }
12
25
  const newRecord = await db.insert(table).values(payload).returning().get();
13
26
  return formatResourceResult(model, newRecord, isAdmin);
14
27
  });
@@ -1,10 +1,5 @@
1
1
  declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<{
2
2
  resource: string;
3
- fields: {
4
- name: string;
5
- type: string;
6
- required: any;
7
- selectOptions: string[] | undefined;
8
- }[];
3
+ fields: import("../../utils/schema.js").Field[];
9
4
  }>>;
10
5
  export default _default;
@@ -37,10 +37,18 @@ export async function checkAdminAccess(event, model, action, context) {
37
37
  if (model === "users" && String(context.id) === String(user.id)) {
38
38
  return true;
39
39
  }
40
- if ("userId" in table) {
41
- const record = await db.select({ userId: table.userId }).from(table).where(eq(table.id, context.id)).get();
42
- if (record && String(record.userId) === String(user.id)) {
43
- return true;
40
+ const hasCreatedBy = "createdBy" in table;
41
+ const hasUserId = "userId" in table;
42
+ if (hasCreatedBy || hasUserId) {
43
+ const query = db.select().from(table).where(eq(table.id, Number(context.id)));
44
+ const record = await query.get();
45
+ if (record) {
46
+ if (hasCreatedBy) {
47
+ if (String(record.createdBy) === String(user.id)) return true;
48
+ }
49
+ if (hasUserId) {
50
+ if (String(record.userId) === String(user.id)) return true;
51
+ }
44
52
  }
45
53
  }
46
54
  } catch (e) {
@@ -1,20 +1,17 @@
1
+ export interface Field {
2
+ name: string;
3
+ type: string;
4
+ required: boolean;
5
+ selectOptions?: string[];
6
+ references?: string;
7
+ }
1
8
  export declare function drizzleTableToFields(table: any, resourceName: string): {
2
9
  resource: string;
3
- fields: {
4
- name: string;
5
- type: string;
6
- required: any;
7
- selectOptions: string[] | undefined;
8
- }[];
10
+ fields: Field[];
9
11
  };
10
12
  export declare function getRelations(): Promise<Record<string, Record<string, string>>>;
11
13
  export declare function getAllSchemas(): Promise<Record<string, any>>;
12
14
  export declare function getSchema(tableName: string): Promise<{
13
15
  resource: string;
14
- fields: {
15
- name: string;
16
- type: string;
17
- required: any;
18
- selectOptions: string[] | undefined;
19
- }[];
16
+ fields: Field[];
20
17
  } | undefined>;
@@ -15,6 +15,20 @@ export function drizzleTableToFields(table, resourceName) {
15
15
  selectOptions
16
16
  });
17
17
  }
18
+ try {
19
+ const config = getTableConfig(table);
20
+ config.foreignKeys.forEach((fk) => {
21
+ const sourceColumnName = fk.reference().columns[0].name;
22
+ const field = fields.find((f) => {
23
+ return f.name === sourceColumnName;
24
+ });
25
+ if (field) {
26
+ const targetTable = fk.reference().foreignTable[Symbol.for("drizzle:Name")];
27
+ field.references = targetTable;
28
+ }
29
+ });
30
+ } catch {
31
+ }
18
32
  return {
19
33
  resource: resourceName,
20
34
  fields
@@ -44,14 +58,18 @@ export async function getRelations() {
44
58
  for (const [tableName, table] of Object.entries(modelTableMap)) {
45
59
  try {
46
60
  const config = getTableConfig(table);
47
- if (config.foreignKeys.length > 0) {
48
- const tableRelations = {};
49
- relations[tableName] = tableRelations;
50
- const columns = getTableColumns(table);
51
- const columnToProperty = {};
52
- for (const [key, col] of Object.entries(columns)) {
53
- columnToProperty[col.name] = key;
61
+ const tableRelations = {};
62
+ relations[tableName] = tableRelations;
63
+ const columns = getTableColumns(table);
64
+ const columnToProperty = {};
65
+ for (const [key, col] of Object.entries(columns)) {
66
+ const columnName = col.name;
67
+ columnToProperty[columnName] = key;
68
+ if (["createdBy", "created_by", "updatedBy", "updated_by", "deletedBy", "deleted_by"].includes(key)) {
69
+ tableRelations[key] = "users";
54
70
  }
71
+ }
72
+ if (config.foreignKeys.length > 0) {
55
73
  config.foreignKeys.forEach((fk) => {
56
74
  const sourceColumnName = fk.reference().columns[0].name;
57
75
  const sourceProperty = columnToProperty[sourceColumnName] || sourceColumnName;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nuxt-auto-crud",
3
- "version": "1.19.1",
3
+ "version": "1.21.0",
4
4
  "description": "Exposes RESTful CRUD APIs for your Nuxt app based solely on your database migrations.",
5
5
  "author": "Cliford Pereira",
6
6
  "license": "MIT",
@@ -78,8 +78,6 @@
78
78
  "drizzle-orm": "^0.38.3",
79
79
  "eslint": "^9.39.1",
80
80
  "nuxt": "^4.2.1",
81
- "nuxt-auth-utils": "^0.5.25",
82
- "nuxt-authorization": "^0.3.5",
83
81
  "typescript": "~5.9.3",
84
82
  "vitest": "^4.0.13",
85
83
  "vue-tsc": "^3.1.5",
@@ -11,6 +11,8 @@ export const useRelationDisplay = (
11
11
  const displayValues = ref<Record<string, Record<string, string>>>({})
12
12
  const headers = useRequestHeaders(['cookie'])
13
13
 
14
+ const forbiddenRelations = ref<Set<string>>(new Set())
15
+
14
16
  const fetchRelations = async () => {
15
17
  // 1. Fetch relations metadata
16
18
  const { data: relations } = await useFetch<Record<string, Record<string, string>>>('/api/_relations')
@@ -45,8 +47,12 @@ export const useRelationDisplay = (
45
47
  )
46
48
  }
47
49
  }
48
- catch (error) {
50
+ catch (error: unknown) {
49
51
  console.error(`Failed to fetch relation data for ${targetTable}:`, error)
52
+ const err = error as { statusCode?: number }
53
+ if (err?.statusCode === 403) {
54
+ forbiddenRelations.value.add(fieldName)
55
+ }
50
56
  }
51
57
  }),
52
58
  )
@@ -63,5 +69,6 @@ export const useRelationDisplay = (
63
69
  fetchRelations,
64
70
  getDisplayValue,
65
71
  relationsMap,
72
+ forbiddenRelations,
66
73
  }
67
74
  }
@@ -9,7 +9,7 @@ import { ensureResourceAccess, formatResourceResult } from '../../utils/handler'
9
9
 
10
10
  export default eventHandler(async (event) => {
11
11
  const { model, id } = getRouterParams(event) as { model: string, id: string }
12
- const isAdmin = await ensureResourceAccess(event, model, 'delete')
12
+ const isAdmin = await ensureResourceAccess(event, model, 'delete', { id })
13
13
 
14
14
  const table = getTableForModel(model) as TableWithId
15
15
  const singularName = getModelSingularName(model)
@@ -1,5 +1,7 @@
1
1
  // server/api/[model]/[id].patch.ts
2
2
  import { eventHandler, getRouterParams, readBody, createError } from 'h3'
3
+ import type { H3Event } from 'h3'
4
+ import { getUserSession } from '#imports'
3
5
  import { eq } from 'drizzle-orm'
4
6
  import { getTableForModel, filterUpdatableFields } from '../../utils/modelMapper'
5
7
  import type { TableWithId } from '../../types'
@@ -28,6 +30,20 @@ export default eventHandler(async (event) => {
28
30
  (payload as any).updatedAt = new Date()
29
31
  }
30
32
 
33
+ // Inject updatedBy if user is authenticated
34
+ try {
35
+ const session = await (getUserSession as (event: H3Event) => Promise<{ user: { id: string | number } | null }>)(event)
36
+ if (session?.user?.id) {
37
+ if ('updatedBy' in table) {
38
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
39
+ (payload as any).updatedBy = session.user.id
40
+ }
41
+ }
42
+ }
43
+ catch {
44
+ // No session available
45
+ }
46
+
31
47
  const updatedRecord = await db
32
48
  .update(table)
33
49
  .set(payload)
@@ -1,5 +1,7 @@
1
1
  // server/api/[model]/index.post.ts
2
2
  import { eventHandler, getRouterParams, readBody } from 'h3'
3
+ import type { H3Event } from 'h3'
4
+ import { getUserSession } from '#imports'
3
5
  import { getTableForModel, filterUpdatableFields } from '../../utils/modelMapper'
4
6
  // @ts-expect-error - hub:db is a virtual alias
5
7
  import { db } from 'hub:db'
@@ -17,6 +19,27 @@ export default eventHandler(async (event) => {
17
19
  // Auto-hash fields based on config (default: ['password'])
18
20
  await hashPayloadFields(payload)
19
21
 
22
+ // Inject createdBy/updatedBy if user is authenticated
23
+ try {
24
+ const session = await (getUserSession as (event: H3Event) => Promise<{ user: { id: string | number } | null }>)(event)
25
+ if (session?.user?.id) {
26
+ // Check if table has columns before assigning (optional but safer if we had strict types)
27
+ // Since we are passing payload to .values(), extra keys might be ignored or cause error depending on driver
28
+ // Using 'in' table check is good practice
29
+ if ('createdBy' in table) {
30
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
31
+ (payload as any).createdBy = session.user.id
32
+ }
33
+ if ('updatedBy' in table) {
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ (payload as any).updatedBy = session.user.id
36
+ }
37
+ }
38
+ }
39
+ catch {
40
+ // No session available
41
+ }
42
+
20
43
  const newRecord = await db.insert(table).values(payload).returning().get()
21
44
 
22
45
  return formatResourceResult(model, newRecord as Record<string, unknown>, isAdmin)
@@ -62,16 +62,27 @@ export async function checkAdminAccess(event: H3Event, model: string, action: st
62
62
  return true
63
63
  }
64
64
 
65
- // Standard case: Check 'userId' column for ownership
66
- // We need to check if table has userId column.
67
- // We cast to any to check property exist roughly or just try query
68
- if ('userId' in table) {
69
- // @ts-expect-error - dyanmic table access
70
- const record = await db.select({ userId: table.userId }).from(table).where(eq(table.id, context.id)).get()
71
-
72
- // If record exists and userId matches session user id
73
- if (record && String(record.userId) === String(user.id)) {
74
- return true
65
+ // Standard case: Check 'createdBy' or 'userId' column for ownership
66
+
67
+ // const columns = table.columns || {}
68
+
69
+ const hasCreatedBy = 'createdBy' in table
70
+ const hasUserId = 'userId' in table
71
+
72
+ if (hasCreatedBy || hasUserId) {
73
+ // @ts-expect-error - table is dynamic
74
+ const query = db.select().from(table).where(eq(table.id, Number(context.id)))
75
+ const record = await query.get()
76
+
77
+ if (record) {
78
+ // Check createdBy
79
+ if (hasCreatedBy) {
80
+ if (String(record.createdBy) === String(user.id)) return true
81
+ }
82
+ // Check userId (legacy)
83
+ if (hasUserId) {
84
+ if (String(record.userId) === String(user.id)) return true
85
+ }
75
86
  }
76
87
  }
77
88
  }
@@ -2,10 +2,18 @@ import { getTableColumns } from 'drizzle-orm'
2
2
  import { getTableConfig } from 'drizzle-orm/sqlite-core'
3
3
  import { modelTableMap } from './modelMapper'
4
4
 
5
+ export interface Field {
6
+ name: string
7
+ type: string
8
+ required: boolean
9
+ selectOptions?: string[]
10
+ references?: string
11
+ }
12
+
5
13
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
6
14
  export function drizzleTableToFields(table: any, resourceName: string) {
7
15
  const columns = getTableColumns(table)
8
- const fields = []
16
+ const fields: Field[] = []
9
17
 
10
18
  for (const [key, col] of Object.entries(columns)) {
11
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -22,6 +30,31 @@ export function drizzleTableToFields(table: any, resourceName: string) {
22
30
  })
23
31
  }
24
32
 
33
+ try {
34
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
+ const config = getTableConfig(table as any)
36
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
37
+ config.foreignKeys.forEach((fk: any) => {
38
+ const sourceColumnName = fk.reference().columns[0].name
39
+ // Find the field that matches this column
40
+ const field = fields.find((f) => {
41
+ // In simple cases, field.name matches column name.
42
+ // If camelCase mapping handles it differently, we might need adjustments,
43
+ // but typically Drizzle key = field name.
44
+ return f.name === sourceColumnName
45
+ })
46
+
47
+ if (field) {
48
+ // Get target table name
49
+ const targetTable = fk.reference().foreignTable[Symbol.for('drizzle:Name')] as string
50
+ field.references = targetTable
51
+ }
52
+ })
53
+ }
54
+ catch {
55
+ // Ignore error if getTableConfig fails (e.g. not a Drizzle table)
56
+ }
57
+
25
58
  return {
26
59
  resource: resourceName,
27
60
  fields,
@@ -63,19 +96,25 @@ export async function getRelations() {
63
96
  try {
64
97
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
98
  const config = getTableConfig(table as any)
66
- if (config.foreignKeys.length > 0) {
67
- const tableRelations: Record<string, string> = {}
68
- relations[tableName] = tableRelations
99
+ const tableRelations: Record<string, string> = {}
100
+ relations[tableName] = tableRelations
69
101
 
70
- // Map column names to property names
102
+ // Map column names to property names
103
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
104
+ const columns = getTableColumns(table as any)
105
+ const columnToProperty: Record<string, string> = {}
106
+ for (const [key, col] of Object.entries(columns)) {
71
107
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
- const columns = getTableColumns(table as any)
73
- const columnToProperty: Record<string, string> = {}
74
- for (const [key, col] of Object.entries(columns)) {
75
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
76
- columnToProperty[(col as any).name] = key
108
+ const columnName = (col as any).name
109
+ columnToProperty[columnName] = key
110
+
111
+ // Auto-link createdBy/updatedBy to users table
112
+ if (['createdBy', 'created_by', 'updatedBy', 'updated_by', 'deletedBy', 'deleted_by'].includes(key)) {
113
+ tableRelations[key] = 'users'
77
114
  }
115
+ }
78
116
 
117
+ if (config.foreignKeys.length > 0) {
79
118
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
80
119
  config.foreignKeys.forEach((fk: any) => {
81
120
  const sourceColumnName = fk.reference().columns[0].name