|
|
@@ -2,9 +2,27 @@
|
|
|
|
|
|
import { useState, useEffect } from 'react';
|
|
|
import { useAuth } from '@/lib/auth-context';
|
|
|
-import { usersApi, settingsApi } from '@/lib/api';
|
|
|
+import { usersApi, settingsApi, invitationsApi } from '@/lib/api';
|
|
|
import { ProfilePictureUpload } from '@/components/settings/ProfilePictureUpload';
|
|
|
|
|
|
+async function safeCopy(text: string): Promise<void> {
|
|
|
+ if (typeof window === 'undefined') return;
|
|
|
+ try {
|
|
|
+ const cb = navigator.clipboard;
|
|
|
+ if (cb && typeof cb.writeText === 'function') {
|
|
|
+ await cb.writeText(text);
|
|
|
+ } else {
|
|
|
+ const el = document.createElement('textarea');
|
|
|
+ el.value = text;
|
|
|
+ el.style.cssText = 'position:fixed;top:-999px;left:-999px;opacity:0';
|
|
|
+ document.body.appendChild(el);
|
|
|
+ el.focus(); el.select();
|
|
|
+ try { document.execCommand('copy'); } catch { /* ignore */ }
|
|
|
+ document.body.removeChild(el);
|
|
|
+ }
|
|
|
+ } catch { /* ignore */ }
|
|
|
+}
|
|
|
+
|
|
|
export default function SettingsPage() {
|
|
|
const { user, token, updateUserData } = useAuth();
|
|
|
const [name, setName] = useState(user?.name ?? '');
|
|
|
@@ -18,6 +36,13 @@ export default function SettingsPage() {
|
|
|
const [registrationEnabled, setRegistrationEnabled] = useState(true);
|
|
|
const [loadingReg, setLoadingReg] = useState(false);
|
|
|
|
|
|
+ // Workspace invite (admin only)
|
|
|
+ const [inviteEmail, setInviteEmail] = useState('');
|
|
|
+ const [inviting, setInviting] = useState(false);
|
|
|
+ const [inviteError, setInviteError] = useState('');
|
|
|
+ const [inviteLink, setInviteLink] = useState('');
|
|
|
+ const [linkCopied, setLinkCopied] = useState(false);
|
|
|
+
|
|
|
const isAdmin = user?.globalRole === 'ADMIN';
|
|
|
|
|
|
useEffect(() => {
|
|
|
@@ -84,6 +109,26 @@ export default function SettingsPage() {
|
|
|
}
|
|
|
};
|
|
|
|
|
|
+ const handleWorkspaceInvite = async (e: React.FormEvent) => {
|
|
|
+ e.preventDefault();
|
|
|
+ if (!token || !inviteEmail.trim()) return;
|
|
|
+ setInviting(true);
|
|
|
+ setInviteError('');
|
|
|
+ setInviteLink('');
|
|
|
+ setLinkCopied(false);
|
|
|
+ try {
|
|
|
+ const { inviteUrl } = await invitationsApi.inviteMember(token, inviteEmail.trim());
|
|
|
+ setInviteLink(inviteUrl);
|
|
|
+ await safeCopy(inviteUrl);
|
|
|
+ setLinkCopied(true);
|
|
|
+ setInviteEmail('');
|
|
|
+ } catch (err) {
|
|
|
+ setInviteError(err instanceof Error ? err.message : 'Failed to create invitation');
|
|
|
+ } finally {
|
|
|
+ setInviting(false);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
if (!user || !token) return null;
|
|
|
|
|
|
return (
|
|
|
@@ -241,6 +286,76 @@ export default function SettingsPage() {
|
|
|
: 'Registration is closed — only invited users can join via invite link.'}
|
|
|
</p>
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* Invite members */}
|
|
|
+ <div className="pt-4 border-t" style={{ borderColor: 'rgba(255,255,255,0.06)' }}>
|
|
|
+ <p className="text-sm font-medium mb-1" style={{ color: 'var(--text)' }}>
|
|
|
+ Invite Member
|
|
|
+ </p>
|
|
|
+ <p className="text-xs mb-4" style={{ color: 'var(--text-muted)' }}>
|
|
|
+ Generate an invitation link and share it directly with a colleague.
|
|
|
+ </p>
|
|
|
+
|
|
|
+ <form onSubmit={handleWorkspaceInvite} className="space-y-3">
|
|
|
+ <div className="flex items-end gap-3 flex-wrap">
|
|
|
+ <div className="flex-1 min-w-[200px]">
|
|
|
+ <input
|
|
|
+ type="email"
|
|
|
+ className="input"
|
|
|
+ value={inviteEmail}
|
|
|
+ onChange={e => setInviteEmail(e.target.value)}
|
|
|
+ placeholder="colleague@company.com"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ type="submit"
|
|
|
+ disabled={inviting || !inviteEmail.trim()}
|
|
|
+ className="btn btn-primary btn-md shrink-0"
|
|
|
+ >
|
|
|
+ {inviting ? 'Creating…' : (
|
|
|
+ <span className="flex items-center gap-1.5">
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
|
|
+ </svg>
|
|
|
+ Create & Copy Link
|
|
|
+ </span>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {inviteError && (
|
|
|
+ <p className="text-xs" style={{ color: '#F87171' }}>{inviteError}</p>
|
|
|
+ )}
|
|
|
+
|
|
|
+ {inviteLink && (
|
|
|
+ <div className="rounded-lg p-3 animate-scale-in"
|
|
|
+ style={{ background: 'rgba(34,197,94,0.08)', border: '1px solid rgba(34,197,94,0.20)' }}>
|
|
|
+ <div className="flex items-center gap-2 mb-1.5">
|
|
|
+ <svg className="w-3.5 h-3.5 shrink-0" style={{ color: '#86EFAC' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
+ </svg>
|
|
|
+ <span className="text-xs font-medium" style={{ color: '#86EFAC' }}>
|
|
|
+ {linkCopied ? 'Link created & copied!' : 'Link created!'}
|
|
|
+ </span>
|
|
|
+ <button
|
|
|
+ type="button"
|
|
|
+ onClick={async () => { await safeCopy(inviteLink); setLinkCopied(true); setTimeout(() => setLinkCopied(false), 2000); }}
|
|
|
+ className="ml-auto text-[10px] px-2 py-0.5 rounded"
|
|
|
+ style={{ background: 'rgba(255,255,255,0.06)', color: 'var(--text-muted)' }}
|
|
|
+ >
|
|
|
+ {linkCopied ? 'Copied!' : 'Copy again'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <p className="text-[10px] break-all" style={{ color: 'rgba(134,239,172,0.7)' }}>
|
|
|
+ {inviteLink}
|
|
|
+ </p>
|
|
|
+ <p className="text-[10px] mt-1.5" style={{ color: 'rgba(134,239,172,0.5)' }}>
|
|
|
+ Link expires in 7 days. Share it with {inviteEmail || 'your colleague'}.
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </form>
|
|
|
+ </div>
|
|
|
</section>
|
|
|
)}
|
|
|
|