From 1ca8e6178ee429ac52673b709c6d97ec8f426611 Mon Sep 17 00:00:00 2001 From: Admin Date: Sat, 5 Sep 2026 20:44:16 +0330 Subject: [PATCH] improvement: Nothing done is related to subscriptions!!! --- backend/src/common/errors/error-codes.ts | 3 + .../appointments/appointments.service.ts | 14 +- .../modules/patients/patients.controller.ts | 27 ++- .../src/modules/patients/patients.service.ts | 57 +++--- backend/src/modules/staff/staff.controller.ts | 14 ++ backend/src/modules/staff/staff.service.ts | 74 +++++++- .../modules/treatments/treatments.service.ts | 23 ++- frontend/messages/en.json | 18 ++ frontend/messages/fa.json | 18 ++ frontend/messages/nl.json | 18 ++ .../[locale]/(public)/accept-invite/page.tsx | 28 ++- .../src/components/ui/staff/StaffPage.tsx | 177 ++++++++++++++++++ frontend/src/lib/api/staff.ts | 17 ++ 13 files changed, 431 insertions(+), 57 deletions(-) diff --git a/backend/src/common/errors/error-codes.ts b/backend/src/common/errors/error-codes.ts index 79dfe99..42f4fe5 100644 --- a/backend/src/common/errors/error-codes.ts +++ b/backend/src/common/errors/error-codes.ts @@ -84,6 +84,7 @@ export const ErrorCode = { APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER', PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND', + PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE', WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID', WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED', @@ -110,6 +111,8 @@ export const ErrorCode = { STAFF_CANNOT_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER', STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER', STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_OWNER', + STAFF_CANNOT_CLEAR_OWN_PASSWORD: 'STAFF_CANNOT_CLEAR_OWN_PASSWORD', + STAFF_PASSWORD_CLEAR_ACTIVE_ONLY: 'STAFF_PASSWORD_CLEAR_ACTIVE_ONLY', ORG_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF', ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE', diff --git a/backend/src/modules/appointments/appointments.service.ts b/backend/src/modules/appointments/appointments.service.ts index 2b760df..adc1c86 100644 --- a/backend/src/modules/appointments/appointments.service.ts +++ b/backend/src/modules/appointments/appointments.service.ts @@ -341,12 +341,16 @@ export class AppointmentsService { } } - private async ensurePatientInOrg(patientId: string, _organizationId: string) { - const patient = await this.prisma.patient.findUnique({ - where: { id: patientId }, - select: { id: true, isWalkIn: true }, + private async ensurePatientInOrg(patientId: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { + id: patientId, + isWalkIn: false, + createdByOrganizationId: organizationId, + }, + select: { id: true }, }); - if (!patient || patient.isWalkIn) { + if (!patient) { throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } } diff --git a/backend/src/modules/patients/patients.controller.ts b/backend/src/modules/patients/patients.controller.ts index b40b2f0..ff4007a 100644 --- a/backend/src/modules/patients/patients.controller.ts +++ b/backend/src/modules/patients/patients.controller.ts @@ -25,16 +25,17 @@ export class PatientsController { constructor(private readonly patientsService: PatientsService) {} @Post() - @ApiOperation({ summary: 'Create or return existing global patient by mobile' }) + @ApiOperation({ summary: 'Create or return this clinic’s patient by mobile' }) create(@Body() createPatientDto: CreatePatientDto, @Req() req) { const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); return this.patientsService.create(createPatientDto, organizationId); } @Get() - @ApiOperation({ summary: 'Search all patients globally' }) - findAll(@Query() query: ListPatientsDto) { - return this.patientsService.findAll(query); + @ApiOperation({ summary: 'Search patients created by the current clinic' }) + findAll(@Query() query: ListPatientsDto, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.findAll(query, organizationId); } @Get(':id/appointments') @@ -48,14 +49,20 @@ export class PatientsController { } @Get(':id') - @ApiOperation({ summary: 'Get one patient by id' }) - findOne(@Param('id') id: string) { - return this.patientsService.findOne(id); + @ApiOperation({ summary: 'Get one patient created by the current clinic' }) + findOne(@Param('id') id: string, @Req() req) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.findOne(id, organizationId); } @Patch(':id') - @ApiOperation({ summary: 'Update global patient record' }) - update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) { - return this.patientsService.update(id, updatePatientDto); + @ApiOperation({ summary: 'Update a patient created by the current clinic' }) + update( + @Param('id') id: string, + @Body() updatePatientDto: UpdatePatientDto, + @Req() req, + ) { + const organizationId = this.patientsService.getOrganizationIdFromUser(req.user); + return this.patientsService.update(id, updatePatientDto, organizationId); } } diff --git a/backend/src/modules/patients/patients.service.ts b/backend/src/modules/patients/patients.service.ts index 050da08..cdd78f5 100644 --- a/backend/src/modules/patients/patients.service.ts +++ b/backend/src/modules/patients/patients.service.ts @@ -21,7 +21,13 @@ export class PatientsService { }); if (existing) { - return { success: true, data: existing, existing: true as const }; + if ( + !existing.isWalkIn && + existing.createdByOrganizationId === organizationId + ) { + return { success: true, data: existing, existing: true as const }; + } + throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT); } const patient = await this.prisma.patient.create({ @@ -39,12 +45,13 @@ export class PatientsService { return { success: true, data: patient, existing: false as const }; } - async findAll(query: ListPatientsDto) { + async findAll(query: ListPatientsDto, organizationId: string) { const { page = 1, limit = 10, q } = query; const skip = (page - 1) * limit; const where = { isWalkIn: false, + createdByOrganizationId: organizationId, ...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}), }; @@ -72,27 +79,13 @@ export class PatientsService { }; } - async findOne(id: string) { - const patient = await this.prisma.patient.findUnique({ - where: { id }, - }); - - if (!patient || patient.isWalkIn) { - throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); - } - + async findOne(id: string, organizationId: string) { + const patient = await this.findNamedPatientInOrg(id, organizationId); return { success: true, data: patient }; } - async update(id: string, updatePatientDto: UpdatePatientDto) { - await this.ensurePatient(id); - const patient = await this.prisma.patient.findUnique({ - where: { id }, - select: { isWalkIn: true }, - }); - if (patient?.isWalkIn) { - throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); - } + async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) { + await this.findNamedPatientInOrg(id, organizationId); const data: { firstName?: string; @@ -110,7 +103,15 @@ export class PatientsService { data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName'); } if (updatePatientDto.mobile !== undefined) { - data.mobile = this.resolveMobile(updatePatientDto.mobile); + const mobile = this.resolveMobile(updatePatientDto.mobile); + const taken = await this.prisma.patient.findUnique({ + where: { mobile }, + select: { id: true, createdByOrganizationId: true, isWalkIn: true }, + }); + if (taken && taken.id !== id) { + throw new AppException(ErrorCode.PATIENT_MOBILE_UNAVAILABLE, HttpStatus.CONFLICT); + } + data.mobile = mobile; } if (updatePatientDto.email !== undefined) { data.email = updatePatientDto.email?.trim() || null; @@ -138,7 +139,7 @@ export class PatientsService { actorUserId: string, ) { await this.assertCanViewPatients(actorUserId, organizationId); - await this.ensurePatient(patientId); + await this.findNamedPatientInOrg(patientId, organizationId); const items = await this.prisma.appointment.findMany({ where: { organizationId, patientId }, @@ -241,14 +242,18 @@ export class PatientsService { } } - private async ensurePatient(id: string) { - const patient = await this.prisma.patient.findUnique({ - where: { id }, - select: { id: true }, + private async findNamedPatientInOrg(id: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { + id, + isWalkIn: false, + createdByOrganizationId: organizationId, + }, }); if (!patient) { throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); } + return patient; } } diff --git a/backend/src/modules/staff/staff.controller.ts b/backend/src/modules/staff/staff.controller.ts index a6fc1a8..1f54804 100644 --- a/backend/src/modules/staff/staff.controller.ts +++ b/backend/src/modules/staff/staff.controller.ts @@ -61,6 +61,20 @@ export class StaffController { return this.staffService.invite(req.user.id, organizationId, dto); } + @Post('members/:membershipId/clear-password') + @UseGuards(JwtAuthGuard) + @ApiOperation({ + summary: + 'Clear a staff member password and return a setup link (owner or TAB_STAFF_EDIT; active members only)', + }) + clearPassword( + @Req() req: { user: { id: string; organizationId?: string } }, + @Param('membershipId') membershipId: string, + ) { + const organizationId = this.staffService.getOrganizationIdFromUser(req.user); + return this.staffService.clearPassword(req.user.id, organizationId, membershipId); + } + @Post('members/:membershipId/invitation-link') @UseGuards(JwtAuthGuard) @ApiOperation({ diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 09e4399..aa9fc59 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -49,7 +49,7 @@ export class StaffService { this.prisma.membership.findMany({ where: { organizationId }, include: { - user: { select: { id: true, email: true, name: true } }, + user: { select: { id: true, email: true, name: true, passwordHash: true } }, permissions: { include: { permission: true } }, invitations: { orderBy: { createdAt: 'desc' }, @@ -80,6 +80,7 @@ export class StaffService { isOwner: m.isOwner, isActive: m.isOwner ? true : m.isActive, invitationStatus: this.getInvitationStatus(m), + hasPassword: Boolean(m.user.passwordHash), invitedAt: m.invitations[0]?.createdAt?.toISOString() || null, acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null, permissions: m.isOwner @@ -310,6 +311,77 @@ export class StaffService { organizationName: org.name, expiresAt: invitation.expiresAt.toISOString(), status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', + mode: invitation.membership.isActive ? 'password_setup' : 'join', + }, + }; + } + + async clearPassword( + actorUserId: string, + organizationId: string, + membershipId: string, + ) { + const actor = await this.getActorMembership(actorUserId, organizationId); + if (!actor || !this.canEditStaff(actor)) { + throw new AppException(ErrorCode.PERMISSION_EDIT_STAFF, HttpStatus.FORBIDDEN); + } + + const membership = await this.prisma.membership.findFirst({ + where: { id: membershipId, organizationId }, + include: { + user: { select: { id: true, email: true } }, + }, + }); + + if (!membership) { + throw new AppException(ErrorCode.STAFF_MEMBER_NOT_FOUND, HttpStatus.NOT_FOUND); + } + if (membership.isOwner) { + throw new AppException(ErrorCode.STAFF_CANNOT_EDIT_OWNER, HttpStatus.FORBIDDEN); + } + if (membership.userId === actorUserId) { + throw new AppException(ErrorCode.STAFF_CANNOT_CLEAR_OWN_PASSWORD, HttpStatus.BAD_REQUEST); + } + if (!membership.isActive) { + throw new AppException(ErrorCode.STAFF_PASSWORD_CLEAR_ACTIVE_ONLY, HttpStatus.BAD_REQUEST); + } + + const plainToken = this.generateInviteToken(); + const tokenHash = this.hashInviteToken(plainToken); + + const invitation = await this.prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { id: membership.userId }, + data: { passwordHash: null }, + }); + await tx.session.deleteMany({ + where: { userId: membership.userId }, + }); + await tx.staffInvitation.updateMany({ + where: { + membershipId: membership.id, + acceptedAt: null, + revokedAt: null, + }, + data: { revokedAt: new Date() }, + }); + return tx.staffInvitation.create({ + data: { + membershipId: membership.id, + invitedById: actorUserId, + tokenHash, + expiresAt: this.getInviteExpiryDate(), + }, + }); + }); + + return { + success: true, + data: { + membershipId: membership.id, + invitationId: invitation.id, + email: membership.user.email, + invitationUrl: this.buildInviteUrl(plainToken), }, }; } diff --git a/backend/src/modules/treatments/treatments.service.ts b/backend/src/modules/treatments/treatments.service.ts index 72792c7..0af3c7a 100644 --- a/backend/src/modules/treatments/treatments.service.ts +++ b/backend/src/modules/treatments/treatments.service.ts @@ -222,14 +222,7 @@ export class TreatmentsService { const sentinel = await ensureWalkInPatient(this.prisma, organizationId); patientId = sentinel.id; } else { - await this.ensurePatientExists(dto.patientId!); - const patient = await this.prisma.patient.findUnique({ - where: { id: dto.patientId! }, - select: { isWalkIn: true }, - }); - if (patient?.isWalkIn) { - throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST); - } + await this.ensurePatientInOrg(dto.patientId!, organizationId); patientId = dto.patientId!; } @@ -1670,6 +1663,20 @@ export class TreatmentsService { } } + private async ensurePatientInOrg(patientId: string, organizationId: string) { + const patient = await this.prisma.patient.findFirst({ + where: { + id: patientId, + isWalkIn: false, + createdByOrganizationId: organizationId, + }, + select: { id: true }, + }); + if (!patient) { + throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND); + } + } + private async ensureTreatmentProvider( treatmentId: string, organizationId: string, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 0e05950..a3b8c92 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -122,6 +122,9 @@ "labelCreatePassword": "Create password", "labelConfirmPassword": "Confirm password", "activateAccount": "Activate account", + "setPasswordTitle": "Set your password", + "setPasswordSubmit": "Set password", + "passwordSetupAlreadyDone": "This password setup link is no longer valid. You can log in now.", "invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...", "invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.", "errorAcceptInvitation": "Could not accept invitation", @@ -322,6 +325,18 @@ "disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.", "disableMemberButton": "Disable member", "editModalTitle": "Edit member", + "removePassword": "Remove password", + "copyPasswordSetupLink": "Copy password setup link", + "removePasswordModalTitle": "Remove password", + "removePasswordConfirm": "Remove the password for {name} ({email})?", + "removePasswordBullet1": "They will not be able to sign in until they set a new password with the setup link.", + "removePasswordBullet2": "You cannot choose their new password. Share the setup link with them.", + "removePasswordBullet3": "This signs them out of every organization they belong to.", + "removePasswordButton": "Remove password and copy link", + "passwordSetupLinkHeading": "Password setup link", + "passwordSetupShareHint": "Share this link so they can set a new password. Login will fail until they finish.", + "successPasswordCleared": "Password removed for {name}. Share the setup link with them.", + "errorClearPassword": "Could not remove the password.", "loadingWorkingHours": "Loading working hours…", "errorLoadStaff": "Failed to load staff.", "errorCopyInvite": "Could not copy invitation link.", @@ -1214,6 +1229,7 @@ "APPOINTMENT_NOT_FOUND": "Appointment not found.", "APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.", "PATIENT_NOT_FOUND": "Patient not found.", + "PATIENT_MOBILE_UNAVAILABLE": "This mobile number cannot be added for this clinic.", "WORKING_HOURS_INVALID": "Working hours are invalid. Check that shifts do not overlap.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "Set owner working hours from account settings.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "These hours conflict with upcoming appointments. Reschedule or remove those appointments first.", @@ -1237,6 +1253,8 @@ "STAFF_CANNOT_ENABLE_OWNER": "The organization owner cannot be enabled this way.", "STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.", "STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.", + "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "You cannot remove your own password here. Use account settings or forgot password.", + "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Password can only be removed for active members. Pending members use the invitation link.", "ORG_CANNOT_LINK_SELF": "You cannot link an organization to itself.", "ORG_LINK_WRONG_TYPE": "You can only link to the matching organization type (clinic or lab).", "ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.", diff --git a/frontend/messages/fa.json b/frontend/messages/fa.json index 23565fe..48e0910 100644 --- a/frontend/messages/fa.json +++ b/frontend/messages/fa.json @@ -122,6 +122,9 @@ "labelCreatePassword": "ایجاد رمز عبور", "labelConfirmPassword": "تأیید رمز عبور", "activateAccount": "فعال‌سازی حساب", + "setPasswordTitle": "رمز عبور خود را تنظیم کنید", + "setPasswordSubmit": "تنظیم رمز عبور", + "passwordSetupAlreadyDone": "این لینک تنظیم رمز دیگر معتبر نیست. اکنون می‌توانید وارد شوید.", "invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...", "invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.", "errorAcceptInvitation": "پذیرش دعوتنامه امکان‌پذیر نبود", @@ -322,6 +325,18 @@ "disableBullet3": "غیرفعال‌سازی یک مجوز در طرح شما را آزاد می‌کند تا بتوانید شخص دیگری را دعوت کنید.", "disableMemberButton": "غیرفعال‌سازی عضو", "editModalTitle": "ویرایش عضو", + "removePassword": "حذف رمز عبور", + "copyPasswordSetupLink": "کپی لینک تنظیم رمز", + "removePasswordModalTitle": "حذف رمز عبور", + "removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟", + "removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمی‌توانند وارد شوند.", + "removePasswordBullet2": "شما رمز جدید را انتخاب نمی‌کنید. لینک تنظیم را برایشان بفرستید.", + "removePasswordBullet3": "از همه سازمان‌هایی که عضو آن هستند خارج می‌شوند.", + "removePasswordButton": "حذف رمز و کپی لینک", + "passwordSetupLinkHeading": "لینک تنظیم رمز عبور", + "passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.", + "successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.", + "errorClearPassword": "حذف رمز عبور امکان‌پذیر نبود.", "loadingWorkingHours": "در حال بارگذاری ساعات کاری...", "errorLoadStaff": "بارگذاری کارکنان ناموفق بود.", "errorCopyInvite": "کپی لینک دعوتنامه امکان‌پذیر نبود.", @@ -1215,6 +1230,7 @@ "APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.", "APPOINTMENT_NOT_PROVIDER": "شما ارائه‌دهنده این نوبت نیستید.", "PATIENT_NOT_FOUND": "بیمار یافت نشد.", + "PATIENT_MOBILE_UNAVAILABLE": "این شماره موبایل را نمی‌توان برای این کلینیک ثبت کرد.", "WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. هم‌پوشانی شیفت‌ها را بررسی کنید.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبت‌های آینده تداخل دارد. ابتدا آن نوبت‌ها را تغییر دهید یا حذف کنید.", @@ -1238,6 +1254,8 @@ "STAFF_CANNOT_ENABLE_OWNER": "مالک سازمان را نمی‌توان این‌گونه فعال کرد.", "STAFF_CANNOT_DISABLE_OWNER": "مالک سازمان را نمی‌توان غیرفعال کرد.", "STAFF_CANNOT_REMOVE_OWNER": "مالک سازمان را نمی‌توان حذف کرد.", + "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "نمی‌توانید رمز عبور خود را از اینجا حذف کنید. از تنظیمات حساب یا فراموشی رمز استفاده کنید.", + "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "رمز عبور را فقط برای اعضای فعال می‌توان حذف کرد. اعضای در انتظار از لینک دعوت استفاده می‌کنند.", "ORG_CANNOT_LINK_SELF": "نمی‌توانید سازمان را به خودش متصل کنید.", "ORG_LINK_WRONG_TYPE": "فقط می‌توانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.", "ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.", diff --git a/frontend/messages/nl.json b/frontend/messages/nl.json index 16f4b43..05a0758 100644 --- a/frontend/messages/nl.json +++ b/frontend/messages/nl.json @@ -122,6 +122,9 @@ "labelCreatePassword": "Wachtwoord aanmaken", "labelConfirmPassword": "Bevestig wachtwoord", "activateAccount": "Account activeren", + "setPasswordTitle": "Stel uw wachtwoord in", + "setPasswordSubmit": "Wachtwoord instellen", + "passwordSetupAlreadyDone": "Deze wachtwoordlink is niet meer geldig. U kunt nu inloggen.", "invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...", "invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.", "errorAcceptInvitation": "Kon uitnodiging niet accepteren", @@ -322,6 +325,18 @@ "disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.", "disableMemberButton": "Lid uitschakelen", "editModalTitle": "Lid bewerken", + "removePassword": "Wachtwoord verwijderen", + "copyPasswordSetupLink": "Wachtwoordlink kopiëren", + "removePasswordModalTitle": "Wachtwoord verwijderen", + "removePasswordConfirm": "Wachtwoord van {name} ({email}) verwijderen?", + "removePasswordBullet1": "Zij kunnen niet inloggen tot ze via de instellink een nieuw wachtwoord kiezen.", + "removePasswordBullet2": "U kunt hun nieuwe wachtwoord niet kiezen. Deel de instellink met hen.", + "removePasswordBullet3": "Dit meldt hen af bij elke organisatie waar zij lid van zijn.", + "removePasswordButton": "Wachtwoord verwijderen en link kopiëren", + "passwordSetupLinkHeading": "Wachtwoord-instellink", + "passwordSetupShareHint": "Deel deze link zodat zij een nieuw wachtwoord kunnen instellen. Inloggen mislukt tot dat is afgerond.", + "successPasswordCleared": "Wachtwoord van {name} is verwijderd. Deel de instellink met hen.", + "errorClearPassword": "Kon het wachtwoord niet verwijderen.", "loadingWorkingHours": "Werktijden laden...", "errorLoadStaff": "Medewerkers laden mislukt.", "errorCopyInvite": "Kon uitnodigingslink niet kopiëren.", @@ -1214,6 +1229,7 @@ "APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.", "APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.", "PATIENT_NOT_FOUND": "Patiënt niet gevonden.", + "PATIENT_MOBILE_UNAVAILABLE": "Dit mobiele nummer kan niet voor deze kliniek worden toegevoegd.", "WORKING_HOURS_INVALID": "De werktijden zijn ongeldig. Controleer of diensten niet overlappen.", "WORKING_HOURS_OWNER_NOT_ALLOWED": "Stel werktijden van de eigenaar in via accountinstellingen.", "WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "Deze tijden conflicteren met aankomende afspraken. Plan die eerst om of verwijder ze.", @@ -1237,6 +1253,8 @@ "STAFF_CANNOT_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.", "STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.", "STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.", + "STAFF_CANNOT_CLEAR_OWN_PASSWORD": "U kunt hier uw eigen wachtwoord niet verwijderen. Gebruik accountinstellingen of wachtwoord vergeten.", + "STAFF_PASSWORD_CLEAR_ACTIVE_ONLY": "Het wachtwoord kan alleen voor actieve leden worden verwijderd. Leden in afwachting gebruiken de uitnodigingslink.", "ORG_CANNOT_LINK_SELF": "U kunt een organisatie niet aan zichzelf koppelen.", "ORG_LINK_WRONG_TYPE": "U kunt alleen koppelen aan het bijbehorende type (kliniek of lab).", "ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.", diff --git a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx index cf86a72..f69f07b 100644 --- a/frontend/src/app/[locale]/(public)/accept-invite/page.tsx +++ b/frontend/src/app/[locale]/(public)/accept-invite/page.tsx @@ -30,6 +30,7 @@ function AcceptInviteContent() { organizationName: string; expiresAt: string; status: 'PENDING' | 'ACCEPTED'; + mode: 'join' | 'password_setup'; } | null>(null); const [name, setName] = useState(''); @@ -48,10 +49,17 @@ function AcceptInviteContent() { setError(''); try { const res = await staffApi.previewInvite(token); - setInviteInfo(res.data); + setInviteInfo({ + ...res.data, + mode: res.data.mode === 'password_setup' ? 'password_setup' : 'join', + }); setName(res.data.name || ''); if (res.data.status === 'ACCEPTED') { - setSuccess(t('invitationAlreadyAccepted')); + setSuccess( + res.data.mode === 'password_setup' + ? t('passwordSetupAlreadyDone') + : t('invitationAlreadyAccepted'), + ); } } catch (e: unknown) { setError(getUserFacingError(e, tErrors, t('errorLoadInvitation'))); @@ -65,7 +73,9 @@ function AcceptInviteContent() { if (!token) return; setError(''); setSuccess(''); - if (!name.trim()) { + const isPasswordSetup = inviteInfo?.mode === 'password_setup'; + const nameToSubmit = isPasswordSetup ? (inviteInfo?.name || '').trim() : name.trim(); + if (!isPasswordSetup && !nameToSubmit) { setError(t('nameRequired')); return; } @@ -83,7 +93,7 @@ function AcceptInviteContent() { try { await staffApi.acceptInvite({ token, - name: name.trim(), + name: nameToSubmit || inviteInfo?.name || '', password, }); accepted = true; @@ -115,7 +125,9 @@ function AcceptInviteContent() { return (
-

{t('acceptInviteTitle')}

+

+ {inviteInfo?.mode === 'password_setup' ? t('setPasswordTitle') : t('acceptInviteTitle')} +

{loading ? (

{t('loadingInvitation')}

@@ -147,7 +159,9 @@ function AcceptInviteContent() { {inviteInfo?.status !== 'ACCEPTED' && (
- setName(e.target.value)} /> + {inviteInfo?.mode !== 'password_setup' && ( + setName(e.target.value)} /> + )}
)} diff --git a/frontend/src/components/ui/staff/StaffPage.tsx b/frontend/src/components/ui/staff/StaffPage.tsx index fe4c64b..a90af2c 100644 --- a/frontend/src/components/ui/staff/StaffPage.tsx +++ b/frontend/src/components/ui/staff/StaffPage.tsx @@ -79,6 +79,10 @@ function canShareStaffInviteLink(member: StaffMemberDto): boolean { ); } +function canIssuePasswordSetup(member: StaffMemberDto, actorUserId?: string): boolean { + return !member.isOwner && member.isActive && member.userId !== actorUserId; +} + function canDisableStaff(member: StaffMemberDto): boolean { return !member.isOwner && member.isActive; } @@ -189,6 +193,12 @@ export function StaffPage() { invitationStatus: 'PENDING' | 'ACCEPTED'; } | null>(null); const [pendingInviteLinks, setPendingInviteLinks] = useState>({}); + const [lastPasswordSetupInfo, setLastPasswordSetupInfo] = useState<{ + membershipId: string; + name: string; + email: string; + invitationUrl: string; + } | null>(null); const [editing, setEditing] = useState(null); const [editStep, setEditStep] = useState<1 | 2>(1); @@ -205,6 +215,8 @@ export function StaffPage() { const [disablingMembershipId, setDisablingMembershipId] = useState(null); const [enableTarget, setEnableTarget] = useState(null); const [enablingMembershipId, setEnablingMembershipId] = useState(null); + const [clearPasswordTarget, setClearPasswordTarget] = useState(null); + const [clearingMembershipId, setClearingMembershipId] = useState(null); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const highlightMembershipIds = useMemo( @@ -520,6 +532,45 @@ export function StaffPage() { } } + async function issuePasswordSetupLink(member: StaffMemberDto, copyToClipboard: boolean) { + setClearingMembershipId(member.id); + toast.setError(''); + try { + const res = await staffApi.clearPassword(member.id); + setLastPasswordSetupInfo({ + membershipId: member.id, + name: member.name, + email: member.email, + invitationUrl: res.data.invitationUrl, + }); + setClearPasswordTarget(null); + setEditing(null); + setEditStep(1); + await load(); + toast.showSuccess(t('successPasswordCleared', { name: member.name })); + if (copyToClipboard) { + try { + await navigator.clipboard.writeText(res.data.invitationUrl); + setCopiedInviteMembershipId(member.id); + setTimeout(() => setCopiedInviteMembershipId(null), 1500); + } catch { + /* banner still shows the URL */ + } + } + } catch (e) { + toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword'))); + } finally { + setClearingMembershipId(null); + } + } + + async function confirmClearPassword() { + if (!clearPasswordTarget || !canIssuePasswordSetup(clearPasswordTarget, user?.id)) { + return; + } + await issuePasswordSetupLink(clearPasswordTarget, true); + } + if (!currentOrganization || !canViewStaff(currentOrganization)) { return (

{t('redirecting')}

@@ -640,6 +691,54 @@ export function StaffPage() {
)} + {lastPasswordSetupInfo && ( +
+ +

+ {t('successPasswordCleared', { name: lastPasswordSetupInfo.name })} +

+
+

+ {t('passwordSetupLinkHeading')} +

+ + {lastPasswordSetupInfo.invitationUrl} + + +

{t('passwordSetupShareHint')}

+
+
+ )} + {loading ? (

{t('loadingTeam')}

) : ( @@ -1061,6 +1160,59 @@ export function StaffPage() {
)} + {clearPasswordTarget && ( +
+
+
+

+ {t('removePasswordModalTitle')} +

+ { + if (clearingMembershipId) return; + setClearPasswordTarget(null); + }} + /> +
+

+ {t('removePasswordConfirm', { + name: clearPasswordTarget.name, + email: clearPasswordTarget.email, + })} +

+
    +
  • {t('removePasswordBullet1')}
  • +
  • {t('removePasswordBullet2')}
  • +
  • {t('removePasswordBullet3')}
  • +
+
+ + +
+
+
+ )} + {editing && (
+ {canEdit && canIssuePasswordSetup(editing, user?.id) && ( +
+ {editing.hasPassword ? ( + + ) : ( + + )} +
+ )} ) : editLoadingWorkingHours ? (

{t('loadingWorkingHours')}

diff --git a/frontend/src/lib/api/staff.ts b/frontend/src/lib/api/staff.ts index 760169c..deca72d 100644 --- a/frontend/src/lib/api/staff.ts +++ b/frontend/src/lib/api/staff.ts @@ -11,6 +11,7 @@ export interface StaffMemberDto { invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED'; invitedAt: string | null; acceptedAt: string | null; + hasPassword: boolean; permissions: string[] | null; } @@ -46,6 +47,7 @@ export interface PreviewInviteResponse { organizationName: string; expiresAt: string; status: 'PENDING' | 'ACCEPTED'; + mode: 'join' | 'password_setup'; }; } @@ -79,6 +81,21 @@ export const staffApi = { return response.data; }, + clearPassword: async ( + membershipId: string, + ): Promise<{ + success: boolean; + data: { + membershipId: string; + invitationId: string; + email: string; + invitationUrl: string; + }; + }> => { + const response = await apiClient.post(`/staff/members/${membershipId}/clear-password`); + return response.data; + }, + previewInvite: async (token: string): Promise => { const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`); return response.data;