Compare commits

..

8 Commits

58 changed files with 2549 additions and 379 deletions

View File

@@ -0,0 +1,29 @@
-- AlterTable
ALTER TABLE "memberships" ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT true;
-- CreateTable
CREATE TABLE "staff_invitations" (
"id" TEXT NOT NULL,
"membershipId" TEXT NOT NULL,
"invitedById" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "staff_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "staff_invitations_tokenHash_key" ON "staff_invitations"("tokenHash");
-- CreateIndex
CREATE INDEX "staff_invitations_membershipId_createdAt_idx" ON "staff_invitations"("membershipId", "createdAt");
-- AddForeignKey
ALTER TABLE "staff_invitations" ADD CONSTRAINT "staff_invitations_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "memberships"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "staff_invitations" ADD CONSTRAINT "staff_invitations_invitedById_fkey" FOREIGN KEY ("invitedById") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "organizations" DROP CONSTRAINT "organizations_planId_fkey";
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- Allow organizations without an active subscription plan.
ALTER TABLE "organizations"
ALTER COLUMN "planId" DROP NOT NULL;

View File

@@ -0,0 +1,24 @@
-- Ensure Treatment feature and permissions exist for existing databases.
WITH treatment_feature AS (
INSERT INTO "features" ("id", "name")
VALUES (md5(random()::text || clock_timestamp()::text), 'Treatment')
ON CONFLICT ("name") DO UPDATE SET "name" = EXCLUDED."name"
RETURNING "id"
),
selected_feature AS (
SELECT "id" FROM treatment_feature
UNION ALL
SELECT f."id" FROM "features" f WHERE f."name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_READ', sf."id"
FROM selected_feature sf
ON CONFLICT ("name") DO NOTHING;
WITH treatment_feature AS (
SELECT "id" FROM "features" WHERE "name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_EDIT', tf."id"
FROM treatment_feature tf
ON CONFLICT ("name") DO NOTHING;

View File

@@ -20,6 +20,7 @@ model User {
memberships Membership[] memberships Membership[]
ownedOrganizations Organization[] @relation("OrganizationOwner") ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -51,8 +52,8 @@ model Organization {
owner User @relation("OrganizationOwner", fields: [ownerId], references: [id]) owner User @relation("OrganizationOwner", fields: [ownerId], references: [id])
memberships Membership[] memberships Membership[]
planId String planId String?
plan Plan @relation(fields: [planId], references: [id]) plan Plan? @relation(fields: [planId], references: [id])
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")
@@ -122,11 +123,13 @@ model Membership {
organizationId String organizationId String
isOwner Boolean @default(false) isOwner Boolean @default(false)
isActive Boolean @default(true)
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
organization Organization @relation(fields: [organizationId], references: [id]) organization Organization @relation(fields: [organizationId], references: [id])
permissions MembershipPermission[] permissions MembershipPermission[]
invitations StaffInvitation[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -135,6 +138,26 @@ model Membership {
@@map("memberships") @@map("memberships")
} }
model StaffInvitation {
id String @id @default(uuid())
membershipId String
invitedById String
tokenHash String @unique
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
membership Membership @relation(fields: [membershipId], references: [id], onDelete: Cascade)
invitedBy User @relation(fields: [invitedById], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([membershipId, createdAt])
@@map("staff_invitations")
}
model Permission { model Permission {
id String @id @default(uuid()) id String @id @default(uuid())
name String @unique name String @unique

View File

@@ -45,10 +45,10 @@ async function main() {
// Create plans // Create plans
const plans = [ const plans = [
{ name: 'trial', maxUsers: 5, price: 0, features: {} }, { name: 'trial', maxUsers: 5, price: 0, features: {} },
{ name: 'Small', maxUsers: 5, price: 79, features: {} }, { name: 'Small', maxUsers: 5, price: 150, features: {} },
{ name: 'Medium', maxUsers: 10, price: 129, features: {} }, { name: 'Medium', maxUsers: 10, price: 250, features: {} },
{ name: 'Large', maxUsers: 15, price: 179, features: {} }, { name: 'Large', maxUsers: 15, price: 400, features: {} },
{ name: 'Enterprise', maxUsers: 999999, price: 299, features: {} }, { name: 'Enterprise', maxUsers: 999999, price: 1000, features: {} },
]; ];
for (const plan of plans) { for (const plan of plans) {
@@ -69,21 +69,25 @@ async function main() {
name: 'Today', name: 'Today',
permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'],
}, },
{
name: 'Staff',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
},
{
name: 'Labs / Clinics',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
},
{ {
name: 'Patients', name: 'Patients',
permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'],
}, },
{ {
name: 'Appointments', name: 'Appointment',
permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'],
}, },
{ {
name: 'Staff Management', name: 'Treatment',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
},
{
name: 'Lab Management',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
}, },
{ {
name: 'Billing', name: 'Billing',

View File

@@ -7,6 +7,7 @@ import { AppService } from './app.service';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅ import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module'; import { PatientsModule } from './modules/patients/patients.module';
import { StaffModule } from './modules/staff/staff.module';
@Module({ @Module({
imports: [ imports: [
@@ -17,6 +18,7 @@ import { PatientsModule } from './modules/patients/patients.module';
PrismaModule, // ✅ ADD THIS PrismaModule, // ✅ ADD THIS
AuthModule, AuthModule,
PatientsModule, PatientsModule,
StaffModule,
AdminModule.forRoot(), AdminModule.forRoot(),
], ],
controllers: [AppController], controllers: [AppController],

View File

@@ -0,0 +1,37 @@
import { isUnlimitedSeats, normalizeTabPermissions, SEAT_UNLIMITED_THRESHOLD } from './permissions';
describe('normalizeTabPermissions', () => {
it('adds READ when EDIT is present', () => {
expect(normalizeTabPermissions(['TAB_PATIENTS_EDIT'])).toEqual([
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
]);
});
it('dedupes and sorts', () => {
expect(
normalizeTabPermissions([
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_TODAY_READ',
'bogus',
]),
).toEqual(['TAB_TODAY_READ', 'TAB_TODAY_EDIT']);
});
it('accepts empty array', () => {
expect(normalizeTabPermissions([])).toEqual([]);
});
});
describe('isUnlimitedSeats', () => {
it('treats sentinel as unlimited', () => {
expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD)).toBe(true);
expect(isUnlimitedSeats(SEAT_UNLIMITED_THRESHOLD + 1)).toBe(true);
});
it('treats normal caps as limited', () => {
expect(isUnlimitedSeats(5)).toBe(false);
expect(isUnlimitedSeats(15)).toBe(false);
});
});

View File

@@ -0,0 +1,60 @@
/** Tab permissions — keep in sync with prisma seed and AuthService ALL_PERMISSIONS */
export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT',
] as const;
export type TabPermission = (typeof ALL_TAB_PERMISSIONS)[number];
const ALL_TAB_SET = new Set<string>(ALL_TAB_PERMISSIONS);
const TAB_ORDER_INDEX = new Map<string, number>(
ALL_TAB_PERMISSIONS.map((p, i) => [p, i]),
);
/** Enterprise / unlimited seat plans use this sentinel in seed data */
export const SEAT_UNLIMITED_THRESHOLD = 999999;
export function isUnlimitedSeats(maxUsers: number): boolean {
return maxUsers >= SEAT_UNLIMITED_THRESHOLD;
}
/** EDIT implies READ for the same feature tab */
const EDIT_TO_READ: Record<string, string> = {
TAB_TODAY_EDIT: 'TAB_TODAY_READ',
TAB_PATIENTS_EDIT: 'TAB_PATIENTS_READ',
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
};
/**
* Dedupe, drop unknown strings, and add implied READ permissions for each EDIT.
*/
export function normalizeTabPermissions(names: string[]): string[] {
const out = new Set<string>();
for (const raw of names) {
const n = typeof raw === 'string' ? raw.trim() : '';
if (!n || !ALL_TAB_SET.has(n)) continue;
out.add(n);
const read = EDIT_TO_READ[n];
if (read) out.add(read);
}
return [...out].sort((a, b) => (TAB_ORDER_INDEX.get(a) ?? 0) - (TAB_ORDER_INDEX.get(b) ?? 0));
}

View File

@@ -18,20 +18,33 @@ import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [ const ALL_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_LAB_READ',
'TAB_LAB_EDIT', 'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ', 'TAB_BILLING_READ',
'TAB_BILLING_EDIT', 'TAB_BILLING_EDIT',
'TAB_REPORTS_READ', 'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT', 'TAB_REPORTS_EDIT',
]; ];
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_TREATMENT_READ',
'TAB_BILLING_READ',
'TAB_REPORTS_READ',
];
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@@ -48,8 +61,9 @@ export class AuthService {
*/ */
async validateUser(email: string, password: string): Promise<any> { async validateUser(email: string, password: string): Promise<any> {
try { try {
const normalizedEmail = email.trim().toLowerCase();
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email }, where: { email: normalizedEmail },
include: { include: {
memberships: { memberships: {
include: { include: {
@@ -135,21 +149,20 @@ export class AuthService {
}); });
// Transform memberships to include organization info and permissions // Transform memberships to include organization info and permissions
const organizations = user.memberships?.map(membership => ({ const organizations = this.toActiveOrganizations(user.memberships).map(membership => ({
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, // 'CLINIC' or 'LAB' type: membership.organization.type.name, // 'CLINIC' or 'LAB'
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers, maxUsers: membership.organization.plan.maxUsers,
price: membership.organization.plan.price,
} }
: undefined, : undefined,
})) || []; }));
return { return {
success: true, success: true,
@@ -177,7 +190,8 @@ export class AuthService {
* @returns Created user info without password * @returns Created user info without password
*/ */
async register(registerDto: RegisterDto) { 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 // 1. Check existing user
const existingUser = await this.prisma.user.findUnique({ const existingUser = await this.prisma.user.findUnique({
@@ -250,16 +264,13 @@ export class AuthService {
async createOrganization(userId: string, dto: CreateOrganizationDto) { async createOrganization(userId: string, dto: CreateOrganizationDto) {
const owner = await this.prisma.user.findUnique({ const owner = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true, trialUsedAt: true }, select: { id: true },
}); });
if (!owner) { if (!owner) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const planName = dto.planName?.trim() || 'Small';
const effectivePlanName = owner.trialUsedAt ? planName : 'trial';
const organization = await this.prisma.$transaction(async (tx) => { const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({ const createdOrganization = await tx.organization.create({
data: { data: {
@@ -268,9 +279,6 @@ export class AuthService {
owner: { owner: {
connect: { id: userId }, connect: { id: userId },
}, },
plan: {
connect: { name: effectivePlanName },
},
type: { type: {
connect: { name: dto.organizationType }, connect: { name: dto.organizationType },
}, },
@@ -284,14 +292,6 @@ export class AuthService {
isOwner: true, isOwner: true,
}, },
}); });
if (!owner.trialUsedAt) {
await tx.user.update({
where: { id: userId },
data: { trialUsedAt: new Date() },
});
}
return createdOrganization; return createdOrganization;
}); });
@@ -342,21 +342,20 @@ export class AuthService {
const { passwordHash, ...result } = user; const { passwordHash, ...result } = user;
// Transform memberships for frontend consumption // Transform memberships for frontend consumption
const organizations = user.memberships?.map(membership => ({ const organizations = this.toActiveOrganizations(user.memberships).map(membership => ({
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers, maxUsers: membership.organization.plan.maxUsers,
price: membership.organization.plan.price,
} }
: undefined, : undefined,
})) || []; }));
return { return {
success: true, success: true,
@@ -462,21 +461,20 @@ export class AuthService {
}); });
// Transform memberships for response // Transform memberships for response
const organizations = session.user.memberships?.map(membership => ({ const organizations = this.toActiveOrganizations(session.user.memberships).map(membership => ({
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers, maxUsers: membership.organization.plan.maxUsers,
price: membership.organization.plan.price,
} }
: undefined, : undefined,
})) || []; }));
return { return {
success: true, success: true,
@@ -668,21 +666,20 @@ export class AuthService {
const { passwordHash, ...user } = session.user; const { passwordHash, ...user } = session.user;
const organizations = session.user.memberships?.map(membership => ({ const organizations = this.toActiveOrganizations(session.user.memberships).map(membership => ({
id: membership.organization.id, id: membership.organization.id,
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers, maxUsers: membership.organization.plan.maxUsers,
price: membership.organization.plan.price,
} }
: undefined, : undefined,
})) || []; }));
return { return {
success: true, success: true,
@@ -722,6 +719,9 @@ export class AuthService {
if (!membership) { if (!membership) {
throw new UnauthorizedException('Access denied to this organization'); throw new UnauthorizedException('Access denied to this organization');
} }
if (!membership.isOwner && !membership.isActive) {
throw new UnauthorizedException('Your invitation is still pending activation');
}
// 2. Build payload WITH org context // 2. Build payload WITH org context
const payload = { const payload = {
@@ -738,9 +738,7 @@ export class AuthService {
}); });
// 4. Format permissions // 4. Format permissions
const permissions = membership.isOwner const permissions = this.getMembershipPermissions(membership);
? ALL_PERMISSIONS
: membership.permissions.map(p => p.permission.name);
return { return {
success: true, success: true,
@@ -751,18 +749,48 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions,
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
maxUsers: membership.organization.plan.maxUsers, maxUsers: membership.organization.plan.maxUsers,
price: membership.organization.plan.price,
} }
: undefined, : undefined,
}, },
permissions,
}, },
}; };
} }
private toActiveOrganizations(
memberships: Array<{
isOwner: boolean;
isActive: boolean;
organization: {
id: string;
name: string;
type: { name: string };
plan?: { name: string; maxUsers: number; price: number } | null;
};
permissions?: Array<{ permission: { name: string } }>;
}> = [],
) {
return memberships.filter((m) => m.isOwner || m.isActive);
}
private getMembershipPermissions(membership: {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers: number; price: number } | null;
};
permissions?: Array<{ permission: { name: string } }>;
}): string[] {
if (membership.isOwner) {
return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS;
}
return membership.permissions?.map((p) => p.permission.name) || [];
}
/** /**
* Owner-only subscription / seat alerts for the current org (from JWT). * 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). * Used for a subtle warning indicator in the app shell (not staff-facing banners).
@@ -773,9 +801,12 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
daysUntilPlanEnd: null,
planEndsAt: null,
}, },
}; };
} }
@@ -794,18 +825,42 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
daysUntilPlanEnd: null,
planEndsAt: null,
}, },
}; };
} }
const org = membership.organization; const org = membership.organization;
const plan = org.plan; const plan = org.plan;
if (!plan) {
return {
success: true,
data: {
showWarning: true,
noActiveSubscription: true,
seatsLow: false,
trialEndingSoon: false,
trialExpired: false,
seatsUsed: 0,
seatsLimit: null,
daysUntilTrialEnd: null,
trialEndsAt: null,
daysUntilPlanEnd: null,
planEndsAt: null,
},
};
}
const maxUsers = plan.maxUsers; const maxUsers = plan.maxUsers;
const seatsUsed = await this.prisma.membership.count({ const seatsUsed = await this.prisma.membership.count({
where: { organizationId: org.id }, where: {
organizationId: org.id,
OR: [{ isOwner: true }, { isActive: true }],
},
}); });
const unlimited = maxUsers >= 999999; const unlimited = maxUsers >= 999999;
@@ -813,23 +868,16 @@ export class AuthService {
const seatsLow = const seatsLow =
!unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0;
let trialEndingSoon = false; // Current pricing model: trial lasts 30 days; paid plans last 90 days.
let trialExpired = false; const durationDays = plan.name === 'trial' ? 30 : 90;
let daysUntilTrialEnd: number | null = null;
let trialEndsAt: string | null = null;
if (plan.name === 'trial') {
const end = new Date(org.createdAt); const end = new Date(org.createdAt);
end.setDate(end.getDate() + 30); end.setDate(end.getDate() + durationDays);
trialEndsAt = end.toISOString(); const planEndsAt = end.toISOString();
const ms = end.getTime() - Date.now(); const ms = end.getTime() - Date.now();
daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); const daysUntilPlanEnd = Math.ceil(ms / (1000 * 60 * 60 * 24));
if (daysUntilTrialEnd <= 0) {
trialExpired = true; const trialExpired = plan.name === 'trial' && daysUntilPlanEnd <= 0;
} else if (daysUntilTrialEnd <= 7) { const trialEndingSoon = plan.name === 'trial' && daysUntilPlanEnd > 0 && daysUntilPlanEnd <= 7;
trialEndingSoon = true;
}
}
const showWarning = seatsLow || trialEndingSoon || trialExpired; const showWarning = seatsLow || trialEndingSoon || trialExpired;
@@ -837,13 +885,16 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning, showWarning,
noActiveSubscription: false,
seatsLow, seatsLow,
trialEndingSoon, trialEndingSoon,
trialExpired, trialExpired,
seatsUsed, seatsUsed,
seatsLimit: maxUsers, seatsLimit: maxUsers,
daysUntilTrialEnd, daysUntilTrialEnd: plan.name === 'trial' ? daysUntilPlanEnd : null,
trialEndsAt, trialEndsAt: plan.name === 'trial' ? planEndsAt : null,
daysUntilPlanEnd,
planEndsAt,
}, },
}; };
} }

View File

@@ -0,0 +1,14 @@
import { IsString, MinLength } from 'class-validator';
export class AcceptStaffInviteDto {
@IsString()
token: string;
@IsString()
@MinLength(8)
password: string;
@IsString()
@MinLength(1)
name: string;
}

View File

@@ -0,0 +1,15 @@
import { IsArray, IsEmail, IsString, MinLength } from 'class-validator';
export class InviteStaffDto {
@IsEmail()
email: string;
@IsString()
@MinLength(1)
name: string;
/** TAB_* permission names; EDIT implies READ after normalization. */
@IsArray()
@IsString({ each: true })
permissionNames: string[];
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class PreviewStaffInviteDto {
@IsString()
token: string;
}

View File

@@ -0,0 +1,13 @@
import { IsArray, IsOptional, IsString, MinLength } from 'class-validator';
export class UpdateStaffMemberDto {
@IsOptional()
@IsString()
@MinLength(1)
name?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissionNames?: string[];
}

View File

@@ -0,0 +1,80 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { InviteStaffDto } from './dto/invite-staff.dto';
import { PreviewStaffInviteDto } from './dto/preview-staff-invite.dto';
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
import { StaffService } from './staff.service';
@ApiTags('staff')
@ApiBearerAuth('JWT-auth')
@Controller('staff')
export class StaffController {
constructor(private readonly staffService: StaffService) {}
@Get('invitations/preview')
@ApiOperation({ summary: 'Preview invite info by token (public)' })
previewInvite(@Query() query: PreviewStaffInviteDto) {
return this.staffService.previewInvite(query.token);
}
@Post('invitations/accept')
@ApiOperation({ summary: 'Accept invite and activate account (public)' })
acceptInvite(@Body() dto: AcceptStaffInviteDto) {
return this.staffService.acceptInvite(dto);
}
@Get()
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List organization members (requires TAB_STAFF_READ or owner)' })
list(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.list(req.user.id, organizationId);
}
@Post('invite')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Invite staff (requires TAB_STAFF_EDIT or owner)' })
invite(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: InviteStaffDto,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.invite(req.user.id, organizationId, dto);
}
@Patch('members/:membershipId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Update staff member name and/or permissions' })
updateMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
@Body() dto: UpdateStaffMemberDto,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.updateMember(req.user.id, organizationId, membershipId, dto);
}
@Delete('members/:membershipId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Remove staff member from organization' })
removeMember(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('membershipId') membershipId: string,
) {
const organizationId = this.staffService.getOrganizationIdFromUser(req.user);
return this.staffService.removeMember(req.user.id, organizationId, membershipId);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { StaffController } from './staff.controller';
import { StaffService } from './staff.service';
@Module({
controllers: [StaffController],
providers: [StaffService, PrismaService],
})
export class StaffModule {}

View File

@@ -0,0 +1,454 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptStaffInviteDto } from './dto/accept-staff-invite.dto';
import { isUnlimitedSeats, normalizeTabPermissions } from '../../common/permissions';
import { InviteStaffDto } from './dto/invite-staff.dto';
import { UpdateStaffMemberDto } from './dto/update-staff-member.dto';
@Injectable()
export class StaffService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canViewStaff(actor)) {
throw new ForbiddenException('You do not have access to staff management');
}
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
const [members, seatsUsed] = await Promise.all([
this.prisma.membership.findMany({
where: { organizationId },
include: {
user: { select: { id: true, email: true, name: true } },
permissions: { include: { permission: true } },
invitations: {
orderBy: { createdAt: 'desc' },
take: 1,
},
},
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
}),
this.prisma.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
}),
]);
const maxUsers = org.plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers);
return {
success: true,
data: {
members: members.map((m) => ({
id: m.id,
userId: m.user.id,
email: m.user.email,
name: m.user.name,
isOwner: m.isOwner,
isActive: m.isOwner ? true : m.isActive,
invitationStatus: this.getInvitationStatus(m),
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
permissions: m.isOwner
? null
: m.permissions.map((p) => p.permission.name),
})),
seats: {
used: seatsUsed,
limit: unlimited ? null : maxUsers,
unlimited,
},
},
};
}
async invite(userId: string, organizationId: string, dto: InviteStaffDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot invite or manage staff');
}
const email = dto.email.trim().toLowerCase();
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
const permissionRows = await this.prisma.permission.findMany({
where: { name: { in: normalizedPerms } },
select: { id: true, name: true },
});
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const result = await this.prisma.$transaction(async (tx) => {
const org = await tx.organization.findUnique({
where: { id: organizationId },
include: { plan: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before inviting staff.',
);
}
const maxUsers = org.plan.maxUsers;
const seatsUsed = await tx.membership.count({
where: {
organizationId,
OR: [{ isOwner: true }, { isActive: true }],
},
});
if (!isUnlimitedSeats(maxUsers) && seatsUsed >= maxUsers) {
throw new BadRequestException(
`Your plan allows ${maxUsers} team members. Remove a member or upgrade to add more.`,
);
}
const existingUser = await tx.user.findUnique({ where: { email } });
let targetUserId: string;
if (existingUser) {
if (existingUser.id === org.ownerId) {
throw new BadRequestException('Organization owner is already a member');
}
const dup = await tx.membership.findUnique({
where: {
userId_organizationId: {
userId: existingUser.id,
organizationId,
},
},
});
if (dup) {
throw new ConflictException('This user is already a member of this organization');
}
targetUserId = existingUser.id;
} else {
const created = await tx.user.create({
data: {
email,
name: dto.name.trim(),
passwordHash: null,
},
});
targetUserId = created.id;
}
const membership = await tx.membership.create({
data: {
userId: targetUserId,
organizationId,
isOwner: false,
isActive: existingUser ? true : false,
},
});
if (permissionRows.length > 0) {
await tx.membershipPermission.createMany({
data: permissionRows.map((p) => ({
membershipId: membership.id,
permissionId: p.id,
})),
});
}
let inviteUrl: string | null = null;
let invitationId: string | null = null;
if (!existingUser) {
const invitation = await tx.staffInvitation.create({
data: {
membershipId: membership.id,
invitedById: userId,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
invitationId = invitation.id;
inviteUrl = this.buildInviteUrl(plainToken);
}
return {
membershipId: membership.id,
userId: targetUserId,
invitationId,
inviteUrl,
isPending: !existingUser,
};
});
return {
success: true,
data: {
membershipId: result.membershipId,
userId: result.userId,
email,
invitationId: result.invitationId,
invitationUrl: result.inviteUrl,
invitationStatus: result.isPending ? 'PENDING' : 'ACCEPTED',
},
};
}
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
const org = invitation.membership.organization;
const user = invitation.membership.user;
return {
success: true,
data: {
email: user.email,
name: user.name,
organizationName: org.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
},
};
}
async acceptInvite(dto: AcceptStaffInviteDto) {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
const passwordHash = await bcrypt.hash(dto.password, 10);
const now = new Date();
await this.prisma.$transaction(async (tx) => {
await tx.user.update({
where: { id: invitation.membership.userId },
data: {
passwordHash,
name: dto.name.trim(),
},
});
await tx.membership.update({
where: { id: invitation.membershipId },
data: { isActive: true },
});
await tx.staffInvitation.update({
where: { id: invitation.id },
data: { acceptedAt: now },
});
});
return {
success: true,
data: {
email: invitation.membership.user.email,
},
message: 'Invitation accepted. You can now log in.',
};
}
async updateMember(
actorUserId: string,
organizationId: string,
membershipId: string,
dto: UpdateStaffMemberDto,
) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot edit staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
include: {
user: true,
permissions: { include: { permission: true } },
},
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Owner membership cannot be edited here');
}
if (dto.name !== undefined) {
await this.prisma.user.update({
where: { id: target.userId },
data: { name: dto.name.trim() },
});
}
if (dto.permissionNames !== undefined) {
const normalizedPerms = normalizeTabPermissions(dto.permissionNames);
const permissionRows = await this.prisma.permission.findMany({
where: { name: { in: normalizedPerms } },
select: { id: true, name: true },
});
if (permissionRows.length !== normalizedPerms.length) {
const ok = new Set(permissionRows.map((p) => p.name));
const missing = normalizedPerms.filter((n) => !ok.has(n));
throw new BadRequestException(`Unknown or invalid permissions: ${missing.join(', ')}`);
}
await this.prisma.$transaction([
this.prisma.membershipPermission.deleteMany({ where: { membershipId: target.id } }),
...(permissionRows.length
? [
this.prisma.membershipPermission.createMany({
data: permissionRows.map((p) => ({
membershipId: target.id,
permissionId: p.id,
})),
}),
]
: []),
]);
}
return { success: true, message: 'Member updated' };
}
async removeMember(actorUserId: string, organizationId: string, membershipId: string) {
const actor = await this.getActorMembership(actorUserId, organizationId);
if (!actor || !this.canEditStaff(actor)) {
throw new ForbiddenException('You cannot remove staff');
}
const target = await this.prisma.membership.findFirst({
where: { id: membershipId, organizationId },
});
if (!target) {
throw new NotFoundException('Member not found');
}
if (target.isOwner) {
throw new ForbiddenException('Cannot remove the organization owner');
}
await this.prisma.membership.delete({ where: { id: membershipId } });
return { success: true, message: 'Member removed' };
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: {
permissions: { include: { permission: true } },
organization: { select: { planId: true } },
},
});
}
private getInvitationStatus(m: {
isOwner: boolean;
isActive: boolean;
invitations: { acceptedAt: Date | null; revokedAt: Date | null; expiresAt: Date }[];
}): 'ACTIVE' | 'PENDING' | 'EXPIRED' {
if (m.isOwner || m.isActive) return 'ACTIVE';
const invitation = m.invitations[0];
if (!invitation) return 'EXPIRED';
if (invitation.acceptedAt || invitation.revokedAt) return 'ACTIVE';
return invitation.expiresAt.getTime() > Date.now() ? 'PENDING' : 'EXPIRED';
}
private generateInviteToken(): string {
return randomBytes(32).toString('hex');
}
private hashInviteToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private getInviteExpiryDate(): Date {
const d = new Date();
d.setDate(d.getDate() + 7);
return d;
}
private buildInviteUrl(token: string): string {
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
return `${appUrl}/accept-invite?token=${encodeURIComponent(token)}`;
}
private async findValidInvitation(token: string) {
const invitation = await this.prisma.staffInvitation.findUnique({
where: { tokenHash: this.hashInviteToken(token) },
include: {
membership: {
include: {
user: { select: { id: true, email: true, name: true } },
organization: { select: { id: true, name: true } },
},
},
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
}
return invitation;
}
private canViewStaff(m: {
isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[];
}): boolean {
if (m.isOwner) return true;
return m.permissions.some(
(p) =>
p.permission.name === 'TAB_STAFF_READ' || p.permission.name === 'TAB_STAFF_EDIT',
);
}
private canEditStaff(m: {
isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[];
}): boolean {
if (m.isOwner) return Boolean(m.organization?.planId);
return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT');
}
}

View File

@@ -9,11 +9,12 @@ const nextConfig = {
// Disable x-powered-by header for security // Disable x-powered-by header for security
poweredByHeader: false, poweredByHeader: false,
// Configure image domains if needed // Configure allowed remote image sources
images: { images: {
domains: process.env.NODE_ENV === 'production' remotePatterns:
? ['yourdomain.com'] process.env.NODE_ENV === 'production'
: ['localhost'], ? [{ protocol: 'https', hostname: 'yourdomain.com' }]
: [{ protocol: 'http', hostname: 'localhost' }],
}, },
// Environment variables that will be available at build time // Environment variables that will be available at build time

View File

@@ -2,9 +2,11 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { Search, Filter, Plus } from 'lucide-react'; import { Search, Filter, Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/Badge'; import { Badge } from '@/components/ui/common/Badge';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
// Mock data matching your design // Mock data matching your design
const invoices = [ const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },
@@ -25,8 +27,10 @@ interface StatCardProps {
color: StatCardColor; color: StatCardColor;
} }
export default function BillingPage() { export default function BillingPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT');
const stats = { const stats = {
total: { count: 235, amount: 80900 }, total: { count: 235, amount: 80900 },
unpaid: { count: 30, amount: 2800 }, unpaid: { count: 30, amount: 2800 },
@@ -38,7 +42,12 @@ export default function BillingPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1> <h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button variant="primary" className="flex items-center gap-2"> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Invoice New Invoice
</Button> </Button>
@@ -158,7 +167,11 @@ export default function BillingPage() {
</Badge> </Badge>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-primary hover:opacity-90 text-sm"> <button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
Edit Edit
</button> </button>
</td> </td>

View File

@@ -1,15 +1,21 @@
'use client'; 'use client';
import { memo, useEffect } from 'react'; import { memo, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { usePathname, useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import Sidebar from '@/components/ui/Sidebar'; import Sidebar from '@/components/ui/common/Sidebar';
import { ThemeToggle } from '@/components/ui/ThemeToggle'; import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu'; import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
} from '@/shared/permissions';
export default function DashboardLayout({ children }: { children: React.ReactNode }) { export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const { user, currentOrganization, isAuthReady } = useAuth(); const { user, currentOrganization, isAuthReady } = useAuth();
const router = useRouter(); const router = useRouter();
const pathname = usePathname();
// ✅ AUTH GUARD (runs once per navigation group) // ✅ AUTH GUARD (runs once per navigation group)
useEffect(() => { useEffect(() => {
@@ -24,7 +30,12 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
router.replace('/select-organization'); router.replace('/select-organization');
return; return;
} }
}, [isAuthReady, user, currentOrganization, router]);
const required = getRequiredReadPermissionForPath(pathname);
if (required && !hasPermission(currentOrganization, required)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}, [isAuthReady, user, currentOrganization, router, pathname]);
// ✅ LOADING ONLY FOR INITIAL LOAD // ✅ LOADING ONLY FOR INITIAL LOAD
if (!isAuthReady) { if (!isAuthReady) {
@@ -66,7 +77,7 @@ const DashboardHeader = memo(function DashboardHeader({
organizationName: string; organizationName: string;
}) { }) {
return ( return (
<header className="h-[71px] flex justify-between items-center gap-4 px-6 border-b border-border/70 backdrop-blur-sm"> <header className="relative z-40 h-[71px] flex justify-between items-center gap-4 px-6 border-b border-border/70 backdrop-blur-sm">
<h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2> <h2 className="text-lg font-medium truncate min-w-0">{organizationName}</h2>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">

View File

@@ -2,18 +2,20 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-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 { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { import {
CreatePatientInput, CreatePatientInput,
CreateTreatmentHistoryInput, CreateTreatmentHistoryInput,
Patient, Patient,
TreatmentHistoryItem, TreatmentHistoryItem,
} from '@/types/patient'; } from '@/types/patient';
import { PatientSearchSelect } from '@/components/patients/PatientSearchSelect'; import { PatientSearchSelect } from '../../../components/ui/patient/PatientSearchSelect';
import { CreatePatientModal } from '@/components/patients/CreatePatientModal'; import { CreatePatientModal } from '../../../components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '@/components/patients/PatientSummaryCard'; import { PatientSummaryCard } from '../../../components/ui/patient/PatientSummaryCard';
import { TreatmentHistoryPreview } from '@/components/patients/TreatmentHistoryPreview'; import { TreatmentHistoryPreview } from '../../../components/ui/patient/TreatmentHistoryPreview';
const EMPTY_PATIENT_FORM: CreatePatientInput = { const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '', firstName: '',
@@ -23,6 +25,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
}; };
export default function PatientsPage() { export default function PatientsPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,6 +38,7 @@ export default function PatientsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>(''); const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo( const sortedPatients = useMemo(
() => () =>
@@ -154,7 +158,16 @@ export default function PatientsPage() {
<div className="relative space-y-6 pb-20"> <div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1> <h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button variant="primary" className="flex items-center gap-2" onClick={() => setIsCreateOpen(true)}> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Patient New Patient
</Button> </Button>
@@ -189,9 +202,13 @@ export default function PatientsPage() {
<div className="flex"> <div className="flex">
<Button <Button
variant="secondary" variant="secondary"
disabled={!selectedPatient} disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment} isLoading={savingTreatment}
onClick={handleQuickAddTreatment} onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
> >
Add Quick Treatment Entry Add Quick Treatment Entry
</Button> </Button>
@@ -201,7 +218,7 @@ export default function PatientsPage() {
</div> </div>
{(errorMessage || successMessage) && ( {(errorMessage || successMessage) && (
<div className="absolute bottom-0 left-0 right-0 z-50 w-full"> <div className="absolute bottom-0 left-0 right-0 z-10 w-full">
{errorMessage && ( {errorMessage && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg"> <div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{errorMessage} {errorMessage}

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
return ( return (
<div className="max-w-xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"

View File

@@ -0,0 +1,20 @@
'use client';
import Link from 'next/link';
import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent';
export default function DashboardOrganizationsSettingsPage() {
return (
<div className="space-y-6">
<div>
<Link
href="/today"
className="text-sm text-primary hover:opacity-90"
>
Back to app
</Link>
</div>
<OrganizationSelectorContent />
</div>
);
}

View File

@@ -5,12 +5,22 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types'; import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
{ id: 'solo', name: 'Solo', maxUsers: 1, price: 19 },
{ id: 'small', name: 'Small', maxUsers: 5, price: 49 },
{ id: 'medium', name: 'Medium', maxUsers: 10, price: 89 },
{ id: 'large', name: 'Large', maxUsers: 15, price: 129 },
{ id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() { export default function SubscriptionsSettingsPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const router = useRouter(); const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null); const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string>(PLAN_OPTIONS[0].id);
const [purchaseNotice, setPurchaseNotice] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (currentOrganization && !currentOrganization.isOwner) { if (currentOrganization && !currentOrganization.isOwner) {
@@ -38,10 +48,27 @@ export default function SubscriptionsSettingsPage() {
} }
const plan = currentOrganization.plan; const plan = currentOrganization.plan;
const hasActiveSubscription = Boolean(plan);
const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId);
const maxUsers = plan?.maxUsers; 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 ( return (
<div className="max-w-xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"
@@ -59,25 +86,54 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
<div className="surface-card p-6 space-y-4"> <div className="surface-card p-6 space-y-4">
<div className="flex flex-wrap gap-4 justify-between"> {!hasActiveSubscription && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization has no active subscription. Select a plan below to start
the purchase process.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div> <div>
<p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p> <p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p>
<p className="text-lg font-medium text-text-primary capitalize"> <p className="text-lg font-medium text-text-primary capitalize">
{plan?.name ?? '—'} {plan?.name ?? '—'}
</p> </p>
</div> </div>
{typeof maxUsers === 'number' && maxUsers < 999999 && (
<div> <div>
<p className="text-xs text-text-muted uppercase tracking-wide">Seats (this org)</p> <p className="text-xs text-text-muted uppercase tracking-wide">Plan price</p>
<p className="text-lg font-medium text-text-primary"> <p className="text-lg font-medium text-text-primary">
{alert?.seatsUsed ?? '—'} / {maxUsers} {typeof plan?.price === 'number' ? `$${plan.price}` : '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Seats used</p>
<p className="text-lg font-medium text-text-primary">
{typeof seatsUsed === 'number' ? seatsUsed : '—'}
{typeof maxUsers === 'number' ? ` / ${isUnlimited ? 'Unlimited' : maxUsers}` : ''}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Seats remaining</p>
<p className="text-lg font-medium text-text-primary">
{isUnlimited ? 'Unlimited' : seatsRemaining ?? '—'}
</p>
</div>
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Days remaining</p>
<p className={`text-lg font-medium ${planDayTone}`}>
{daysUntilPlanEnd ?? '—'}
</p> </p>
</div> </div>
)}
</div> </div>
{alert?.showWarning && ( {alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1"> <div className="text-sm text-text-secondary space-y-1">
{alert.noActiveSubscription && (
<p>No active subscription for this organization.</p>
)}
{alert.trialExpired && ( {alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p> <p>Trial period has ended. Choose a plan when checkout is available.</p>
)} )}
@@ -92,11 +148,52 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
)} )}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
Payment and plan upgrades will connect here. The warning on the settings Choose a plan to continue. Purchase integration is not active yet, so this
icon is only shown to workspace owners when seats are low or the trial window currently prepares the selection step only.
is ending.
</p> </p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLAN_OPTIONS.map((option) => {
const selected = selectedPlanId === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => setSelectedPlanId(option.id)}
className={`rounded-[var(--radius-md)] border p-4 text-left transition-colors ${
selected
? 'border-primary/70 bg-primary-soft'
: 'border-border hover:border-border-strong'
}`}
>
<p className="text-base font-medium text-text-primary">{option.name}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`}
</p>
<p className="text-sm text-text-secondary mt-1">${option.price} / month</p>
</button>
);
})}
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-60"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
`Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`,
);
}}
>
Start purchase process
</button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,44 @@
/** Feature groups for staff invite/edit UI — matches backend seed */
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs / Clinics', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
/** Map EDIT key -> { read, edit } for checkbox grid */
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {};
for (const g of STAFF_FEATURE_GROUPS) {
s[g.edit] = { read: false, edit: false };
}
return s;
}
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
const set = new Set(names);
const s = emptyFeaturePermissionState();
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
s[g.edit] = { read: hasRead, edit: hasEdit };
}
return s;
}
export function permissionNamesFromFeatureState(state: FeaturePermState): string[] {
const out: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const cell = state[g.edit];
if (!cell) continue;
if (cell.edit) out.push(g.edit);
else if (cell.read) out.push(g.read);
}
return out;
}

View File

@@ -1,10 +1,630 @@
export default function StaffPage() { 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation';
import {
firstAccessibleDashboardPath,
canEditStaff,
canViewStaff,
} from '@/shared/permissions';
import {
STAFF_FEATURE_GROUPS,
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
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/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox';
import type { ApiError } from '@/types/api';
type StoredInviteLink = {
membershipId: string;
email: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `staffInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
}
function PermissionGrid({
state,
onChange,
disabled,
organizationType,
}: {
state: FeaturePermState;
onChange: (next: FeaturePermState) => void;
disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) {
const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false };
onChange({
...state,
[editKey]: { read, edit: read ? cur.edit : false },
});
};
const setEdit = (editKey: string, edit: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false };
onChange({
...state,
[editKey]: { read: edit || cur.read, edit },
});
};
return ( return (
<div className="space-y-3"> <div className="grid gap-3 sm:grid-cols-2">
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1> {STAFF_FEATURE_GROUPS.map((g) => {
<p className="text-sm text-text-secondary"> const cell = state[g.edit] ?? { read: false, edit: false };
Staff management module is coming soon. return (
</p> <div
key={g.edit}
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
>
<span className="text-sm font-medium text-text-primary">
{resolveStaffFeatureLabel(g, organizationType)}
</span>
<div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox
checked={cell.read}
disabled={disabled}
label="View"
onChange={(v) => setRead(g.edit, v)}
/>
<Checkbox
checked={cell.edit}
disabled={disabled}
label="Edit"
onChange={(v) => setEdit(g.edit, v)}
/>
</div>
</div>
);
})}
</div>
);
}
export default function StaffPage() {
const router = useRouter();
const { currentOrganization, user } = useAuth();
const [members, setMembers] = useState<StaffMemberDto[]>([]);
const [seats, setSeats] = useState<{
used: number;
limit: number | null;
unlimited: boolean;
} | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [inviteOpen, setInviteOpen] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string;
email: string;
invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState('');
const [editPerms, setEditPerms] = useState(() => emptyFeaturePermissionState());
const [editLoading, setEditLoading] = useState(false);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false;
if (seats.limit == null) return false;
return seats.used >= seats.limit;
}, [seats]);
const load = useCallback(async () => {
setError('');
setLoading(true);
try {
const res = await staffApi.list();
setMembers(res.data.members);
setSeats(res.data.seats);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
if (!currentOrganization?.id || loading) return;
const activeMemberIds = new Set(
members
.filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE')
.map((m) => m.id),
);
let changed = false;
const nextLinks: Record<string, StoredInviteLink> = { ...pendingInviteLinks };
for (const memberId of Object.keys(nextLinks)) {
if (activeMemberIds.has(memberId)) {
delete nextLinks[memberId];
changed = true;
}
}
if (!changed) return;
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}, [currentOrganization?.id, loading, members, pendingInviteLinks]);
useEffect(() => {
void load();
}, [load]);
useEffect(() => {
if (!currentOrganization) return;
if (!canViewStaff(currentOrganization)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}, [currentOrganization, router]);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function submitInvite() {
setInviteLoading(true);
setError('');
setLastInviteInfo(null);
const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim();
try {
const permissionNames = permissionNamesFromFeatureState(invitePerms);
const res = await staffApi.invite({
email: displayEmail,
name: displayName,
permissionNames,
});
setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
invitationStatus: res.data.invitationStatus,
});
if (currentOrganization?.id && res.data.invitationUrl) {
const nextLinks = {
...pendingInviteLinks,
[res.data.membershipId]: {
membershipId: res.data.membershipId,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess('');
setInviteOpen(false);
setInviteEmail('');
setInviteName('');
setInvitePerms(emptyFeaturePermissionState());
await load();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
function openEdit(m: StaffMemberDto) {
if (m.isOwner) return;
setEditing(m);
setEditName(m.name);
setEditPerms(
featureStateFromPermissionNames(m.permissions ?? []),
);
}
async function submitEdit() {
if (!editing) return;
setEditLoading(true);
setError('');
try {
await staffApi.updateMember(editing.id, {
name: editName.trim(),
permissionNames: permissionNamesFromFeatureState(editPerms),
});
setSuccess('Member updated');
setEditing(null);
await load();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setEditLoading(false);
}
}
async function removeMember(m: StaffMemberDto) {
if (m.isOwner) return;
if (m.userId === user?.id) {
if (!confirm('Remove yourself from this organization? You will lose access.')) return;
} else {
if (!confirm(`Remove ${m.name} from this organization?`)) return;
}
setError('');
try {
await staffApi.removeMember(m.id);
setSuccess('Member removed');
await load();
} catch (e) {
setError(formatApiMessage(e));
}
}
if (!currentOrganization || !canViewStaff(currentOrganization)) {
return (
<p className="text-sm text-text-secondary">Redirecting</p>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
<p className="text-sm text-text-secondary mt-1">
Invite teammates, set tab access, and stay within your plan seat limit.
</p>
</div>
<Button
size="sm"
onClick={() => {
if (!canEdit || atSeatLimit) return;
setInviteOpen(true);
setLastInviteInfo(null);
}}
disabled={!canEdit || atSeatLimit}
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
</div>
{seats && (
<p className="text-sm text-text-secondary">
Seats:{' '}
<span className="text-text-primary font-medium">
{seats.used}
{seats.unlimited ? ' (unlimited plan)' : ` / ${seats.limit}`}
</span>
{!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2">
{hasActivePlan
? 'Plan seat limit reached for this organization.'
: 'No active plan selected for this organization. Choose a subscription plan to invite members.'}
</span>
)}
</p>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
{lastInviteInfo && (
<div className="relative rounded-[var(--radius-md)] border border-border-strong bg-background-secondary/90 px-4 py-3 pr-12 shadow-[inset_0_1px_0_rgba(255,255,255,0.04)] space-y-3">
<button
type="button"
className="absolute right-2 top-2 p-1.5 rounded-[var(--radius-sm)] text-text-muted hover:text-text-primary hover:bg-background-card/80"
aria-label="Dismiss"
onClick={() => {
setLastInviteInfo(null);
}}
>
<X className="w-4 h-4" />
</button>
<p className="text-sm text-text-primary pr-6">
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) was invited.
{lastInviteInfo.invitationStatus === 'PENDING'
? ' Invitation is pending until they open the link, set a password, and log in.'
: ' Invitation was accepted immediately.'}
</p>
{lastInviteInfo.invitationUrl && (
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
Invite link
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
{lastInviteInfo.invitationUrl}
</code>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
</Button>
</div>
<p className="text-xs text-text-muted">
Share this link manually via SMS or email. They must set password first.
</p>
</div>
)}
</div>
)}
{loading ? (
<p className="text-sm text-text-secondary">Loading team</p>
) : (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-border/70">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/70 text-left text-text-secondary">
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th>
<th className="p-3 font-medium w-28">Actions</th>
</tr>
</thead>
<tbody>
{members.map((m) => (
<tr key={m.id} className="h-14 border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3">
{m.isOwner ? (
<span className="text-primary font-medium">Owner</span>
) : (
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3 align-middle">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active
</span>
) : m.invitationStatus === 'PENDING' ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
<Clock3 className="h-3 w-3" />
Pending
</span>
) : (
<span className="inline-flex items-center rounded-full border border-red-500/40 bg-red-500/15 px-2 py-0.5 text-xs text-red-300">
Expired
</span>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>
) : (
<span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions, currentOrganization?.type)}
</span>
)}
</td>
<td className="p-3 align-middle">
{!m.isOwner && (
<div className="flex min-h-[36px] items-center justify-end gap-1">
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={async () => {
try {
await navigator.clipboard.writeText(pendingInviteLinks[m.id].invitationUrl);
setCopiedInviteMembershipId(m.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
aria-label="Copy invite link"
title="Copy invite link"
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Edit member"
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
openEdit(m);
}}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Remove member"
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
void removeMember(m);
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{inviteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
aria-labelledby="invite-staff-title"
>
<h2 id="invite-staff-title" className="text-lg font-semibold text-text-primary">
Invite team member
</h2>
<Input
label="Email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
autoComplete="off"
/>
<Input
label="Display name"
value={inviteName}
onChange={(e) => setInviteName(e.target.value)}
/>
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
Cancel
</Button>
<Button
type="button"
isLoading={inviteLoading}
disabled={!inviteEmail.trim() || !inviteName.trim()}
onClick={() => void submitInvite()}
>
Send invite
</Button>
</div>
</div>
</div>
)}
{editing && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
>
<h2 className="text-lg font-semibold text-text-primary">Edit member</h2>
<p className="text-xs text-text-muted">{editing.email}</p>
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}>
Cancel
</Button>
<Button type="button" isLoading={editLoading} onClick={() => void submitEdit()}>
Save
</Button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,76 @@
/**
* Staff route only: tab matrix + checkbox state ↔ TAB_* permission names.
* Add presentational pieces under ./components/ as the UI grows.
*/
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
): string {
if (group.read === 'TAB_LAB_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
}
return group.label;
}
export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {};
for (const g of STAFF_FEATURE_GROUPS) {
s[g.edit] = { read: false, edit: false };
}
return s;
}
export function featureStateFromPermissionNames(names: string[]): FeaturePermState {
const set = new Set(names);
const s = emptyFeaturePermissionState();
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
s[g.edit] = { read: hasRead, edit: hasEdit };
}
return s;
}
export function permissionNamesFromFeatureState(state: FeaturePermState): string[] {
const out: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const cell = state[g.edit];
if (!cell) continue;
if (cell.edit) out.push(g.edit);
else if (cell.read) out.push(g.read);
}
return out;
}
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(
permissionNames: string[] | null | undefined,
organizationType?: OrgType,
): string {
if (!permissionNames?.length) return 'No tab access';
const set = new Set(permissionNames);
const parts: string[] = [];
for (const g of STAFF_FEATURE_GROUPS) {
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue;
const label = resolveStaffFeatureLabel(g, organizationType);
parts.push(hasEdit ? label : `${label} (Read only)`);
}
return parts.length ? parts.join(' · ') : 'No tab access';
}

View File

@@ -1,10 +1,31 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TodayPage() { export default function TodayPage() {
const { currentOrganization } = useAuth();
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return ( return (
<div> <div>
<h1 className="text-2xl font-semibold mb-6"> <h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !! Welcome back Babak !!
</h1> </h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization does not have an active subscription yet.{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
Choose a plan
</Link>{' '}
to start the purchase process.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" /> <Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" /> <Card title="Active Patients" value="675" />

View File

@@ -0,0 +1,10 @@
export default function TreatmentPage() {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary">
Treatment module is coming soon.
</p>
</div>
);
}

View File

@@ -0,0 +1,166 @@
'use client';
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/common/Button';
import { Input } from '@/components/ui/common/Input';
import { staffApi } from '@/lib/api/staff';
function AcceptInviteContent() {
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [inviteInfo, setInviteInfo] = useState<{
email: string;
name: string;
organizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [name, setName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
if (!token) {
setLoading(false);
setError('Invalid invitation link');
return;
}
void (async () => {
setLoading(true);
setError('');
try {
const res = await staffApi.previewInvite(token);
setInviteInfo(res.data);
setName(res.data.name || '');
if (res.data.status === 'ACCEPTED') {
setSuccess('This invitation is already accepted. You can log in now.');
}
} catch (e: any) {
setError(e?.message || 'Could not load invitation');
} finally {
setLoading(false);
}
})();
}, [token]);
async function onAccept() {
if (!token) return;
setError('');
setSuccess('');
if (!name.trim()) {
setError('Name is required');
return;
}
if (password.length < 8) {
setError('Password must be at least 8 characters');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
setSubmitting(true);
try {
await staffApi.acceptInvite({
token,
name: name.trim(),
password,
});
setSuccess('Invitation accepted. Redirecting to login...');
setTimeout(() => {
router.replace('/login');
}, 1000);
} catch (e: any) {
setError(e?.message || 'Could not accept invitation');
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="w-full max-w-md surface-card p-6 space-y-5">
<h1 className="text-xl font-semibold text-text-primary">Accept invitation</h1>
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation...</p>
) : (
<>
{inviteInfo && (
<div className="rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2 text-sm text-text-secondary space-y-1">
<p>
Organization: <span className="text-text-primary">{inviteInfo.organizationName}</span>
</p>
<p>
Email: <span className="text-text-primary">{inviteInfo.email}</span>
</p>
</div>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-3 py-2 text-sm text-text-primary">
{success}
</div>
)}
{inviteInfo?.status !== 'ACCEPTED' && (
<div className="space-y-3">
<Input label="Name" value={name} onChange={(e) => setName(e.target.value)} />
<Input
label="Create password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="Confirm password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
Activate account
</Button>
</div>
)}
<p className="text-xs text-text-muted">
Already have access? <Link href="/login" className="text-primary">Go to login</Link>
</p>
</>
)}
</div>
</div>
);
}
export default function AcceptInvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-sm text-text-secondary">Loading invitation...</p>
</div>
}
>
<AcceptInviteContent />
</Suspense>
);
}

View File

@@ -116,8 +116,8 @@ import Link from 'next/link';
import { Mail, Lock } from 'lucide-react'; import { Mail, Lock } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/common/Input';
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'), email: z.string().email('Please enter a valid email address'),

View File

@@ -2,8 +2,8 @@
import Link from 'next/link'; import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/common/Button';
import { ThemeToggle } from '@/components/ui/ThemeToggle'; import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react'; import { Building2, Beaker, Calendar, Shield, Clock, Users } from 'lucide-react';
export default function HomePage() { export default function HomePage() {

View File

@@ -7,8 +7,8 @@ import * as z from 'zod';
import Link from 'next/link'; import Link from 'next/link';
import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/common/Input';
const registerSchema = z.object({ const registerSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'), name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Please enter a valid email address'), email: z.string().email('Please enter a valid email address'),

View File

@@ -1,170 +1,12 @@
'use client'; 'use client';
import { useState } from 'react'; import { OrganizationSelectorContent } from '@/components/ui/organization/OrganizationSelectorContent';
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';
export default function SelectOrganizationPage() { 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'
? <Building2 className="h-8 w-8 icon-flat" />
: <Beaker className="h-8 w-8 icon-flat" />;
};
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 (
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-text-secondary">Loading...</p>
</div>
);
}
return ( return (
<div className="min-h-screen app-web-bg p-4 sm:p-8"> <div className="min-h-screen app-web-bg p-4 sm:p-8">
<div className="max-w-3xl mx-auto"> <div className="max-w-3xl mx-auto">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8"> <OrganizationSelectorContent />
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
</p>
</div>
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen(prev => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>
{isCreateOpen && (
<div className="surface-card p-6 mb-6 space-y-4">
<Input
label="Organization name"
value={organizationName}
onChange={(event) => setOrganizationName(event.target.value)}
placeholder="Sunshine Dental Clinic"
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
value={organizationEmail}
onChange={(event) => setOrganizationEmail(event.target.value)}
placeholder="contact@sunshineclinic.com"
type="email"
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOrganizationType('CLINIC')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'CLINIC'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Clinic
</button>
<button
type="button"
onClick={() => setOrganizationType('LAB')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Lab
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="primary"
onClick={handleCreateOrganization}
isLoading={isLoading}
disabled={!organizationName.trim() || !organizationEmail.trim()}
>
Create and Continue
</Button>
</div>
</div>
)}
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
</div>
) : (
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,70 @@
'use client';
import { useId } from 'react';
import { Check } from 'lucide-react';
type CheckboxProps = {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
label: string;
id?: string;
className?: string;
};
/**
* App design-system checkbox: primary fill when checked, rounded, focus-visible ring.
*/
export function Checkbox({
checked,
onChange,
disabled = false,
label,
id,
className = '',
}: CheckboxProps) {
const genId = useId();
const inputId = id ?? genId;
return (
<label
htmlFor={inputId}
className={`
inline-flex items-center gap-2.5 cursor-pointer select-none rounded-[var(--radius-sm)] -m-0.5 p-0.5
has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary/45 has-[:focus-visible]:ring-offset-2
has-[:focus-visible]:ring-offset-background-secondary
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className}
`}
>
<input
id={inputId}
type="checkbox"
className="sr-only"
checked={checked}
disabled={disabled}
onChange={(e) => onChange(e.target.checked)}
/>
<span
className={`
flex h-5 w-5 shrink-0 items-center justify-center rounded-[var(--radius-sm)] border-2 transition-all duration-200
shadow-[inset_0_1px_0_rgba(255,255,255,0.05)]
${
checked
? 'border-primary bg-primary shadow-[0_0_0_1px_rgba(9,169,188,0.25)]'
: 'border-border-strong bg-background-card/90 hover:border-border'
}
`}
aria-hidden
>
<Check
strokeWidth={3}
className={`h-3.5 w-3.5 text-primary-contrast transition-all duration-200 ${
checked ? 'scale-100 opacity-100' : 'scale-75 opacity-0'
}`}
/>
</span>
<span className="text-sm text-text-secondary">{label}</span>
</label>
);
}

View File

@@ -1,7 +1,7 @@
// src/components/ui/OrganizationCard.tsx // src/components/ui/OrganizationCard.tsx
import React from 'react'; import React from 'react';
import { Building2, Beaker, ChevronRight } from 'lucide-react'; import { Building2, Beaker, ChevronRight } from 'lucide-react';
import { Organization } from '@/types'; import type { Organization } from '@/types/organization';
interface OrganizationCardProps { interface OrganizationCardProps {
organization: Organization; organization: Organization;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import Link from 'next/link'; import Link from 'next/link';
import { memo } from 'react'; import { memo, useMemo } from 'react';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { import {
LayoutDashboard, LayoutDashboard,
@@ -12,19 +12,40 @@ import {
FileText, FileText,
CreditCard, CreditCard,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canViewTab } from '@/shared/permissions';
const menu = [ const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard }, { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users }, { name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Appointments', path: '/appointments', icon: Calendar }, { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Staff Management', path: '/staff', icon: UserCog }, { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Lab Management', path: '/lab', icon: FlaskConical }, { name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
{ name: 'Billing', path: '/billing', icon: CreditCard }, { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
{ name: 'Reports', path: '/reports', icon: FileText }, { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
]; ];
function Sidebar() { function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const visibleMenu = useMemo(
() => {
const withCounterpartTab = [
menu[0],
menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => canViewTab(currentOrganization, item.read));
},
[counterpartLabel, currentOrganization],
);
return ( return (
<aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col"> <aside className="w-64 bg-background-secondary/90 border-r border-border text-text-primary flex flex-col">
@@ -34,7 +55,7 @@ function Sidebar() {
<div className="mx-4 border-b border-border/70" /> <div className="mx-4 border-b border-border/70" />
<nav className="flex flex-col gap-2 p-4"> <nav className="flex flex-col gap-2 p-4">
{menu.map((item) => { {visibleMenu.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = pathname === item.path; const isActive = pathname === item.path;

View File

@@ -13,10 +13,11 @@ import {
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth'; import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types'; import type { SubscriptionAlertData } from '@/types/subscription';
function warningTooltip(data: SubscriptionAlertData | null): string { function warningTooltip(data: SubscriptionAlertData | null): string {
if (!data?.showWarning) return ''; if (!data?.showWarning) return '';
if (data.noActiveSubscription) return 'No active subscription — review Subscriptions';
if (data.trialExpired) return 'Trial ended — review Subscriptions'; if (data.trialExpired) return 'Trial ended — review Subscriptions';
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions';
if (data.seatsLow) return 'Seats running low — review Subscriptions'; if (data.seatsLow) return 'Seats running low — review Subscriptions';
@@ -68,7 +69,7 @@ export function DashboardAccountMenu() {
}, [logout]); }, [logout]);
return ( return (
<div className="relative" ref={menuRef}> <div className="relative z-[120]" ref={menuRef}>
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@@ -94,7 +95,7 @@ export function DashboardAccountMenu() {
{open && ( {open && (
<div <div
role="menu" role="menu"
className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-50 backdrop-blur-sm" className="absolute right-0 mt-2 w-72 rounded-[var(--radius-md)] border border-border bg-background-secondary/95 py-2 shadow-lg z-[200] backdrop-blur-sm"
> >
<div className="px-3 py-2 border-b border-border/60"> <div className="px-3 py-2 border-b border-border/60">
<p className="text-xs text-text-muted">Signed in</p> <p className="text-xs text-text-muted">Signed in</p>
@@ -106,7 +107,7 @@ export function DashboardAccountMenu() {
<div className="py-1"> <div className="py-1">
<Link <Link
href="/select-organization" href="/settings/organizations"
role="menuitem" role="menuitem"
className="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary hover:bg-background-card/70" className="flex items-center gap-3 px-3 py-2.5 text-sm text-text-primary hover:bg-background-card/70"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}

View File

@@ -0,0 +1,162 @@
'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/common/Input';
import { Button } from '@/components/ui/common/Button';
export function OrganizationSelectorContent() {
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) =>
type === 'CLINIC' ? <Building2 className="h-8 w-8 icon-flat" /> : <Beaker className="h-8 w-8 icon-flat" />;
const handleCreateOrganization = async () => {
try {
clearError();
const createdId = await createOrganization(
organizationName.trim(),
organizationEmail.trim(),
organizationType,
);
setOrganizationName('');
setOrganizationEmail('');
setOrganizationType('CLINIC');
setIsCreateOpen(false);
await selectOrganization(createdId);
} catch {
// handled by auth context
}
};
if (isLoading) {
return <p className="text-text-secondary">Loading...</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-3xl font-semibold text-text-primary">Organizations</h1>
<p className="text-text-secondary mt-2">
Select an organization to continue, or create a new one.
</p>
</div>
<Button
type="button"
variant={isCreateOpen ? 'outline' : 'primary'}
onClick={() => {
clearError();
setIsCreateOpen((prev) => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>
{isCreateOpen && (
<div className="surface-card p-6 space-y-4">
<Input
label="Organization name"
value={organizationName}
onChange={(event) => setOrganizationName(event.target.value)}
placeholder="Sunshine Dental Clinic"
icon={<Building2 className="h-5 w-5 icon-flat" />}
/>
<Input
label="Organization email"
value={organizationEmail}
onChange={(event) => setOrganizationEmail(event.target.value)}
placeholder="contact@sunshineclinic.com"
type="email"
icon={<Mail className="h-5 w-5 icon-flat" />}
/>
<div>
<label className="block text-sm font-medium text-text-secondary mb-2">
Organization type
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setOrganizationType('CLINIC')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'CLINIC'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Clinic
</button>
<button
type="button"
onClick={() => setOrganizationType('LAB')}
className={`p-3 border rounded-[var(--radius-md)] text-sm ${
organizationType === 'LAB'
? 'border-primary/60 bg-primary-soft text-text-primary'
: 'border-border text-text-secondary hover:border-border-strong'
}`}
>
Dental Lab
</button>
</div>
</div>
{error && (
<div className="p-3 bg-red-950/30 border border-red-600/40 rounded-[var(--radius-md)]">
<p className="text-sm text-red-600">{error}</p>
</div>
)}
<div className="flex justify-end">
<Button
type="button"
variant="primary"
onClick={handleCreateOrganization}
isLoading={isLoading}
disabled={!organizationName.trim() || !organizationEmail.trim()}
>
Create and Continue
</Button>
</div>
</div>
)}
{!organizations.length ? (
<div className="surface-card p-8 text-center">
<p className="text-text-secondary">No organizations found. Create your first one to continue.</p>
</div>
) : (
<div className="grid gap-4">
{organizations.map((org) => (
<button
key={org.id}
onClick={() => selectOrganization(org.id)}
className="surface-card p-6 transition-all text-left flex items-center gap-4 hover:border-primary/60"
>
<div className="p-3 bg-primary-soft rounded-[var(--radius-sm)] text-primary">
{getIcon(org.type)}
</div>
<div className="flex-1">
<h3 className="text-lg font-semibold text-text-primary">
{org.name}
</h3>
<p className="text-sm text-text-secondary">
{org.type === 'CLINIC' ? 'Dental Clinic' : 'Dental Lab'}
</p>
</div>
<div className="text-primary text-sm">
Continue
</div>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Button } from '@/components/ui/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/common/Input';
import { CreatePatientInput } from '@/types/patient'; import { CreatePatientInput } from '@/types/patient';
interface CreatePatientModalProps { interface CreatePatientModalProps {

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Input } from '@/components/ui/Input'; import { Input } from '@/components/ui/common/Input';
import { Patient } from '@/types/patient'; import { Patient } from '@/types/patient';
interface PatientSearchSelectProps { interface PatientSearchSelectProps {

View File

@@ -1,6 +1,7 @@
// src/lib/api/auth.ts // src/lib/api/auth.ts
import { apiClient } from './client'; 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 = { export const authApi = {
// Register a new trial organization // Register a new trial organization

View File

@@ -1,6 +1,6 @@
// src/lib/api/client.ts // src/lib/api/client.ts
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import { ApiError } from '@/types'; import type { ApiError } from '@/types/api';
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig { interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
_retry?: boolean; _retry?: boolean;

View File

@@ -0,0 +1,94 @@
import { apiClient } from './client';
export interface StaffMemberDto {
id: string;
userId: string;
email: string;
name: string;
isOwner: boolean;
isActive: boolean;
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED';
invitedAt: string | null;
acceptedAt: string | null;
permissions: string[] | null;
}
export interface StaffListResponse {
success: boolean;
data: {
members: StaffMemberDto[];
seats: {
used: number;
limit: number | null;
unlimited: boolean;
};
};
}
export interface InviteStaffResponse {
success: boolean;
data: {
membershipId: string;
userId: string;
email: string;
invitationId: string | null;
invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED';
};
}
export interface PreviewInviteResponse {
success: boolean;
data: {
email: string;
name: string;
organizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
};
}
export const staffApi = {
list: async (): Promise<StaffListResponse> => {
const response = await apiClient.get('/staff');
return response.data;
},
invite: async (body: {
email: string;
name: string;
permissionNames: string[];
}): Promise<InviteStaffResponse> => {
const response = await apiClient.post('/staff/invite', body);
return response.data;
},
previewInvite: async (token: string): Promise<PreviewInviteResponse> => {
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
return response.data;
},
acceptInvite: async (body: {
token: string;
password: string;
name: string;
}): Promise<{ success: boolean; message: string; data: { email: string } }> => {
const response = await apiClient.post('/staff/invitations/accept', body);
return response.data;
},
updateMember: async (
membershipId: string,
body: { name?: string; permissionNames?: string[] },
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.patch(`/staff/members/${membershipId}`, body);
return response.data;
},
removeMember: async (
membershipId: string,
): Promise<{ success: boolean; message: string }> => {
const response = await apiClient.delete(`/staff/members/${membershipId}`);
return response.data;
},
};

View File

@@ -3,7 +3,7 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import { User, Organization } from '@/types'; import { User, Organization } from '@/types/organization';
interface AuthContextType { interface AuthContextType {
user: User | null; user: User | null;
@@ -223,6 +223,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
name: organization.name, name: organization.name,
type: organization.type as Organization['type'], type: organization.type as Organization['type'],
isOwner: Boolean((organization as { isOwner?: boolean }).isOwner), isOwner: Boolean((organization as { isOwner?: boolean }).isOwner),
permissions: (organization as { permissions?: string[] }).permissions,
plan: (organization as { plan?: Organization['plan'] }).plan, plan: (organization as { plan?: Organization['plan'] }).plan,
}); });

View File

@@ -1,29 +1,22 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password']; const publicRoutes = ['/', '/login', '/register', '/terms', '/privacy', '/forgot-password'];
export function middleware(request: NextRequest) { export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
const token = request.cookies.get('accessToken')?.value; const token = request.cookies.get('accessToken')?.value;
const isAuthenticated = !!token; const isAuthenticated = !!token;
// If a logged-in user opens home, send them to dashboard.
if (isAuthenticated && pathname === '/') { if (isAuthenticated && pathname === '/') {
return NextResponse.redirect(new URL('/today', request.url)); 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)) { if (publicRoutes.includes(pathname)) {
return NextResponse.next(); return NextResponse.next();
} }
// Protected routes: redirect to login if no token
if (!isAuthenticated) { if (!isAuthenticated) {
// Prevent loop: if somehow redirecting to login from login, just continue
if (pathname === '/login') { if (pathname === '/login') {
return NextResponse.next(); return NextResponse.next();
} }

View File

@@ -0,0 +1,50 @@
import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_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;
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';
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')
);
}

View File

@@ -0,0 +1,5 @@
export interface ApiError {
statusCode: number;
message: string | string[];
error?: string;
}

View File

@@ -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;
}

View File

@@ -1,60 +1,5 @@
// src/types/index.ts export * from './organization';
export interface User { export * from './subscription';
id: string; export * from './auth';
email: string; export * from './api';
name: string; export * from './patient';
}
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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,14 @@
/** GET /auth/subscription-alert — owners only get meaningful flags */
export interface SubscriptionAlertData {
showWarning: boolean;
noActiveSubscription?: boolean;
seatsLow: boolean;
trialEndingSoon: boolean;
trialExpired: boolean;
seatsUsed?: number;
seatsLimit?: number | null;
daysUntilTrialEnd?: number | null;
trialEndsAt?: string | null;
daysUntilPlanEnd?: number | null;
planEndsAt?: string | null;
}