diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 311023d..627beee 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -48,8 +48,9 @@ export class AuthService { */ async validateUser(email: string, password: string): Promise { try { + const normalizedEmail = email.trim().toLowerCase(); const user = await this.prisma.user.findUnique({ - where: { email }, + where: { email: normalizedEmail }, include: { memberships: { include: { @@ -147,6 +148,7 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, })); @@ -177,7 +179,8 @@ export class AuthService { * @returns Created user info without password */ async register(registerDto: RegisterDto) { - const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto; + const { password, name, organizationName, organizationEmail, organizationType } = registerDto; + const email = registerDto.email.trim().toLowerCase(); // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ @@ -354,6 +357,7 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, })); @@ -474,6 +478,7 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, })); @@ -680,6 +685,7 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, })); @@ -759,6 +765,7 @@ export class AuthService { ? { name: membership.organization.plan.name, maxUsers: membership.organization.plan.maxUsers, + price: membership.organization.plan.price, } : undefined, }, @@ -770,7 +777,12 @@ export class AuthService { memberships: Array<{ isOwner: boolean; isActive: boolean; - organization: { id: string; name: string; type: { name: string }; plan?: { name: string; maxUsers: number } | null }; + organization: { + id: string; + name: string; + type: { name: string }; + plan?: { name: string; maxUsers: number; price: number } | null; + }; permissions?: Array<{ permission: { name: string } }>; }> = [], ) { @@ -790,6 +802,8 @@ export class AuthService { seatsLow: false, trialEndingSoon: false, trialExpired: false, + daysUntilPlanEnd: null, + planEndsAt: null, }, }; } @@ -811,6 +825,8 @@ export class AuthService { seatsLow: false, trialEndingSoon: false, trialExpired: false, + daysUntilPlanEnd: null, + planEndsAt: null, }, }; } @@ -830,23 +846,16 @@ export class AuthService { 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; + // Current pricing model: trial lasts 30 days; paid plans last 90 days. + const durationDays = plan.name === 'trial' ? 30 : 90; + const end = new Date(org.createdAt); + end.setDate(end.getDate() + durationDays); + const planEndsAt = end.toISOString(); + const ms = end.getTime() - Date.now(); + const daysUntilPlanEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); - 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 trialExpired = plan.name === 'trial' && daysUntilPlanEnd <= 0; + const trialEndingSoon = plan.name === 'trial' && daysUntilPlanEnd > 0 && daysUntilPlanEnd <= 7; const showWarning = seatsLow || trialEndingSoon || trialExpired; @@ -859,8 +868,10 @@ export class AuthService { trialExpired, seatsUsed, seatsLimit: maxUsers, - daysUntilTrialEnd, - trialEndsAt, + daysUntilTrialEnd: plan.name === 'trial' ? daysUntilPlanEnd : null, + trialEndsAt: plan.name === 'trial' ? planEndsAt : null, + daysUntilPlanEnd, + planEndsAt, }, }; } diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 8b9ab96..cb6f00d 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -9,11 +9,12 @@ const nextConfig = { // Disable x-powered-by header for security poweredByHeader: false, - // Configure image domains if needed + // Configure allowed remote image sources images: { - domains: process.env.NODE_ENV === 'production' - ? ['yourdomain.com'] - : ['localhost'], + remotePatterns: + process.env.NODE_ENV === 'production' + ? [{ protocol: 'https', hostname: 'yourdomain.com' }] + : [{ protocol: 'http', hostname: 'localhost' }], }, // Environment variables that will be available at build time diff --git a/frontend/src/access/dashboard-tab-access.ts b/frontend/src/access/dashboard-tab-access.ts deleted file mode 100644 index addcb6d..0000000 --- a/frontend/src/access/dashboard-tab-access.ts +++ /dev/null @@ -1,56 +0,0 @@ -/** - * Dashboard route ↔ TAB_* READ permission mapping and helpers used by the shell - * (Sidebar, layout guard). Cross-cutting access logic lives here — not in `lib`, - * which remains for generic utilities (API client, hooks, etc.). - */ -import type { Organization } from '@/types'; - -const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ - { prefix: '/today', permission: 'TAB_TODAY_READ' }, - { prefix: '/patients', permission: 'TAB_PATIENTS_READ' }, - { prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' }, - { prefix: '/staff', permission: 'TAB_STAFF_READ' }, - { prefix: '/lab', permission: 'TAB_LAB_READ' }, - { prefix: '/billing', permission: 'TAB_BILLING_READ' }, - { prefix: '/reports', permission: 'TAB_REPORTS_READ' }, -]; - -export function hasPermission(org: Organization | null, permission: string): boolean { - if (!org) return false; - if (org.isOwner) return true; - return Boolean(org.permissions?.includes(permission)); -} - -/** Sidebar / route guard: READ access to a tab */ -export function canViewTab(org: Organization | null, readPermission: string): boolean { - return hasPermission(org, readPermission); -} - -export function getRequiredReadPermissionForPath(pathname: string): string | null { - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { - return permission; - } - } - return null; -} - -/** First dashboard route the user may open (ordered). Fallback: account settings. */ -export function firstAccessibleDashboardPath(org: Organization | null): string { - if (!org) return '/today'; - if (org.isOwner) return '/today'; - for (const { prefix, permission } of ROUTE_TAB_READ) { - if (hasPermission(org, permission)) return prefix; - } - return '/settings/account'; -} - -export function canEditStaff(org: Organization | null): boolean { - return hasPermission(org, 'TAB_STAFF_EDIT'); -} - -export function canViewStaff(org: Organization | null): boolean { - return ( - hasPermission(org, 'TAB_STAFF_READ') || hasPermission(org, 'TAB_STAFF_EDIT') - ); -} diff --git a/frontend/src/access/index.ts b/frontend/src/access/index.ts deleted file mode 100644 index abb16af..0000000 --- a/frontend/src/access/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './dashboard-tab-access'; diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index a43a2d3..19598b0 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -2,9 +2,9 @@ 'use client'; import { useState } from 'react'; import { Search, Filter, Plus } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Badge } from '@/components/ui/Badge'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +import { Badge } from '@/components/ui/common/Badge'; // Mock data matching your design const invoices = [ { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 978b342..f371642 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -3,14 +3,14 @@ import { memo, useEffect } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; -import Sidebar from '@/components/ui/Sidebar'; -import { ThemeToggle } from '@/components/ui/ThemeToggle'; -import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu'; +import Sidebar from '@/components/ui/common/Sidebar'; +import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; +import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu'; import { firstAccessibleDashboardPath, getRequiredReadPermissionForPath, hasPermission, -} from '@/access'; +} from '@/shared/permissions'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { const { user, currentOrganization, isAuthReady } = useAuth(); @@ -77,7 +77,7 @@ const DashboardHeader = memo(function DashboardHeader({ organizationName: string; }) { return ( -
+

{organizationName}

diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index d22e28f..62ab228 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; +import { Button } from '@/components/ui/common/Button'; import { patientsApi } from '@/lib/api/patients'; import { CreatePatientInput, @@ -10,10 +10,10 @@ import { Patient, TreatmentHistoryItem, } from '@/types/patient'; -import { PatientSearchSelect } from './components/PatientSearchSelect'; -import { CreatePatientModal } from './components/CreatePatientModal'; -import { PatientSummaryCard } from './components/PatientSummaryCard'; -import { TreatmentHistoryPreview } from './components/TreatmentHistoryPreview'; +import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect'; +import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal'; +import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard'; +import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview'; const EMPTY_PATIENT_FORM: CreatePatientInput = { firstName: '', @@ -201,7 +201,7 @@ export default function PatientsPage() {
{(errorMessage || successMessage) && ( -
+
{errorMessage && (
{errorMessage} diff --git a/frontend/src/app/(dashboard)/settings/organizations/page.tsx b/frontend/src/app/(dashboard)/settings/organizations/page.tsx new file mode 100644 index 0000000..e7f8503 --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/organizations/page.tsx @@ -0,0 +1,20 @@ +'use client'; + +import Link from 'next/link'; +import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; + +export default function DashboardOrganizationsSettingsPage() { + return ( +
+
+ + ← Back to app + +
+ +
+ ); +} diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx index 7ac3bb3..7763f07 100644 --- a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -5,7 +5,7 @@ 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'; +import type { SubscriptionAlertData } from '@/types/subscription'; export default function SubscriptionsSettingsPage() { const { currentOrganization } = useAuth(); @@ -39,9 +39,24 @@ export default function SubscriptionsSettingsPage() { const plan = currentOrganization.plan; const maxUsers = plan?.maxUsers; + const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; + const seatsUsed = alert?.seatsUsed; + const seatsRemaining = + typeof seatsUsed === 'number' && typeof maxUsers === 'number' && !isUnlimited + ? Math.max(0, maxUsers - seatsUsed) + : null; + const daysUntilPlanEnd = alert?.daysUntilPlanEnd ?? null; + const planDayTone = + daysUntilPlanEnd == null + ? 'text-text-primary' + : daysUntilPlanEnd > 20 + ? 'text-emerald-400' + : daysUntilPlanEnd >= 10 + ? 'text-amber-300' + : 'text-red-400'; return ( -
+
-
+

Current plan

{plan?.name ?? '—'}

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

Seats (this org)

-

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

-
- )} +
+

Plan price

+

+ {typeof plan?.price === 'number' ? `$${plan.price}` : '—'} +

+
+
+

Seats used

+

+ {typeof seatsUsed === 'number' ? seatsUsed : '—'} + {typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''} +

+
+
+

Seats remaining

+

+ {isUnlimited ? 'Unlimited' : seatsRemaining ?? '—'} +

+
+
+

Days remaining

+

+ {daysUntilPlanEnd ?? '—'} +

+
{alert?.showWarning && ( diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 888eee8..9249c3f 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -6,7 +6,7 @@ import { firstAccessibleDashboardPath, canEditStaff, canViewStaff, -} from '@/access'; +} from '@/shared/permissions'; import { STAFF_FEATURE_GROUPS, permissionNamesFromFeatureState, @@ -18,10 +18,10 @@ import { import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { staffApi, type StaffMemberDto } from '@/lib/api/staff'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Checkbox } from '@/components/ui/Checkbox'; -import { ApiError } from '@/types'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; +import { Checkbox } from '@/components/ui/common/Checkbox'; +import type { ApiError } from '@/types/api'; function formatApiMessage(err: unknown): string { if (!err || typeof err !== 'object') return 'Something went wrong'; diff --git a/frontend/src/app/(public)/accept-invite/page.tsx b/frontend/src/app/(public)/accept-invite/page.tsx index d367368..7f2553f 100644 --- a/frontend/src/app/(public)/accept-invite/page.tsx +++ b/frontend/src/app/(public)/accept-invite/page.tsx @@ -4,8 +4,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Suspense } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; import { staffApi } from '@/lib/api/staff'; function AcceptInviteContent() { diff --git a/frontend/src/app/(public)/login/page.tsx b/frontend/src/app/(public)/login/page.tsx index b546fa8..280d948 100644 --- a/frontend/src/app/(public)/login/page.tsx +++ b/frontend/src/app/(public)/login/page.tsx @@ -116,8 +116,8 @@ import Link from 'next/link'; import { Mail, Lock } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; const loginSchema = z.object({ email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/app/(public)/page.tsx b/frontend/src/app/(public)/page.tsx index b05dd4e..be60d10 100644 --- a/frontend/src/app/(public)/page.tsx +++ b/frontend/src/app/(public)/page.tsx @@ -2,8 +2,8 @@ import Link from 'next/link'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { ThemeToggle } from '@/components/ui/ThemeToggle'; +import { Button } from '@/components/ui/common/Button'; +import { ThemeToggle } from '@/components/ui/common/ThemeToggle'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; export default function HomePage() { diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 03fc30b..1d32b65 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -7,8 +7,8 @@ import * as z from 'zod'; import Link from 'next/link'; import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; const registerSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters'), email: z.string().email('Please enter a valid email address'), diff --git a/frontend/src/app/(public)/select-organization/page.tsx b/frontend/src/app/(public)/select-organization/page.tsx index 4e9f86d..5307a43 100644 --- a/frontend/src/app/(public)/select-organization/page.tsx +++ b/frontend/src/app/(public)/select-organization/page.tsx @@ -1,170 +1,12 @@ 'use client'; -import { useState } from 'react'; -import { useAuth } from '@/lib/hooks/useAuth'; -import { Building2, Beaker, Mail, Plus } from 'lucide-react'; -import { Input } from '@/components/ui/Input'; -import { Button } from '@/components/ui/Button'; +import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent'; export default function SelectOrganizationPage() { - const { organizations, selectOrganization, createOrganization, isLoading, error, clearError } = useAuth(); - const [isCreateOpen, setIsCreateOpen] = useState(false); - const [organizationName, setOrganizationName] = useState(''); - const [organizationEmail, setOrganizationEmail] = useState(''); - const [organizationType, setOrganizationType] = useState<'CLINIC' | 'LAB'>('CLINIC'); - - const getIcon = (type: string) => { - return type === 'CLINIC' - ? - : ; - }; - - const handleCreateOrganization = async () => { - try { - clearError(); - const createdId = await createOrganization( - organizationName.trim(), - organizationEmail.trim(), - organizationType, - ); - setOrganizationName(''); - setOrganizationEmail(''); - setOrganizationType('CLINIC'); - setIsCreateOpen(false); - await selectOrganization(createdId); - } catch { - // Error is already handled in auth context. - } - }; - - if (isLoading) { - return ( -
-

Loading...

-
- ); - } - return (
-
-
-

Organizations

-

- Select an organization to continue, or create a new one. -

-
- -
- - {isCreateOpen && ( -
- setOrganizationName(event.target.value)} - placeholder="Sunshine Dental Clinic" - icon={} - /> - setOrganizationEmail(event.target.value)} - placeholder="contact@sunshineclinic.com" - type="email" - icon={} - /> -
- -
- - -
-
- {error && ( -
-

{error}

-
- )} -
- -
-
- )} - - {!organizations.length ? ( -
-

No organizations found. Create your first one to continue.

-
- ) : ( -
- {organizations.map((org) => ( - - ))} -
- )} +
); diff --git a/frontend/src/components/ui/Badge.tsx b/frontend/src/components/ui/common/Badge.tsx similarity index 100% rename from frontend/src/components/ui/Badge.tsx rename to frontend/src/components/ui/common/Badge.tsx diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/common/Button.tsx similarity index 100% rename from frontend/src/components/ui/Button.tsx rename to frontend/src/components/ui/common/Button.tsx diff --git a/frontend/src/components/ui/Checkbox.tsx b/frontend/src/components/ui/common/Checkbox.tsx similarity index 100% rename from frontend/src/components/ui/Checkbox.tsx rename to frontend/src/components/ui/common/Checkbox.tsx diff --git a/frontend/src/components/ui/Input.tsx b/frontend/src/components/ui/common/Input.tsx similarity index 100% rename from frontend/src/components/ui/Input.tsx rename to frontend/src/components/ui/common/Input.tsx diff --git a/frontend/src/components/ui/OrganizationCard.tsx b/frontend/src/components/ui/common/OrganizationCard.tsx similarity index 95% rename from frontend/src/components/ui/OrganizationCard.tsx rename to frontend/src/components/ui/common/OrganizationCard.tsx index 4dd5841..0d67055 100644 --- a/frontend/src/components/ui/OrganizationCard.tsx +++ b/frontend/src/components/ui/common/OrganizationCard.tsx @@ -1,7 +1,7 @@ // src/components/ui/OrganizationCard.tsx import React from 'react'; import { Building2, Beaker, ChevronRight } from 'lucide-react'; -import { Organization } from '@/types'; +import type { Organization } from '@/types/organization'; interface OrganizationCardProps { organization: Organization; diff --git a/frontend/src/components/ui/Sidebar.tsx b/frontend/src/components/ui/common/Sidebar.tsx similarity index 97% rename from frontend/src/components/ui/Sidebar.tsx rename to frontend/src/components/ui/common/Sidebar.tsx index cbc23be..2a4e464 100644 --- a/frontend/src/components/ui/Sidebar.tsx +++ b/frontend/src/components/ui/common/Sidebar.tsx @@ -13,7 +13,7 @@ import { CreditCard, } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; -import { canViewTab } from '@/access'; +import { canViewTab } from '@/shared/permissions'; const menu = [ { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, diff --git a/frontend/src/components/ui/ThemeToggle.tsx b/frontend/src/components/ui/common/ThemeToggle.tsx similarity index 100% rename from frontend/src/components/ui/ThemeToggle.tsx rename to frontend/src/components/ui/common/ThemeToggle.tsx diff --git a/frontend/src/components/ui/DashboardAccountMenu.tsx b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx similarity index 96% rename from frontend/src/components/ui/DashboardAccountMenu.tsx rename to frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx index ec0f9ec..038ec2b 100644 --- a/frontend/src/components/ui/DashboardAccountMenu.tsx +++ b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx @@ -13,7 +13,7 @@ import { } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; import { authApi } from '@/lib/api/auth'; -import type { SubscriptionAlertData } from '@/types'; +import type { SubscriptionAlertData } from '@/types/subscription'; function warningTooltip(data: SubscriptionAlertData | null): string { if (!data?.showWarning) return ''; @@ -68,7 +68,7 @@ export function DashboardAccountMenu() { }, [logout]); return ( -
+
+
+ + {isCreateOpen && ( +
+ setOrganizationName(event.target.value)} + placeholder="Sunshine Dental Clinic" + icon={} + /> + setOrganizationEmail(event.target.value)} + placeholder="contact@sunshineclinic.com" + type="email" + icon={} + /> +
+ +
+ + +
+
+ {error && ( +
+

{error}

+
+ )} +
+ +
+
+ )} + + {!organizations.length ? ( +
+

No organizations found. Create your first one to continue.

+
+ ) : ( +
+ {organizations.map((org) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/app/(dashboard)/patients/components/CreatePatientModal.tsx b/frontend/src/components/ui/patient/CreatePatientModal.tsx similarity index 93% rename from frontend/src/app/(dashboard)/patients/components/CreatePatientModal.tsx rename to frontend/src/components/ui/patient/CreatePatientModal.tsx index 46c3f28..6d78e63 100644 --- a/frontend/src/app/(dashboard)/patients/components/CreatePatientModal.tsx +++ b/frontend/src/components/ui/patient/CreatePatientModal.tsx @@ -1,7 +1,7 @@ 'use client'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; +import { Button } from '@/components/ui/common/Button'; +import { Input } from '@/components/ui/common/Input'; import { CreatePatientInput } from '@/types/patient'; interface CreatePatientModalProps { diff --git a/frontend/src/app/(dashboard)/patients/components/PatientSearchSelect.tsx b/frontend/src/components/ui/patient/PatientSearchSelect.tsx similarity index 97% rename from frontend/src/app/(dashboard)/patients/components/PatientSearchSelect.tsx rename to frontend/src/components/ui/patient/PatientSearchSelect.tsx index 2ac0bce..885dbad 100644 --- a/frontend/src/app/(dashboard)/patients/components/PatientSearchSelect.tsx +++ b/frontend/src/components/ui/patient/PatientSearchSelect.tsx @@ -1,7 +1,7 @@ 'use client'; import { Search } from 'lucide-react'; -import { Input } from '@/components/ui/Input'; +import { Input } from '@/components/ui/common/Input'; import { Patient } from '@/types/patient'; interface PatientSearchSelectProps { diff --git a/frontend/src/app/(dashboard)/patients/components/PatientSummaryCard.tsx b/frontend/src/components/ui/patient/PatientSummaryCard.tsx similarity index 100% rename from frontend/src/app/(dashboard)/patients/components/PatientSummaryCard.tsx rename to frontend/src/components/ui/patient/PatientSummaryCard.tsx diff --git a/frontend/src/app/(dashboard)/patients/components/TreatmentHistoryPreview.tsx b/frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx similarity index 100% rename from frontend/src/app/(dashboard)/patients/components/TreatmentHistoryPreview.tsx rename to frontend/src/components/ui/patient/TreatmentHistoryPreview.tsx diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index fae6744..446d1be 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -1,6 +1,7 @@ // src/lib/api/auth.ts import { apiClient } from './client'; -import { AuthResponse, TrialRegistrationData, LoginData, SubscriptionAlertData } from '@/types'; +import type { AuthResponse, TrialRegistrationData, LoginData } from '@/types/auth'; +import type { SubscriptionAlertData } from '@/types/subscription'; export const authApi = { // Register a new trial organization diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 196ba56..47319d7 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -1,6 +1,6 @@ // src/lib/api/client.ts import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; -import { ApiError } from '@/types'; +import type { ApiError } from '@/types/api'; interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { _retry?: boolean; diff --git a/frontend/src/lib/hooks/useAuth.tsx b/frontend/src/lib/hooks/useAuth.tsx index ba0cb50..6e9e7ff 100644 --- a/frontend/src/lib/hooks/useAuth.tsx +++ b/frontend/src/lib/hooks/useAuth.tsx @@ -3,7 +3,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { useRouter } from 'next/navigation'; import { authApi } from '@/lib/api/auth'; -import { User, Organization } from '@/types'; +import { User, Organization } from '@/types/organization'; interface AuthContextType { user: User | null; diff --git a/frontend/src/middleware.ts b/frontend/src/proxy.ts similarity index 66% rename from frontend/src/middleware.ts rename to frontend/src/proxy.ts index 27b6361..4186564 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/proxy.ts @@ -1,29 +1,22 @@ - import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password']; -export function middleware(request: NextRequest) { +export function proxy(request: NextRequest) { const { pathname } = request.nextUrl; const token = request.cookies.get('accessToken')?.value; const isAuthenticated = !!token; - // If a logged-in user opens home, send them to dashboard. if (isAuthenticated && pathname === '/') { return NextResponse.redirect(new URL('/today', request.url)); } - // Always allow public routes first. We intentionally do not block /login or /register - // when a cookie exists, because the cookie might be stale/invalid and the client - // auth check needs to recover gracefully. if (publicRoutes.includes(pathname)) { return NextResponse.next(); } - // Protected routes: redirect to login if no token if (!isAuthenticated) { - // Prevent loop: if somehow redirecting to login from login, just continue if (pathname === '/login') { return NextResponse.next(); } @@ -40,4 +33,4 @@ export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], -}; \ No newline at end of file +}; diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts index da42afa..bafa09b 100644 --- a/frontend/src/shared/permissions.ts +++ b/frontend/src/shared/permissions.ts @@ -1,4 +1,4 @@ -import type { Organization } from '@/types'; +import type { Organization } from '@/types/organization'; const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ { prefix: '/today', permission: 'TAB_TODAY_READ' }, diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..d37aeeb --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,5 @@ +export interface ApiError { + statusCode: number; + message: string | string[]; + error?: string; +} diff --git a/frontend/src/types/auth.ts b/frontend/src/types/auth.ts new file mode 100644 index 0000000..5b443b7 --- /dev/null +++ b/frontend/src/types/auth.ts @@ -0,0 +1,25 @@ +import type { Organization, User } from './organization'; + +export interface AuthResponse { + success: boolean; + data: { + accessToken: string; + refreshToken: string; + user: User; + organizations: Organization[]; + }; +} + +export interface TrialRegistrationData { + email: string; + password: string; + name: string; + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +} + +export interface LoginData { + email: string; + password: string; +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 3100c77..1b11605 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -1,60 +1,5 @@ -// src/types/index.ts -export interface User { - id: string; - email: string; - name: string; -} - -export interface Organization { - id: string; - name: string; - type: 'CLINIC' | 'LAB'; - isOwner: boolean; - permissions?: string[]; - plan?: { - name: string; - maxUsers: number; - }; -} - -/** GET /auth/subscription-alert — owners only get meaningful flags */ -export interface SubscriptionAlertData { - showWarning: boolean; - seatsLow: boolean; - trialEndingSoon: boolean; - trialExpired: boolean; - seatsUsed?: number; - seatsLimit?: number; - daysUntilTrialEnd?: number | null; - trialEndsAt?: string | null; -} - -export interface AuthResponse { - success: boolean; - data: { - accessToken: string; - refreshToken: string; - user: User; - organizations: Organization[]; - }; -} - -export interface TrialRegistrationData { - email: string; - password: string; - name: string; - organizationName: string; - organizationEmail: string; - organizationType: 'CLINIC' | 'LAB'; -} - -export interface LoginData { - email: string; - password: string; -} - -export interface ApiError { - statusCode: number; - message: string | string[]; - error?: string; -} \ No newline at end of file +export * from './organization'; +export * from './subscription'; +export * from './auth'; +export * from './api'; +export * from './patient'; \ No newline at end of file diff --git a/frontend/src/types/organization.ts b/frontend/src/types/organization.ts new file mode 100644 index 0000000..1e19d36 --- /dev/null +++ b/frontend/src/types/organization.ts @@ -0,0 +1,20 @@ +export interface User { + id: string; + email: string; + name: string; +} + +export interface OrganizationPlan { + name: string; + maxUsers: number; + price?: number; +} + +export interface Organization { + id: string; + name: string; + type: 'CLINIC' | 'LAB'; + isOwner: boolean; + permissions?: string[]; + plan?: OrganizationPlan; +} diff --git a/frontend/src/types/subscription.ts b/frontend/src/types/subscription.ts new file mode 100644 index 0000000..cd11f65 --- /dev/null +++ b/frontend/src/types/subscription.ts @@ -0,0 +1,13 @@ +/** GET /auth/subscription-alert — owners only get meaningful flags */ +export interface SubscriptionAlertData { + showWarning: boolean; + seatsLow: boolean; + trialEndingSoon: boolean; + trialExpired: boolean; + seatsUsed?: number; + seatsLimit?: number; + daysUntilTrialEnd?: number | null; + trialEndsAt?: string | null; + daysUntilPlanEnd?: number | null; + planEndsAt?: string | null; +}