diff --git a/.cursor/rules/adminjs.mdc b/.cursor/rules/adminjs.mdc new file mode 100644 index 0000000..e12d316 --- /dev/null +++ b/.cursor/rules/adminjs.mdc @@ -0,0 +1,26 @@ +--- +description: AdminJS panel must stay in sync with Prisma schema changes +globs: backend/src/admin/**,backend/prisma/schema.prisma,backend/prisma/migrations/** +alwaysApply: false +--- + +# AdminJS ↔ Prisma sync (required) + +Ops panel at `/admin` (`backend/src/admin/`). Resources are a **manual allowlist** — Prisma does **not** auto-update AdminJS. + +## When you change `schema.prisma` + +**Before finishing the task**, update AdminJS: + +1. Open [`backend/src/admin/resources.ts`](backend/src/admin/resources.ts) (`buildAdminResources`). +2. **New model** ops may need to inspect/fix → add `resource(...)` + navigation group + hide secrets. +3. **Renamed / removed model** → update or remove the matching resource (broken `getModelByName` breaks `/admin` boot). +4. **New secret fields** (hashes, tokens, share tokens) → hide via `isVisible: false` (list/filter/show/edit). +5. **Catalog-like reference data** → list/show/edit only; disable `new` / `delete` / `bulkDelete`. +6. Skip pure join/cursor tables unless ops need them (`LabCaseUserReadState`, `LabCaseUserTabReadState`, working-hours, `LabCaseAttachment`). + +Auth: `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` — production login disabled if password missing or still `admin123`. + +## Secrets to hide + +`passwordHash`, session `token`/`refreshToken`, invite/OTP `tokenHash`/`codeHash`, `LabCase.accessToken`. diff --git a/.cursor/rules/backend-nestjs.mdc b/.cursor/rules/backend-nestjs.mdc index 07d3bdf..9fd02c4 100644 --- a/.cursor/rules/backend-nestjs.mdc +++ b/.cursor/rules/backend-nestjs.mdc @@ -35,6 +35,7 @@ throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN); - Schema: `backend/prisma/schema.prisma` - Always add a migration for schema changes (`npm run prisma:migrate` in backend). - Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`. +- **Schema change ⇒ AdminJS:** update `backend/src/admin/resources.ts` in the same change (add/rename/remove resources, hide new secrets). See `.cursor/rules/adminjs.mdc`. ## API responses diff --git a/.cursor/rules/dyolink-overview.mdc b/.cursor/rules/dyolink-overview.mdc index 9b21daf..8bfbad4 100644 --- a/.cursor/rules/dyolink-overview.mdc +++ b/.cursor/rules/dyolink-overview.mdc @@ -19,6 +19,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr - **Never commit or push** unless the user explicitly asks. - Prefer minimal diffs; reuse existing components and API patterns. - After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`. +- Prisma `schema.prisma` changes ⇒ update AdminJS allowlist (`backend/src/admin/resources.ts`) — `.cursor/rules/adminjs.mdc`. ## i18n diff --git a/.cursor/skills/add-feature/SKILL.md b/.cursor/skills/add-feature/SKILL.md index bc97fbe..23a502d 100644 --- a/.cursor/skills/add-feature/SKILL.md +++ b/.cursor/skills/add-feature/SKILL.md @@ -11,7 +11,7 @@ Follow this checklist. Adapt steps if the feature is read-only or org-type-speci ``` - [ ] 1. Permissions & org type -- [ ] 2. Backend module +- [ ] 2. Backend module (+ Prisma / AdminJS if new models) - [ ] 3. Frontend UI + thin page - [ ] 4. i18n (en, fa, nl) - [ ] 5. Verify build / tsc @@ -40,6 +40,7 @@ backend/src/modules/{feature}/ - Service-level permission checks with `hasEffectivePermission`. - DTOs use `ErrorCode` validation messages. - Register in `app.module.ts`. +- If you add/change Prisma models: update AdminJS allowlist in `backend/src/admin/resources.ts` (same PR). See `.cursor/rules/adminjs.mdc`. ## 3. Frontend diff --git a/AGENTS.md b/AGENTS.md index b6e107a..950ec5d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,11 +82,14 @@ frontend/src/ backend/src/ modules/{feature}/ → controller, service, dto, module common/ → guards, permissions, errors, utils + admin/ → AdminJS `/admin` panel (curated Prisma resources) prisma/ → schema, migrations, seed ``` Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures. +**AdminJS:** Manual resource allowlist in `backend/src/admin/resources.ts`. **Whenever `schema.prisma` changes**, update AdminJS resources in the same task (new/renamed/removed models, hide secrets). Rule: `.cursor/rules/adminjs.mdc`. + ## Git & commits - **Do not commit or push** unless the user explicitly asks. diff --git a/CLAUDE.md b/CLAUDE.md index e686f19..31c68dc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,6 +96,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B, ### Layout conventions worth knowing before you create a file - **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`. +- **AdminJS (`/admin`)** resources are a manual allowlist in `backend/src/admin/resources.ts` — update them whenever `schema.prisma` changes (see `.cursor/rules/adminjs.mdc`). - **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it. - **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input. - Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`. diff --git a/backend/src/admin/admin.module.ts b/backend/src/admin/admin.module.ts index 1d7fd04..ea58ef9 100644 --- a/backend/src/admin/admin.module.ts +++ b/backend/src/admin/admin.module.ts @@ -1,14 +1,16 @@ -// backend/src/admin/admin.module.ts import { DynamicModule, Module } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { componentLoader, Components } from './components'; import { ConfigModule, ConfigService } from '@nestjs/config'; -import { Database, Resource, getModelByName } from '@adminjs/prisma'; // 👈 Add getModelByName +import { Database, Resource } from '@adminjs/prisma'; import AdminJS from 'adminjs'; +import { buildAdminResources } from './resources'; -// Register the adapter AdminJS.registerAdapter({ Database, Resource }); +const LOCAL_DEFAULT_ADMIN_PASSWORD = 'admin123'; +const LOCAL_DEFAULT_ADMIN_EMAIL = 'admin@dyolink.com'; + @Module({ imports: [ConfigModule], }) @@ -17,10 +19,24 @@ export class AdminModule { const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs'); const authenticate = async (email: string, password: string) => { + const isProduction = process.env.NODE_ENV === 'production'; const adminEmail = - process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com'; - const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123'; - if (email === adminEmail && password === adminPassword) { + process.env.ADMINJS_EMAIL?.trim() || LOCAL_DEFAULT_ADMIN_EMAIL; + const adminPassword = process.env.ADMINJS_PASSWORD; + + if (isProduction) { + if ( + !adminPassword || + adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD + ) { + return null; + } + } + + const effectivePassword = + adminPassword || LOCAL_DEFAULT_ADMIN_PASSWORD; + + if (email === adminEmail && password === effectivePassword) { return { email, role: 'admin' }; } return null; @@ -38,68 +54,54 @@ export class AdminModule { config.get('jwt.secret') || config.get('JWT_SECRET') || 'secret-key-change-this'; - if ( - process.env.NODE_ENV === 'production' && - !process.env.ADMINJS_PASSWORD - ) { - console.warn( - '⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.', - ); + + if (process.env.NODE_ENV === 'production') { + const adminPassword = process.env.ADMINJS_PASSWORD; + if ( + !adminPassword || + adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD + ) { + console.error( + '❌ AdminJS: ADMINJS_PASSWORD is missing or still the local default. Login is disabled until you set a strong password in backend.env.', + ); + } } + return { adminJsOptions: { rootPath: '/admin', - resources: [ - // ✅ Use getModelByName helper - { - resource: { - model: getModelByName('User'), - client: prisma, - }, - options: { - properties: { - passwordHash: { isVisible: false }, - }, - }, - }, - { - resource: { - model: getModelByName('Organization'), - client: prisma, - }, - options: {}, - }, - { - resource: { - model: getModelByName('OrganizationType'), - client: prisma, - }, - options: {}, - }, - { - resource: { - model: getModelByName('Plan'), - client: prisma, - }, - options: {}, - }, - { - resource: { - model: getModelByName('Membership'), - client: prisma, - }, - options: {}, - }, - { - resource: { - model: getModelByName('Session'), - client: prisma, - }, - options: {}, - }, - ], + resources: buildAdminResources(prisma), componentLoader, - dashboard: { component: Components.Dashboard }, + dashboard: { + component: Components.Dashboard, + handler: async () => { + const [clinicType, labType] = await Promise.all([ + prisma.organizationType.findUnique({ + where: { name: 'CLINIC' }, + }), + prisma.organizationType.findUnique({ + where: { name: 'LAB' }, + }), + ]); + + const [clinics, labs, users, labCases] = await Promise.all([ + clinicType + ? prisma.organization.count({ + where: { typeId: clinicType.id }, + }) + : Promise.resolve(0), + labType + ? prisma.organization.count({ + where: { typeId: labType.id }, + }) + : Promise.resolve(0), + prisma.user.count(), + prisma.labCase.count(), + ]); + + return { clinics, labs, users, labCases }; + }, + }, branding: { companyName: 'DyoLink Admin', logo: false, @@ -127,4 +129,4 @@ export class AdminModule { ], }; } -} \ No newline at end of file +} diff --git a/backend/src/admin/components.ts b/backend/src/admin/components.ts index 069a1d1..2054049 100644 --- a/backend/src/admin/components.ts +++ b/backend/src/admin/components.ts @@ -5,7 +5,6 @@ const componentLoader = new ComponentLoader(); const Components = { Dashboard: componentLoader.add('Dashboard', './dashboard'), - // You can add more components here as needed }; export { componentLoader, Components }; \ No newline at end of file diff --git a/backend/src/admin/dashboard.tsx b/backend/src/admin/dashboard.tsx index 6e5b241..a26bb42 100644 --- a/backend/src/admin/dashboard.tsx +++ b/backend/src/admin/dashboard.tsx @@ -1,32 +1,81 @@ -// backend/src/admin/dashboard-simple.tsx // @ts-nocheck -import React from 'react'; -import { Box, H2, Text, Badge } from '@adminjs/design-system'; +import React, { useEffect, useState } from 'react'; +import { Box, H2, Text } from '@adminjs/design-system'; +import { ApiClient } from 'adminjs'; + +type DashboardStats = { + clinics: number; + labs: number; + users: number; + labCases: number; +}; + +const StatCard = ({ + label, + value, + loading, +}: { + label: string; + value?: number; + loading: boolean; +}) => ( + + {label} + + {loading ? '…' : (value ?? '—')} + + +); const Dashboard = () => { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const api = new ApiClient(); + api + .getDashboard() + .then((response) => { + setStats(response.data as DashboardStats); + setError(null); + }) + .catch(() => { + setError('Could not load dashboard stats.'); + }) + .finally(() => { + setLoading(false); + }); + }, []); + return ( -

Welcome to DyoLink Admin Panel

- Manage your dental clinics, labs, users, and subscriptions. - - - -
🏥 Clinics
-
12
+

DyoLink Admin

+ Manage clinics, labs, users, and production data. + + {error ? ( + + {error} - -
🔬 Labs
-
8
+ ) : ( + + + + + - -
👥 Users
-
45
-
-
+ )}
); }; -export default Dashboard; \ No newline at end of file +export default Dashboard; diff --git a/backend/src/admin/resources.ts b/backend/src/admin/resources.ts new file mode 100644 index 0000000..b5d889f --- /dev/null +++ b/backend/src/admin/resources.ts @@ -0,0 +1,169 @@ +import { getModelByName } from '@adminjs/prisma'; +import type { PrismaService } from '../../prisma/prisma.service'; + +type ResourceOptions = { + navigation?: { name: string; icon?: string }; + properties?: Record; + actions?: Record< + string, + { isAccessible?: boolean } + >; +}; + +type AdminResource = { + resource: { model: ReturnType; client: PrismaService }; + options: ResourceOptions; +}; + +const hide = (...propertyNames: string[]): ResourceOptions['properties'] => + Object.fromEntries( + propertyNames.map((name) => [ + name, + { isVisible: { list: false, filter: false, show: false, edit: false } }, + ]), + ); + +const catalogActions: ResourceOptions['actions'] = { + new: { isAccessible: false }, + delete: { isAccessible: false }, + bulkDelete: { isAccessible: false }, +}; + +function resource( + client: PrismaService, + modelName: string, + options: ResourceOptions = {}, +): AdminResource { + return { + resource: { + model: getModelByName(modelName), + client, + }, + options, + }; +} + +/** Curated AdminJS allowlist — keep in sync when adding ops-relevant Prisma models. */ +export function buildAdminResources(prisma: PrismaService): AdminResource[] { + return [ + // Identity + resource(prisma, 'User', { + navigation: { name: 'Identity', icon: 'User' }, + properties: hide('passwordHash'), + }), + resource(prisma, 'Session', { + navigation: { name: 'Identity', icon: 'User' }, + properties: hide('token', 'refreshToken'), + }), + resource(prisma, 'PhoneVerificationCode', { + navigation: { name: 'Identity', icon: 'User' }, + properties: hide('codeHash'), + }), + + // Orgs & access + resource(prisma, 'Organization', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'OrganizationType', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'Plan', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'Membership', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'Permission', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'MembershipPermission', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'Feature', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'StaffInvitation', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + properties: hide('tokenHash'), + }), + resource(prisma, 'OrganizationLink', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + }), + resource(prisma, 'OrganizationInvitation', { + navigation: { name: 'Orgs & access', icon: 'Settings' }, + properties: hide('tokenHash'), + }), + + // Clinic + resource(prisma, 'Patient', { + navigation: { name: 'Clinic', icon: 'Healthcare' }, + }), + resource(prisma, 'Appointment', { + navigation: { name: 'Clinic', icon: 'Healthcare' }, + }), + resource(prisma, 'Treatment', { + navigation: { name: 'Clinic', icon: 'Healthcare' }, + }), + resource(prisma, 'TreatmentDetail', { + navigation: { name: 'Clinic', icon: 'Healthcare' }, + }), + resource(prisma, 'TreatmentDetailAttachment', { + navigation: { name: 'Clinic', icon: 'Healthcare' }, + }), + + // Lab + resource(prisma, 'LabCase', { + navigation: { name: 'Lab', icon: 'Archive' }, + properties: hide('accessToken'), + }), + resource(prisma, 'LabCaseLine', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseDetail', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseSend', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseToothProsthesis', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseTask', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseTaskStatusEvent', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseComment', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'LabCaseActivity', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + resource(prisma, 'UserNotification', { + navigation: { name: 'Lab', icon: 'Archive' }, + }), + + // Catalog — edit OK; create/delete via seed/migrations + resource(prisma, 'TreatmentType', { + navigation: { name: 'Catalog', icon: 'Catalog' }, + actions: catalogActions, + }), + resource(prisma, 'ProsthesisType', { + navigation: { name: 'Catalog', icon: 'Catalog' }, + actions: catalogActions, + }), + resource(prisma, 'LabWorkflowStep', { + navigation: { name: 'Catalog', icon: 'Catalog' }, + actions: catalogActions, + }), + resource(prisma, 'ProsthesisTypeStep', { + navigation: { name: 'Catalog', icon: 'Catalog' }, + actions: catalogActions, + }), + resource(prisma, 'CatalogTranslation', { + navigation: { name: 'Catalog', icon: 'Catalog' }, + actions: catalogActions, + }), + ]; +}