Ver Fonte

已成功的部分,现在要进行测试了

Y7000\张扬阳 há 2 semanas atrás
pai
commit
0ecff59f31
75 ficheiros alterados com 5607 adições e 198 exclusões
  1. 5 1
      .env
  2. 4 0
      .env.example
  3. 7 0
      .gitignore
  4. 65 0
      AGENTS.md
  5. 20 0
      components.json
  6. 3 0
      drizzle/0002_married_puppet_master.sql
  7. 686 0
      drizzle/meta/0002_snapshot.json
  8. 7 0
      drizzle/meta/_journal.json
  9. 6 0
      next-env.d.ts
  10. 7 0
      next.config.ts
  11. 745 1
      node_modules/.package-lock.json
  12. 1157 116
      package-lock.json
  13. 29 2
      package.json
  14. 7 0
      postcss.config.mjs
  15. 321 16
      src/actions/media.ts
  16. 82 0
      src/app/admin/permissions/actions.ts
  17. 165 0
      src/app/admin/permissions/page.tsx
  18. 91 0
      src/app/admin/users/actions.ts
  19. 132 0
      src/app/admin/users/page.tsx
  20. 3 0
      src/app/api/auth/[...nextauth]/route.ts
  21. 29 0
      src/app/api/media/[id]/status/route.ts
  22. 22 0
      src/app/api/media/route.ts
  23. 34 0
      src/app/api/media/upload/route.ts
  24. 1 0
      src/app/global.d.ts
  25. 28 0
      src/app/globals.css
  26. 16 0
      src/app/layout.tsx
  27. 26 0
      src/app/login/actions.ts
  28. 49 0
      src/app/login/page.tsx
  29. 159 0
      src/app/media-console.tsx
  30. 42 0
      src/app/media/[id]/hls-player.tsx
  31. 87 0
      src/app/media/[id]/page.tsx
  32. 48 0
      src/app/media/actions.ts
  33. 89 0
      src/app/media/media-list-client.tsx
  34. 72 0
      src/app/media/page.tsx
  35. 16 0
      src/app/page.tsx
  36. 27 0
      src/app/session-bar.tsx
  37. 21 0
      src/auth-types.d.ts
  38. 96 0
      src/auth.ts
  39. 61 0
      src/components/confirm-submit.tsx
  40. 11 0
      src/components/ui/badge.tsx
  41. 38 0
      src/components/ui/button.tsx
  42. 18 0
      src/components/ui/card.tsx
  43. 35 0
      src/components/ui/dialog.tsx
  44. 16 0
      src/components/ui/input.tsx
  45. 6 0
      src/components/ui/label.tsx
  46. 13 0
      src/components/ui/select.tsx
  47. 26 0
      src/components/ui/table.tsx
  48. 35 0
      src/components/ui/tabs.tsx
  49. 13 0
      src/components/ui/textarea.tsx
  50. 1 0
      src/db/index.ts
  51. 3 0
      src/db/schema/media.ts
  52. 28 5
      src/db/seed.ts
  53. 38 0
      src/lib/auth/admin.ts
  54. 19 0
      src/lib/auth/password.ts
  55. 14 16
      src/lib/auth/permission.ts
  56. 27 0
      src/lib/auth/request-context.ts
  57. 9 0
      src/lib/config.ts
  58. 3 1
      src/lib/minio.ts
  59. 2 6
      src/lib/queue/index.ts
  60. 6 0
      src/lib/utils.ts
  61. 27 17
      src/workers/media-processor.ts
  62. 44 0
      tests/apply-media-production-migration.ts
  63. 56 0
      tests/deep-sql-debug.ts
  64. 51 0
      tests/force-migrate.ts
  65. 24 0
      tests/inspect-media-columns.ts
  66. 22 10
      tests/integration/media-pipeline.test.ts
  67. 69 0
      tests/test-media-delete.ts
  68. 100 0
      tests/test-media-list.ts
  69. 88 0
      tests/test-media-permission.ts
  70. 45 0
      tests/test-media-status.ts
  71. 40 0
      tests/test-password-auth.ts
  72. 62 0
      tests/test-permission-admin.ts
  73. 59 0
      tests/test-upload.ts
  74. 63 0
      tests/test-user-admin.ts
  75. 31 7
      tsconfig.json

+ 5 - 1
.env

@@ -10,4 +10,8 @@ MINIO_SECRET_KEY="minioadminpassword"
 
 # --- Redis Configuration (for BullMQ) ---
 REDIS_HOST="127.0.0.1"
-REDIS_PORT=6379
+REDIS_PORT=6379
+
+# --- Auth.js ---
+AUTH_SECRET="GjjUROlF4Wwr9q4nYL0HhJ/Jj3hxGNDjDYMMSysBcGs="
+AUTH_TRUST_HOST=true

+ 4 - 0
.env.example

@@ -1 +1,5 @@
 DATABASE_URL="postgresql://knowledge:MkhCJGAnJKyFYL2A@60.204.184.98:30032/knowledge"
+MEDIA_BUCKET_NAME="zyy"
+MEDIA_URL_EXPIRY_SECONDS=3600
+AUTH_SECRET="replace-with-a-long-random-secret"
+AUTH_TRUST_HOST=true

+ 7 - 0
.gitignore

@@ -0,0 +1,7 @@
+node_modules/
+dist/
+.next/
+next-dev.log
+next-dev.err.log
+media-worker.log
+media-worker.err.log

+ 65 - 0
AGENTS.md

@@ -0,0 +1,65 @@
+# AGENTS.md
+
+Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
+
+**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
+
+## 1. Think Before Coding
+
+**Don't assume. Don't hide confusion. Surface tradeoffs.**
+
+Before implementing:
+- State your assumptions explicitly. If uncertain, ask.
+- If multiple interpretations exist, present them - don't pick silently.
+- If a simpler approach exists, say so. Push back when warranted.
+- If something is unclear, stop. Name what's confusing. Ask.
+
+## 2. Simplicity First
+
+**Minimum code that solves the problem. Nothing speculative.**
+
+- No features beyond what was asked.
+- No abstractions for single-use code.
+- No "flexibility" or "configurability" that wasn't requested.
+- No error handling for impossible scenarios.
+- If you write 200 lines and it could be 50, rewrite it.
+
+Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
+
+## 3. Surgical Changes
+
+**Touch only what you must. Clean up only your own mess.**
+
+When editing existing code:
+- Don't "improve" adjacent code, comments, or formatting.
+- Don't refactor things that aren't broken.
+- Match existing style, even if you'd do it differently.
+- If you notice unrelated dead code, mention it - don't delete it.
+
+When your changes create orphans:
+- Remove imports/variables/functions that YOUR changes made unused.
+- Don't remove pre-existing dead code unless asked.
+
+The test: Every changed line should trace directly to the user's request.
+
+## 4. Goal-Driven Execution
+
+**Define success criteria. Loop until verified.**
+
+Transform tasks into verifiable goals:
+- "Add validation" → "Write tests for invalid inputs, then make them pass"
+- "Fix the bug" → "Write a test that reproduces it, then make it pass"
+- "Refactor X" → "Ensure tests pass before and after"
+
+For multi-step tasks, state a brief plan:
+```
+1. [Step] → verify: [check]
+2. [Step] → verify: [check]
+3. [Step] → verify: [check]
+```
+
+Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
+
+---
+
+**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.

+ 20 - 0
components.json

@@ -0,0 +1,20 @@
+{
+  "$schema": "https://ui.shadcn.com/schema.json",
+  "style": "new-york",
+  "rsc": true,
+  "tsx": true,
+  "tailwind": {
+    "config": "",
+    "css": "src/app/globals.css",
+    "baseColor": "slate",
+    "cssVariables": true
+  },
+  "aliases": {
+    "components": "@/components",
+    "utils": "@/lib/utils",
+    "ui": "@/components/ui",
+    "lib": "@/lib",
+    "hooks": "@/hooks"
+  },
+  "iconLibrary": "lucide"
+}

+ 3 - 0
drizzle/0002_married_puppet_master.sql

@@ -0,0 +1,3 @@
+ALTER TABLE "media" ADD COLUMN "resource_id" uuid;--> statement-breakpoint
+ALTER TABLE "media" ADD COLUMN "error_message" text;--> statement-breakpoint
+ALTER TABLE "media" ADD CONSTRAINT "media_resource_id_resources_id_fk" FOREIGN KEY ("resource_id") REFERENCES "public"."resources"("id") ON DELETE set null ON UPDATE no action;

+ 686 - 0
drizzle/meta/0002_snapshot.json

@@ -0,0 +1,686 @@
+{
+  "id": "718fba5d-df8f-4964-9b78-9e04641b62ec",
+  "prevId": "e8379a18-09c8-4738-8c16-7951d2260472",
+  "version": "7",
+  "dialect": "postgresql",
+  "tables": {
+    "public.group_roles": {
+      "name": "group_roles",
+      "schema": "",
+      "columns": {
+        "group_id": {
+          "name": "group_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role_id": {
+          "name": "role_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "group_roles_group_id_groups_id_fk": {
+          "name": "group_roles_group_id_groups_id_fk",
+          "tableFrom": "group_roles",
+          "tableTo": "groups",
+          "columnsFrom": [
+            "group_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "group_roles_role_id_roles_id_fk": {
+          "name": "group_roles_role_id_roles_id_fk",
+          "tableFrom": "group_roles",
+          "tableTo": "roles",
+          "columnsFrom": [
+            "role_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "group_roles_group_id_role_id_pk": {
+          "name": "group_roles_group_id_role_id_pk",
+          "columns": [
+            "group_id",
+            "role_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.groups": {
+      "name": "groups",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "parent_id": {
+          "name": "parent_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.permissions": {
+      "name": "permissions",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "resource_type": {
+          "name": "resource_type",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.role_permissions": {
+      "name": "role_permissions",
+      "schema": "",
+      "columns": {
+        "role_id": {
+          "name": "role_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "permission_id": {
+          "name": "permission_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "role_permissions_role_id_roles_id_fk": {
+          "name": "role_permissions_role_id_roles_id_fk",
+          "tableFrom": "role_permissions",
+          "tableTo": "roles",
+          "columnsFrom": [
+            "role_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "role_permissions_permission_id_permissions_id_fk": {
+          "name": "role_permissions_permission_id_permissions_id_fk",
+          "tableFrom": "role_permissions",
+          "tableTo": "permissions",
+          "columnsFrom": [
+            "permission_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "role_permissions_role_id_permission_id_pk": {
+          "name": "role_permissions_role_id_permission_id_pk",
+          "columns": [
+            "role_id",
+            "permission_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.roles": {
+      "name": "roles",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "description": {
+          "name": "description",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "roles_name_unique": {
+          "name": "roles_name_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "name"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.user_groups": {
+      "name": "user_groups",
+      "schema": "",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "group_id": {
+          "name": "group_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "user_groups_user_id_users_id_fk": {
+          "name": "user_groups_user_id_users_id_fk",
+          "tableFrom": "user_groups",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "user_groups_group_id_groups_id_fk": {
+          "name": "user_groups_group_id_groups_id_fk",
+          "tableFrom": "user_groups",
+          "tableTo": "groups",
+          "columnsFrom": [
+            "group_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "user_groups_user_id_group_id_pk": {
+          "name": "user_groups_user_id_group_id_pk",
+          "columns": [
+            "user_id",
+            "group_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.user_roles": {
+      "name": "user_roles",
+      "schema": "",
+      "columns": {
+        "user_id": {
+          "name": "user_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "role_id": {
+          "name": "role_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "user_roles_user_id_users_id_fk": {
+          "name": "user_roles_user_id_users_id_fk",
+          "tableFrom": "user_roles",
+          "tableTo": "users",
+          "columnsFrom": [
+            "user_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        },
+        "user_roles_role_id_roles_id_fk": {
+          "name": "user_roles_role_id_roles_id_fk",
+          "tableFrom": "user_roles",
+          "tableTo": "roles",
+          "columnsFrom": [
+            "role_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {
+        "user_roles_user_id_role_id_pk": {
+          "name": "user_roles_user_id_role_id_pk",
+          "columns": [
+            "user_id",
+            "role_id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.users": {
+      "name": "users",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "name": {
+          "name": "name",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "password_hash": {
+          "name": "password_hash",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "users_email_unique": {
+          "name": "users_email_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "email"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.media": {
+      "name": "media",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "resource_id": {
+          "name": "resource_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "filename": {
+          "name": "filename",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "storage_key": {
+          "name": "storage_key",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "mime_type": {
+          "name": "mime_type",
+          "type": "varchar(100)",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "size": {
+          "name": "size",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "status": {
+          "name": "status",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "'pending'"
+        },
+        "metadata": {
+          "name": "metadata",
+          "type": "jsonb",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "error_message": {
+          "name": "error_message",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "media_resource_id_resources_id_fk": {
+          "name": "media_resource_id_resources_id_fk",
+          "tableFrom": "media",
+          "tableTo": "resources",
+          "columnsFrom": [
+            "resource_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "media_storage_key_unique": {
+          "name": "media_storage_key_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "storage_key"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.acl_rules": {
+      "name": "acl_rules",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "resource_id": {
+          "name": "resource_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "subject_type": {
+          "name": "subject_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "subject_id": {
+          "name": "subject_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "permission_type": {
+          "name": "permission_type",
+          "type": "varchar(20)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "action": {
+          "name": "action",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "acl_rules_resource_id_resources_id_fk": {
+          "name": "acl_rules_resource_id_resources_id_fk",
+          "tableFrom": "acl_rules",
+          "tableTo": "resources",
+          "columnsFrom": [
+            "resource_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "cascade",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    },
+    "public.resources": {
+      "name": "resources",
+      "schema": "",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "uuid",
+          "primaryKey": true,
+          "notNull": true,
+          "default": "gen_random_uuid()"
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "path": {
+          "name": "path",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "type": {
+          "name": "type",
+          "type": "varchar(50)",
+          "primaryKey": false,
+          "notNull": true
+        },
+        "owner_id": {
+          "name": "owner_id",
+          "type": "uuid",
+          "primaryKey": false,
+          "notNull": false
+        },
+        "created_at": {
+          "name": "created_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        },
+        "updated_at": {
+          "name": "updated_at",
+          "type": "timestamp",
+          "primaryKey": false,
+          "notNull": true,
+          "default": "now()"
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {
+        "resources_owner_id_users_id_fk": {
+          "name": "resources_owner_id_users_id_fk",
+          "tableFrom": "resources",
+          "tableTo": "users",
+          "columnsFrom": [
+            "owner_id"
+          ],
+          "columnsTo": [
+            "id"
+          ],
+          "onDelete": "set null",
+          "onUpdate": "no action"
+        }
+      },
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {
+        "resources_path_unique": {
+          "name": "resources_path_unique",
+          "nullsNotDistinct": false,
+          "columns": [
+            "path"
+          ]
+        }
+      },
+      "policies": {},
+      "checkConstraints": {},
+      "isRLSEnabled": false
+    }
+  },
+  "enums": {},
+  "schemas": {},
+  "sequences": {},
+  "roles": {},
+  "policies": {},
+  "views": {},
+  "_meta": {
+    "columns": {},
+    "schemas": {},
+    "tables": {}
+  }
+}

+ 7 - 0
drizzle/meta/_journal.json

@@ -15,6 +15,13 @@
       "when": 1776570803965,
       "tag": "0001_slow_black_panther",
       "breakpoints": true
+    },
+    {
+      "idx": 2,
+      "version": "7",
+      "when": 1776672653049,
+      "tag": "0002_married_puppet_master",
+      "breakpoints": true
     }
   ]
 }

+ 6 - 0
next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+/// <reference path="./.next/types/routes.d.ts" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 7 - 0
next.config.ts

@@ -0,0 +1,7 @@
+import type { NextConfig } from 'next';
+
+const nextConfig: NextConfig = {
+  serverExternalPackages: ['bullmq'],
+};
+
+export default nextConfig;

+ 745 - 1
node_modules/.package-lock.json

@@ -4,6 +4,47 @@
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@auth/core": {
+      "version": "0.41.2",
+      "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.41.2.tgz",
+      "integrity": "sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==",
+      "license": "ISC",
+      "dependencies": {
+        "@panva/hkdf": "^1.2.1",
+        "jose": "^6.0.6",
+        "oauth4webapi": "^3.3.0",
+        "preact": "10.24.3",
+        "preact-render-to-string": "6.5.11"
+      },
+      "peerDependencies": {
+        "@simplewebauthn/browser": "^9.0.1",
+        "@simplewebauthn/server": "^9.0.2",
+        "nodemailer": "^7.0.7"
+      },
+      "peerDependenciesMeta": {
+        "@simplewebauthn/browser": {
+          "optional": true
+        },
+        "@simplewebauthn/server": {
+          "optional": true
+        },
+        "nodemailer": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/@drizzle-team/brocli": {
       "version": "0.10.2",
       "resolved": "https://registry.npmjs.org/@drizzle-team/brocli/-/brocli-0.10.2.tgz",
@@ -11,6 +52,16 @@
       "dev": true,
       "license": "Apache-2.0"
     },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
     "node_modules/@esbuild-kit/core-utils": {
       "version": "3.3.2",
       "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@@ -107,12 +158,86 @@
         "node": ">=18"
       }
     },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
     "node_modules/@ioredis/commands": {
       "version": "1.5.1",
       "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
       "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==",
       "license": "MIT"
     },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/remapping": {
+      "version": "2.3.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+      "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.5",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
     "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
       "version": "3.0.3",
       "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
@@ -126,6 +251,28 @@
         "win32"
       ]
     },
+    "node_modules/@next/env": {
+      "version": "15.5.15",
+      "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.15.tgz",
+      "integrity": "sha512-vcmyu5/MyFzN7CdqRHO3uHO44p/QPCZkuTUXroeUmhNP8bL5PHFEhik22JUazt+CDDoD6EpBYRCaS2pISL+/hg==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "15.5.15",
+      "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.15.tgz",
+      "integrity": "sha512-nFucjVdwlFqxh/JG3hWSJ4p8+YJV7Ii8aPDuBQULB6DzUF4UNZETXLfEUk+oI2zEznWWULPt7MeuTE6xtK1HSA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
     "node_modules/@nodable/entities": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz",
@@ -138,6 +285,119 @@
       ],
       "license": "MIT"
     },
+    "node_modules/@panva/hkdf": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz",
+      "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.15",
+      "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
+      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@tailwindcss/node": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
+      "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/remapping": "^2.3.5",
+        "enhanced-resolve": "^5.19.0",
+        "jiti": "^2.6.1",
+        "lightningcss": "1.32.0",
+        "magic-string": "^0.30.21",
+        "source-map-js": "^1.2.1",
+        "tailwindcss": "4.2.2"
+      }
+    },
+    "node_modules/@tailwindcss/oxide": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz",
+      "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 20"
+      },
+      "optionalDependencies": {
+        "@tailwindcss/oxide-android-arm64": "4.2.2",
+        "@tailwindcss/oxide-darwin-arm64": "4.2.2",
+        "@tailwindcss/oxide-darwin-x64": "4.2.2",
+        "@tailwindcss/oxide-freebsd-x64": "4.2.2",
+        "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2",
+        "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2",
+        "@tailwindcss/oxide-linux-arm64-musl": "4.2.2",
+        "@tailwindcss/oxide-linux-x64-gnu": "4.2.2",
+        "@tailwindcss/oxide-linux-x64-musl": "4.2.2",
+        "@tailwindcss/oxide-wasm32-wasi": "4.2.2",
+        "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2",
+        "@tailwindcss/oxide-win32-x64-msvc": "4.2.2"
+      }
+    },
+    "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz",
+      "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 20"
+      }
+    },
+    "node_modules/@tailwindcss/postcss": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz",
+      "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "@tailwindcss/node": "4.2.2",
+        "@tailwindcss/oxide": "4.2.2",
+        "postcss": "^8.5.6",
+        "tailwindcss": "4.2.2"
+      }
+    },
+    "node_modules/@tailwindcss/postcss/node_modules/postcss": {
+      "version": "8.5.10",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
+      "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
     "node_modules/@types/fluent-ffmpeg": {
       "version": "2.1.28",
       "resolved": "https://registry.npmjs.org/@types/fluent-ffmpeg/-/fluent-ffmpeg-2.1.28.tgz",
@@ -168,12 +428,41 @@
         "pg-types": "^2.2.0"
       }
     },
+    "node_modules/@types/react": {
+      "version": "19.2.14",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
+      "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.2.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.2.3",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+      "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.2.0"
+      }
+    },
     "node_modules/async": {
       "version": "3.2.6",
       "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
       "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
       "license": "MIT"
     },
+    "node_modules/bcryptjs": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+      "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+      "license": "BSD-3-Clause",
+      "bin": {
+        "bcrypt": "bin/bcrypt"
+      }
+    },
     "node_modules/block-stream2": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
@@ -220,6 +509,53 @@
         "uuid": "11.1.0"
       }
     },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001788",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
+      "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/class-variance-authority": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz",
+      "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "clsx": "^2.1.1"
+      },
+      "funding": {
+        "url": "https://polar.sh/cva"
+      }
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/clsx": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+      "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
     "node_modules/cluster-key-slot": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
@@ -241,6 +577,13 @@
         "node": ">=12.0.0"
       }
     },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/debug": {
       "version": "4.4.3",
       "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -281,7 +624,6 @@
       "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
       "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
       "license": "Apache-2.0",
-      "optional": true,
       "engines": {
         "node": ">=8"
       }
@@ -439,6 +781,19 @@
         }
       }
     },
+    "node_modules/enhanced-resolve": {
+      "version": "5.20.1",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz",
+      "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==",
+      "license": "MIT",
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.3.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
     "node_modules/esbuild": {
       "version": "0.25.12",
       "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -564,6 +919,18 @@
         "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
       }
     },
+    "node_modules/graceful-fs": {
+      "version": "4.2.11",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+      "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+      "license": "ISC"
+    },
+    "node_modules/hls.js": {
+      "version": "1.6.16",
+      "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz",
+      "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==",
+      "license": "Apache-2.0"
+    },
     "node_modules/inherits": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -609,6 +976,73 @@
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
       "license": "ISC"
     },
+    "node_modules/jiti": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+      "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+      "license": "MIT",
+      "bin": {
+        "jiti": "lib/jiti-cli.mjs"
+      }
+    },
+    "node_modules/jose": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
+      "integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
+    "node_modules/lightningcss": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+      "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+      "license": "MPL-2.0",
+      "dependencies": {
+        "detect-libc": "^2.0.3"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "lightningcss-android-arm64": "1.32.0",
+        "lightningcss-darwin-arm64": "1.32.0",
+        "lightningcss-darwin-x64": "1.32.0",
+        "lightningcss-freebsd-x64": "1.32.0",
+        "lightningcss-linux-arm-gnueabihf": "1.32.0",
+        "lightningcss-linux-arm64-gnu": "1.32.0",
+        "lightningcss-linux-arm64-musl": "1.32.0",
+        "lightningcss-linux-x64-gnu": "1.32.0",
+        "lightningcss-linux-x64-musl": "1.32.0",
+        "lightningcss-win32-arm64-msvc": "1.32.0",
+        "lightningcss-win32-x64-msvc": "1.32.0"
+      }
+    },
+    "node_modules/lightningcss-win32-x64-msvc": {
+      "version": "1.32.0",
+      "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+      "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MPL-2.0",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
     "node_modules/lodash": {
       "version": "4.18.1",
       "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
@@ -627,6 +1061,15 @@
       "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
       "license": "MIT"
     },
+    "node_modules/lucide-react": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.8.0.tgz",
+      "integrity": "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw==",
+      "license": "ISC",
+      "peerDependencies": {
+        "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/luxon": {
       "version": "3.7.2",
       "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
@@ -636,6 +1079,15 @@
         "node": ">=12"
       }
     },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
     "node_modules/mime-db": {
       "version": "1.52.0",
       "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
@@ -718,6 +1170,103 @@
         "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
       }
     },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/next": {
+      "version": "15.5.15",
+      "resolved": "https://registry.npmjs.org/next/-/next-15.5.15.tgz",
+      "integrity": "sha512-VSqCrJwtLVGwAVE0Sb/yikrQfkwkZW9p+lL/J4+xe+G3ZA+QnWPqgcfH1tDUEuk9y+pthzzVFp4L/U8JerMfMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "15.5.15",
+        "@swc/helpers": "0.5.15",
+        "caniuse-lite": "^1.0.30001579",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.6"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "15.5.15",
+        "@next/swc-darwin-x64": "15.5.15",
+        "@next/swc-linux-arm64-gnu": "15.5.15",
+        "@next/swc-linux-arm64-musl": "15.5.15",
+        "@next/swc-linux-x64-gnu": "15.5.15",
+        "@next/swc-linux-x64-musl": "15.5.15",
+        "@next/swc-win32-arm64-msvc": "15.5.15",
+        "@next/swc-win32-x64-msvc": "15.5.15",
+        "sharp": "^0.34.3"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.51.1",
+        "babel-plugin-react-compiler": "*",
+        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/next-auth": {
+      "version": "5.0.0-beta.31",
+      "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.31.tgz",
+      "integrity": "sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==",
+      "license": "ISC",
+      "dependencies": {
+        "@auth/core": "0.41.2"
+      },
+      "peerDependencies": {
+        "@simplewebauthn/browser": "^9.0.1",
+        "@simplewebauthn/server": "^9.0.2",
+        "next": "^14.0.0-0 || ^15.0.0 || ^16.0.0",
+        "nodemailer": "^7.0.7",
+        "react": "^18.2.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@simplewebauthn/browser": {
+          "optional": true
+        },
+        "@simplewebauthn/server": {
+          "optional": true
+        },
+        "nodemailer": {
+          "optional": true
+        }
+      }
+    },
     "node_modules/node-abort-controller": {
       "version": "3.1.1",
       "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
@@ -739,6 +1288,15 @@
         "node-gyp-build-optional-packages-test": "build-test.js"
       }
     },
+    "node_modules/oauth4webapi": {
+      "version": "3.8.5",
+      "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
+      "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/panva"
+      }
+    },
     "node_modules/path-expression-matcher": {
       "version": "1.5.0",
       "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz",
@@ -843,6 +1401,40 @@
         "split2": "^4.1.0"
       }
     },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/postcss": {
+      "version": "8.4.31",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
     "node_modules/postgres-array": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -882,6 +1474,25 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/preact": {
+      "version": "10.24.3",
+      "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz",
+      "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/preact"
+      }
+    },
+    "node_modules/preact-render-to-string": {
+      "version": "6.5.11",
+      "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz",
+      "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==",
+      "license": "MIT",
+      "peerDependencies": {
+        "preact": ">=10"
+      }
+    },
     "node_modules/query-string": {
       "version": "7.1.3",
       "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -900,6 +1511,27 @@
         "url": "https://github.com/sponsors/sindresorhus"
       }
     },
+    "node_modules/react": {
+      "version": "19.2.5",
+      "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
+      "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.2.5",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
+      "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.27.0"
+      },
+      "peerDependencies": {
+        "react": "^19.2.5"
+      }
+    },
     "node_modules/readable-stream": {
       "version": "3.6.2",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
@@ -974,6 +1606,12 @@
         "node": ">=11.0.0"
       }
     },
+    "node_modules/scheduler": {
+      "version": "0.27.0",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+      "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+      "license": "MIT"
+    },
     "node_modules/semver": {
       "version": "7.7.4",
       "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -986,6 +1624,51 @@
         "node": ">=10"
       }
     },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
     "node_modules/source-map": {
       "version": "0.6.1",
       "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
@@ -996,6 +1679,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/source-map-support": {
       "version": "0.5.21",
       "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
@@ -1076,6 +1768,58 @@
       ],
       "license": "MIT"
     },
+    "node_modules/styled-jsx": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
+      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tailwind-merge": {
+      "version": "3.5.0",
+      "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
+      "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
+      "license": "MIT",
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/dcastil"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
+      "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==",
+      "license": "MIT"
+    },
+    "node_modules/tapable": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz",
+      "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
     "node_modules/through2": {
       "version": "4.0.2",
       "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",

Diff do ficheiro suprimidas por serem muito extensas
+ 1157 - 116
package-lock.json


+ 29 - 2
package.json

@@ -4,7 +4,20 @@
   "description": "",
   "main": "index.js",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "dev": "next dev",
+    "build": "next build",
+    "start": "next start",
+    "typecheck": "next typegen && tsc --noEmit",
+    "test:upload": "tsx tests/test-upload.ts",
+    "test:media-status": "tsx tests/test-media-status.ts",
+    "test:media-permission": "tsx tests/test-media-permission.ts",
+    "test:media-list": "tsx tests/test-media-list.ts",
+    "test:media-delete": "tsx tests/test-media-delete.ts",
+    "test:password-auth": "tsx tests/test-password-auth.ts",
+    "test:user-admin": "tsx tests/test-user-admin.ts",
+    "test:permission-admin": "tsx tests/test-permission-admin.ts",
+    "test:media-pipeline": "tsx tests/integration/media-pipeline.test.ts",
+    "worker:media": "tsx src/workers/media-processor.ts"
   },
   "repository": {
     "type": "git",
@@ -15,16 +28,30 @@
   "license": "ISC",
   "type": "commonjs",
   "dependencies": {
+    "@tailwindcss/postcss": "^4.2.2",
     "@types/fluent-ffmpeg": "^2.1.28",
+    "bcryptjs": "^3.0.3",
     "bullmq": "^5.74.1",
+    "class-variance-authority": "^0.7.1",
+    "clsx": "^2.1.1",
     "dotenv": "^17.4.2",
     "drizzle-orm": "^0.45.2",
     "fluent-ffmpeg": "^2.1.3",
+    "hls.js": "^1.6.16",
+    "lucide-react": "^1.8.0",
     "minio": "^8.0.7",
-    "pg": "^8.20.0"
+    "next": "^15.0.0",
+    "next-auth": "^5.0.0-beta.31",
+    "pg": "^8.20.0",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "tailwind-merge": "^3.5.0",
+    "tailwindcss": "^4.2.2"
   },
   "devDependencies": {
     "@types/pg": "^8.20.0",
+    "@types/react": "^19.0.0",
+    "@types/react-dom": "^19.0.0",
     "drizzle-kit": "^0.31.10",
     "tsx": "^4.21.0",
     "typescript": "^6.0.3"

+ 7 - 0
postcss.config.mjs

@@ -0,0 +1,7 @@
+const config = {
+  plugins: {
+    '@tailwindcss/postcss': {},
+  },
+};
+
+export default config;

+ 321 - 16
src/actions/media.ts

@@ -1,36 +1,341 @@
 import { media } from '@/db/schema/media';
+import { resources } from '@/db/schema/resource';
 import { db } from '@/db/index';
 import minioClient from '@/lib/minio';
-import { mediaQueue } from '@/lib/queue';
+import { mediaBucketName, mediaUrlExpirySeconds } from '@/lib/config';
+import { checkPermission } from '@/lib/auth/permission';
+import type { AuthContext } from '@/lib/auth/types';
+import { and, desc, eq, ilike } from 'drizzle-orm';
+
+type MediaMetadata = {
+  hlsPath?: string;
+  processedAt?: string;
+};
+
+export type MediaStatusResult = {
+  id: string;
+  resourceId: string | null;
+  filename: string;
+  status: string;
+  hlsPath: string | null;
+  hlsUrl: string | null;
+  errorMessage: string | null;
+  metadata: MediaMetadata | null;
+  createdAt: Date;
+  updatedAt: Date;
+};
+
+export type MediaListItem = MediaStatusResult & {
+  resourcePath: string | null;
+  ownerId: string | null;
+};
+
+type UploadMediaOptions = {
+  ownerId?: string | null;
+};
+
+type GetMediaStatusOptions = {
+  auth?: AuthContext | null;
+};
+
+type ListMediaOptions = GetMediaStatusOptions & {
+  query?: string;
+  status?: string;
+};
+
+async function removeObjectIfExists(objectName: string) {
+  try {
+    await minioClient.removeObject(mediaBucketName, objectName);
+  } catch (error: any) {
+    if (error?.code !== 'NoSuchKey' && error?.code !== 'NotFound') {
+      throw error;
+    }
+  }
+}
+
+async function removePrefix(prefix: string) {
+  const objectNames: string[] = [];
+  const stream = minioClient.listObjectsV2(mediaBucketName, prefix, true);
+
+  await new Promise<void>((resolve, reject) => {
+    stream.on('data', (object) => {
+      if (object.name) objectNames.push(object.name);
+    });
+    stream.on('error', reject);
+    stream.on('end', resolve);
+  });
+
+  if (objectNames.length > 0) {
+    await minioClient.removeObjects(mediaBucketName, objectNames);
+  }
+}
 
 /**
- * 上传媒体文件并触发处理任务
+ * 上传媒体文件并触发处理任务 (MVP 版本)
+ *
+ * 实现流程:
+ * 1. 解析输入数据
+ * 2. 在数据库事务中创建 media 和 resource 记录
+ * 3. 将原始文件上传至 MinIO
+ * 4. 向 BullMQ 队列投递转码任务
  *
  * @param file FormData 中的 File 对象 (在 Server Action 中通常为 Blob 或 Buffer)
- * @returns 返回创建的媒体记录
+ * @returns 返回创建的媒体记录和资源信息
  */
-export async function uploadMedia(file: { name: string; type: string; size: number; arrayBuffer: () => Promise<ArrayBuffer> }) {
-  const storageKey = `uploads/${Date.now()}-${file.name}`;
+export async function uploadMedia(
+  file: { name: string; type: string; size: number; arrayBuffer: () => Promise<ArrayBuffer> },
+  options: UploadMediaOptions = {}
+) {
+  const objectName = `${Date.now()}-${file.name}`;
+  const storageKey = `uploads/${objectName}`;
+  const resourcePath = `/media/${objectName}`;
   const buffer = Buffer.from(await file.arrayBuffer());
 
-  // 1. 并行执行:写入 MinIO 和 创建数据库记录
-  const [mediaRecord] = await Promise.all([
-    db.insert(media).values({
+  const { mediaRecord, resourceRecord } = await db.transaction(async (tx) => {
+    const [resourceResult] = await tx.insert(resources).values({
+      name: file.name,
+      path: resourcePath,
+      type: 'file',
+      ownerId: options.ownerId || null,
+    }).returning();
+
+    if (!resourceResult) {
+      throw new Error('Failed to create resource record');
+    }
+
+    const [mediaResult] = await tx.insert(media).values({
+      resourceId: resourceResult.id,
       filename: file.name,
       storageKey: storageKey,
       mimeType: file.type,
       size: BigInt(file.size),
       status: 'pending',
-    }).returning(),
+      errorMessage: null,
+    }).returning();
+
+    if (!mediaResult) {
+      throw new Error('Failed to create media record');
+    }
+
+    return { mediaRecord: mediaResult, resourceRecord: resourceResult };
+  });
+
+  try {
+    const exists = await minioClient.bucketExists(mediaBucketName);
+
+    if (!exists) {
+      await minioClient.makeBucket(mediaBucketName);
+    }
+
+    await minioClient.putObject(mediaBucketName, storageKey, buffer);
+  } catch (error: any) {
+    await db.update(media)
+      .set({
+        status: 'failed',
+        errorMessage: error instanceof Error ? error.message : 'MinIO upload failed',
+        updatedAt: new Date(),
+      })
+      .where(eq(media.id, mediaRecord.id));
+
+    throw error;
+  }
+
+  try {
+    const { mediaQueue } = await import('@/lib/queue/index');
+    await mediaQueue.add(
+      'process-media',
+      {
+        mediaId: mediaRecord.id,
+        storageKey: storageKey,
+      },
+      {
+        attempts: 3,
+        backoff: {
+          type: 'exponential',
+          delay: 3000,
+        },
+        removeOnComplete: 100,
+        removeOnFail: 100,
+      }
+    );
+  } catch (error) {
+    await db.update(media)
+      .set({
+        status: 'failed',
+        errorMessage: error instanceof Error ? error.message : 'Queue submission failed',
+        updatedAt: new Date(),
+      })
+      .where(eq(media.id, mediaRecord.id));
+
+    throw error;
+  }
+
+  return {
+    media: mediaRecord,
+    resource: resourceRecord,
+  };
+}
+
+export async function getMediaStatus(
+  mediaId: string,
+  options: GetMediaStatusOptions = {}
+): Promise<MediaStatusResult | null> {
+  const [mediaRecord] = await db
+    .select()
+    .from(media)
+    .where(eq(media.id, mediaId))
+    .limit(1);
+
+  if (!mediaRecord) {
+    return null;
+  }
+
+  const [resourceRecord] = mediaRecord.resourceId
+    ? await db
+      .select()
+      .from(resources)
+      .where(eq(resources.id, mediaRecord.resourceId))
+      .limit(1)
+    : [];
+
+  if (options.auth && resourceRecord) {
+    const permission = await checkPermission(options.auth, resourceRecord.path, 'read', 'video');
+
+    if (!permission.granted) {
+      throw new Error(permission.reason || 'Media access denied');
+    }
+  }
+
+  const metadata = mediaRecord.metadata as MediaMetadata | null;
+  const hlsPath = metadata?.hlsPath || null;
+  const hlsUrl = mediaRecord.status === 'completed' && hlsPath
+    ? await minioClient.presignedGetObject(mediaBucketName, hlsPath, mediaUrlExpirySeconds)
+    : null;
+
+  return {
+    id: mediaRecord.id,
+    resourceId: mediaRecord.resourceId,
+    filename: mediaRecord.filename,
+    status: mediaRecord.status,
+    hlsPath,
+    hlsUrl,
+    errorMessage: mediaRecord.errorMessage,
+    metadata,
+    createdAt: mediaRecord.createdAt,
+    updatedAt: mediaRecord.updatedAt,
+  };
+}
+
+export async function listMedia(options: ListMediaOptions = {}): Promise<MediaListItem[]> {
+  const filters = [];
+  const query = options.query?.trim();
+  const status = options.status?.trim();
+
+  if (query) {
+    filters.push(ilike(media.filename, `%${query}%`));
+  }
+
+  if (status && status !== 'all') {
+    filters.push(eq(media.status, status));
+  }
+
+  const rows = await db
+    .select({
+      id: media.id,
+      resourceId: media.resourceId,
+      filename: media.filename,
+      status: media.status,
+      metadata: media.metadata,
+      errorMessage: media.errorMessage,
+      createdAt: media.createdAt,
+      updatedAt: media.updatedAt,
+      resourcePath: resources.path,
+      ownerId: resources.ownerId,
+    })
+    .from(media)
+    .leftJoin(resources, eq(media.resourceId, resources.id))
+    .where(filters.length > 0 ? and(...filters) : undefined)
+    .orderBy(desc(media.createdAt))
+    .limit(100);
+
+  const visibleRows = [];
+
+  for (const row of rows) {
+    if (options.auth && row.resourcePath) {
+      const permission = await checkPermission(options.auth, row.resourcePath, 'read', 'video');
+      if (!permission.granted) {
+        continue;
+      }
+    }
+
+    visibleRows.push(row);
+  }
+
+  return Promise.all(visibleRows.map(async (row) => {
+    const metadata = row.metadata as MediaMetadata | null;
+    const hlsPath = metadata?.hlsPath || null;
+    const hlsUrl = row.status === 'completed' && hlsPath
+      ? await minioClient.presignedGetObject(mediaBucketName, hlsPath, mediaUrlExpirySeconds)
+      : null;
+
+    return {
+      id: row.id,
+      resourceId: row.resourceId,
+      filename: row.filename,
+      status: row.status,
+      hlsPath,
+      hlsUrl,
+      errorMessage: row.errorMessage,
+      metadata,
+      createdAt: row.createdAt,
+      updatedAt: row.updatedAt,
+      resourcePath: row.resourcePath,
+      ownerId: row.ownerId,
+    };
+  }));
+}
+
+export async function deleteMedia(mediaId: string, options: GetMediaStatusOptions = {}) {
+  if (!options.auth) {
+    throw new Error('Unauthorized');
+  }
+
+  const [mediaRecord] = await db
+    .select()
+    .from(media)
+    .where(eq(media.id, mediaId))
+    .limit(1);
+
+  if (!mediaRecord) {
+    return { deleted: false };
+  }
+
+  const [resourceRecord] = mediaRecord.resourceId
+    ? await db.select().from(resources).where(eq(resources.id, mediaRecord.resourceId)).limit(1)
+    : [];
+
+  if (resourceRecord) {
+    const permission = await checkPermission(options.auth, resourceRecord.path, 'delete', 'video');
+    if (!permission.granted && resourceRecord.ownerId !== options.auth.userId) {
+      throw new Error(permission.reason || 'Media delete denied');
+    }
+  }
+
+  const metadata = mediaRecord.metadata as MediaMetadata | null;
+  const hlsPath = metadata?.hlsPath || null;
+  const hlsPrefix = hlsPath?.includes('/') ? hlsPath.slice(0, hlsPath.lastIndexOf('/') + 1) : null;
 
-    minioClient.putObject('zyy', storageKey, buffer)
-  ]);
+  await removeObjectIfExists(mediaRecord.storageKey);
+  if (hlsPrefix) {
+    await removePrefix(hlsPrefix);
+  }
 
-  // 2. 向 BullMQ 投递任务
-  await mediaQueue.add('process-media', {
-    mediaId: mediaRecord.id,
-    storageKey: storageKey,
+  await db.transaction(async (tx) => {
+    await tx.delete(media).where(eq(media.id, mediaId));
+    if (mediaRecord.resourceId) {
+      await tx.delete(resources).where(eq(resources.id, mediaRecord.resourceId));
+    }
   });
 
-  return mediaRecord;
+  return { deleted: true };
 }

+ 82 - 0
src/app/admin/permissions/actions.ts

@@ -0,0 +1,82 @@
+'use server';
+
+import { eq, and } from 'drizzle-orm';
+import { revalidatePath } from 'next/cache';
+import { db } from '@/db';
+import { aclRules, resources } from '@/db/schema/resource';
+import { permissions, rolePermissions } from '@/db/schema/auth';
+import { requireAdmin } from '@/lib/auth/admin';
+
+function required(formData: FormData, key: string) {
+  const value = formData.get(key);
+  if (typeof value !== 'string' || !value.trim()) {
+    throw new Error(`Missing ${key}`);
+  }
+  return value.trim();
+}
+
+export async function createPermissionAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.insert(permissions).values({
+    action: required(formData, 'action'),
+    resourceType: required(formData, 'resourceType'),
+  }).onConflictDoNothing();
+
+  revalidatePath('/admin/permissions');
+}
+
+export async function assignPermissionToRoleAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.insert(rolePermissions).values({
+    roleId: required(formData, 'roleId'),
+    permissionId: required(formData, 'permissionId'),
+  }).onConflictDoNothing();
+
+  revalidatePath('/admin/permissions');
+}
+
+export async function removePermissionFromRoleAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.delete(rolePermissions).where(and(
+    eq(rolePermissions.roleId, required(formData, 'roleId')),
+    eq(rolePermissions.permissionId, required(formData, 'permissionId')),
+  ));
+
+  revalidatePath('/admin/permissions');
+}
+
+export async function createAclRuleAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.insert(aclRules).values({
+    resourceId: required(formData, 'resourceId'),
+    subjectType: required(formData, 'subjectType'),
+    subjectId: required(formData, 'subjectId'),
+    permissionType: required(formData, 'permissionType'),
+    action: required(formData, 'action'),
+  });
+
+  revalidatePath('/admin/permissions');
+}
+
+export async function deleteAclRuleAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.delete(aclRules).where(eq(aclRules.id, required(formData, 'aclRuleId')));
+  revalidatePath('/admin/permissions');
+}
+
+export async function createResourceAction(formData: FormData) {
+  await requireAdmin();
+
+  await db.insert(resources).values({
+    name: required(formData, 'name'),
+    path: required(formData, 'path'),
+    type: required(formData, 'type'),
+  }).onConflictDoNothing();
+
+  revalidatePath('/admin/permissions');
+}

+ 165 - 0
src/app/admin/permissions/page.tsx

@@ -0,0 +1,165 @@
+import { redirect } from 'next/navigation';
+import { db } from '@/db';
+import { groups, permissions, rolePermissions, roles, users } from '@/db/schema/auth';
+import { aclRules, resources } from '@/db/schema/resource';
+import { requireAdmin } from '@/lib/auth/admin';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Dialog } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { Tabs } from '@/components/ui/tabs';
+import {
+  assignPermissionToRoleAction,
+  createAclRuleAction,
+  createPermissionAction,
+  createResourceAction,
+  deleteAclRuleAction,
+  removePermissionFromRoleAction,
+} from './actions';
+
+export default async function PermissionsAdminPage() {
+  try { await requireAdmin(); } catch { redirect('/login'); }
+
+  const [roleRows, permissionRows, rolePermissionRows, resourceRows, aclRows, userRows, groupRows] = await Promise.all([
+    db.select().from(roles).orderBy(roles.name),
+    db.select().from(permissions).orderBy(permissions.resourceType, permissions.action),
+    db.select().from(rolePermissions),
+    db.select().from(resources).orderBy(resources.path),
+    db.select().from(aclRules).orderBy(aclRules.createdAt),
+    db.select().from(users).orderBy(users.email),
+    db.select().from(groups).orderBy(groups.name),
+  ]);
+
+  const permissionById = new Map(permissionRows.map((permission) => [permission.id, permission]));
+  const resourceById = new Map(resourceRows.map((resource) => [resource.id, resource]));
+  const userById = new Map(userRows.map((user) => [user.id, user.email]));
+  const groupById = new Map(groupRows.map((group) => [group.id, group.name]));
+
+  const actions = (
+    <div className="flex flex-wrap gap-2">
+      <Dialog title="创建权限" trigger="创建权限">
+        <form className="grid gap-4" action={createPermissionAction}>
+          <Label>动作<Input name="action" placeholder="read" required /></Label>
+          <Label>资源类型<Input name="resourceType" placeholder="video" required /></Label>
+          <Button type="submit">创建</Button>
+        </form>
+      </Dialog>
+
+      <Dialog title="角色授权" trigger="角色授权">
+        <form className="grid gap-4" action={assignPermissionToRoleAction}>
+          <Label>角色<Select name="roleId">{roleRows.map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Label>
+          <Label>权限<Select name="permissionId">{permissionRows.map((permission) => <option key={permission.id} value={permission.id}>{permission.resourceType}:{permission.action}</option>)}</Select></Label>
+          <Button type="submit">授权</Button>
+        </form>
+      </Dialog>
+
+      <Dialog title="登记资源" trigger="登记资源">
+        <form className="grid gap-4" action={createResourceAction}>
+          <Label>名称<Input name="name" required /></Label>
+          <Label>路径<Input name="path" placeholder="/media/example.mp4" required /></Label>
+          <Label>类型<Input name="type" placeholder="file" required /></Label>
+          <Button type="submit">登记</Button>
+        </form>
+      </Dialog>
+
+      <Dialog title="添加 ACL" trigger="添加 ACL">
+        <form className="grid gap-4" action={createAclRuleAction}>
+          <Label>资源<Select name="resourceId">{resourceRows.map((resource) => <option key={resource.id} value={resource.id}>{resource.path}</option>)}</Select></Label>
+          <Label>主体类型<Select name="subjectType"><option value="user">user</option><option value="group">group</option></Select></Label>
+          <Label>主体<Select name="subjectId">{userRows.map((user) => <option key={user.id} value={user.id}>user:{user.email}</option>)}{groupRows.map((group) => <option key={group.id} value={group.id}>group:{group.name}</option>)}</Select></Label>
+          <Label>权限类型<Select name="permissionType"><option value="allow">allow</option><option value="deny">deny</option></Select></Label>
+          <Label>动作<Input name="action" placeholder="read" required /></Label>
+          <Button type="submit">添加</Button>
+        </form>
+      </Dialog>
+    </div>
+  );
+
+  const rolePermissionsContent = (
+    <Card>
+      <CardHeader><CardTitle>角色权限</CardTitle></CardHeader>
+      <CardContent>
+        <Table>
+          <TableHeader>
+            <TableRow><TableHead>角色</TableHead><TableHead>权限</TableHead></TableRow>
+          </TableHeader>
+          <TableBody>
+            {roleRows.map((role) => (
+              <TableRow key={role.id}>
+                <TableCell className="font-medium">{role.name}</TableCell>
+                <TableCell>
+                  <div className="flex flex-wrap gap-2">
+                    {rolePermissionRows.filter((row) => row.roleId === role.id).map((row) => {
+                      const permission = row.permissionId ? permissionById.get(row.permissionId) : null;
+                      if (!permission) return null;
+                      return (
+                        <form action={removePermissionFromRoleAction} key={permission.id}>
+                          <input type="hidden" name="roleId" value={role.id} />
+                          <input type="hidden" name="permissionId" value={permission.id} />
+                          <button className="rounded-full border px-3 py-1 text-xs" type="submit">{permission.resourceType}:{permission.action} x</button>
+                        </form>
+                      );
+                    })}
+                  </div>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </CardContent>
+    </Card>
+  );
+
+  const aclContent = (
+    <Card>
+      <CardHeader><CardTitle>ACL 规则</CardTitle></CardHeader>
+      <CardContent>
+        <Table>
+          <TableHeader>
+            <TableRow><TableHead>资源</TableHead><TableHead>主体</TableHead><TableHead>规则</TableHead><TableHead>操作</TableHead></TableRow>
+          </TableHeader>
+          <TableBody>
+            {aclRows.map((rule) => (
+              <TableRow key={rule.id}>
+                <TableCell className="[overflow-wrap:anywhere]">{resourceById.get(rule.resourceId)?.path || rule.resourceId}</TableCell>
+                <TableCell className="[overflow-wrap:anywhere]">{rule.subjectType}:{rule.subjectType === 'user' ? userById.get(rule.subjectId) : groupById.get(rule.subjectId)}</TableCell>
+                <TableCell><Badge>{rule.permissionType}:{rule.action}</Badge></TableCell>
+                <TableCell>
+                  <form action={deleteAclRuleAction}>
+                    <input type="hidden" name="aclRuleId" value={rule.id} />
+                    <Button variant="outline" size="sm" type="submit">删除</Button>
+                  </form>
+                </TableCell>
+              </TableRow>
+            ))}
+          </TableBody>
+        </Table>
+      </CardContent>
+    </Card>
+  );
+
+  return (
+    <main className="min-h-screen p-8 max-md:p-5">
+      <section className="mx-auto max-w-6xl">
+        <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
+          <div>
+            <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Admin</p>
+            <h1 className="text-4xl font-semibold max-md:text-3xl">权限管理</h1>
+          </div>
+          {actions}
+        </div>
+        <Tabs
+          defaultValue="roles"
+          items={[
+            { value: 'roles', label: '角色权限', content: rolePermissionsContent },
+            { value: 'acl', label: 'ACL 规则', content: aclContent },
+          ]}
+        />
+      </section>
+    </main>
+  );
+}

+ 91 - 0
src/app/admin/users/actions.ts

@@ -0,0 +1,91 @@
+'use server';
+
+import { eq } from 'drizzle-orm';
+import { revalidatePath } from 'next/cache';
+import { requireAdmin } from '@/lib/auth/admin';
+import { hashPassword } from '@/lib/auth/password';
+import { db } from '@/db';
+import { groups, roles, userGroups, userRoles, users } from '@/db/schema/auth';
+
+function readRequiredString(formData: FormData, key: string) {
+  const value = formData.get(key);
+
+  if (typeof value !== 'string' || !value.trim()) {
+    throw new Error(`Missing ${key}`);
+  }
+
+  return value.trim();
+}
+
+export async function createUserAction(formData: FormData) {
+  await requireAdmin();
+
+  const email = readRequiredString(formData, 'email').toLowerCase();
+  const name = readRequiredString(formData, 'name');
+  const password = readRequiredString(formData, 'password');
+
+  await db.insert(users).values({
+    email,
+    name,
+    passwordHash: await hashPassword(password),
+  }).onConflictDoNothing({ target: users.email });
+
+  revalidatePath('/admin/users');
+}
+
+export async function resetPasswordAction(formData: FormData) {
+  await requireAdmin();
+
+  const userId = readRequiredString(formData, 'userId');
+  const password = readRequiredString(formData, 'password');
+
+  await db
+    .update(users)
+    .set({ passwordHash: await hashPassword(password), updatedAt: new Date() })
+    .where(eq(users.id, userId));
+
+  revalidatePath('/admin/users');
+}
+
+export async function assignRoleAction(formData: FormData) {
+  await requireAdmin();
+
+  const userId = readRequiredString(formData, 'userId');
+  const roleId = readRequiredString(formData, 'roleId');
+
+  await db.insert(userRoles).values({ userId, roleId }).onConflictDoNothing();
+  revalidatePath('/admin/users');
+}
+
+export async function assignGroupAction(formData: FormData) {
+  await requireAdmin();
+
+  const userId = readRequiredString(formData, 'userId');
+  const groupId = readRequiredString(formData, 'groupId');
+
+  await db.insert(userGroups).values({ userId, groupId }).onConflictDoNothing();
+  revalidatePath('/admin/users');
+}
+
+export async function createGroupAction(formData: FormData) {
+  await requireAdmin();
+
+  const name = readRequiredString(formData, 'name');
+
+  await db.insert(groups).values({ name }).onConflictDoNothing();
+  revalidatePath('/admin/users');
+}
+
+export async function createRoleAction(formData: FormData) {
+  await requireAdmin();
+
+  const name = readRequiredString(formData, 'name');
+  const description = formData.get('description');
+
+  await db.insert(roles).values({
+    name,
+    description: typeof description === 'string' && description.trim() ? description.trim() : null,
+  }).onConflictDoNothing();
+
+  revalidatePath('/admin/users');
+}

+ 132 - 0
src/app/admin/users/page.tsx

@@ -0,0 +1,132 @@
+import { redirect } from 'next/navigation';
+import { db } from '@/db';
+import { groups, roles, userGroups, userRoles, users } from '@/db/schema/auth';
+import { requireAdmin } from '@/lib/auth/admin';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Dialog } from '@/components/ui/dialog';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select } from '@/components/ui/select';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { assignGroupAction, assignRoleAction, createGroupAction, createRoleAction, createUserAction, resetPasswordAction } from './actions';
+
+export default async function UsersAdminPage() {
+  try { await requireAdmin(); } catch { redirect('/login'); }
+
+  const [userRows, roleRows, groupRows, directRoleRows, groupMemberRows] = await Promise.all([
+    db.select().from(users).orderBy(users.email),
+    db.select().from(roles).orderBy(roles.name),
+    db.select().from(groups).orderBy(groups.name),
+    db.select().from(userRoles),
+    db.select().from(userGroups),
+  ]);
+
+  const roleNameById = new Map(roleRows.map((role) => [role.id, role.name]));
+  const groupNameById = new Map(groupRows.map((group) => [group.id, group.name]));
+
+  const headerActions = (
+    <div className="flex flex-wrap gap-2">
+      <Dialog title="创建用户" trigger="创建用户">
+        <form className="grid gap-4" action={createUserAction}>
+          <Label>邮箱<Input name="email" type="email" required /></Label>
+          <Label>姓名<Input name="name" required /></Label>
+          <Label>初始密码<Input name="password" type="password" required /></Label>
+          <Button type="submit">创建</Button>
+        </form>
+      </Dialog>
+      <Dialog title="创建用户组" trigger="创建用户组">
+        <form className="grid gap-4" action={createGroupAction}>
+          <Label>组名<Input name="name" required /></Label>
+          <Button type="submit">创建组</Button>
+        </form>
+      </Dialog>
+      <Dialog title="创建角色" trigger="创建角色">
+        <form className="grid gap-4" action={createRoleAction}>
+          <Label>角色名<Input name="name" required /></Label>
+          <Label>描述<Input name="description" /></Label>
+          <Button type="submit">创建角色</Button>
+        </form>
+      </Dialog>
+    </div>
+  );
+
+  return (
+    <main className="min-h-screen p-8 max-md:p-5">
+      <section className="mx-auto max-w-6xl">
+        <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
+          <div>
+            <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Admin</p>
+            <h1 className="text-4xl font-semibold max-md:text-3xl">用户管理</h1>
+          </div>
+          {headerActions}
+        </div>
+
+        <Card className="overflow-hidden">
+          <CardContent className="p-0">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>用户</TableHead>
+                  <TableHead>角色</TableHead>
+                  <TableHead>用户组</TableHead>
+                  <TableHead>操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {userRows.map((user) => {
+                  const directRoles = directRoleRows
+                    .filter((row) => row.userId === user.id)
+                    .map((row) => row.roleId ? roleNameById.get(row.roleId) : null)
+                    .filter(Boolean);
+                  const memberGroups = groupMemberRows
+                    .filter((row) => row.userId === user.id)
+                    .map((row) => row.groupId ? groupNameById.get(row.groupId) : null)
+                    .filter(Boolean);
+
+                  return (
+                    <TableRow key={user.id}>
+                      <TableCell>
+                        <div className="min-w-0">
+                          <strong className="[overflow-wrap:anywhere]">{user.email}</strong>
+                          <small className="mt-1 block text-[hsl(var(--muted-foreground))]">{user.name || '-'}</small>
+                        </div>
+                      </TableCell>
+                      <TableCell className="[overflow-wrap:anywhere]">{directRoles.join(', ') || '-'}</TableCell>
+                      <TableCell className="[overflow-wrap:anywhere]">{memberGroups.join(', ') || '-'}</TableCell>
+                      <TableCell>
+                        <div className="flex flex-wrap gap-2">
+                          <Dialog title="重置密码" trigger="重置密码">
+                            <form className="grid gap-4" action={resetPasswordAction}>
+                              <input type="hidden" name="userId" value={user.id} />
+                              <Label>新密码<Input name="password" type="password" required /></Label>
+                              <Button type="submit">保存</Button>
+                            </form>
+                          </Dialog>
+                          <Dialog title="分配角色" trigger="分配角色">
+                            <form className="grid gap-4" action={assignRoleAction}>
+                              <input type="hidden" name="userId" value={user.id} />
+                              <Label>角色<Select name="roleId" required>{roleRows.map((role) => <option key={role.id} value={role.id}>{role.name}</option>)}</Select></Label>
+                              <Button type="submit">分配</Button>
+                            </form>
+                          </Dialog>
+                          <Dialog title="加入用户组" trigger="加入组">
+                            <form className="grid gap-4" action={assignGroupAction}>
+                              <input type="hidden" name="userId" value={user.id} />
+                              <Label>用户组<Select name="groupId" required>{groupRows.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}</Select></Label>
+                              <Button type="submit">加入</Button>
+                            </form>
+                          </Dialog>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  );
+                })}
+              </TableBody>
+            </Table>
+          </CardContent>
+        </Card>
+      </section>
+    </main>
+  );
+}

+ 3 - 0
src/app/api/auth/[...nextauth]/route.ts

@@ -0,0 +1,3 @@
+import { handlers } from '@/auth';
+
+export const { GET, POST } = handlers;

+ 29 - 0
src/app/api/media/[id]/status/route.ts

@@ -0,0 +1,29 @@
+import { NextResponse } from 'next/server';
+import { getMediaStatus } from '@/actions/media';
+import { getAuthContextFromRequest } from '@/lib/auth/request-context';
+
+export async function GET(
+  request: Request,
+  context: { params: Promise<{ id: string }> }
+) {
+  try {
+    const { id } = await context.params;
+    const auth = await getAuthContextFromRequest(request);
+    const media = await getMediaStatus(id, { auth });
+
+    if (!media) {
+      return NextResponse.json({ error: 'Media not found' }, { status: 404 });
+    }
+
+    return NextResponse.json({ media });
+  } catch (error) {
+    if (error instanceof Error && error.message.includes('denied')) {
+      return NextResponse.json({ error: error.message }, { status: 403 });
+    }
+
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : 'Status lookup failed' },
+      { status: 500 }
+    );
+  }
+}

+ 22 - 0
src/app/api/media/route.ts

@@ -0,0 +1,22 @@
+import { NextResponse } from 'next/server';
+import { listMedia } from '@/actions/media';
+import { getAuthContextFromRequest } from '@/lib/auth/request-context';
+
+export async function GET(request: Request) {
+  try {
+    const auth = await getAuthContextFromRequest(request);
+
+    if (!auth) {
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+    }
+
+    const media = await listMedia({ auth });
+
+    return NextResponse.json({ media });
+  } catch (error) {
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : 'Media list lookup failed' },
+      { status: 500 }
+    );
+  }
+}

+ 34 - 0
src/app/api/media/upload/route.ts

@@ -0,0 +1,34 @@
+import { NextResponse } from 'next/server';
+import { uploadMedia } from '@/actions/media';
+import { getAuthContextFromRequest } from '@/lib/auth/request-context';
+
+export async function POST(request: Request) {
+  try {
+    const auth = await getAuthContextFromRequest(request);
+    if (!auth) {
+      return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
+    }
+
+    const formData = await request.formData();
+    const file = formData.get('file');
+
+    if (!(file instanceof File)) {
+      return NextResponse.json({ error: 'Missing file' }, { status: 400 });
+    }
+
+    const result = await uploadMedia(file, { ownerId: auth.userId });
+
+    return NextResponse.json({
+      media: {
+        mediaId: result.media.id,
+        filename: result.media.filename,
+        status: result.media.status,
+      },
+    });
+  } catch (error) {
+    return NextResponse.json(
+      { error: error instanceof Error ? error.message : 'Upload failed' },
+      { status: 500 }
+    );
+  }
+}

+ 1 - 0
src/app/global.d.ts

@@ -0,0 +1 @@
+declare module '*.css';

+ 28 - 0
src/app/globals.css

@@ -0,0 +1,28 @@
+@import "tailwindcss";
+
+:root {
+  --background: 210 20% 98%;
+  --foreground: 215 28% 17%;
+  --card: 0 0% 100%;
+  --card-foreground: 215 28% 17%;
+  --primary: 204 63% 32%;
+  --primary-foreground: 0 0% 100%;
+  --muted: 210 16% 93%;
+  --muted-foreground: 215 13% 43%;
+  --destructive: 0 68% 42%;
+  --destructive-foreground: 0 0% 100%;
+  --border: 214 20% 86%;
+  --input: 214 20% 86%;
+  --ring: 204 63% 32%;
+}
+
+* {
+  border-color: hsl(var(--border));
+}
+
+body {
+  margin: 0;
+  background: hsl(var(--background));
+  color: hsl(var(--foreground));
+  font-family: Arial, "Microsoft YaHei", sans-serif;
+}

+ 16 - 0
src/app/layout.tsx

@@ -0,0 +1,16 @@
+import './globals.css';
+import type { Metadata } from 'next';
+import type { ReactNode } from 'react';
+
+export const metadata: Metadata = {
+  title: '企业知识库媒体处理',
+  description: '媒体上传、转码状态与 HLS 播放测试页面',
+};
+
+export default function RootLayout({ children }: { children: ReactNode }) {
+  return (
+    <html lang="zh-CN">
+      <body>{children}</body>
+    </html>
+  );
+}

+ 26 - 0
src/app/login/actions.ts

@@ -0,0 +1,26 @@
+'use server';
+
+import { AuthError } from 'next-auth';
+import { redirect } from 'next/navigation';
+import { signIn, signOut } from '@/auth';
+
+export async function loginAction(formData: FormData) {
+  try {
+    await signIn('credentials', {
+      email: formData.get('email'),
+      password: formData.get('password'),
+      redirectTo: '/',
+    });
+  } catch (error) {
+    if (error instanceof AuthError) {
+      redirect('/login?error=CredentialsSignin');
+    }
+
+    throw error;
+  }
+}
+
+export async function logoutAction() {
+  await signOut({ redirectTo: '/login' });
+  redirect('/login');
+}

+ 49 - 0
src/app/login/page.tsx

@@ -0,0 +1,49 @@
+import { redirect } from 'next/navigation';
+import { auth } from '@/auth';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { loginAction } from './actions';
+
+export default async function LoginPage({
+  searchParams,
+}: {
+  searchParams: Promise<{ error?: string }>;
+}) {
+  const session = await auth();
+  const params = await searchParams;
+
+  if (session?.user?.id) {
+    redirect('/');
+  }
+
+  return (
+    <main className="grid min-h-screen place-items-center p-6">
+      <Card className="w-full max-w-[420px]">
+        <CardHeader>
+          <p className="text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Auth</p>
+          <CardTitle className="text-3xl">登录</CardTitle>
+        </CardHeader>
+        <CardContent>
+          <form className="grid gap-4" action={loginAction}>
+            <Label>
+              邮箱
+              <Input name="email" type="email" defaultValue="admin@ekb.com" required />
+            </Label>
+            <Label>
+              密码
+              <Input name="password" type="password" defaultValue="hashed_password_here" required />
+            </Label>
+            {params.error ? (
+              <div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">
+                邮箱或密码不正确
+              </div>
+            ) : null}
+            <Button type="submit">登录</Button>
+          </form>
+        </CardContent>
+      </Card>
+    </main>
+  );
+}

+ 159 - 0
src/app/media-console.tsx

@@ -0,0 +1,159 @@
+'use client';
+
+import { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react';
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import HlsPlayer from './media/[id]/hls-player';
+
+type MediaStatus = {
+  id: string;
+  resourceId: string | null;
+  filename: string;
+  status: string;
+  hlsPath: string | null;
+  hlsUrl: string | null;
+  errorMessage: string | null;
+  metadata: { hlsPath?: string; processedAt?: string } | null;
+  createdAt: string;
+  updatedAt: string;
+};
+
+type UploadResponse = { mediaId: string; filename: string; status: string };
+const terminalStates = new Set(['completed', 'failed']);
+
+export default function MediaConsole() {
+  const [selectedFile, setSelectedFile] = useState<File | null>(null);
+  const [uploading, setUploading] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [mediaStatus, setMediaStatus] = useState<MediaStatus | null>(null);
+  const [lastUpload, setLastUpload] = useState<UploadResponse | null>(null);
+  const inputRef = useRef<HTMLInputElement | null>(null);
+  const mediaId = lastUpload?.mediaId;
+
+  useEffect(() => {
+    if (!mediaId || terminalStates.has(mediaStatus?.status || '')) return;
+    let cancelled = false;
+
+    async function pollStatus() {
+      try {
+        const response = await fetch(`/api/media/${mediaId}/status`, { cache: 'no-store' });
+        const payload = await response.json();
+        if (!response.ok) throw new Error(payload.error || '状态查询失败');
+        if (!cancelled) setMediaStatus(payload.media);
+      } catch (err) {
+        if (!cancelled) setError(err instanceof Error ? err.message : '状态查询失败');
+      }
+    }
+
+    pollStatus();
+    const timer = window.setInterval(pollStatus, 1500);
+    return () => { cancelled = true; window.clearInterval(timer); };
+  }, [mediaId, mediaStatus?.status]);
+
+  function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
+    setSelectedFile(event.target.files?.[0] || null);
+    setError(null);
+  }
+
+  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
+    event.preventDefault();
+    if (!selectedFile) { setError('请选择一个视频文件'); return; }
+    setUploading(true);
+    setError(null);
+    setMediaStatus(null);
+    setLastUpload(null);
+    const formData = new FormData();
+    formData.append('file', selectedFile);
+
+    try {
+      const response = await fetch('/api/media/upload', { method: 'POST', body: formData });
+      const payload = await response.json();
+      if (!response.ok) throw new Error(payload.error || '上传失败');
+      setLastUpload(payload.media);
+      setMediaStatus({
+        id: payload.media.mediaId,
+        filename: payload.media.filename,
+        status: payload.media.status,
+        hlsPath: null,
+        hlsUrl: null,
+        resourceId: null,
+        errorMessage: null,
+        metadata: null,
+        createdAt: new Date().toISOString(),
+        updatedAt: new Date().toISOString(),
+      });
+    } catch (err) {
+      setError(err instanceof Error ? err.message : '上传失败');
+    } finally {
+      setUploading(false);
+    }
+  }
+
+  const canUpload = Boolean(selectedFile) && !uploading;
+
+  return (
+    <main className="min-h-screen p-8 max-md:p-5">
+      <section className="mx-auto max-w-6xl">
+        <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
+          <div>
+            <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Media Pipeline</p>
+            <h1 className="text-4xl font-semibold max-md:text-3xl">媒体处理控制台</h1>
+          </div>
+          <Badge>{mediaStatus?.status || 'idle'}</Badge>
+        </div>
+
+        <form className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-white p-5 max-md:grid-cols-1" onSubmit={handleSubmit}>
+          <input ref={inputRef} className="hidden" type="file" accept="video/*" onChange={handleFileChange} />
+          <Button variant="outline" type="button" onClick={() => inputRef.current?.click()}>选择视频</Button>
+          <div className="grid min-w-0 gap-1">
+            <strong className="[overflow-wrap:anywhere]">{selectedFile?.name || '未选择文件'}</strong>
+            <span className="text-sm text-[hsl(var(--muted-foreground))]">{selectedFile ? `${Math.ceil(selectedFile.size / 1024)} KB` : '支持 MP4 等视频文件'}</span>
+          </div>
+          <Button type="submit" disabled={!canUpload}>{uploading ? '上传中' : '上传并转码'}</Button>
+        </form>
+
+        {error ? <div className="mt-4 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">{error}</div> : null}
+
+        <section className="mt-5 grid grid-cols-2 gap-5 max-md:grid-cols-1">
+          <Card>
+            <CardHeader><CardTitle>处理状态</CardTitle></CardHeader>
+            <CardContent>
+              <dl className="grid gap-3">
+                {[
+                  ['媒体 ID', mediaStatus?.id || '-'],
+                  ['资源 ID', mediaStatus?.resourceId || '-'],
+                  ['文件名', mediaStatus?.filename || selectedFile?.name || '-'],
+                  ['HLS Key', mediaStatus?.hlsPath || '-'],
+                  ['完成时间', mediaStatus?.metadata?.processedAt || '-'],
+                  ['失败原因', mediaStatus?.errorMessage || '-'],
+                ].map(([label, value]) => (
+                  <div className="grid grid-cols-[100px_minmax(0,1fr)] gap-3 border-b pb-3 last:border-b-0" key={label}>
+                    <dt className="text-[hsl(var(--muted-foreground))]">{label}</dt>
+                    <dd className="[overflow-wrap:anywhere]">{value}</dd>
+                  </div>
+                ))}
+              </dl>
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader><CardTitle>播放预览</CardTitle></CardHeader>
+            <CardContent className="grid gap-4">
+              {mediaStatus?.hlsUrl ? (
+                <>
+                  <HlsPlayer src={mediaStatus.hlsUrl} />
+                  <Button variant="outline" type="button" onClick={() => { window.location.href = '/media'; }}>查看媒体列表</Button>
+                </>
+              ) : (
+                <div className="grid aspect-video w-full place-items-center rounded-md border border-dashed text-[hsl(var(--muted-foreground))]">
+                  {mediaStatus?.status === 'failed' ? '处理失败' : '等待 HLS 输出'}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+        </section>
+      </section>
+    </main>
+  );
+}

+ 42 - 0
src/app/media/[id]/hls-player.tsx

@@ -0,0 +1,42 @@
+'use client';
+
+import Hls from 'hls.js';
+import { useEffect, useRef, useState } from 'react';
+
+export default function HlsPlayer({ src }: { src: string }) {
+  const videoRef = useRef<HTMLVideoElement | null>(null);
+  const [error, setError] = useState<string | null>(null);
+
+  useEffect(() => {
+    const video = videoRef.current;
+    if (!video) return;
+
+    if (video.canPlayType('application/vnd.apple.mpegurl')) {
+      video.src = src;
+      return;
+    }
+
+    if (!Hls.isSupported()) {
+      setError('当前浏览器不支持 HLS 播放');
+      return;
+    }
+
+    const hls = new Hls();
+    hls.loadSource(src);
+    hls.attachMedia(video);
+    hls.on(Hls.Events.ERROR, (_event, data) => {
+      if (data.fatal) {
+        setError(data.details || 'HLS 播放失败');
+      }
+    });
+
+    return () => hls.destroy();
+  }, [src]);
+
+  return (
+    <div className="grid gap-3">
+      <video ref={videoRef} className="aspect-video w-full rounded-md bg-slate-950" controls />
+      {error ? <div className="rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">{error}</div> : null}
+    </div>
+  );
+}

+ 87 - 0
src/app/media/[id]/page.tsx

@@ -0,0 +1,87 @@
+import Link from 'next/link';
+import { notFound, redirect } from 'next/navigation';
+import { auth } from '@/auth';
+import { getMediaStatus } from '@/actions/media';
+import { Badge } from '@/components/ui/badge';
+import { buttonVariants } from '@/components/ui/button';
+import ConfirmSubmit from '@/components/confirm-submit';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { cn } from '@/lib/utils';
+import { deleteMediaAction } from '../actions';
+import HlsPlayer from './hls-player';
+
+export default async function MediaDetailPage({ params }: { params: Promise<{ id: string }> }) {
+  const session = await auth();
+  if (!session?.user?.id) redirect('/login');
+
+  const { id } = await params;
+  const media = await getMediaStatus(id, {
+    auth: {
+      userId: session.user.id,
+      groupIds: session.user.groupIds || [],
+    },
+  }).catch(() => null);
+
+  if (!media) notFound();
+
+  return (
+    <main className="min-h-screen p-8 max-md:p-5">
+      <section className="mx-auto max-w-6xl">
+        <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
+          <div>
+            <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Media Player</p>
+            <h1 className="text-4xl font-semibold max-md:text-3xl">{media.filename}</h1>
+          </div>
+          <div className="flex flex-wrap gap-2">
+            <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/media">返回列表</Link>
+            <ConfirmSubmit
+              action={deleteMediaAction}
+              title="删除媒体"
+              description={`确认删除 ${media.filename}?原始文件、HLS 输出和资源记录都会被清理。`}
+              confirmText="删除媒体"
+              fields={{ mediaId: media.id }}
+            >
+              删除媒体
+            </ConfirmSubmit>
+          </div>
+        </div>
+
+        <section className="grid grid-cols-[minmax(0,1.45fr)_minmax(320px,0.55fr)] gap-5 max-lg:grid-cols-1">
+          <Card>
+            <CardHeader><CardTitle>播放</CardTitle></CardHeader>
+            <CardContent>
+              {media.hlsUrl ? (
+                <HlsPlayer src={media.hlsUrl} />
+              ) : (
+                <div className="grid aspect-video place-items-center rounded-md border border-dashed text-[hsl(var(--muted-foreground))]">
+                  {media.status === 'failed' ? '处理失败' : '媒体尚未完成转码'}
+                </div>
+              )}
+            </CardContent>
+          </Card>
+
+          <Card>
+            <CardHeader><CardTitle>媒体信息</CardTitle></CardHeader>
+            <CardContent>
+              <dl className="grid gap-3">
+                {[
+                  ['状态', media.status],
+                  ['媒体 ID', media.id],
+                  ['资源 ID', media.resourceId || '-'],
+                  ['HLS Key', media.hlsPath || '-'],
+                  ['完成时间', media.metadata?.processedAt || '-'],
+                  ['失败原因', media.errorMessage || '-'],
+                ].map(([label, value]) => (
+                  <div className="grid gap-1 border-b pb-3 last:border-b-0" key={label}>
+                    <dt className="text-sm text-[hsl(var(--muted-foreground))]">{label}</dt>
+                    <dd className="[overflow-wrap:anywhere]">{label === '状态' ? <Badge>{value}</Badge> : value}</dd>
+                  </div>
+                ))}
+              </dl>
+            </CardContent>
+          </Card>
+        </section>
+      </section>
+    </main>
+  );
+}

+ 48 - 0
src/app/media/actions.ts

@@ -0,0 +1,48 @@
+'use server';
+
+import { redirect } from 'next/navigation';
+import { revalidatePath } from 'next/cache';
+import { auth } from '@/auth';
+import { deleteMedia } from '@/actions/media';
+
+export async function deleteMediaAction(formData: FormData) {
+  const session = await auth();
+  const mediaId = formData.get('mediaId');
+
+  if (!session?.user?.id || typeof mediaId !== 'string' || !mediaId) {
+    throw new Error('Unauthorized');
+  }
+
+  await deleteMedia(mediaId, {
+    auth: {
+      userId: session.user.id,
+      groupIds: session.user.groupIds || [],
+    },
+  });
+
+  revalidatePath('/media');
+  redirect('/media');
+}
+
+export async function deleteMediaBatchAction(formData: FormData) {
+  const session = await auth();
+  const mediaIds = formData.get('mediaIds');
+
+  if (!session?.user?.id || typeof mediaIds !== 'string') {
+    throw new Error('Unauthorized');
+  }
+
+  const ids = mediaIds.split(',').map((id) => id.trim()).filter(Boolean);
+
+  for (const mediaId of ids) {
+    await deleteMedia(mediaId, {
+      auth: {
+        userId: session.user.id,
+        groupIds: session.user.groupIds || [],
+      },
+    });
+  }
+
+  revalidatePath('/media');
+  redirect('/media');
+}

+ 89 - 0
src/app/media/media-list-client.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+import Link from 'next/link';
+import { Play, Trash2 } from 'lucide-react';
+import { useMemo, useState } from 'react';
+import type { MediaListItem } from '@/actions/media';
+import ConfirmSubmit from '@/components/confirm-submit';
+import { Badge } from '@/components/ui/badge';
+import { buttonVariants } from '@/components/ui/button';
+import { Card } from '@/components/ui/card';
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
+import { cn } from '@/lib/utils';
+import { deleteMediaBatchAction, deleteMediaAction } from './actions';
+
+export default function MediaListClient({ mediaItems }: { mediaItems: MediaListItem[] }) {
+  const [selectedIds, setSelectedIds] = useState<string[]>([]);
+  const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
+  const allSelected = mediaItems.length > 0 && selectedIds.length === mediaItems.length;
+
+  function toggleOne(id: string) {
+    setSelectedIds((current) => current.includes(id) ? current.filter((item) => item !== id) : [...current, id]);
+  }
+
+  function toggleAll() {
+    setSelectedIds(allSelected ? [] : mediaItems.map((item) => item.id));
+  }
+
+  return (
+    <Card className="overflow-hidden">
+      <div className="flex items-center justify-between gap-3 border-b p-4">
+        <span className="text-sm text-[hsl(var(--muted-foreground))]">已选择 {selectedIds.length} 项</span>
+        {selectedIds.length > 0 ? (
+          <ConfirmSubmit
+            action={deleteMediaBatchAction}
+            title="批量删除媒体"
+            description={`确认删除选中的 ${selectedIds.length} 个媒体?原始文件、HLS 输出和资源记录都会被清理。`}
+            confirmText="批量删除"
+            fields={{ mediaIds: selectedIds.join(',') }}
+          >
+            <span className="inline-flex items-center gap-2"><Trash2 className="size-4" />批量删除</span>
+          </ConfirmSubmit>
+        ) : null}
+      </div>
+      <Table>
+        <TableHeader>
+          <TableRow>
+            <TableHead><input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="选择全部媒体" /></TableHead>
+            <TableHead>文件</TableHead>
+            <TableHead>状态</TableHead>
+            <TableHead>资源路径</TableHead>
+            <TableHead>Owner</TableHead>
+            <TableHead>操作</TableHead>
+          </TableRow>
+        </TableHeader>
+        <TableBody>
+          {mediaItems.map((item) => (
+            <TableRow key={item.id}>
+              <TableCell><input type="checkbox" checked={selectedSet.has(item.id)} onChange={() => toggleOne(item.id)} aria-label={`选择 ${item.filename}`} /></TableCell>
+              <TableCell><strong>{item.filename}</strong><small className="mt-1 block [overflow-wrap:anywhere] text-[hsl(var(--muted-foreground))]">{item.id}</small></TableCell>
+              <TableCell><Badge>{item.status}</Badge></TableCell>
+              <TableCell className="[overflow-wrap:anywhere]">{item.resourcePath || '-'}</TableCell>
+              <TableCell className="[overflow-wrap:anywhere]">{item.ownerId || '-'}</TableCell>
+              <TableCell>
+                <div className="flex flex-wrap gap-2">
+                  {item.status === 'completed' ? (
+                    <Link className={cn(buttonVariants({ variant: 'outline', size: 'sm' }))} href={`/media/${item.id}`}>
+                      <Play className="size-4" />播放
+                    </Link>
+                  ) : null}
+                  <ConfirmSubmit
+                    action={deleteMediaAction}
+                    title="删除媒体"
+                    description={`确认删除 ${item.filename}?原始文件、HLS 输出和资源记录都会被清理。`}
+                    confirmText="删除"
+                    fields={{ mediaId: item.id }}
+                  >
+                    <span className="inline-flex items-center gap-2"><Trash2 className="size-4" />删除</span>
+                  </ConfirmSubmit>
+                  {item.errorMessage ? <span className="text-xs text-red-800">{item.errorMessage}</span> : null}
+                </div>
+              </TableCell>
+            </TableRow>
+          ))}
+        </TableBody>
+      </Table>
+      {mediaItems.length === 0 ? <div className="grid min-h-44 place-items-center rounded-lg border border-dashed text-[hsl(var(--muted-foreground))]">暂无可见媒体</div> : null}
+    </Card>
+  );
+}

+ 72 - 0
src/app/media/page.tsx

@@ -0,0 +1,72 @@
+import Link from 'next/link';
+import { Search } from 'lucide-react';
+import { redirect } from 'next/navigation';
+import { auth } from '@/auth';
+import { listMedia } from '@/actions/media';
+import { buttonVariants } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Select } from '@/components/ui/select';
+import { cn } from '@/lib/utils';
+import MediaListClient from './media-list-client';
+
+type MediaListPageProps = {
+  searchParams?: Promise<{
+    q?: string;
+    status?: string;
+  }>;
+};
+
+const statusOptions = [
+  { value: 'all', label: '全部状态' },
+  { value: 'pending', label: '等待处理' },
+  { value: 'processing', label: '处理中' },
+  { value: 'completed', label: '已完成' },
+  { value: 'failed', label: '失败' },
+];
+
+export default async function MediaListPage({ searchParams }: MediaListPageProps) {
+  const session = await auth();
+  if (!session?.user?.id) redirect('/login');
+
+  const params = await searchParams;
+  const query = params?.q?.trim() || '';
+  const status = params?.status?.trim() || 'all';
+
+  const mediaItems = await listMedia({
+    auth: { userId: session.user.id, groupIds: session.user.groupIds || [] },
+    query,
+    status,
+  });
+
+  return (
+    <main className="min-h-screen p-8 max-md:p-5">
+      <section className="mx-auto max-w-6xl">
+        <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
+          <div>
+            <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Media Library</p>
+            <h1 className="text-4xl font-semibold max-md:text-3xl">媒体列表</h1>
+          </div>
+          <Link className={cn(buttonVariants({ variant: 'outline' }))} href="/">上传媒体</Link>
+        </div>
+
+        <form className="mb-4 flex flex-wrap items-center gap-3" action="/media">
+          <div className="relative min-w-72 flex-1 max-md:min-w-full">
+            <Search className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[hsl(var(--muted-foreground))]" aria-hidden="true" />
+            <Input className="pl-9" name="q" defaultValue={query} placeholder="搜索文件名" />
+          </div>
+          <Select name="status" defaultValue={status} className="w-40 max-md:w-full">
+            {statusOptions.map((option) => (
+              <option key={option.value} value={option.value}>{option.label}</option>
+            ))}
+          </Select>
+          <button className={cn(buttonVariants())} type="submit">筛选</button>
+          {(query || status !== 'all') ? (
+            <Link className={cn(buttonVariants({ variant: 'ghost' }))} href="/media">清除</Link>
+          ) : null}
+        </form>
+
+        <MediaListClient mediaItems={mediaItems} />
+      </section>
+    </main>
+  );
+}

+ 16 - 0
src/app/page.tsx

@@ -0,0 +1,16 @@
+import MediaConsole from './media-console';
+import SessionBar from './session-bar';
+import { auth } from '@/auth';
+import { redirect } from 'next/navigation';
+
+export default async function HomePage() {
+  const session = await auth();
+  if (!session?.user?.id) redirect('/login');
+
+  return (
+    <>
+      <SessionBar />
+      <MediaConsole />
+    </>
+  );
+}

+ 27 - 0
src/app/session-bar.tsx

@@ -0,0 +1,27 @@
+import Link from 'next/link';
+import { auth } from '@/auth';
+import { Button, buttonVariants } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { logoutAction } from './login/actions';
+
+export default async function SessionBar() {
+  const session = await auth();
+
+  return (
+    <div className="mx-auto -mb-3 mt-5 flex max-w-6xl items-center justify-end gap-3 px-8 text-sm text-[hsl(var(--muted-foreground))]">
+      {session?.user?.id ? (
+        <>
+          <span>{session.user.email || session.user.name || session.user.id}</span>
+          <Link className={cn(buttonVariants({ variant: 'ghost', size: 'sm' }))} href="/media">媒体列表</Link>
+          <Link className={cn(buttonVariants({ variant: 'ghost', size: 'sm' }))} href="/admin/users">用户管理</Link>
+          <Link className={cn(buttonVariants({ variant: 'ghost', size: 'sm' }))} href="/admin/permissions">权限管理</Link>
+          <form action={logoutAction}>
+            <Button variant="ghost" size="sm" type="submit">退出</Button>
+          </form>
+        </>
+      ) : (
+        <Link className={cn(buttonVariants({ variant: 'ghost', size: 'sm' }))} href="/login">登录</Link>
+      )}
+    </div>
+  );
+}

+ 21 - 0
src/auth-types.d.ts

@@ -0,0 +1,21 @@
+import type { DefaultSession } from 'next-auth';
+
+declare module 'next-auth' {
+  interface Session {
+    user: {
+      id: string;
+      groupIds: string[];
+    } & DefaultSession['user'];
+  }
+
+  interface User {
+    id: string;
+  }
+}
+
+declare module 'next-auth/jwt' {
+  interface JWT {
+    userId?: string;
+    groupIds?: string[];
+  }
+}

+ 96 - 0
src/auth.ts

@@ -0,0 +1,96 @@
+import NextAuth from 'next-auth';
+import Credentials from 'next-auth/providers/credentials';
+import { eq } from 'drizzle-orm';
+import { db } from '@/db';
+import { userGroups, users } from '@/db/schema/auth';
+import { hashPassword, isPasswordHash, verifyPassword } from '@/lib/auth/password';
+
+async function getUserGroupIds(userId: string) {
+  const memberships = await db
+    .select({ groupId: userGroups.groupId })
+    .from(userGroups)
+    .where(eq(userGroups.userId, userId));
+
+  return memberships
+    .map((membership) => membership.groupId)
+    .filter((groupId): groupId is string => Boolean(groupId));
+}
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+  trustHost: true,
+  session: {
+    strategy: 'jwt',
+  },
+  pages: {
+    signIn: '/login',
+  },
+  providers: [
+    Credentials({
+      credentials: {
+        email: {},
+        password: {},
+      },
+      async authorize(credentials) {
+        const email = typeof credentials?.email === 'string'
+          ? credentials.email.trim().toLowerCase()
+          : '';
+        const password = typeof credentials?.password === 'string'
+          ? credentials.password
+          : '';
+
+        if (!email || !password) {
+          return null;
+        }
+
+        const [user] = await db
+          .select()
+          .from(users)
+          .where(eq(users.email, email))
+          .limit(1);
+
+        if (!user) {
+          return null;
+        }
+
+        const passwordMatches = await verifyPassword(password, user.passwordHash);
+
+        if (!passwordMatches) {
+          return null;
+        }
+
+        if (!isPasswordHash(user.passwordHash)) {
+          await db
+            .update(users)
+            .set({ passwordHash: await hashPassword(password), updatedAt: new Date() })
+            .where(eq(users.id, user.id));
+        }
+
+        return {
+          id: user.id,
+          email: user.email,
+          name: user.name,
+        };
+      },
+    }),
+  ],
+  callbacks: {
+    async jwt({ token, user }) {
+      if (user?.id) {
+        token.userId = user.id;
+        token.groupIds = await getUserGroupIds(user.id);
+      }
+
+      return token;
+    },
+    async session({ session, token }) {
+      if (session.user) {
+        session.user.id = typeof token.userId === 'string' ? token.userId : '';
+        session.user.groupIds = Array.isArray(token.groupIds)
+          ? token.groupIds.filter((groupId): groupId is string => typeof groupId === 'string')
+          : [];
+      }
+
+      return session;
+    },
+  },
+});

+ 61 - 0
src/components/confirm-submit.tsx

@@ -0,0 +1,61 @@
+'use client';
+
+import { ReactNode, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+
+type ConfirmSubmitProps = {
+  action: (formData: FormData) => void | Promise<void>;
+  children: ReactNode;
+  title: string;
+  description: string;
+  confirmText?: string;
+  fields?: Record<string, string>;
+};
+
+export default function ConfirmSubmit({
+  action,
+  children,
+  title,
+  description,
+  confirmText = '确认',
+  fields = {},
+}: ConfirmSubmitProps) {
+  const [open, setOpen] = useState(false);
+  const formRef = useRef<HTMLFormElement | null>(null);
+
+  return (
+    <>
+      <Button variant="destructive" size="sm" type="button" onClick={() => setOpen(true)}>
+        {children}
+      </Button>
+      <form ref={formRef} action={action}>
+        {Object.entries(fields).map(([name, value]) => (
+          <input key={name} type="hidden" name={name} value={value} />
+        ))}
+      </form>
+      {open ? (
+        <div className="fixed inset-0 z-50 grid place-items-center bg-black/35 p-4" role="dialog" aria-modal="true">
+          <div className="w-full max-w-md rounded-lg border bg-white shadow-lg">
+            <div className="grid gap-2 border-b p-5">
+              <h2 className="text-lg font-semibold">{title}</h2>
+              <p className="text-sm text-[hsl(var(--muted-foreground))]">{description}</p>
+            </div>
+            <div className="flex justify-end gap-2 p-5">
+              <Button variant="outline" type="button" onClick={() => setOpen(false)}>取消</Button>
+              <Button
+                variant="destructive"
+                type="button"
+                onClick={() => {
+                  setOpen(false);
+                  formRef.current?.requestSubmit();
+                }}
+              >
+                {confirmText}
+              </Button>
+            </div>
+          </div>
+        </div>
+      ) : null}
+    </>
+  );
+}

+ 11 - 0
src/components/ui/badge.tsx

@@ -0,0 +1,11 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export function Badge({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
+  return (
+    <span
+      className={cn('inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium', className)}
+      {...props}
+    />
+  );
+}

+ 38 - 0
src/components/ui/button.tsx

@@ -0,0 +1,38 @@
+import * as React from 'react';
+import { cva, type VariantProps } from 'class-variance-authority';
+import { cn } from '@/lib/utils';
+
+const buttonVariants = cva(
+  'inline-flex h-10 items-center justify-center rounded-md px-4 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] disabled:pointer-events-none disabled:opacity-50',
+  {
+    variants: {
+      variant: {
+        default: 'bg-[hsl(var(--primary))] text-[hsl(var(--primary-foreground))] hover:opacity-90',
+        outline: 'border bg-white text-[hsl(var(--primary))] hover:bg-[hsl(var(--muted))]',
+        ghost: 'text-[hsl(var(--primary))] hover:bg-[hsl(var(--muted))]',
+        destructive: 'bg-[hsl(var(--destructive))] text-[hsl(var(--destructive-foreground))] hover:opacity-90',
+      },
+      size: {
+        default: 'h-10 px-4',
+        sm: 'h-8 px-3 text-xs',
+      },
+    },
+    defaultVariants: {
+      variant: 'default',
+      size: 'default',
+    },
+  }
+);
+
+export interface ButtonProps
+  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
+    VariantProps<typeof buttonVariants> {}
+
+export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
+  ({ className, variant, size, ...props }, ref) => (
+    <button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
+  )
+);
+Button.displayName = 'Button';
+
+export { buttonVariants };

+ 18 - 0
src/components/ui/card.tsx

@@ -0,0 +1,18 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn('rounded-lg border bg-white text-[hsl(var(--card-foreground))]', className)} {...props} />;
+}
+
+export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn('grid gap-1.5 p-5', className)} {...props} />;
+}
+
+export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
+  return <h2 className={cn('text-lg font-semibold', className)} {...props} />;
+}
+
+export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
+  return <div className={cn('p-5 pt-0', className)} {...props} />;
+}

+ 35 - 0
src/components/ui/dialog.tsx

@@ -0,0 +1,35 @@
+'use client';
+
+import { ReactNode, useState } from 'react';
+import { Button, type ButtonProps } from './button';
+import { cn } from '@/lib/utils';
+
+type DialogProps = {
+  trigger: ReactNode;
+  title: string;
+  children: ReactNode;
+  triggerVariant?: ButtonProps['variant'];
+};
+
+export function Dialog({ trigger, title, children, triggerVariant = 'outline' }: DialogProps) {
+  const [open, setOpen] = useState(false);
+
+  return (
+    <>
+      <Button type="button" variant={triggerVariant} onClick={() => setOpen(true)}>
+        {trigger}
+      </Button>
+      {open ? (
+        <div className="fixed inset-0 z-50 grid place-items-center bg-black/35 p-4" role="dialog" aria-modal="true">
+          <div className="w-full max-w-lg rounded-lg border bg-white shadow-lg">
+            <div className="flex items-center justify-between border-b p-5">
+              <h2 className="text-lg font-semibold">{title}</h2>
+              <Button type="button" variant="ghost" size="sm" onClick={() => setOpen(false)}>关闭</Button>
+            </div>
+            <div className={cn('p-5')}>{children}</div>
+          </div>
+        </div>
+      ) : null}
+    </>
+  );
+}

+ 16 - 0
src/components/ui/input.tsx

@@ -0,0 +1,16 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
+  ({ className, ...props }, ref) => (
+    <input
+      ref={ref}
+      className={cn(
+        'flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] disabled:cursor-not-allowed disabled:opacity-50',
+        className
+      )}
+      {...props}
+    />
+  )
+);
+Input.displayName = 'Input';

+ 6 - 0
src/components/ui/label.tsx

@@ -0,0 +1,6 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export function Label({ className, ...props }: React.LabelHTMLAttributes<HTMLLabelElement>) {
+  return <label className={cn('grid gap-2 text-sm font-medium text-[hsl(var(--muted-foreground))]', className)} {...props} />;
+}

+ 13 - 0
src/components/ui/select.tsx

@@ -0,0 +1,13 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export const Select = React.forwardRef<HTMLSelectElement, React.SelectHTMLAttributes<HTMLSelectElement>>(
+  ({ className, ...props }, ref) => (
+    <select
+      ref={ref}
+      className={cn('flex h-10 w-full rounded-md border bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))]', className)}
+      {...props}
+    />
+  )
+);
+Select.displayName = 'Select';

+ 26 - 0
src/components/ui/table.tsx

@@ -0,0 +1,26 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export function Table({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) {
+  return <table className={cn('w-full caption-bottom text-sm', className)} {...props} />;
+}
+
+export function TableHeader({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
+  return <thead className={cn('[&_tr]:border-b', className)} {...props} />;
+}
+
+export function TableBody({ className, ...props }: React.HTMLAttributes<HTMLTableSectionElement>) {
+  return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />;
+}
+
+export function TableRow({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) {
+  return <tr className={cn('border-b transition-colors hover:bg-[hsl(var(--muted))]', className)} {...props} />;
+}
+
+export function TableHead({ className, ...props }: React.ThHTMLAttributes<HTMLTableCellElement>) {
+  return <th className={cn('h-10 px-3 text-left align-middle font-medium text-[hsl(var(--muted-foreground))]', className)} {...props} />;
+}
+
+export function TableCell({ className, ...props }: React.TdHTMLAttributes<HTMLTableCellElement>) {
+  return <td className={cn('p-3 align-middle', className)} {...props} />;
+}

+ 35 - 0
src/components/ui/tabs.tsx

@@ -0,0 +1,35 @@
+'use client';
+
+import { ReactNode, useState } from 'react';
+import { cn } from '@/lib/utils';
+
+type TabItem = {
+  value: string;
+  label: string;
+  content: ReactNode;
+};
+
+export function Tabs({ items, defaultValue }: { items: TabItem[]; defaultValue?: string }) {
+  const [active, setActive] = useState(defaultValue || items[0]?.value);
+
+  return (
+    <div className="grid gap-4">
+      <div className="inline-flex w-fit rounded-md border bg-white p-1">
+        {items.map((item) => (
+          <button
+            className={cn(
+              'rounded px-3 py-1.5 text-sm text-[hsl(var(--muted-foreground))]',
+              active === item.value && 'bg-[hsl(var(--primary))] text-white'
+            )}
+            key={item.value}
+            type="button"
+            onClick={() => setActive(item.value)}
+          >
+            {item.label}
+          </button>
+        ))}
+      </div>
+      {items.find((item) => item.value === active)?.content}
+    </div>
+  );
+}

+ 13 - 0
src/components/ui/textarea.tsx

@@ -0,0 +1,13 @@
+import * as React from 'react';
+import { cn } from '@/lib/utils';
+
+export const Textarea = React.forwardRef<HTMLTextAreaElement, React.TextareaHTMLAttributes<HTMLTextAreaElement>>(
+  ({ className, ...props }, ref) => (
+    <textarea
+      ref={ref}
+      className={cn('min-h-24 w-full rounded-md border bg-white px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-[hsl(var(--ring))] disabled:cursor-not-allowed disabled:opacity-50', className)}
+      {...props}
+    />
+  )
+);
+Textarea.displayName = 'Textarea';

+ 1 - 0
src/db/index.ts

@@ -9,6 +9,7 @@ dotenv.config();
 
 const pool = new Pool({
   connectionString: process.env.DATABASE_URL,
+  connectionTimeoutMillis: 10000,
 });
 
 export const db = drizzle(pool, { schema: { ...authSchema, ...resourceSchema, ...mediaSchema } });

+ 3 - 0
src/db/schema/media.ts

@@ -1,13 +1,16 @@
 import { pgTable, uuid, text, timestamp, varchar, bigint, jsonb } from "drizzle-orm/pg-core";
+import { resources } from "./resource";
 
 export const media = pgTable("media", {
   id: uuid("id").primaryKey().defaultRandom(),
+  resourceId: uuid("resource_id").references(() => resources.id, { onDelete: "set null" }),
   filename: varchar("filename", { length: 255 }).notNull(),
   storageKey: text("storage_key").notNull().unique(),
   mimeType: varchar("mime_type", { length: 100 }),
   size: bigint("size", { mode: 'bigint' }).notNull(),
   status: varchar("status", { length: 20 }).notNull().default('pending'), // pending, processing, completed, failed
   metadata: jsonb("metadata"), // Stores resolution, duration, etc.
+  errorMessage: text("error_message"),
   createdAt: timestamp("created_at").defaultNow().notNull(),
   updatedAt: timestamp("updated_at").defaultNow().notNull(),
 });

+ 28 - 5
src/db/seed.ts

@@ -1,7 +1,8 @@
 import { db } from './index';
-import { users, groups, roles, permissions, userGroups, groupRoles, rolePermissions, userRoles, resources, aclRules } from './schema/auth';
+import { users, groups, roles, permissions, userGroups, groupRoles, rolePermissions, userRoles } from './schema/auth';
 import { resources as resourceSchema, aclRules as aclRulesSchema } from './schema/resource';
 import { eq, and } from 'drizzle-orm';
+import { hashPassword } from '../lib/auth/password';
 
 async function seed() {
   console.log('🌱 开始执行 Seed 脚本 (稳健 Upsert 模式)...');
@@ -45,34 +46,56 @@ async function seed() {
       resourceType: 'document'
     }).onConflictDoNothing().returning();
 
+    const [readVideoPerm] = await db.insert(permissions).values({
+      action: 'read',
+      resourceType: 'video'
+    }).onConflictDoNothing().returning();
+
+    const [writeVideoPerm] = await db.insert(permissions).values({
+      action: 'write',
+      resourceType: 'video'
+    }).onConflictDoNothing().returning();
+
+    const [deleteVideoPerm] = await db.insert(permissions).values({
+      action: 'delete',
+      resourceType: 'video'
+    }).onConflictDoNothing().returning();
+
     // 绑定权限到角色 (使用 upsert 避免重复插入关联)
     await db.insert(rolePermissions).values([
       { roleId: adminRole.id, permissionId: readPerm.id },
       { roleId: adminRole.id, permissionId: writePerm.id },
+      { roleId: adminRole.id, permissionId: readVideoPerm.id },
+      { roleId: adminRole.id, permissionId: writeVideoPerm.id },
+      { roleId: adminRole.id, permissionId: deleteVideoPerm.id },
       { roleId: editorRole.id, permissionId: readPerm.id },
       { roleId: editorRole.id, permissionId: writePerm.id },
+      { roleId: editorRole.id, permissionId: readVideoPerm.id },
+      { roleId: editorRole.id, permissionId: writeVideoPerm.id },
       { roleId: viewerRole.id, permissionId: readPerm.id },
+      { roleId: viewerRole.id, permissionId: readVideoPerm.id },
     ]).onConflictDoNothing();
 
     // --- 2. 创建用户与组 ---
     console.log('👥 正在同步测试用户与组织...');
+    const defaultPasswordHash = await hashPassword('hashed_password_here');
 
     const [adminUser] = await db.insert(users).values({
       email: 'admin@ekb.com',
       name: 'System Admin',
-      passwordHash: 'hashed_password_here'
+      passwordHash: defaultPasswordHash
     }).onConflictDoUpdate({
       target: users.email,
-      set: { name: 'System Admin' }
+      set: { name: 'System Admin', passwordHash: defaultPasswordHash }
     }).returning();
 
     const [testUser] = await db.insert(users).values({
       email: 'tester@ekb.com',
       name: 'Test User',
-      passwordHash: 'hashed_password_here'
+      passwordHash: defaultPasswordHash
     }).onConflictDoUpdate({
       target: users.email,
-      set: { name: 'Test User' }
+      set: { name: 'Test User', passwordHash: defaultPasswordHash }
     }).returning();
 
     const [engGroup] = await db.insert(groups).values({

+ 38 - 0
src/lib/auth/admin.ts

@@ -0,0 +1,38 @@
+import { eq, sql } from 'drizzle-orm';
+import { auth } from '@/auth';
+import { db } from '@/db';
+import { roles, userRoles } from '@/db/schema/auth';
+
+export async function requireAdmin() {
+  const session = await auth();
+
+  if (!session?.user?.id) {
+    throw new Error('Unauthorized');
+  }
+
+  const adminRole = await db.execute(sql`
+    SELECT r.id
+    FROM roles r
+    LEFT JOIN user_roles ur ON ur.role_id = r.id
+    LEFT JOIN group_roles gr ON gr.role_id = r.id
+    LEFT JOIN user_groups ug ON ug.group_id = gr.group_id
+    WHERE r.name = 'admin'
+      AND (ur.user_id = ${session.user.id} OR ug.user_id = ${session.user.id})
+    LIMIT 1
+  `);
+
+  if (adminRole.rows.length === 0) {
+    throw new Error('Forbidden');
+  }
+
+  return session;
+}
+
+export async function ensureRoleByName(name: string) {
+  const [role] = await db.select().from(roles).where(eq(roles.name, name)).limit(1);
+  return role;
+}
+
+export async function assignUserRole(userId: string, roleId: string) {
+  await db.insert(userRoles).values({ userId, roleId }).onConflictDoNothing();
+}

+ 19 - 0
src/lib/auth/password.ts

@@ -0,0 +1,19 @@
+import bcrypt from 'bcryptjs';
+
+const BCRYPT_PREFIXES = ['$2a$', '$2b$', '$2y$'];
+
+export function isPasswordHash(value: string) {
+  return BCRYPT_PREFIXES.some((prefix) => value.startsWith(prefix));
+}
+
+export async function hashPassword(password: string) {
+  return bcrypt.hash(password, 12);
+}
+
+export async function verifyPassword(password: string, passwordHash: string) {
+  if (!isPasswordHash(passwordHash)) {
+    return password === passwordHash;
+  }
+
+  return bcrypt.compare(password, passwordHash);
+}

+ 14 - 16
src/lib/auth/permission.ts

@@ -1,7 +1,5 @@
-import { eq, and, inArray, sql } from 'drizzle-orm';
+import { eq, sql } from 'drizzle-orm';
 import { db } from '../../db';
-import { users, groups, roles, permissions, userGroups, groupRoles, rolePermissions, userRoles } from '../../db/schema/auth';
-import { resources, aclRules } from '../../db/schema/resource';
 import { AuthContext, PermissionAction, ResourceType, PermissionResult } from './types';
 
 /**
@@ -30,18 +28,6 @@ export async function checkPermission(
     hierarchyPaths.push(currentPath);
   }
 
-  // 2. 检查 ACL 规则 (优先级最高)
-  // 我们需要查询该路径及其所有父路径上,针对当前用户或其所属组的 ACL 规则
-  const aclRulesResult = await db.query.aclRules.findMany({
-    where: and(
-      inArray(aclRules.subjectId, allSubjectIds),
-      inArray(aclRules.action, [action]),
-      // 这里需要通过 SQL 实现路径匹配,Drizzle query API 对此支持有限,改用 sql 辅助
-    ),
-  });
-
-  // 由于 Drizzle query API 在处理复杂的路径前缀匹配时不够灵活,
-  // 我们使用更直接的 SQL 查询来获取所有相关的 ACL 规则
   const relevantAclRules = await db.execute(sql`
     SELECT subject_type, subject_id, permission_type, action
     FROM acl_rules
@@ -70,7 +56,19 @@ export async function checkPermission(
 
     if (hasAllow) {
       return { granted: true, reason: 'Explicitly allowed by ACL rule' };
-    }
+      }
+  }
+
+  const ownedResource = await db.execute(sql`
+    SELECT id
+    FROM resources
+    WHERE path = ${resourcePath}
+      AND owner_id = ${userId}
+    LIMIT 1
+  `);
+
+  if (ownedResource.rows.length > 0) {
+    return { granted: true, reason: 'Granted to resource owner' };
   }
 
   // 3. 检查 RBAC 权限 (兜底逻辑)

+ 27 - 0
src/lib/auth/request-context.ts

@@ -0,0 +1,27 @@
+import type { AuthContext } from './types';
+import { auth } from '@/auth';
+
+export async function getAuthContextFromRequest(request: Request): Promise<AuthContext | null> {
+  const session = await auth();
+
+  if (session?.user?.id) {
+    return {
+      userId: session.user.id,
+      groupIds: session.user.groupIds || [],
+    };
+  }
+
+  const userId = request.headers.get('x-user-id');
+
+  if (!userId) {
+    return null;
+  }
+
+  const groupIds = request.headers
+    .get('x-group-ids')
+    ?.split(',')
+    .map((groupId) => groupId.trim())
+    .filter(Boolean) || [];
+
+  return { userId, groupIds };
+}

+ 9 - 0
src/lib/config.ts

@@ -0,0 +1,9 @@
+import 'dotenv/config';
+
+export const mediaBucketName = process.env.MEDIA_BUCKET_NAME || 'zyy';
+export const mediaUrlExpirySeconds = parseInt(process.env.MEDIA_URL_EXPIRY_SECONDS || '3600', 10);
+
+export const redisConnection = {
+  host: process.env.REDIS_HOST || 'localhost',
+  port: parseInt(process.env.REDIS_PORT || '6379', 10),
+};

+ 3 - 1
src/lib/minio.ts

@@ -1,4 +1,5 @@
 import * as Minio from 'minio';
+import 'dotenv/config';
 
 const minioClient = new Minio.Client({
   endPoint: process.env.MINIO_ENDPOINT || 'localhost',
@@ -6,6 +7,7 @@ const minioClient = new Minio.Client({
   useSSL: process.env.MINIO_USE_SSL === 'true',
   accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
   secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin',
-});
+  forcePathStyle: true,
+} as Minio.ClientOptions & { forcePathStyle: boolean });
 
 export default minioClient;

+ 2 - 6
src/lib/queue/index.ts

@@ -1,13 +1,9 @@
 import { Queue } from 'bullmq';
 import * as dotenv from 'dotenv';
+import { redisConnection } from '@/lib/config';
 
 dotenv.config();
 
-const REDIS_CONNECTION = {
-  host: process.env.REDIS_HOST || 'localhost',
-  port: parseInt(process.env.REDIS_PORT || '6379'),
-};
-
 export const mediaQueue = new Queue('media-processing', {
-  connection: REDIS_CONNECTION,
+  connection: redisConnection,
 });

+ 6 - 0
src/lib/utils.ts

@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx';
+import { twMerge } from 'tailwind-merge';
+
+export function cn(...inputs: ClassValue[]) {
+  return twMerge(clsx(inputs));
+}

+ 27 - 17
src/workers/media-processor.ts

@@ -3,16 +3,11 @@ import * as path from 'path';
 import * as fs from 'fs';
 import * as os from 'os';
 import ffmpeg from 'fluent-ffmpeg';
+import { eq } from 'drizzle-orm';
 import minioClient from '@/lib/minio';
 import { media } from '@/db/schema/media';
 import { db } from '@/db/index';
-
-const REDIS_CONNECTION = {
-  host: process.env.REDIS_HOST || 'localhost',
-  port: parseInt(process.env.REDIS_PORT || '6379'),
-};
-
-const BUCKET_NAME = 'zyy';
+import { mediaBucketName, redisConnection } from '@/lib/config';
 
 export async function startMediaWorker() {
   console.log('🚀 Media Worker starting...');
@@ -32,11 +27,11 @@ export async function startMediaWorker() {
       try {
         // 2. 更新数据库状态为 processing
         await db.update(media)
-          .set({ status: 'processing', updatedAt: new Date() })
-          .where(media.id.eq(mediaId));
+          .set({ status: 'processing', errorMessage: null, updatedAt: new Date() })
+          .where(eq(media.id, mediaId));
 
         // 3. 从 MinIO 下载原始文件
-        const stream = await minioClient.getObject(BUCKET_NAME, storageKey);
+        const stream = await minioClient.getObject(mediaBucketName, storageKey);
         const writeStream = fs.createWriteStream(inputFilePath);
         await new Promise((resolve, reject) => {
           stream.pipe(writeStream).on('finish', resolve).on('error', reject);
@@ -48,8 +43,12 @@ export async function startMediaWorker() {
         await new Promise((resolve, reject) => {
           ffmpeg(inputFilePath)
             .outputOptions([
+              '-c:v libx264',
+              '-preset veryfast',
               '-profile:v baseline',
-              '-level 3.0',
+              '-level 3.1',
+              '-pix_fmt yuv420p',
+              '-an',
               '-start_number 0',
               '-hls_time 10',
               '-hls_list_size 0',
@@ -66,8 +65,7 @@ export async function startMediaWorker() {
         const files = fs.readdirSync(outputDir);
         for (const file of files) {
           const filePath = path.join(outputDir, file);
-          const stat = fs.statSync(filePath);
-          await minioClient.putObject(BUCKET_NAME, hlsPrefix + file, fs.createReadStream(filePath));
+          await minioClient.putObject(mediaBucketName, hlsPrefix + file, fs.createReadStream(filePath));
         }
 
         // 6. 获取 FFmpeg 生成的元数据 (这里简化处理,实际应解析 ffprobe)
@@ -81,23 +79,28 @@ export async function startMediaWorker() {
           .set({
             status: 'completed',
             metadata: metadata as any,
+            errorMessage: null,
             updatedAt: new Date(),
           })
-          .where(media.id.eq(mediaId));
+          .where(eq(media.id, mediaId));
 
         console.log(`[Job ${job.id}] Successfully processed media ID: ${mediaId}`);
       } catch (error) {
         console.error(`[Job ${job.id}] Failed processing media ID: ${mediaId}`, error);
         await db.update(media)
-          .set({ status: 'failed', updatedAt: new Date() })
-          .where(media.id.eq(mediaId));
+          .set({
+            status: 'failed',
+            errorMessage: error instanceof Error ? error.message : 'Media processing failed',
+            updatedAt: new Date(),
+          })
+          .where(eq(media.id, mediaId));
         throw error;
       } finally {
         // 8. 清理临时文件
         fs.rmSync(tempDir, { recursive: true, force: true });
       }
     },
-    { connection: REDIS_CONNECTION }
+    { connection: redisConnection }
   );
 
   worker.on('failed', (job, err) => {
@@ -106,3 +109,10 @@ export async function startMediaWorker() {
 
   return worker;
 }
+
+if (require.main === module) {
+  startMediaWorker().catch((error) => {
+    console.error('Media Worker failed to start:', error);
+    process.exit(1);
+  });
+}

+ 44 - 0
tests/apply-media-production-migration.ts

@@ -0,0 +1,44 @@
+import 'dotenv/config';
+import { Pool } from 'pg';
+
+async function main() {
+  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+  try {
+    await pool.query(`
+      ALTER TABLE media
+      ADD COLUMN IF NOT EXISTS resource_id uuid
+    `);
+
+    await pool.query(`
+      ALTER TABLE media
+      ADD COLUMN IF NOT EXISTS error_message text
+    `);
+
+    await pool.query(`
+      DO $$
+      BEGIN
+        IF NOT EXISTS (
+          SELECT 1
+          FROM pg_constraint
+          WHERE conname = 'media_resource_id_resources_id_fk'
+        ) THEN
+          ALTER TABLE media
+          ADD CONSTRAINT media_resource_id_resources_id_fk
+          FOREIGN KEY (resource_id)
+          REFERENCES resources(id)
+          ON DELETE SET NULL;
+        END IF;
+      END $$;
+    `);
+
+    console.log('✅ Media production migration applied.');
+  } finally {
+    await pool.end();
+  }
+}
+
+main().catch((error) => {
+  console.error('❌ Migration failed:', error);
+  process.exit(1);
+});

+ 56 - 0
tests/deep-sql-debug.ts

@@ -0,0 +1,56 @@
+import 'dotenv/config';
+import { Pool } from 'pg';
+
+async function debugInsert() {
+  const pool = new Pool({
+    connectionString: process.env.DATABASE_URL,
+  });
+
+  console.log('🚀 Starting Deep SQL Debugging...');
+
+  const testData = {
+    filename: 'test-video.mp4',
+    storageKey: `uploads/${Date.now()}-test.mp4`,
+    mimeType: 'video/mp4',
+    size: 1024, // Using number first
+    status: 'pending'
+  };
+
+  try {
+    console.log('\n--- Test 1: Basic Insert with Number for size ---');
+    const res1 = await pool.query(
+      `INSERT INTO media (filename, storage_key, mime_type, size, status)
+       VALUES ($1, $2, $3, $4, $5) RETURNING id`,
+      [testData.filename, testData.storageKey, testData.mimeType, testData.size, testData.status]
+    );
+    console.log('✅ Success! ID:', res1.rows[0].id);
+
+    console.log('\n--- Test 2: Insert with BigInt string for size ---');
+    const res2 = await pool.query(
+      `INSERT INTO media (filename, storage_key, mime_type, size, status)
+       VALUES ($1, $2, $3, $4, $5) RETURNING id`,
+      [testData.filename, testData.storageKey, testData.mimeType, '1024', testData.status]
+    );
+    console.log('✅ Success! ID:', res2.rows[0].id);
+
+    console.log('\n--- Test 3: Insert with actual BigInt ---');
+    const res3 = await pool.query(
+      `INSERT INTO media (filename, storage_key, mime_type, size, status)
+       VALUES ($1, $2, $3, $4, $5) RETURNING id`,
+      [testData.filename, testData.storageKey, testData.mimeType, BigInt(testData.size), testData.status]
+    );
+    console.log('✅ Success! ID:', res3.rows[0].id);
+
+  } catch (err: any) {
+    console.error('\n❌ SQL Error Detected!');
+    console.error('Message:', err.message);
+    console.error('Code:', err.code);
+    console.error('Detail:', err.detail);
+    console.error('Hint:', err.hint);
+    console.error('Table:', err.table);
+  } finally {
+    await pool.end();
+  }
+}
+
+debugInsert();

+ 51 - 0
tests/force-migrate.ts

@@ -0,0 +1,51 @@
+import 'dotenv/config';
+import { Pool } from 'pg';
+import fs from 'fs';
+import path from 'path';
+
+async function forceMigrate() {
+  const pool = new Pool({
+    connectionString: process.env.DATABASE_URL,
+  });
+
+  console.log('🚀 Starting Force Migration (Manual SQL Execution)...');
+
+  const migrationDir = path.join(process.cwd(), 'drizzle');
+  const files = fs.readdirSync(migrationDir)
+    .filter(file => file.endsWith('.sql'))
+    .sort();
+
+  console.log('Found migration files:', files);
+
+  for (const file of files) {
+    const filePath = path.join(migrationDir, file);
+    const sql = fs.readFileSync(filePath, 'utf8');
+
+    console.log(`\n--- Executing: ${file} ---`);
+    try {
+      // We split by semicolon to execute statements one by one for better error granularity
+      // Note: This is a simple splitter and might not handle complex multi-line statements perfectly,
+      // but for standard Drizzle migrations it usually works.
+      const statements = sql.split(';').map(s => s.trim()).filter(s => s.length > 0);
+
+      for (const statement of statements) {
+        console.log(`Executing: ${statement.substring(0, 50)}${statement.length > 50 ? '...' : ''}`);
+        await pool.query(statement);
+      }
+      console.log(`✅ Successfully applied ${file}`);
+    } catch (err: any) {
+      console.error(`❌ Failed executing ${file}`);
+      console.error('Error Message:', err.message);
+      console.error('Error Code:', err.code);
+      if (err.detail) console.error('Detail:', err.detail);
+      if (err.hint) console.error('Hint:', err.hint);
+      // We stop on error to prevent cascading failures
+      process.exit(1);
+    }
+  }
+
+  console.log('\n✨ All migrations applied successfully!');
+  await pool.end();
+}
+
+forceMigrate();

+ 24 - 0
tests/inspect-media-columns.ts

@@ -0,0 +1,24 @@
+import 'dotenv/config';
+import { Pool } from 'pg';
+
+async function main() {
+  const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+  try {
+    const result = await pool.query(`
+      SELECT column_name
+      FROM information_schema.columns
+      WHERE table_name = 'media'
+      ORDER BY ordinal_position
+    `);
+
+    console.log(result.rows.map((row) => row.column_name).join('\n'));
+  } finally {
+    await pool.end();
+  }
+}
+
+main().catch((error) => {
+  console.error(error);
+  process.exit(1);
+});

+ 22 - 10
tests/integration/media-pipeline.test.ts

@@ -1,7 +1,5 @@
-import { uploadMedia } from '../../src/actions/media';
-import { db } from '../../src/db/index';
-import { media } from '../../src/db/schema/media';
-import minioClient from '../../src/lib/minio';
+import { getMediaStatus, uploadMedia } from '../../src/actions/media';
+import { mediaQueue } from '../../src/lib/queue/index';
 import { startMediaWorker } from '../../src/workers/media-processor';
 import * as fs from 'fs';
 import * as path from 'path';
@@ -9,6 +7,7 @@ import * as os from 'os';
 
 async function runTest() {
   console.log('🧪 Starting Media Pipeline Integration Test...');
+  let exitCode = 1;
 
   // 1. Setup: Create a dummy video file (mp4)
   const testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'test-media-'));
@@ -23,6 +22,11 @@ async function runTest() {
   fs.copyFileSync('test_sample.mp4', testFilePath);
 
   try {
+    await mediaQueue.drain(true);
+    await mediaQueue.clean(0, 1000, 'failed');
+    await mediaQueue.clean(0, 1000, 'completed');
+    console.log('✅ Queue cleaned');
+
     // 2. Start Worker
     const worker = await startMediaWorker();
     console.log('✅ Worker started');
@@ -33,11 +37,17 @@ async function runTest() {
       name: 'test_sample.mp4',
       type: 'video/mp4',
       size: fs.statSync(testFilePath).size,
-      arrayBuffer: async () => fs.readFileSync(testFilePath).buffer,
+      arrayBuffer: async () => {
+        const fileBuffer = fs.readFileSync(testFilePath);
+        return fileBuffer.buffer.slice(
+          fileBuffer.byteOffset,
+          fileBuffer.byteOffset + fileBuffer.byteLength
+        );
+      },
     };
 
     const record = await uploadMedia(fileMock as any);
-    console.log(`✅ Uploaded. Media ID: ${record.id}, Status: ${record.status}`);
+    console.log(`✅ Uploaded. Media ID: ${record.media.id}, Status: ${record.media.status}`);
 
     // 4. Wait for Worker to process (Polling)
     console.log('⏳ Waiting for worker to complete processing...');
@@ -47,13 +57,12 @@ async function runTest() {
 
     while (attempts < maxAttempts) {
       await new Promise(r => setTimeout(r, 1000));
-      const updatedRecord = await db.query.media.findFirst({
-        where: (m, { eq }) => eq(m.id, record.id),
-      });
+      const updatedRecord = await getMediaStatus(record.media.id);
 
       if (updatedRecord?.status === 'completed') {
         console.log('🎉 Success! Media status is COMPLETED');
         console.log('Metadata:', updatedRecord.metadata);
+        console.log('HLS URL generated:', Boolean(updatedRecord.hlsUrl));
         completed = true;
         break;
       } else if (updatedRecord?.status === 'failed') {
@@ -69,14 +78,17 @@ async function runTest() {
       console.error('\n❌ Timeout: Worker did not complete processing in time.');
     } else {
       console.log('\n✨ Integration Test Passed!');
+      exitCode = 0;
     }
 
+    await worker.close();
   } catch (err) {
     console.error('❌ Test failed with error:', err);
   } finally {
     // Cleanup
     fs.rmSync(testDir, { recursive: true, force: true });
-    process.exit(0);
+    await mediaQueue.close();
+    process.exit(exitCode);
   }
 }
 

+ 69 - 0
tests/test-media-delete.ts

@@ -0,0 +1,69 @@
+import { eq } from 'drizzle-orm';
+import { deleteMedia } from '../src/actions/media';
+import { db } from '../src/db';
+import { users } from '../src/db/schema/auth';
+import { media } from '../src/db/schema/media';
+import { resources } from '../src/db/schema/resource';
+import minioClient from '../src/lib/minio';
+import { mediaBucketName } from '../src/lib/config';
+
+async function upsertUser(email: string, name: string) {
+  const [existing] = await db.select().from(users).where(eq(users.email, email)).limit(1);
+  if (existing) return existing;
+  const [created] = await db.insert(users).values({ email, name, passwordHash: 'test_password_hash' }).returning();
+  return created;
+}
+
+async function runTest() {
+  console.log('🧹 Testing media delete...');
+
+  const owner = await upsertUser('media-delete-owner@ekb.com', 'Media Delete Owner');
+  const outsider = await upsertUser('media-delete-outsider@ekb.com', 'Media Delete Outsider');
+  const suffix = Date.now();
+  const storageKey = `uploads/delete-test-${suffix}.txt`;
+
+  const exists = await minioClient.bucketExists(mediaBucketName);
+  if (!exists) await minioClient.makeBucket(mediaBucketName);
+  await minioClient.putObject(mediaBucketName, storageKey, Buffer.from('delete test'));
+
+  const [resource] = await db.insert(resources).values({
+    name: `delete-test-${suffix}.txt`,
+    path: `/delete-test/${suffix}.txt`,
+    type: 'file',
+    ownerId: owner.id,
+  }).returning();
+
+  const [mediaRecord] = await db.insert(media).values({
+    resourceId: resource.id,
+    filename: `delete-test-${suffix}.txt`,
+    storageKey,
+    mimeType: 'text/plain',
+    size: BigInt(11),
+    status: 'completed',
+  }).returning();
+
+  try {
+    await deleteMedia(mediaRecord.id, { auth: { userId: outsider.id, groupIds: [] } });
+    console.error('❌ Outsider unexpectedly deleted media.');
+    process.exit(1);
+  } catch {
+    // Expected.
+  }
+
+  const result = await deleteMedia(mediaRecord.id, { auth: { userId: owner.id, groupIds: [] } });
+  const [deletedMedia] = await db.select().from(media).where(eq(media.id, mediaRecord.id)).limit(1);
+  const [deletedResource] = await db.select().from(resources).where(eq(resources.id, resource.id)).limit(1);
+
+  if (!result.deleted || deletedMedia || deletedResource) {
+    console.error('❌ Media delete did not remove expected database rows.');
+    process.exit(1);
+  }
+
+  console.log('✅ Media delete passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('❌ Test failed with error:', error);
+  process.exit(1);
+});

+ 100 - 0
tests/test-media-list.ts

@@ -0,0 +1,100 @@
+import { eq } from 'drizzle-orm';
+import { listMedia } from '../src/actions/media';
+import { db } from '../src/db';
+import { users } from '../src/db/schema/auth';
+import { media } from '../src/db/schema/media';
+import { resources } from '../src/db/schema/resource';
+
+async function upsertUser(email: string, name: string) {
+  const [existing] = await db.select().from(users).where(eq(users.email, email)).limit(1);
+
+  if (existing) {
+    return existing;
+  }
+
+  const [created] = await db.insert(users).values({
+    email,
+    name,
+    passwordHash: 'test_password_hash',
+  }).returning();
+
+  return created;
+}
+
+async function runTest() {
+  console.log('Testing media list visibility and filters...');
+
+  const owner = await upsertUser('media-list-owner@ekb.com', 'Media List Owner');
+  const outsider = await upsertUser('media-list-outsider@ekb.com', 'Media List Outsider');
+  const suffix = Date.now();
+
+  const [resource] = await db.insert(resources).values({
+    name: `media-list-${suffix}.mp4`,
+    path: `/media-list/${suffix}.mp4`,
+    type: 'file',
+    ownerId: owner.id,
+  }).returning();
+
+  const [createdMedia] = await db.insert(media).values({
+    resourceId: resource.id,
+    filename: `media-list-${suffix}.mp4`,
+    storageKey: `uploads/media-list-${suffix}.mp4`,
+    mimeType: 'video/mp4',
+    size: BigInt(1),
+    status: 'pending',
+  }).returning();
+
+  const [completedResource] = await db.insert(resources).values({
+    name: `media-list-filter-${suffix}.mp4`,
+    path: `/media-list/filter-${suffix}.mp4`,
+    type: 'file',
+    ownerId: owner.id,
+  }).returning();
+
+  const [completedMedia] = await db.insert(media).values({
+    resourceId: completedResource.id,
+    filename: `media-list-filter-${suffix}.mp4`,
+    storageKey: `uploads/media-list-filter-${suffix}.mp4`,
+    mimeType: 'video/mp4',
+    size: BigInt(1),
+    status: 'completed',
+  }).returning();
+
+  const ownerList = await listMedia({ auth: { userId: owner.id, groupIds: [] } });
+  const outsiderList = await listMedia({ auth: { userId: outsider.id, groupIds: [] } });
+
+  const ownerCanSee = ownerList.some((item) => item.id === createdMedia.id);
+  const outsiderCanSee = outsiderList.some((item) => item.id === createdMedia.id);
+
+  if (!ownerCanSee || outsiderCanSee) {
+    console.error('Media list visibility did not respect ownership permissions.');
+    process.exit(1);
+  }
+
+  const queryList = await listMedia({
+    auth: { userId: owner.id, groupIds: [] },
+    query: `filter-${suffix}`,
+  });
+  const completedList = await listMedia({
+    auth: { userId: owner.id, groupIds: [] },
+    status: 'completed',
+  });
+
+  const queryMatched = queryList.some((item) => item.id === completedMedia.id)
+    && !queryList.some((item) => item.id === createdMedia.id);
+  const statusMatched = completedList.some((item) => item.id === completedMedia.id)
+    && !completedList.some((item) => item.id === createdMedia.id);
+
+  if (!queryMatched || !statusMatched) {
+    console.error('Media list filters did not match expected results.');
+    process.exit(1);
+  }
+
+  console.log('Media list visibility and filters passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('Test failed with error:', error);
+  process.exit(1);
+});

+ 88 - 0
tests/test-media-permission.ts

@@ -0,0 +1,88 @@
+import { eq } from 'drizzle-orm';
+import { getMediaStatus } from '../src/actions/media';
+import { db } from '../src/db';
+import { users } from '../src/db/schema/auth';
+import { media } from '../src/db/schema/media';
+import { resources } from '../src/db/schema/resource';
+
+async function upsertUser(email: string, name: string) {
+  const [existing] = await db.select().from(users).where(eq(users.email, email)).limit(1);
+
+  if (existing) {
+    return existing;
+  }
+
+  const [created] = await db.insert(users).values({
+    email,
+    name,
+    passwordHash: 'test_password_hash',
+  }).returning();
+
+  return created;
+}
+
+async function runTest() {
+  console.log('🔐 Testing media permission checks...');
+
+  const owner = await upsertUser('media-owner@ekb.com', 'Media Owner');
+  const outsider = await upsertUser('media-outsider@ekb.com', 'Media Outsider');
+
+  const [latestCompletedMedia] = await db
+    .select()
+    .from(media)
+    .where(eq(media.status, 'completed'))
+    .limit(1);
+
+  if (!latestCompletedMedia?.metadata) {
+    console.error('❌ No completed media found. Run npm run test:media-pipeline first.');
+    process.exit(1);
+  }
+
+  const pathSuffix = `${Date.now()}-permission-test.mp4`;
+  const [resource] = await db.insert(resources).values({
+    name: 'permission-test.mp4',
+    path: `/media/${pathSuffix}`,
+    type: 'file',
+    ownerId: owner.id,
+  }).returning();
+
+  const [ownedMedia] = await db.insert(media).values({
+    resourceId: resource.id,
+    filename: 'permission-test.mp4',
+    storageKey: `uploads/${pathSuffix}`,
+    mimeType: 'video/mp4',
+    size: BigInt(1),
+    status: 'completed',
+    metadata: latestCompletedMedia.metadata,
+  }).returning();
+
+  const ownerStatus = await getMediaStatus(ownedMedia.id, {
+    auth: { userId: owner.id, groupIds: [] },
+  });
+
+  if (!ownerStatus?.hlsUrl) {
+    console.error('❌ Owner did not receive playback URL.');
+    process.exit(1);
+  }
+
+  try {
+    await getMediaStatus(ownedMedia.id, {
+      auth: { userId: outsider.id, groupIds: [] },
+    });
+    console.error('❌ Outsider unexpectedly received media access.');
+    process.exit(1);
+  } catch (error) {
+    const message = error instanceof Error ? error.message : String(error);
+    if (!message.includes('No matching permission')) {
+      throw error;
+    }
+  }
+
+  console.log('✅ Media permission checks passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('❌ Test failed with error:', error);
+  process.exit(1);
+});

+ 45 - 0
tests/test-media-status.ts

@@ -0,0 +1,45 @@
+import { and, desc, eq, sql } from 'drizzle-orm';
+import { getMediaStatus } from '../src/actions/media';
+import { db } from '../src/db';
+import { media } from '../src/db/schema/media';
+
+async function runTest() {
+  console.log('Testing media status lookup...');
+
+  const [latestCompletedMedia] = await db
+    .select()
+    .from(media)
+    .where(and(eq(media.status, 'completed'), sql`${media.metadata}->>'hlsPath' is not null`))
+    .orderBy(desc(media.updatedAt))
+    .limit(1);
+
+  if (!latestCompletedMedia) {
+    console.error('No completed media with HLS output found. Run npm run test:media-pipeline first.');
+    process.exit(1);
+  }
+
+  const status = await getMediaStatus(latestCompletedMedia.id);
+
+  if (!status) {
+    console.error('getMediaStatus returned null.');
+    process.exit(1);
+  }
+
+  console.log('Media ID:', status.id);
+  console.log('Status:', status.status);
+  console.log('HLS Path:', status.hlsPath);
+  console.log('HLS URL generated:', Boolean(status.hlsUrl));
+
+  if (status.status !== 'completed' || !status.hlsPath || !status.hlsUrl) {
+    console.error('Media status response is missing completed playback data.');
+    process.exit(1);
+  }
+
+  console.log('Media status lookup passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('Test failed with error:', error);
+  process.exit(1);
+});

+ 40 - 0
tests/test-password-auth.ts

@@ -0,0 +1,40 @@
+import { eq } from 'drizzle-orm';
+import { db } from '../src/db';
+import { users } from '../src/db/schema/auth';
+import { isPasswordHash, verifyPassword } from '../src/lib/auth/password';
+
+async function runTest() {
+  console.log('🔑 Testing password hashing...');
+
+  const [admin] = await db
+    .select()
+    .from(users)
+    .where(eq(users.email, 'admin@ekb.com'))
+    .limit(1);
+
+  if (!admin) {
+    console.error('❌ admin@ekb.com not found. Run npx tsx src/db/seed.ts first.');
+    process.exit(1);
+  }
+
+  if (!isPasswordHash(admin.passwordHash)) {
+    console.error('❌ Admin password is not stored as a bcrypt hash.');
+    process.exit(1);
+  }
+
+  const validPassword = await verifyPassword('hashed_password_here', admin.passwordHash);
+  const invalidPassword = await verifyPassword('wrong-password', admin.passwordHash);
+
+  if (!validPassword || invalidPassword) {
+    console.error('❌ Password verification returned unexpected results.');
+    process.exit(1);
+  }
+
+  console.log('✅ Password hashing checks passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('❌ Test failed with error:', error);
+  process.exit(1);
+});

+ 62 - 0
tests/test-permission-admin.ts

@@ -0,0 +1,62 @@
+import { and, eq } from 'drizzle-orm';
+import { db } from '../src/db';
+import { permissions, rolePermissions, roles, users } from '../src/db/schema/auth';
+import { aclRules, resources } from '../src/db/schema/resource';
+
+async function runTest() {
+  console.log('🛡️ Testing permission administration data flow...');
+
+  const suffix = Date.now();
+  const [permission] = await db.insert(permissions).values({
+    action: `read-${suffix}`,
+    resourceType: 'video',
+  }).returning();
+
+  const [viewerRole] = await db.select().from(roles).where(eq(roles.name, 'viewer')).limit(1);
+  const [tester] = await db.select().from(users).where(eq(users.email, 'tester@ekb.com')).limit(1);
+
+  if (!viewerRole || !tester) {
+    console.error('❌ Required seed data missing. Run npx tsx src/db/seed.ts first.');
+    process.exit(1);
+  }
+
+  await db.insert(rolePermissions).values({
+    roleId: viewerRole.id,
+    permissionId: permission.id,
+  }).onConflictDoNothing();
+
+  const [rolePermission] = await db.select().from(rolePermissions).where(and(
+    eq(rolePermissions.roleId, viewerRole.id),
+    eq(rolePermissions.permissionId, permission.id),
+  )).limit(1);
+
+  const [resource] = await db.insert(resources).values({
+    name: `permission-admin-${suffix}.mp4`,
+    path: `/permission-admin/${suffix}.mp4`,
+    type: 'file',
+  }).returning();
+
+  const [aclRule] = await db.insert(aclRules).values({
+    resourceId: resource.id,
+    subjectType: 'user',
+    subjectId: tester.id,
+    permissionType: 'deny',
+    action: 'read',
+  }).returning();
+
+  await db.delete(aclRules).where(eq(aclRules.id, aclRule.id));
+  const [deletedAcl] = await db.select().from(aclRules).where(eq(aclRules.id, aclRule.id)).limit(1);
+
+  if (!rolePermission || deletedAcl) {
+    console.error('❌ Permission admin data flow failed.');
+    process.exit(1);
+  }
+
+  console.log('✅ Permission administration data flow passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('❌ Test failed with error:', error);
+  process.exit(1);
+});

+ 59 - 0
tests/test-upload.ts

@@ -0,0 +1,59 @@
+import * as dotenv from 'dotenv';
+import * as fs from 'fs';
+import path from 'path';
+import { uploadMedia } from '../src/actions/media';
+import { mediaBucketName } from '../src/lib/config';
+import minioClient from '../src/lib/minio';
+
+dotenv.config({ path: path.resolve(process.cwd(), '.env') });
+
+async function runTest() {
+  console.log('--- Environment Check ---');
+  console.log('MINIO_ENDPOINT:', process.env.MINIO_ENDPOINT);
+  console.log('-------------------------');
+
+  if (!fs.existsSync('test_sample.mp4')) {
+    console.error('Missing test_sample.mp4 in project root.');
+    process.exit(1);
+  }
+
+  console.log('Starting end-to-end upload test...');
+  console.log('1. Calling uploadMedia action...');
+
+  try {
+    const fileBuffer = fs.readFileSync('test_sample.mp4');
+    const result = await uploadMedia({
+      name: 'test_sample.mp4',
+      type: 'video/mp4',
+      size: fileBuffer.byteLength,
+      arrayBuffer: async () => fileBuffer.buffer.slice(
+        fileBuffer.byteOffset,
+        fileBuffer.byteOffset + fileBuffer.byteLength
+      ),
+    });
+
+    console.log('Action returned successfully.');
+    console.log('Media ID:', result.media.id);
+    console.log('Resource ID:', result.resource.id);
+
+    console.log('\n2. Verifying database record...');
+    if (result.media.status !== 'pending' || !result.media.storageKey) {
+      console.error('Upload did not create the expected pending media record.');
+      process.exit(1);
+    }
+    console.log('Database record verification passed.');
+
+    console.log('\n3. Verifying MinIO file...');
+    await minioClient.statObject(mediaBucketName, result.media.storageKey);
+    console.log('MinIO file verification passed.');
+  } catch (error: any) {
+    console.error('\nTest failed.');
+    console.error('Error Message:', error.message);
+    if (error.code) console.error('Error Code:', error.code);
+    process.exit(1);
+  }
+
+  process.exit(0);
+}
+
+runTest();

+ 63 - 0
tests/test-user-admin.ts

@@ -0,0 +1,63 @@
+import { eq } from 'drizzle-orm';
+import { db } from '../src/db';
+import { groups, roles, userGroups, userRoles, users } from '../src/db/schema/auth';
+import { hashPassword, verifyPassword } from '../src/lib/auth/password';
+
+async function runTest() {
+  console.log('👤 Testing user administration data flow...');
+
+  const email = `admin-flow-${Date.now()}@ekb.com`;
+  const [createdUser] = await db.insert(users).values({
+    email,
+    name: 'Admin Flow User',
+    passwordHash: await hashPassword('initial-password'),
+  }).returning();
+
+  const [viewerRole] = await db.select().from(roles).where(eq(roles.name, 'viewer')).limit(1);
+  const [engGroup] = await db.select().from(groups).where(eq(groups.name, 'Engineering Department')).limit(1);
+
+  if (!viewerRole || !engGroup) {
+    console.error('❌ Required seed role/group missing. Run npx tsx src/db/seed.ts first.');
+    process.exit(1);
+  }
+
+  await db.insert(userRoles).values({
+    userId: createdUser.id,
+    roleId: viewerRole.id,
+  }).onConflictDoNothing();
+
+  await db.insert(userGroups).values({
+    userId: createdUser.id,
+    groupId: engGroup.id,
+  }).onConflictDoNothing();
+
+  const newPasswordHash = await hashPassword('reset-password');
+  await db.update(users)
+    .set({ passwordHash: newPasswordHash, updatedAt: new Date() })
+    .where(eq(users.id, createdUser.id));
+
+  const [updatedUser] = await db.select().from(users).where(eq(users.id, createdUser.id)).limit(1);
+  const [assignedRole] = await db.select().from(userRoles).where(eq(userRoles.userId, createdUser.id)).limit(1);
+  const [assignedGroup] = await db.select().from(userGroups).where(eq(userGroups.userId, createdUser.id)).limit(1);
+
+  if (!updatedUser || !assignedRole || !assignedGroup) {
+    console.error('❌ User admin write flow did not persist expected rows.');
+    process.exit(1);
+  }
+
+  const resetPasswordWorks = await verifyPassword('reset-password', updatedUser.passwordHash);
+  const oldPasswordWorks = await verifyPassword('initial-password', updatedUser.passwordHash);
+
+  if (!resetPasswordWorks || oldPasswordWorks) {
+    console.error('❌ Password reset verification failed.');
+    process.exit(1);
+  }
+
+  console.log('✅ User administration data flow passed.');
+  process.exit(0);
+}
+
+runTest().catch((error) => {
+  console.error('❌ Test failed with error:', error);
+  process.exit(1);
+});

+ 31 - 7
tsconfig.json

@@ -1,19 +1,43 @@
 {
   "compilerOptions": {
     "target": "ESNext",
-    "module": "NodeNext",
-    "moduleResolution": "NodeNext",
+    "lib": [
+      "DOM",
+      "DOM.Iterable",
+      "ESNext"
+    ],
+    "jsx": "preserve",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
     "baseUrl": ".",
+    "ignoreDeprecations": "6.0",
     "paths": {
-      "@/*": ["./src/*"]
+      "@/*": [
+        "./src/*"
+      ]
     },
     "outDir": "./dist",
-    "rootDir": "./src",
+    "rootDir": ".",
     "strict": true,
     "esModuleInterop": true,
     "skipLibCheck": true,
-    "forceConsistentCasingInFileNames": true
+    "forceConsistentCasingInFileNames": true,
+    "allowJs": true,
+    "noEmit": true,
+    "incremental": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ]
   },
-  "include": ["src/**/*"],
-  "exclude": ["node_modules"]
+  "include": [
+    "src/**/*",
+    ".next/types/**/*.ts"
+  ],
+  "exclude": [
+    "node_modules"
+  ]
 }

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff