Files
wursor/packages/app/src/components/shell/WorkspaceSettingsForm.tsx
T
SinachPatandClaude Sonnet 4.6 bbef7d73f8 fix: replace stale userId reference in invite button opacity
One userId reference survived the rename to email in WorkspaceSettingsForm,
causing a TypeScript build failure on Vercel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 22:19:12 +01:00

404 lines
15 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useRef } from 'react';
import { useRouter } from 'next/navigation';
interface WorkspaceSettingsFormProps {
workspaceId: string;
workspaceName: string;
memberRole: string;
}
const SECTION: React.CSSProperties = {
background: 'var(--card-bg)',
border: '1px solid var(--card-border)',
borderRadius: 14,
padding: '24px 28px',
marginBottom: 20,
transition: 'background 0.2s, border-color 0.2s',
};
const LABEL: React.CSSProperties = {
display: 'block',
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--card-text)',
marginBottom: 6,
};
const INPUT: React.CSSProperties = {
width: '100%',
fontSize: '0.875rem',
padding: '9px 12px',
border: '1px solid var(--input-border)',
borderRadius: 9,
fontFamily: "'Inter', -apple-system, sans-serif",
color: 'var(--input-text)',
background: 'var(--input-bg)',
boxSizing: 'border-box',
};
const BTN_PRIMARY: React.CSSProperties = {
display: 'inline-flex', alignItems: 'center', gap: 6,
background: 'var(--btn-bg)', color: 'var(--btn-fg)',
fontSize: '0.875rem', fontWeight: 600,
padding: '9px 20px', borderRadius: 9,
border: 'none', cursor: 'pointer', letterSpacing: '-0.01em',
};
// Matches TeamRoleSchema in @originmain/origin-graph — keep in sync.
type TeamRole = 'OWNER' | 'DESIGNER' | 'ENGINEER' | 'PM' | 'VIEWER';
function TeamInviteForm({ workspaceId }: { workspaceId: string }) {
const [email, setEmail] = useState('');
const [role, setRole] = useState<TeamRole>('DESIGNER');
const [status, setStatus] = useState<'idle' | 'loading' | 'done' | 'conflict' | 'error'>('idle');
const [errorMsg, setErrorMsg] = useState('');
const resetTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Clear any pending reset timers on unmount to avoid setState-after-unmount.
useEffect(() => { return () => { clearTimeout(resetTimer.current); }; }, []);
const submit = useCallback(async () => {
const trimmed = email.trim().toLowerCase();
if (!trimmed) return;
clearTimeout(resetTimer.current);
setStatus('loading');
setErrorMsg('');
try {
const res = await fetch(`/api/workspace/${workspaceId}/invite`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: trimmed, role }),
});
if (res.status === 409) { setStatus('conflict'); return; }
if (!res.ok) {
const data = await res.json() as { error?: string };
throw new Error(data.error ?? `HTTP ${res.status}`);
}
setStatus('done');
setEmail('');
resetTimer.current = setTimeout(() => setStatus('idle'), 3000);
} catch (e) {
setErrorMsg(e instanceof Error ? e.message : 'Invite failed');
setStatus('error');
resetTimer.current = setTimeout(() => setStatus('idle'), 4000);
}
}, [email, role, workspaceId]);
const roles: TeamRole[] = ['DESIGNER', 'ENGINEER', 'PM', 'VIEWER', 'OWNER'];
return (
<div>
{/* Role picker */}
<label style={LABEL}>Role</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
{roles.map(r => (
<button
key={r}
onClick={() => setRole(r)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '5px 13px', borderRadius: 8,
border: `1px solid ${role === r ? '#0066FF' : 'rgba(0,0,0,0.12)'}`,
background: role === r ? 'rgba(0,102,255,0.08)' : 'var(--btn-idle-bg)',
color: role === r ? '#0066FF' : 'var(--btn-idle-fg)',
cursor: 'pointer',
}}
>
{r.charAt(0) + r.slice(1).toLowerCase()}
</button>
))}
</div>
{/* Email input + submit */}
<label style={LABEL} htmlFor="invite-email">Email address</label>
<div style={{ display: 'flex', gap: 10 }}>
<input
id="invite-email"
type="email"
style={INPUT}
placeholder="colleague@company.com"
value={email}
onChange={e => setEmail(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void submit(); }}
disabled={status === 'loading'}
/>
<button
onClick={() => void submit()}
disabled={status === 'loading' || !email.trim()}
style={{
...BTN_PRIMARY,
flexShrink: 0,
opacity: (status === 'loading' || !email.trim()) ? 0.5 : 1,
transition: 'opacity 0.15s',
}}
>
{status === 'loading' ? 'Inviting…' : 'Invite'}
</button>
</div>
{/* Feedback */}
{status === 'done' && (
<p style={{ fontSize: '0.8125rem', color: '#10B981', marginTop: 8 }}> Member added successfully</p>
)}
{status === 'conflict' && (
<p style={{ fontSize: '0.8125rem', color: '#F59E0B', marginTop: 8 }}>User is already a member of this workspace</p>
)}
{status === 'error' && (
<p style={{ fontSize: '0.8125rem', color: '#EF4444', marginTop: 8 }}>{errorMsg || 'Invite failed — try again'}</p>
)}
</div>
);
}
export function WorkspaceSettingsForm({ workspaceId, workspaceName, memberRole }: WorkspaceSettingsFormProps) {
const router = useRouter();
const isOwner = memberRole === 'OWNER';
/* ── Rename ── */
const [name, setName] = useState(workspaceName);
const [renameStatus, setRenameStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const renameTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
useEffect(() => { return () => { clearTimeout(renameTimer.current); }; }, []);
async function saveName() {
if (!name.trim() || name.trim() === workspaceName) return;
clearTimeout(renameTimer.current);
setRenameStatus('saving');
try {
const res = await fetch(`/api/workspace/${workspaceId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim() }),
});
if (!res.ok) throw new Error('Rename failed');
setRenameStatus('saved');
router.refresh();
renameTimer.current = setTimeout(() => setRenameStatus('idle'), 2000);
} catch {
setRenameStatus('error');
renameTimer.current = setTimeout(() => setRenameStatus('idle'), 3000);
}
}
/* ── IDE Token ── */
const [agentType, setAgentType] = useState<'CURSOR' | 'CLAUDE_CODE' | 'GENERIC'>('CLAUDE_CODE');
const [tokenResult, setTokenResult] = useState<{
token: string;
cursorConfig: { cursorrules: string; settings: unknown };
claudeCodeConfig: { claudeMd: string; settings: unknown };
} | null>(null);
const [tokenLoading, setTokenLoading] = useState(false);
const [tokenError, setTokenError] = useState('');
const [copiedToken, setCopiedToken] = useState(false);
async function issueToken() {
setTokenLoading(true);
setTokenError('');
setTokenResult(null);
try {
const res = await fetch(`/api/workspace/${workspaceId}/tokens`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentType, workspaceName }),
});
if (!res.ok) throw new Error('Token issuance failed');
const data = await res.json() as typeof tokenResult;
setTokenResult(data);
} catch (e) {
setTokenError(e instanceof Error ? e.message : 'Failed to issue token');
} finally {
setTokenLoading(false);
}
}
function copyToken() {
if (!tokenResult) return;
void navigator.clipboard.writeText(tokenResult.token);
setCopiedToken(true);
setTimeout(() => setCopiedToken(false), 2000);
}
const configText = tokenResult
? agentType === 'CURSOR'
? tokenResult.cursorConfig.cursorrules
: tokenResult.claudeCodeConfig.claudeMd
: '';
return (
<>
{/* General */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--card-text)', margin: '0 0 20px' }}>
General
</h2>
<label style={LABEL} htmlFor="ws-name">Workspace name</label>
<div style={{ display: 'flex', gap: 10, marginBottom: 4 }}>
<input
id="ws-name"
style={INPUT}
value={name}
onChange={e => setName(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') void saveName(); }}
disabled={!isOwner}
/>
<button
onClick={() => void saveName()}
disabled={!isOwner || renameStatus === 'saving' || name.trim() === workspaceName}
style={{
...BTN_PRIMARY,
opacity: (!isOwner || name.trim() === workspaceName) ? 0.4 : 1,
flexShrink: 0,
}}
>
{renameStatus === 'saving' ? 'Saving…' : renameStatus === 'saved' ? '✓ Saved' : 'Rename'}
</button>
</div>
{renameStatus === 'error' && (
<span style={{ fontSize: '0.75rem', color: '#EF4444' }}>Rename failed try again</span>
)}
{!isOwner && (
<span style={{ fontSize: '0.75rem', color: 'var(--card-muted)' }}>Only workspace owners can rename.</span>
)}
</div>
{/* IDE Integration */}
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--card-text)', margin: '0 0 6px' }}>
IDE Integration
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 20px', lineHeight: 1.6 }}>
Issue a signed workspace token and copy the config snippet into your editor. Tokens expire after 30 days.
</p>
<label style={LABEL}>Agent type</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{(['CLAUDE_CODE', 'CURSOR', 'GENERIC'] as const).map(t => (
<button
key={t}
onClick={() => setAgentType(t)}
style={{
fontSize: '0.75rem', fontWeight: 600, padding: '6px 14px', borderRadius: 8,
border: `1px solid ${agentType === t ? '#0066FF' : 'var(--btn-idle-border)'}`,
background: agentType === t ? 'rgba(0,102,255,0.08)' : 'var(--btn-idle-bg)',
color: agentType === t ? '#0066FF' : 'var(--btn-idle-fg)',
cursor: 'pointer',
}}
>
{t === 'CLAUDE_CODE' ? 'Claude Code' : t === 'CURSOR' ? 'Cursor' : 'Generic'}
</button>
))}
</div>
<button
onClick={() => void issueToken()}
disabled={tokenLoading}
style={{ ...BTN_PRIMARY, opacity: tokenLoading ? 0.6 : 1, marginBottom: tokenResult ? 16 : 0 }}
>
{tokenLoading ? 'Generating…' : 'Generate token'}
</button>
{tokenError && (
<p style={{ fontSize: '0.75rem', color: '#EF4444', marginTop: 8 }}>{tokenError}</p>
)}
{tokenResult && (
<div style={{ marginTop: 16 }}>
{/* Token display */}
<label style={{ ...LABEL, marginBottom: 6 }}>Workspace token</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input
readOnly
value={tokenResult.token}
style={{ ...INPUT, fontFamily: 'monospace', fontSize: '0.75rem', color: '#0066FF' }}
/>
<button
onClick={copyToken}
style={{
...BTN_PRIMARY, background: copiedToken ? '#10B981' : '#0066FF', flexShrink: 0,
transition: 'background 0.2s',
}}
>
{copiedToken ? '✓ Copied' : 'Copy'}
</button>
</div>
{/* Config snippet */}
<label style={{ ...LABEL, marginBottom: 6 }}>
{agentType === 'CURSOR' ? '.cursorrules snippet' : 'CLAUDE.md snippet'}
</label>
<pre style={{
background: '#0A0A0A', color: 'rgba(255,255,255,0.75)',
borderRadius: 10, padding: '14px 16px', fontSize: '0.625rem',
fontFamily: 'monospace', overflowX: 'auto', lineHeight: 1.7,
margin: 0, maxHeight: 260, overflow: 'auto',
whiteSpace: 'pre-wrap', wordBreak: 'break-all',
}}>
{configText}
</pre>
<p style={{ fontSize: '0.75rem', color: 'var(--card-muted)', marginTop: 8, lineHeight: 1.5 }}>
Paste this into your{' '}
{agentType === 'CURSOR' ? (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>.cursorrules</code>
) : (
<code style={{ fontFamily: 'monospace', background: 'rgba(0,0,0,0.06)', padding: '1px 5px', borderRadius: 4 }}>CLAUDE.md</code>
)}{' '}
file to enable the Originmain MCP server in your editor.
</p>
</div>
)}
</div>
{/* Team */}
{isOwner && (
<div style={SECTION}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: 'var(--card-text)', margin: '0 0 6px' }}>
Team
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 20px', lineHeight: 1.6 }}>
Add a team member using their email address. They must sign in with the same address.
</p>
<TeamInviteForm workspaceId={workspaceId} />
</div>
)}
{/* Danger zone */}
{isOwner && (
<div style={{ ...SECTION, borderColor: 'rgba(239,68,68,0.2)', background: 'rgba(239,68,68,0.02)' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, color: '#EF4444', margin: '0 0 8px' }}>
Danger zone
</h2>
<p style={{ fontSize: '0.8125rem', color: 'var(--card-muted)', margin: '0 0 16px', lineHeight: 1.6 }}>
Deleting this workspace is permanent and cannot be undone. All projects and artboards will be lost.
</p>
<button
style={{
fontSize: '0.875rem', fontWeight: 600, padding: '9px 20px', borderRadius: 9,
border: '1px solid rgba(239,68,68,0.35)', background: 'transparent',
color: '#EF4444', cursor: 'pointer',
}}
onClick={async () => {
if (!window.confirm(`Delete workspace "${workspaceName}"? This cannot be undone. All projects and artboards will be permanently lost.`)) return;
try {
const res = await fetch(`/api/workspace/${workspaceId}`, { method: 'DELETE' });
if (!res.ok) {
const body = await res.json().catch(() => ({})) as { error?: string };
throw new Error(body.error ?? `Server error ${res.status}`);
}
router.push('/workspaces');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
window.alert(`Could not delete workspace: ${msg}`);
}
}}
>
Delete workspace
</button>
</div>
)}
</>
);
}