update adming js
This commit is contained in:
@@ -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<string>('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 {
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}) => (
|
||||
<Box p="lg" bg="primary20" style={{ flex: 1, minWidth: '140px' }}>
|
||||
<Text>{label}</Text>
|
||||
<Box mt="default" style={{ fontSize: '2rem', fontWeight: 'bold' }}>
|
||||
{loading ? '…' : (value ?? '—')}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const Dashboard = () => {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<Box variant="grey">
|
||||
<Box variant="white" p="xl">
|
||||
<H2>Welcome to DyoLink Admin Panel</H2>
|
||||
<Text>Manage your dental clinics, labs, users, and subscriptions.</Text>
|
||||
|
||||
<Box mt="xl" style={{ display: 'flex', gap: '20px' }}>
|
||||
<Box p="lg" bg="primary20" style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '1.5rem' }}>🏥 Clinics</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>12</div>
|
||||
<H2>DyoLink Admin</H2>
|
||||
<Text>Manage clinics, labs, users, and production data.</Text>
|
||||
|
||||
{error ? (
|
||||
<Box mt="xl">
|
||||
<Text>{error}</Text>
|
||||
</Box>
|
||||
<Box p="lg" bg="secondary20" style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '1.5rem' }}>🔬 Labs</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>8</div>
|
||||
) : (
|
||||
<Box
|
||||
mt="xl"
|
||||
style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}
|
||||
>
|
||||
<StatCard label="Clinics" value={stats?.clinics} loading={loading} />
|
||||
<StatCard label="Labs" value={stats?.labs} loading={loading} />
|
||||
<StatCard label="Users" value={stats?.users} loading={loading} />
|
||||
<StatCard
|
||||
label="Lab cases"
|
||||
value={stats?.labCases}
|
||||
loading={loading}
|
||||
/>
|
||||
</Box>
|
||||
<Box p="lg" bg="info20" style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: '1.5rem' }}>👥 Users</div>
|
||||
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>45</div>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
export default Dashboard;
|
||||
|
||||
169
backend/src/admin/resources.ts
Normal file
169
backend/src/admin/resources.ts
Normal file
@@ -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<string, { isVisible?: boolean | { list?: boolean; filter?: boolean; show?: boolean; edit?: boolean } }>;
|
||||
actions?: Record<
|
||||
string,
|
||||
{ isAccessible?: boolean }
|
||||
>;
|
||||
};
|
||||
|
||||
type AdminResource = {
|
||||
resource: { model: ReturnType<typeof getModelByName>; 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,
|
||||
}),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user