diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 73d51d8..532c14d 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,6 +1,5 @@ // backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; -import * as bcrypt from 'bcrypt'; import { config } from 'dotenv'; import path from 'path'; @@ -29,14 +28,14 @@ async function main() { console.log('✅ Database connected successfully'); // Create organization types - const clinicType = await prisma.organizationType.upsert({ + await prisma.organizationType.upsert({ where: { name: 'CLINIC' }, update: {}, create: { name: 'CLINIC' }, }); console.log('✅ Created clinic type'); - const labType = await prisma.organizationType.upsert({ + await prisma.organizationType.upsert({ where: { name: 'LAB' }, update: {}, create: { name: 'LAB' }, @@ -61,32 +60,39 @@ async function main() { } console.log('✅ Created plans'); - // Create features and permissions + // Minimal permission model (confirmed): + // - Sidebar tabs use READ/EDIT + // - EDIT implies READ in app logic + // - Owners effectively get all permissions const features = [ { - name: 'Patient Management', - permissions: ['VIEW_PATIENTS', 'CREATE_PATIENTS', 'EDIT_PATIENTS', 'DELETE_PATIENTS'] + name: 'Today', + permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], }, { - name: 'Order Management', - permissions: ['VIEW_ORDERS', 'CREATE_ORDERS', 'EDIT_ORDERS', 'DELETE_ORDERS', 'TRACK_ORDERS'] + name: 'Patients', + permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], }, { - name: 'Case Management', - permissions: ['VIEW_CASES', 'CREATE_CASES', 'EDIT_CASES', 'DELETE_CASES'] + name: 'Appointments', + permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], }, { - name: 'Reports', - permissions: ['VIEW_REPORTS', 'EXPORT_REPORTS'] + name: 'Staff Management', + permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], }, { - name: 'Team Management', - permissions: ['INVITE_USERS', 'REMOVE_USERS', 'MANAGE_PERMISSIONS'] + name: 'Lab Management', + permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'], }, { name: 'Billing', - permissions: ['VIEW_INVOICES', 'CREATE_INVOICES', 'MANAGE_PAYMENTS'] - } + permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'], + }, + { + name: 'Reports', + permissions: ['TAB_REPORTS_READ', 'TAB_REPORTS_EDIT'], + }, ]; for (const feature of features) { @@ -109,7 +115,7 @@ async function main() { } console.log('✅ Created features and permissions'); - console.log('🌱 Seeding completed successfully!'); `` + console.log('🌱 Seeding completed successfully!'); } main() diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index 7cde9f7..860aade 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -145,6 +145,20 @@ export class AuthController { return this.authService.getProfile(req.user.id); } + @Get('subscription-alert') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: + 'Owner-only: seat / trial status for warning indicator (current org from JWT)', + }) + async getSubscriptionAlert(@Req() req) { + return this.authService.getOwnerSubscriptionAlert( + req.user.id, + req.user.organizationId, + ); + } + // ========================= // LOGOUT // ========================= diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 0429885..e041323 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -16,27 +16,20 @@ import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ - 'VIEW_PATIENTS', - 'CREATE_PATIENTS', - 'EDIT_PATIENTS', - 'DELETE_PATIENTS', - 'VIEW_ORDERS', - 'CREATE_ORDERS', - 'EDIT_ORDERS', - 'DELETE_ORDERS', - 'TRACK_ORDERS', - 'VIEW_CASES', - 'CREATE_CASES', - 'EDIT_CASES', - 'DELETE_CASES', - 'VIEW_REPORTS', - 'EXPORT_REPORTS', - 'INVITE_USERS', - 'REMOVE_USERS', - 'MANAGE_PERMISSIONS', - 'VIEW_INVOICES', - 'CREATE_INVOICES', - 'MANAGE_PAYMENTS', + 'TAB_TODAY_READ', + 'TAB_TODAY_EDIT', + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_STAFF_READ', + 'TAB_STAFF_EDIT', + 'TAB_LAB_READ', + 'TAB_LAB_EDIT', + 'TAB_BILLING_READ', + 'TAB_BILLING_EDIT', + 'TAB_REPORTS_READ', + 'TAB_REPORTS_EDIT', ]; @Injectable() @@ -63,6 +56,7 @@ export class AuthService { organization: { include: { type: true, // Include organization type (CLINIC/LAB) + plan: true, } }, permissions: { @@ -149,6 +143,12 @@ export class AuthService { permissions: membership.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -322,6 +322,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -346,7 +347,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -412,6 +421,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -457,7 +467,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -629,6 +647,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -654,7 +673,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -711,7 +738,9 @@ export class AuthService { }); // 4. Format permissions - const permissions = membership.permissions.map(p => p.permission.name); + const permissions = membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions.map(p => p.permission.name); return { success: true, @@ -721,9 +750,101 @@ export class AuthService { id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, + isOwner: membership.isOwner, + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, }, permissions, }, }; } + + /** + * Owner-only subscription / seat alerts for the current org (from JWT). + * Used for a subtle warning indicator in the app shell (not staff-facing banners). + */ + async getOwnerSubscriptionAlert(userId: string, organizationId: string | undefined) { + if (!organizationId) { + return { + success: true, + data: { + showWarning: false, + seatsLow: false, + trialEndingSoon: false, + trialExpired: false, + }, + }; + } + + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId }, + include: { + organization: { + include: { plan: true }, + }, + }, + }); + + if (!membership || !membership.isOwner) { + return { + success: true, + data: { + showWarning: false, + seatsLow: false, + trialEndingSoon: false, + trialExpired: false, + }, + }; + } + + const org = membership.organization; + const plan = org.plan; + const maxUsers = plan.maxUsers; + const seatsUsed = await this.prisma.membership.count({ + where: { organizationId: org.id }, + }); + + const unlimited = maxUsers >= 999999; + const remaining = unlimited ? Infinity : maxUsers - seatsUsed; + const seatsLow = + !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; + + let trialEndingSoon = false; + let trialExpired = false; + let daysUntilTrialEnd: number | null = null; + let trialEndsAt: string | null = null; + + if (plan.name === 'trial') { + const end = new Date(org.createdAt); + end.setDate(end.getDate() + 30); + trialEndsAt = end.toISOString(); + const ms = end.getTime() - Date.now(); + daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); + if (daysUntilTrialEnd <= 0) { + trialExpired = true; + } else if (daysUntilTrialEnd <= 7) { + trialEndingSoon = true; + } + } + + const showWarning = seatsLow || trialEndingSoon || trialExpired; + + return { + success: true, + data: { + showWarning, + seatsLow, + trialEndingSoon, + trialExpired, + seatsUsed, + seatsLimit: maxUsers, + daysUntilTrialEnd, + trialEndsAt, + }, + }; + } } \ No newline at end of file diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 62735b2..c23c6d9 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,18 +1,15 @@ 'use client'; -import { memo, useCallback, useEffect } from 'react'; +import { memo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import Sidebar from '@/components/ui/Sidebar'; import { ThemeToggle } from '@/components/ui/ThemeToggle'; -import { LogOut } from 'lucide-react'; +import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { - const { user, currentOrganization, isAuthReady, logout } = useAuth(); + const { user, currentOrganization, isAuthReady } = useAuth(); const router = useRouter(); - const handleLogout = useCallback(() => { - void logout(); - }, [logout]); // ✅ AUTH GUARD (runs once per navigation group) useEffect(() => { @@ -51,11 +48,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
- +
@@ -69,27 +62,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const DashboardHeader = memo(function DashboardHeader({ organizationName, - userName, - onLogout, }: { organizationName: string; - userName: string; - onLogout: () => void; }) { return ( -
-

{organizationName}

+
+

{organizationName}

-
+
- {userName} - +
); diff --git a/frontend/src/app/(dashboard)/settings/account/page.tsx b/frontend/src/app/(dashboard)/settings/account/page.tsx new file mode 100644 index 0000000..e7379bb --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/account/page.tsx @@ -0,0 +1,29 @@ +'use client'; + +import Link from 'next/link'; + +export default function AccountSettingsPage() { + return ( +
+
+ + ← Back to app + +

Account

+

+ Profile and security settings for your login. +

+
+ +
+

+ Password change and profile editing will be wired here next (e.g. invite + flow, reset password). +

+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx new file mode 100644 index 0000000..7ac3bb3 --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import type { SubscriptionAlertData } from '@/types'; + +export default function SubscriptionsSettingsPage() { + const { currentOrganization } = useAuth(); + const router = useRouter(); + const [alert, setAlert] = useState(null); + + useEffect(() => { + if (currentOrganization && !currentOrganization.isOwner) { + router.replace('/today'); + } + }, [currentOrganization, router]); + + useEffect(() => { + if (!currentOrganization?.isOwner) return; + void authApi.getSubscriptionAlert().then((r) => { + if (r.success) setAlert(r.data); + }); + }, [currentOrganization?.id, currentOrganization?.isOwner]); + + if (!currentOrganization) { + return ( +

Loading...

+ ); + } + + if (!currentOrganization.isOwner) { + return ( +

Redirecting...

+ ); + } + + const plan = currentOrganization.plan; + const maxUsers = plan?.maxUsers; + + return ( +
+
+ + ← Back to app + +

Subscriptions

+

+ Your DyoLink workspace plan and seats for{' '} + {currentOrganization.name}. + Clinic and lab income tracking stays under the sidebar{' '} + Billing tab. +

+
+ +
+
+
+

Current plan

+

+ {plan?.name ?? '—'} +

+
+ {typeof maxUsers === 'number' && maxUsers < 999999 && ( +
+

Seats (this org)

+

+ {alert?.seatsUsed ?? '—'} / {maxUsers} +

+
+ )} +
+ + {alert?.showWarning && ( +
+ {alert.trialExpired && ( +

Trial period has ended. Choose a plan when checkout is available.

+ )} + {!alert.trialExpired && alert.trialEndingSoon && ( +

+ Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s). +

+ )} + {!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && ( +

Seat usage is high for this organization.

+ )} +
+ )} + +

+ Payment and plan upgrades will connect here. The warning on the settings + icon is only shown to workspace owners when seats are low or the trial window + is ending. +

+
+
+ ); +} diff --git a/frontend/src/components/ui/DashboardAccountMenu.tsx b/frontend/src/components/ui/DashboardAccountMenu.tsx new file mode 100644 index 0000000..ec0f9ec --- /dev/null +++ b/frontend/src/components/ui/DashboardAccountMenu.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { + Settings, + AlertTriangle, + Building2, + CreditCard, + User, + LogOut, + ChevronDown, +} from 'lucide-react'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import type { SubscriptionAlertData } from '@/types'; + +function warningTooltip(data: SubscriptionAlertData | null): string { + if (!data?.showWarning) return ''; + if (data.trialExpired) return 'Trial ended — review Subscriptions'; + if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; + if (data.seatsLow) return 'Seats running low — review Subscriptions'; + return 'Review Subscriptions'; +} + +export function DashboardAccountMenu() { + const { user, currentOrganization, logout } = useAuth(); + const [open, setOpen] = useState(false); + const menuRef = useRef(null); + const [alert, setAlert] = useState(null); + const isOwner = currentOrganization?.isOwner ?? false; + + useEffect(() => { + const onDocClick = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', onDocClick); + return () => document.removeEventListener('mousedown', onDocClick); + }, []); + + useEffect(() => { + if (!isOwner || !currentOrganization) { + setAlert(null); + return; + } + let cancelled = false; + void (async () => { + try { + const res = await authApi.getSubscriptionAlert(); + if (!cancelled && res.success) setAlert(res.data); + } catch { + if (!cancelled) setAlert(null); + } + })(); + return () => { + cancelled = true; + }; + }, [isOwner, currentOrganization?.id]); + + const showWarning = Boolean(isOwner && alert?.showWarning); + const tooltip = useMemo(() => warningTooltip(alert), [alert]); + + const handleLogout = useCallback(() => { + setOpen(false); + void logout(); + }, [logout]); + + return ( +
+ + + {open && ( +
+
+

Signed in

+

{user?.email}

+

+ {currentOrganization?.name} +

+
+ +
+ setOpen(false)} + > + + Switch organization + + + {isOwner && ( + setOpen(false)} + > + + Subscriptions + + )} + + setOpen(false)} + > + + Account + +
+ +
+ +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/Sidebar.tsx index 9078bf8..79d8d82 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/Sidebar.tsx @@ -11,7 +11,6 @@ import { FlaskConical, FileText, CreditCard, - Building2 } from 'lucide-react'; const menu = [ @@ -22,19 +21,19 @@ const menu = [ { name: 'Lab Management', path: '/lab', icon: FlaskConical }, { name: 'Billing', path: '/billing', icon: CreditCard }, { name: 'Reports', path: '/reports', icon: FileText }, - { name: 'Organizations', path: '/select-organization', icon: Building2 }, ]; function Sidebar() { const pathname = usePathname(); return ( -