improvements/subscription-v1 #75
@@ -84,6 +84,7 @@ export const ErrorCode = {
|
|||||||
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
|
APPOINTMENT_NOT_PROVIDER: 'APPOINTMENT_NOT_PROVIDER',
|
||||||
|
|
||||||
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
|
PATIENT_NOT_FOUND: 'PATIENT_NOT_FOUND',
|
||||||
|
PATIENT_MOBILE_UNAVAILABLE: 'PATIENT_MOBILE_UNAVAILABLE',
|
||||||
|
|
||||||
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
WORKING_HOURS_INVALID: 'WORKING_HOURS_INVALID',
|
||||||
WORKING_HOURS_OWNER_NOT_ALLOWED: 'WORKING_HOURS_OWNER_NOT_ALLOWED',
|
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_ENABLE_OWNER: 'STAFF_CANNOT_ENABLE_OWNER',
|
||||||
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
|
STAFF_CANNOT_DISABLE_OWNER: 'STAFF_CANNOT_DISABLE_OWNER',
|
||||||
STAFF_CANNOT_REMOVE_OWNER: 'STAFF_CANNOT_REMOVE_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_CANNOT_LINK_SELF: 'ORG_CANNOT_LINK_SELF',
|
||||||
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
|
ORG_LINK_WRONG_TYPE: 'ORG_LINK_WRONG_TYPE',
|
||||||
|
|||||||
@@ -341,12 +341,16 @@ export class AppointmentsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
private async ensurePatientInOrg(patientId: string, organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findUnique({
|
const patient = await this.prisma.patient.findFirst({
|
||||||
where: { id: patientId },
|
where: {
|
||||||
select: { id: true, isWalkIn: true },
|
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);
|
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,17 @@ export class PatientsController {
|
|||||||
constructor(private readonly patientsService: PatientsService) {}
|
constructor(private readonly patientsService: PatientsService) {}
|
||||||
|
|
||||||
@Post()
|
@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) {
|
create(@Body() createPatientDto: CreatePatientDto, @Req() req) {
|
||||||
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
return this.patientsService.create(createPatientDto, organizationId);
|
return this.patientsService.create(createPatientDto, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'Search all patients globally' })
|
@ApiOperation({ summary: 'Search patients created by the current clinic' })
|
||||||
findAll(@Query() query: ListPatientsDto) {
|
findAll(@Query() query: ListPatientsDto, @Req() req) {
|
||||||
return this.patientsService.findAll(query);
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.patientsService.findAll(query, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id/appointments')
|
@Get(':id/appointments')
|
||||||
@@ -48,14 +49,20 @@ export class PatientsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Get one patient by id' })
|
@ApiOperation({ summary: 'Get one patient created by the current clinic' })
|
||||||
findOne(@Param('id') id: string) {
|
findOne(@Param('id') id: string, @Req() req) {
|
||||||
return this.patientsService.findOne(id);
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.patientsService.findOne(id, organizationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id')
|
@Patch(':id')
|
||||||
@ApiOperation({ summary: 'Update global patient record' })
|
@ApiOperation({ summary: 'Update a patient created by the current clinic' })
|
||||||
update(@Param('id') id: string, @Body() updatePatientDto: UpdatePatientDto) {
|
update(
|
||||||
return this.patientsService.update(id, updatePatientDto);
|
@Param('id') id: string,
|
||||||
|
@Body() updatePatientDto: UpdatePatientDto,
|
||||||
|
@Req() req,
|
||||||
|
) {
|
||||||
|
const organizationId = this.patientsService.getOrganizationIdFromUser(req.user);
|
||||||
|
return this.patientsService.update(id, updatePatientDto, organizationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,14 @@ export class PatientsService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
|
if (
|
||||||
|
!existing.isWalkIn &&
|
||||||
|
existing.createdByOrganizationId === organizationId
|
||||||
|
) {
|
||||||
return { success: true, data: existing, existing: true as const };
|
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({
|
const patient = await this.prisma.patient.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -39,12 +45,13 @@ export class PatientsService {
|
|||||||
return { success: true, data: patient, existing: false as const };
|
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 { page = 1, limit = 10, q } = query;
|
||||||
const skip = (page - 1) * limit;
|
const skip = (page - 1) * limit;
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
isWalkIn: false,
|
isWalkIn: false,
|
||||||
|
createdByOrganizationId: organizationId,
|
||||||
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
|
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,27 +79,13 @@ export class PatientsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: string) {
|
async findOne(id: string, organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findUnique({
|
const patient = await this.findNamedPatientInOrg(id, organizationId);
|
||||||
where: { id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!patient || patient.isWalkIn) {
|
|
||||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true, data: patient };
|
return { success: true, data: patient };
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, updatePatientDto: UpdatePatientDto) {
|
async update(id: string, updatePatientDto: UpdatePatientDto, organizationId: string) {
|
||||||
await this.ensurePatient(id);
|
await this.findNamedPatientInOrg(id, organizationId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
firstName?: string;
|
firstName?: string;
|
||||||
@@ -110,7 +103,15 @@ export class PatientsService {
|
|||||||
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
|
data.lastName = this.requireNonEmptyName(updatePatientDto.lastName, 'lastName');
|
||||||
}
|
}
|
||||||
if (updatePatientDto.mobile !== undefined) {
|
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) {
|
if (updatePatientDto.email !== undefined) {
|
||||||
data.email = updatePatientDto.email?.trim() || null;
|
data.email = updatePatientDto.email?.trim() || null;
|
||||||
@@ -138,7 +139,7 @@ export class PatientsService {
|
|||||||
actorUserId: string,
|
actorUserId: string,
|
||||||
) {
|
) {
|
||||||
await this.assertCanViewPatients(actorUserId, organizationId);
|
await this.assertCanViewPatients(actorUserId, organizationId);
|
||||||
await this.ensurePatient(patientId);
|
await this.findNamedPatientInOrg(patientId, organizationId);
|
||||||
|
|
||||||
const items = await this.prisma.appointment.findMany({
|
const items = await this.prisma.appointment.findMany({
|
||||||
where: { organizationId, patientId },
|
where: { organizationId, patientId },
|
||||||
@@ -241,14 +242,18 @@ export class PatientsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensurePatient(id: string) {
|
private async findNamedPatientInOrg(id: string, organizationId: string) {
|
||||||
const patient = await this.prisma.patient.findUnique({
|
const patient = await this.prisma.patient.findFirst({
|
||||||
where: { id },
|
where: {
|
||||||
select: { id: true },
|
id,
|
||||||
|
isWalkIn: false,
|
||||||
|
createdByOrganizationId: organizationId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!patient) {
|
if (!patient) {
|
||||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
return patient;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,20 @@ export class StaffController {
|
|||||||
return this.staffService.invite(req.user.id, organizationId, dto);
|
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')
|
@Post('members/:membershipId/invitation-link')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class StaffService {
|
|||||||
this.prisma.membership.findMany({
|
this.prisma.membership.findMany({
|
||||||
where: { organizationId },
|
where: { organizationId },
|
||||||
include: {
|
include: {
|
||||||
user: { select: { id: true, email: true, name: true } },
|
user: { select: { id: true, email: true, name: true, passwordHash: true } },
|
||||||
permissions: { include: { permission: true } },
|
permissions: { include: { permission: true } },
|
||||||
invitations: {
|
invitations: {
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -80,6 +80,7 @@ export class StaffService {
|
|||||||
isOwner: m.isOwner,
|
isOwner: m.isOwner,
|
||||||
isActive: m.isOwner ? true : m.isActive,
|
isActive: m.isOwner ? true : m.isActive,
|
||||||
invitationStatus: this.getInvitationStatus(m),
|
invitationStatus: this.getInvitationStatus(m),
|
||||||
|
hasPassword: Boolean(m.user.passwordHash),
|
||||||
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
|
invitedAt: m.invitations[0]?.createdAt?.toISOString() || null,
|
||||||
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
|
acceptedAt: m.invitations[0]?.acceptedAt?.toISOString() || null,
|
||||||
permissions: m.isOwner
|
permissions: m.isOwner
|
||||||
@@ -310,6 +311,77 @@ export class StaffService {
|
|||||||
organizationName: org.name,
|
organizationName: org.name,
|
||||||
expiresAt: invitation.expiresAt.toISOString(),
|
expiresAt: invitation.expiresAt.toISOString(),
|
||||||
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
|
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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -222,14 +222,7 @@ export class TreatmentsService {
|
|||||||
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
|
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
|
||||||
patientId = sentinel.id;
|
patientId = sentinel.id;
|
||||||
} else {
|
} else {
|
||||||
await this.ensurePatientExists(dto.patientId!);
|
await this.ensurePatientInOrg(dto.patientId!, organizationId);
|
||||||
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);
|
|
||||||
}
|
|
||||||
patientId = dto.patientId!;
|
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(
|
private async ensureTreatmentProvider(
|
||||||
treatmentId: string,
|
treatmentId: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
|
|||||||
@@ -122,6 +122,9 @@
|
|||||||
"labelCreatePassword": "Create password",
|
"labelCreatePassword": "Create password",
|
||||||
"labelConfirmPassword": "Confirm password",
|
"labelConfirmPassword": "Confirm password",
|
||||||
"activateAccount": "Activate account",
|
"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...",
|
"invitationAcceptedRedirect": "Invitation accepted. Opening your workspace...",
|
||||||
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
|
"invitationAcceptedSignInFailed": "Account activated, but sign-in failed. Please log in with your password.",
|
||||||
"errorAcceptInvitation": "Could not accept invitation",
|
"errorAcceptInvitation": "Could not accept invitation",
|
||||||
@@ -322,6 +325,18 @@
|
|||||||
"disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.",
|
"disableBullet3": "Disabling frees one seat on your plan so you can invite someone else.",
|
||||||
"disableMemberButton": "Disable member",
|
"disableMemberButton": "Disable member",
|
||||||
"editModalTitle": "Edit 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…",
|
"loadingWorkingHours": "Loading working hours…",
|
||||||
"errorLoadStaff": "Failed to load staff.",
|
"errorLoadStaff": "Failed to load staff.",
|
||||||
"errorCopyInvite": "Could not copy invitation link.",
|
"errorCopyInvite": "Could not copy invitation link.",
|
||||||
@@ -1214,6 +1229,7 @@
|
|||||||
"APPOINTMENT_NOT_FOUND": "Appointment not found.",
|
"APPOINTMENT_NOT_FOUND": "Appointment not found.",
|
||||||
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
|
"APPOINTMENT_NOT_PROVIDER": "You are not the provider for this appointment.",
|
||||||
"PATIENT_NOT_FOUND": "Patient not found.",
|
"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_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_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.",
|
"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_ENABLE_OWNER": "The organization owner cannot be enabled this way.",
|
||||||
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
|
"STAFF_CANNOT_DISABLE_OWNER": "The organization owner cannot be disabled.",
|
||||||
"STAFF_CANNOT_REMOVE_OWNER": "The organization owner cannot be removed.",
|
"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_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_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.",
|
"ORG_TARGET_NO_SUBSCRIPTION": "The other organization does not have an active subscription.",
|
||||||
|
|||||||
@@ -122,6 +122,9 @@
|
|||||||
"labelCreatePassword": "ایجاد رمز عبور",
|
"labelCreatePassword": "ایجاد رمز عبور",
|
||||||
"labelConfirmPassword": "تأیید رمز عبور",
|
"labelConfirmPassword": "تأیید رمز عبور",
|
||||||
"activateAccount": "فعالسازی حساب",
|
"activateAccount": "فعالسازی حساب",
|
||||||
|
"setPasswordTitle": "رمز عبور خود را تنظیم کنید",
|
||||||
|
"setPasswordSubmit": "تنظیم رمز عبور",
|
||||||
|
"passwordSetupAlreadyDone": "این لینک تنظیم رمز دیگر معتبر نیست. اکنون میتوانید وارد شوید.",
|
||||||
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
|
"invitationAcceptedRedirect": "دعوتنامه پذیرفته شد. در حال ورود به فضای کاری...",
|
||||||
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
|
"invitationAcceptedSignInFailed": "حساب فعال شد، اما ورود انجام نشد. لطفاً با رمز عبور خود وارد شوید.",
|
||||||
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
|
"errorAcceptInvitation": "پذیرش دعوتنامه امکانپذیر نبود",
|
||||||
@@ -322,6 +325,18 @@
|
|||||||
"disableBullet3": "غیرفعالسازی یک مجوز در طرح شما را آزاد میکند تا بتوانید شخص دیگری را دعوت کنید.",
|
"disableBullet3": "غیرفعالسازی یک مجوز در طرح شما را آزاد میکند تا بتوانید شخص دیگری را دعوت کنید.",
|
||||||
"disableMemberButton": "غیرفعالسازی عضو",
|
"disableMemberButton": "غیرفعالسازی عضو",
|
||||||
"editModalTitle": "ویرایش عضو",
|
"editModalTitle": "ویرایش عضو",
|
||||||
|
"removePassword": "حذف رمز عبور",
|
||||||
|
"copyPasswordSetupLink": "کپی لینک تنظیم رمز",
|
||||||
|
"removePasswordModalTitle": "حذف رمز عبور",
|
||||||
|
"removePasswordConfirm": "رمز عبور {name} ({email}) حذف شود؟",
|
||||||
|
"removePasswordBullet1": "تا وقتی با لینک تنظیم، رمز جدید نگذارند، نمیتوانند وارد شوند.",
|
||||||
|
"removePasswordBullet2": "شما رمز جدید را انتخاب نمیکنید. لینک تنظیم را برایشان بفرستید.",
|
||||||
|
"removePasswordBullet3": "از همه سازمانهایی که عضو آن هستند خارج میشوند.",
|
||||||
|
"removePasswordButton": "حذف رمز و کپی لینک",
|
||||||
|
"passwordSetupLinkHeading": "لینک تنظیم رمز عبور",
|
||||||
|
"passwordSetupShareHint": "این لینک را به اشتراک بگذارید تا رمز جدید بگذارند. تا تکمیل این کار ورود ناموفق است.",
|
||||||
|
"successPasswordCleared": "رمز {name} حذف شد. لینک تنظیم را برایشان بفرستید.",
|
||||||
|
"errorClearPassword": "حذف رمز عبور امکانپذیر نبود.",
|
||||||
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
|
"loadingWorkingHours": "در حال بارگذاری ساعات کاری...",
|
||||||
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
|
"errorLoadStaff": "بارگذاری کارکنان ناموفق بود.",
|
||||||
"errorCopyInvite": "کپی لینک دعوتنامه امکانپذیر نبود.",
|
"errorCopyInvite": "کپی لینک دعوتنامه امکانپذیر نبود.",
|
||||||
@@ -1215,6 +1230,7 @@
|
|||||||
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
|
"APPOINTMENT_NOT_FOUND": "نوبت یافت نشد.",
|
||||||
"APPOINTMENT_NOT_PROVIDER": "شما ارائهدهنده این نوبت نیستید.",
|
"APPOINTMENT_NOT_PROVIDER": "شما ارائهدهنده این نوبت نیستید.",
|
||||||
"PATIENT_NOT_FOUND": "بیمار یافت نشد.",
|
"PATIENT_NOT_FOUND": "بیمار یافت نشد.",
|
||||||
|
"PATIENT_MOBILE_UNAVAILABLE": "این شماره موبایل را نمیتوان برای این کلینیک ثبت کرد.",
|
||||||
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. همپوشانی شیفتها را بررسی کنید.",
|
"WORKING_HOURS_INVALID": "ساعات کاری نامعتبر است. همپوشانی شیفتها را بررسی کنید.",
|
||||||
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
|
"WORKING_HOURS_OWNER_NOT_ALLOWED": "ساعات کاری مالک را از تنظیمات حساب تنظیم کنید.",
|
||||||
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبتهای آینده تداخل دارد. ابتدا آن نوبتها را تغییر دهید یا حذف کنید.",
|
"WORKING_HOURS_CONFLICTS_WITH_APPOINTMENTS": "این ساعات با نوبتهای آینده تداخل دارد. ابتدا آن نوبتها را تغییر دهید یا حذف کنید.",
|
||||||
@@ -1238,6 +1254,8 @@
|
|||||||
"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_PASSWORD_CLEAR_ACTIVE_ONLY": "رمز عبور را فقط برای اعضای فعال میتوان حذف کرد. اعضای در انتظار از لینک دعوت استفاده میکنند.",
|
||||||
"ORG_CANNOT_LINK_SELF": "نمیتوانید سازمان را به خودش متصل کنید.",
|
"ORG_CANNOT_LINK_SELF": "نمیتوانید سازمان را به خودش متصل کنید.",
|
||||||
"ORG_LINK_WRONG_TYPE": "فقط میتوانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
|
"ORG_LINK_WRONG_TYPE": "فقط میتوانید به نوع سازمان متناظر (کلینیک یا لابراتوار) متصل شوید.",
|
||||||
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",
|
"ORG_TARGET_NO_SUBSCRIPTION": "سازمان مقابل اشتراک فعال ندارد.",
|
||||||
|
|||||||
@@ -122,6 +122,9 @@
|
|||||||
"labelCreatePassword": "Wachtwoord aanmaken",
|
"labelCreatePassword": "Wachtwoord aanmaken",
|
||||||
"labelConfirmPassword": "Bevestig wachtwoord",
|
"labelConfirmPassword": "Bevestig wachtwoord",
|
||||||
"activateAccount": "Account activeren",
|
"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...",
|
"invitationAcceptedRedirect": "Uitnodiging geaccepteerd. Uw werkruimte wordt geopend...",
|
||||||
"invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.",
|
"invitationAcceptedSignInFailed": "Account geactiveerd, maar aanmelden is mislukt. Log in met uw wachtwoord.",
|
||||||
"errorAcceptInvitation": "Kon uitnodiging niet accepteren",
|
"errorAcceptInvitation": "Kon uitnodiging niet accepteren",
|
||||||
@@ -322,6 +325,18 @@
|
|||||||
"disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.",
|
"disableBullet3": "Uitschakelen maakt één plaats vrij in uw abonnement, zodat u iemand anders kunt uitnodigen.",
|
||||||
"disableMemberButton": "Lid uitschakelen",
|
"disableMemberButton": "Lid uitschakelen",
|
||||||
"editModalTitle": "Lid bewerken",
|
"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...",
|
"loadingWorkingHours": "Werktijden laden...",
|
||||||
"errorLoadStaff": "Medewerkers laden mislukt.",
|
"errorLoadStaff": "Medewerkers laden mislukt.",
|
||||||
"errorCopyInvite": "Kon uitnodigingslink niet kopiëren.",
|
"errorCopyInvite": "Kon uitnodigingslink niet kopiëren.",
|
||||||
@@ -1214,6 +1229,7 @@
|
|||||||
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
|
"APPOINTMENT_NOT_FOUND": "Afspraak niet gevonden.",
|
||||||
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
|
"APPOINTMENT_NOT_PROVIDER": "U bent niet de zorgverlener van deze afspraak.",
|
||||||
"PATIENT_NOT_FOUND": "Patiënt niet gevonden.",
|
"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_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_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.",
|
"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_ENABLE_OWNER": "De eigenaar kan op deze manier niet worden ingeschakeld.",
|
||||||
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
|
"STAFF_CANNOT_DISABLE_OWNER": "De eigenaar kan niet worden uitgeschakeld.",
|
||||||
"STAFF_CANNOT_REMOVE_OWNER": "De eigenaar kan niet worden verwijderd.",
|
"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_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_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.",
|
"ORG_TARGET_NO_SUBSCRIPTION": "De andere organisatie heeft geen actief abonnement.",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function AcceptInviteContent() {
|
|||||||
organizationName: string;
|
organizationName: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
status: 'PENDING' | 'ACCEPTED';
|
status: 'PENDING' | 'ACCEPTED';
|
||||||
|
mode: 'join' | 'password_setup';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
@@ -48,10 +49,17 @@ function AcceptInviteContent() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const res = await staffApi.previewInvite(token);
|
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 || '');
|
setName(res.data.name || '');
|
||||||
if (res.data.status === 'ACCEPTED') {
|
if (res.data.status === 'ACCEPTED') {
|
||||||
setSuccess(t('invitationAlreadyAccepted'));
|
setSuccess(
|
||||||
|
res.data.mode === 'password_setup'
|
||||||
|
? t('passwordSetupAlreadyDone')
|
||||||
|
: t('invitationAlreadyAccepted'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
|
setError(getUserFacingError(e, tErrors, t('errorLoadInvitation')));
|
||||||
@@ -65,7 +73,9 @@ function AcceptInviteContent() {
|
|||||||
if (!token) return;
|
if (!token) return;
|
||||||
setError('');
|
setError('');
|
||||||
setSuccess('');
|
setSuccess('');
|
||||||
if (!name.trim()) {
|
const isPasswordSetup = inviteInfo?.mode === 'password_setup';
|
||||||
|
const nameToSubmit = isPasswordSetup ? (inviteInfo?.name || '').trim() : name.trim();
|
||||||
|
if (!isPasswordSetup && !nameToSubmit) {
|
||||||
setError(t('nameRequired'));
|
setError(t('nameRequired'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ function AcceptInviteContent() {
|
|||||||
try {
|
try {
|
||||||
await staffApi.acceptInvite({
|
await staffApi.acceptInvite({
|
||||||
token,
|
token,
|
||||||
name: name.trim(),
|
name: nameToSubmit || inviteInfo?.name || '',
|
||||||
password,
|
password,
|
||||||
});
|
});
|
||||||
accepted = true;
|
accepted = true;
|
||||||
@@ -115,7 +125,9 @@ function AcceptInviteContent() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4 py-8">
|
<div className="min-h-[100dvh] app-web-bg flex items-center justify-center px-4 py-8">
|
||||||
<div className="w-full max-w-md surface-card p-4 sm:p-6 space-y-5">
|
<div className="w-full max-w-md surface-card p-4 sm:p-6 space-y-5">
|
||||||
<h1 className="text-lg sm:text-xl font-semibold text-text-primary">{t('acceptInviteTitle')}</h1>
|
<h1 className="text-lg sm:text-xl font-semibold text-text-primary">
|
||||||
|
{inviteInfo?.mode === 'password_setup' ? t('setPasswordTitle') : t('acceptInviteTitle')}
|
||||||
|
</h1>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-text-secondary">{t('loadingInvitation')}</p>
|
<p className="text-sm text-text-secondary">{t('loadingInvitation')}</p>
|
||||||
@@ -147,7 +159,9 @@ function AcceptInviteContent() {
|
|||||||
|
|
||||||
{inviteInfo?.status !== 'ACCEPTED' && (
|
{inviteInfo?.status !== 'ACCEPTED' && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{inviteInfo?.mode !== 'password_setup' && (
|
||||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
)}
|
||||||
<Input
|
<Input
|
||||||
label={t('labelCreatePassword')}
|
label={t('labelCreatePassword')}
|
||||||
type="password"
|
type="password"
|
||||||
@@ -163,7 +177,7 @@ function AcceptInviteContent() {
|
|||||||
passwordToggleLabels={passwordToggleLabels}
|
passwordToggleLabels={passwordToggleLabels}
|
||||||
/>
|
/>
|
||||||
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
|
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
|
||||||
{t('activateAccount')}
|
{inviteInfo?.mode === 'password_setup' ? t('setPasswordSubmit') : t('activateAccount')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 {
|
function canDisableStaff(member: StaffMemberDto): boolean {
|
||||||
return !member.isOwner && member.isActive;
|
return !member.isOwner && member.isActive;
|
||||||
}
|
}
|
||||||
@@ -189,6 +193,12 @@ export function StaffPage() {
|
|||||||
invitationStatus: 'PENDING' | 'ACCEPTED';
|
invitationStatus: 'PENDING' | 'ACCEPTED';
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
|
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
|
||||||
|
const [lastPasswordSetupInfo, setLastPasswordSetupInfo] = useState<{
|
||||||
|
membershipId: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
invitationUrl: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
|
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
|
||||||
const [editStep, setEditStep] = useState<1 | 2>(1);
|
const [editStep, setEditStep] = useState<1 | 2>(1);
|
||||||
@@ -205,6 +215,8 @@ export function StaffPage() {
|
|||||||
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
|
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
|
||||||
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
|
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | null>(null);
|
||||||
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
const [enablingMembershipId, setEnablingMembershipId] = useState<string | null>(null);
|
||||||
|
const [clearPasswordTarget, setClearPasswordTarget] = useState<StaffMemberDto | null>(null);
|
||||||
|
const [clearingMembershipId, setClearingMembershipId] = useState<string | null>(null);
|
||||||
|
|
||||||
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
|
||||||
const highlightMembershipIds = useMemo(
|
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)) {
|
if (!currentOrganization || !canViewStaff(currentOrganization)) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-text-secondary">{t('redirecting')}</p>
|
<p className="text-sm text-text-secondary">{t('redirecting')}</p>
|
||||||
@@ -640,6 +691,54 @@ export function StaffPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{lastPasswordSetupInfo && (
|
||||||
|
<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={tCommon('dismiss')}
|
||||||
|
onClick={() => setLastPasswordSetupInfo(null)}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<p className="text-sm text-text-primary pr-6">
|
||||||
|
{t('successPasswordCleared', { name: lastPasswordSetupInfo.name })}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 pt-1 border-t border-border/60">
|
||||||
|
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
|
||||||
|
{t('passwordSetupLinkHeading')}
|
||||||
|
</p>
|
||||||
|
<code className="block text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
|
||||||
|
{lastPasswordSetupInfo.invitationUrl}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={clearingMembershipId === lastPasswordSetupInfo.membershipId}
|
||||||
|
onClick={async () => {
|
||||||
|
setClearingMembershipId(lastPasswordSetupInfo.membershipId);
|
||||||
|
toast.setError('');
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lastPasswordSetupInfo.invitationUrl);
|
||||||
|
setCopiedInviteMembershipId(lastPasswordSetupInfo.membershipId);
|
||||||
|
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
|
||||||
|
} catch (e) {
|
||||||
|
toast.showError(getUserFacingError(e, tErrors, t('errorClearPassword')));
|
||||||
|
} finally {
|
||||||
|
setClearingMembershipId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copiedInviteMembershipId === lastPasswordSetupInfo.membershipId
|
||||||
|
? tCommon('copied')
|
||||||
|
: tCommon('copyLink')}
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-text-muted">{t('passwordSetupShareHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
|
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
|
||||||
) : (
|
) : (
|
||||||
@@ -1061,6 +1160,59 @@ export function StaffPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{clearPasswordTarget && (
|
||||||
|
<div className="fixed inset-0 z-[60] flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/55">
|
||||||
|
<div
|
||||||
|
className="surface-card w-full sm:max-w-md max-h-[90dvh] overflow-y-auto p-4 sm:p-5 space-y-4 shadow-xl rounded-t-[var(--radius-lg)] sm:rounded-[var(--radius-lg)]"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="clear-password-title"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h2 id="clear-password-title" className="text-lg font-semibold text-text-primary pr-2">
|
||||||
|
{t('removePasswordModalTitle')}
|
||||||
|
</h2>
|
||||||
|
<DialogCloseButton
|
||||||
|
onClick={() => {
|
||||||
|
if (clearingMembershipId) return;
|
||||||
|
setClearPasswordTarget(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{t('removePasswordConfirm', {
|
||||||
|
name: clearPasswordTarget.name,
|
||||||
|
email: clearPasswordTarget.email,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<ul className="text-sm text-text-secondary space-y-2 list-disc ps-5">
|
||||||
|
<li>{t('removePasswordBullet1')}</li>
|
||||||
|
<li>{t('removePasswordBullet2')}</li>
|
||||||
|
<li>{t('removePasswordBullet3')}</li>
|
||||||
|
</ul>
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-1">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={Boolean(clearingMembershipId)}
|
||||||
|
onClick={() => setClearPasswordTarget(null)}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
isLoading={clearingMembershipId === clearPasswordTarget.id}
|
||||||
|
disabled={Boolean(clearingMembershipId)}
|
||||||
|
onClick={() => confirmClearPassword()}
|
||||||
|
>
|
||||||
|
{t('removePasswordButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{editing && (
|
{editing && (
|
||||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
|
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
|
||||||
<div
|
<div
|
||||||
@@ -1099,6 +1251,31 @@ export function StaffPage() {
|
|||||||
organizationType={currentOrganization?.type}
|
organizationType={currentOrganization?.type}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{canEdit && canIssuePasswordSetup(editing, user?.id) && (
|
||||||
|
<div className="pt-3 border-t border-border/60">
|
||||||
|
{editing.hasPassword ? (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
disabled={Boolean(clearingMembershipId)}
|
||||||
|
onClick={() => setClearPasswordTarget(editing)}
|
||||||
|
>
|
||||||
|
{t('removePassword')}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
isLoading={clearingMembershipId === editing.id}
|
||||||
|
onClick={() => void issuePasswordSetupLink(editing, true)}
|
||||||
|
>
|
||||||
|
{t('copyPasswordSetupLink')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : editLoadingWorkingHours ? (
|
) : editLoadingWorkingHours ? (
|
||||||
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>
|
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export interface StaffMemberDto {
|
|||||||
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
|
invitationStatus: 'ACTIVE' | 'PENDING' | 'EXPIRED' | 'DISABLED';
|
||||||
invitedAt: string | null;
|
invitedAt: string | null;
|
||||||
acceptedAt: string | null;
|
acceptedAt: string | null;
|
||||||
|
hasPassword: boolean;
|
||||||
permissions: string[] | null;
|
permissions: string[] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ export interface PreviewInviteResponse {
|
|||||||
organizationName: string;
|
organizationName: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
status: 'PENDING' | 'ACCEPTED';
|
status: 'PENDING' | 'ACCEPTED';
|
||||||
|
mode: 'join' | 'password_setup';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +81,21 @@ export const staffApi = {
|
|||||||
return response.data;
|
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<PreviewInviteResponse> => {
|
previewInvite: async (token: string): Promise<PreviewInviteResponse> => {
|
||||||
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
|
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
Reference in New Issue
Block a user