feature: sending invitation link for newly created staff implemented.

This commit is contained in:
2026-04-30 12:55:39 +03:30
parent c39bd872bd
commit 1b8856f64e
10 changed files with 527 additions and 64 deletions

View File

@@ -15,7 +15,7 @@ import {
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
import { UserPlus, Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/Button';
@@ -105,12 +105,12 @@ export default function StaffPage() {
const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false);
const [lastTempPassword, setLastTempPassword] = useState<string | null>(null);
const [copiedPw, setCopiedPw] = useState(false);
const [copiedInviteLink, setCopiedInviteLink] = useState(false);
const [lastInviteInfo, setLastInviteInfo] = useState<{
name: string;
email: string;
isNewAccount: boolean;
invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
@@ -159,7 +159,6 @@ export default function StaffPage() {
async function submitInvite() {
setInviteLoading(true);
setError('');
setLastTempPassword(null);
setLastInviteInfo(null);
const displayName = inviteName.trim();
const displayEmail = inviteEmail.trim();
@@ -170,12 +169,11 @@ export default function StaffPage() {
name: displayName,
permissionNames,
});
const isNew = Boolean(res.data.temporaryPassword);
setLastTempPassword(res.data.temporaryPassword);
setLastInviteInfo({
name: displayName,
email: res.data.email,
isNewAccount: isNew,
invitationUrl: res.data.invitationUrl,
invitationStatus: res.data.invitationStatus,
});
setSuccess('');
setInviteOpen(false);
@@ -235,17 +233,6 @@ export default function StaffPage() {
}
}
async function copyTempPassword() {
if (!lastTempPassword) return;
try {
await navigator.clipboard.writeText(lastTempPassword);
setCopiedPw(true);
setTimeout(() => setCopiedPw(false), 2000);
} catch {
setError('Could not copy to clipboard');
}
}
if (!currentOrganization || !canViewStaff(currentOrganization)) {
return (
<p className="text-sm text-text-secondary">Redirecting</p>
@@ -266,7 +253,6 @@ export default function StaffPage() {
size="sm"
onClick={() => {
setInviteOpen(true);
setLastTempPassword(null);
setLastInviteInfo(null);
}}
disabled={atSeatLimit}
@@ -313,44 +299,45 @@ export default function StaffPage() {
aria-label="Dismiss"
onClick={() => {
setLastInviteInfo(null);
setLastTempPassword(null);
}}
>
<X className="w-4 h-4" />
</button>
<p className="text-sm text-text-primary pr-6">
{lastInviteInfo.isNewAccount ? (
<>
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) new
account created and added to this organization.
{lastTempPassword
? ' Share the temporary password below so they can sign in.'
: ''}
</>
) : (
<>
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) this
person already had an account. They can select <span className="font-medium">{currentOrganization.name}</span>{' '}
from the organization switcher after signing in.
</>
)}
<span className="font-medium">{lastInviteInfo.name}</span> ({lastInviteInfo.email}) was invited.
{lastInviteInfo.invitationStatus === 'PENDING'
? ' Invitation is pending until they open the link, set a password, and log in.'
: ' Invitation was accepted immediately.'}
</p>
{lastTempPassword && (
{lastInviteInfo.invitationUrl && (
<div className="space-y-2 pt-1 border-t border-border/60">
<p className="text-xs font-medium text-text-secondary uppercase tracking-wide">
Temporary password (copy now not stored)
Invite link
</p>
<div className="flex flex-wrap items-center gap-2">
<code className="text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono">
{lastTempPassword}
<code className="text-sm px-2 py-1.5 rounded-[var(--radius-sm)] bg-background-card border border-border font-mono break-all">
{lastInviteInfo.invitationUrl}
</code>
<Button type="button" variant="outline" size="sm" onClick={() => void copyTempPassword()}>
{copiedPw ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="ml-1">{copiedPw ? 'Copied' : 'Copy'}</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={async () => {
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true);
setTimeout(() => setCopiedInviteLink(false), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedInviteLink ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="ml-1">{copiedInviteLink ? 'Copied' : 'Copy link'}</span>
</Button>
</div>
<p className="text-xs text-text-muted">
They should sign in with this password once, then change it under account settings.
Share this link manually via SMS or email. They must set password first.
</p>
</div>
)}
@@ -367,6 +354,7 @@ export default function StaffPage() {
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th>
{canEdit && <th className="p-3 font-medium w-28">Actions</th>}
</tr>
@@ -383,6 +371,22 @@ export default function StaffPage() {
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active
</span>
) : m.invitationStatus === 'PENDING' ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
<Clock3 className="h-3 w-3" />
Pending
</span>
) : (
<span className="inline-flex items-center rounded-full border border-red-500/40 bg-red-500/15 px-2 py-0.5 text-xs text-red-300">
Expired
</span>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>