improvement: Nothing done is related to subscriptions!!!
This commit is contained in:
@@ -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 (
|
||||
<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">
|
||||
<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 ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingInvitation')}</p>
|
||||
@@ -147,7 +159,9 @@ function AcceptInviteContent() {
|
||||
|
||||
{inviteInfo?.status !== 'ACCEPTED' && (
|
||||
<div className="space-y-3">
|
||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
{inviteInfo?.mode !== 'password_setup' && (
|
||||
<Input label={t('labelName')} value={name} onChange={(e) => setName(e.target.value)} />
|
||||
)}
|
||||
<Input
|
||||
label={t('labelCreatePassword')}
|
||||
type="password"
|
||||
@@ -163,7 +177,7 @@ function AcceptInviteContent() {
|
||||
passwordToggleLabels={passwordToggleLabels}
|
||||
/>
|
||||
<Button type="button" fullWidth isLoading={submitting} onClick={() => onAccept()}>
|
||||
{t('activateAccount')}
|
||||
{inviteInfo?.mode === 'password_setup' ? t('setPasswordSubmit') : t('activateAccount')}
|
||||
</Button>
|
||||
</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 {
|
||||
return !member.isOwner && member.isActive;
|
||||
}
|
||||
@@ -189,6 +193,12 @@ export function StaffPage() {
|
||||
invitationStatus: 'PENDING' | 'ACCEPTED';
|
||||
} | null>(null);
|
||||
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 [editStep, setEditStep] = useState<1 | 2>(1);
|
||||
@@ -205,6 +215,8 @@ export function StaffPage() {
|
||||
const [disablingMembershipId, setDisablingMembershipId] = useState<string | null>(null);
|
||||
const [enableTarget, setEnableTarget] = useState<StaffMemberDto | 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 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 (
|
||||
<p className="text-sm text-text-secondary">{t('redirecting')}</p>
|
||||
@@ -640,6 +691,54 @@ export function StaffPage() {
|
||||
</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 ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingTeam')}</p>
|
||||
) : (
|
||||
@@ -1061,6 +1160,59 @@ export function StaffPage() {
|
||||
</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 && (
|
||||
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center p-0 sm:p-4 bg-black/50">
|
||||
<div
|
||||
@@ -1099,6 +1251,31 @@ export function StaffPage() {
|
||||
organizationType={currentOrganization?.type}
|
||||
/>
|
||||
</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 ? (
|
||||
<p className="text-sm text-text-secondary">{t('loadingWorkingHours')}</p>
|
||||
|
||||
@@ -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<PreviewInviteResponse> => {
|
||||
const response = await apiClient.get(`/staff/invitations/preview?token=${encodeURIComponent(token)}`);
|
||||
return response.data;
|
||||
|
||||
Reference in New Issue
Block a user