feature: sending invitation link for newly created staff implemented.
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user