fix(team): remove unsupported Create Team UI, surface real errors (#3723) (#4163)

This commit is contained in:
oxoxDev
2026-06-28 21:23:28 -07:00
committed by GitHub
parent a474beca7c
commit ee68e7800d
19 changed files with 227 additions and 95 deletions
@@ -101,17 +101,15 @@ describe('<TeamPanel />', () => {
await waitFor(() => expect(refreshTeams).toHaveBeenCalled());
});
it('disables the Create button until a name is typed and calls createTeam on submit', async () => {
it('does not render a create-team control and shows the personal-team note', async () => {
const Panel = await importPanel();
renderPanel(Panel);
const createBtn = screen.getByRole('button', { name: 'Create' });
expect(createBtn).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
expect(createBtn).not.toBeDisabled();
fireEvent.click(createBtn);
await waitFor(() => expect(teamApiMock.createTeam).toHaveBeenCalledWith('New Team'));
// Create was removed (issue #3723): backend caps one owned team per user.
expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('Team name')).not.toBeInTheDocument();
expect(screen.getByText(/personal team is created automatically/i)).toBeInTheDocument();
expect(teamApiMock.createTeam).not.toHaveBeenCalled();
});
it('joins a team when the user submits a join code', async () => {
@@ -161,13 +159,13 @@ describe('<TeamPanel />', () => {
expect(navigateToTeamManagement).toHaveBeenCalledWith('team-a');
});
it('surfaces the localized error when createTeam rejects', async () => {
teamApiMock.createTeam.mockRejectedValueOnce(new Error('boom'));
it('falls back to the localized error when joinTeam rejects without a reason', async () => {
teamApiMock.joinTeam.mockRejectedValueOnce(new Error('boom'));
const Panel = await importPanel();
renderPanel(Panel);
fireEvent.change(screen.getByPlaceholderText('Team name'), { target: { value: 'New Team' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => expect(screen.getByText('Failed to create team')).toBeInTheDocument());
fireEvent.change(screen.getByPlaceholderText('Invite code'), { target: { value: 'X' } });
fireEvent.click(screen.getByRole('button', { name: 'Join' }));
await waitFor(() => expect(screen.getByText('boom')).toBeInTheDocument());
});
});
@@ -12,6 +12,7 @@ import Button from '../../ui/Button';
import { SettingsBadge, SettingsSection, SettingsTextField } from '../controls';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';
import SettingsPanel from '../layout/SettingsPanel';
import { teamErrorMessage } from './teamErrorMessage';
const log = debug('core-rpc:error');
@@ -21,10 +22,8 @@ const TeamPanel = () => {
const { snapshot, teams, refresh, refreshTeams } = useCoreState();
const user = snapshot.currentUser;
const [newTeamName, setNewTeamName] = useState('');
const [joinCode, setJoinCode] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const [isJoining, setIsJoining] = useState(false);
const [isSwitching, setIsSwitching] = useState<string | null>(null);
const [isLeaving, setIsLeaving] = useState<string | null>(null);
@@ -60,26 +59,6 @@ const TeamPanel = () => {
});
}, [refreshTeamsWithLoading]);
const handleCreateTeam = async () => {
const name = newTeamName.trim();
if (!name) return;
setIsCreating(true);
setError(null);
try {
await teamApi.createTeam(name);
setNewTeamName('');
await refreshTeamsWithLoading();
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: t('team.failedToCreate')
);
} finally {
setIsCreating(false);
}
};
const handleJoinTeam = async () => {
const code = joinCode.trim();
if (!code) return;
@@ -90,11 +69,7 @@ const TeamPanel = () => {
setJoinCode('');
await Promise.all([refresh(), refreshTeamsWithLoading()]);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: t('team.invalidInviteCode')
);
setError(teamErrorMessage(err, t('team.invalidInviteCode')));
} finally {
setIsJoining(false);
}
@@ -108,11 +83,7 @@ const TeamPanel = () => {
await teamApi.switchTeam(teamId);
await Promise.all([refresh(), refreshTeamsWithLoading()]);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: t('team.failedToSwitch')
);
setError(teamErrorMessage(err, t('team.failedToSwitch')));
} finally {
setIsSwitching(null);
}
@@ -133,11 +104,7 @@ const TeamPanel = () => {
await Promise.all([refresh(), refreshTeamsWithLoading()]);
setTeamToLeave(null);
} catch (err) {
setError(
err && typeof err === 'object' && 'error' in err
? String(err.error)
: t('team.failedToLeave')
);
setError(teamErrorMessage(err, t('team.failedToLeave')));
} finally {
setIsLeaving(null);
}
@@ -260,29 +227,8 @@ const TeamPanel = () => {
</SettingsSection>
)}
<SettingsSection title={t('team.createNewTeam')}>
<div className="flex gap-2 px-4 py-3">
<SettingsTextField
className="flex-1"
value={newTeamName}
onChange={e => setNewTeamName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && void handleCreateTeam()}
placeholder={t('team.teamName')}
aria-label={t('team.teamName')}
inputSize="sm"
/>
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void handleCreateTeam()}
disabled={isCreating || !newTeamName.trim()}>
{isCreating ? t('team.creating') : t('common.create')}
</Button>
</div>
</SettingsSection>
<SettingsSection title={t('team.joinExistingTeam')}>
<p className="px-4 pt-3 text-xs text-content-muted">{t('team.personalAutoCreatedNote')}</p>
<div className="flex gap-2 px-4 py-3">
<SettingsTextField
mono
@@ -25,6 +25,7 @@ vi.mock('../../../../lib/i18n/I18nContext', () => ({
'team.yourTeams': 'Your Teams',
'team.createNewTeam': 'Create New Team',
'team.joinExistingTeam': 'Join Existing Team',
'team.personalAutoCreatedNote': 'Personal team is auto-created. Join below.',
'team.teamName': 'Team Name',
'team.inviteCode': 'Invite Code',
'team.creating': 'Creating...',
@@ -151,37 +152,24 @@ describe('TeamPanel — role badge rendering (line 165)', () => {
});
});
describe('TeamPanel — create team (line 281)', () => {
describe('TeamPanel — create team removed (issue #3723)', () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateTeam.mockResolvedValue({});
setupState({ teams: [] });
});
it('calls createTeam when form is submitted (line 281)', async () => {
it('does not render a create-team control', () => {
render(<TeamPanel />);
const input = screen.getByPlaceholderText('Team Name') ?? screen.getByLabelText('Team Name');
fireEvent.change(input, { target: { value: 'My New Team' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(mockCreateTeam).toHaveBeenCalledWith('My New Team');
});
// The "Create New Team" section is gone — backend caps one owned team per
// user, so the control could never succeed (always 404'd).
expect(screen.queryByRole('button', { name: 'Create' })).not.toBeInTheDocument();
expect(screen.queryByLabelText('Team Name')).not.toBeInTheDocument();
expect(mockCreateTeam).not.toHaveBeenCalled();
});
it('shows an error banner when createTeam fails', async () => {
mockCreateTeam.mockRejectedValue({ error: 'Team limit reached' });
it('renders the personal-team explanatory note', () => {
render(<TeamPanel />);
const input = screen.getByLabelText('Team Name');
fireEvent.change(input, { target: { value: 'New Team' } });
fireEvent.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(screen.getByText('Team limit reached')).toBeInTheDocument();
});
expect(screen.getByText('Personal team is auto-created. Join below.')).toBeInTheDocument();
});
});
@@ -197,7 +185,6 @@ describe('TeamPanel — join team (line 304)', () => {
const input = screen.getByLabelText('Invite Code');
fireEvent.change(input, { target: { value: 'CODE-XYZ' } });
// Need to find the Join button — it's the second submit button (after Create)
const joinBtn = screen.getByRole('button', { name: /^Join$/i });
fireEvent.click(joinBtn);
@@ -218,6 +205,25 @@ describe('TeamPanel — join team (line 304)', () => {
expect(screen.getByText('Code not found')).toBeInTheDocument();
});
});
it('surfaces the real backend reason from a CoreRpcError (issue #3723)', async () => {
// Production rejects with a CoreRpcError — which has no `.error` field, so
// the old `'error' in err` check dropped the reason. Verify it now shows.
mockJoinTeam.mockRejectedValue(
new CoreRpcError(
'POST /teams/join failed (400 Bad Request): {"error":"Invite expired"}',
'unknown'
)
);
render(<TeamPanel />);
fireEvent.change(screen.getByLabelText('Invite Code'), { target: { value: 'OLD-CODE' } });
fireEvent.click(screen.getByRole('button', { name: /^Join$/i }));
await waitFor(() => {
expect(screen.getByText('Invite expired')).toBeInTheDocument();
});
});
});
describe('TeamPanel — leave team modal (line 327)', () => {
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest';
import { CoreRpcError } from '../../../services/coreRpcClient';
import { teamErrorMessage } from './teamErrorMessage';
const FALLBACK = 'Something went wrong';
describe('teamErrorMessage', () => {
it('prefers a structured reason from CoreRpcError.data', () => {
const err = new CoreRpcError('GET /teams failed (403 Forbidden)', 'unknown', undefined, {
message: 'Not a member of this team',
});
expect(teamErrorMessage(err, FALLBACK)).toBe('Not a member of this team');
});
it('lifts the human field out of a JSON body in CoreRpcError.message', () => {
const err = new CoreRpcError(
'POST /teams/join failed (400 Bad Request): {"error":"Invite expired"}',
'unknown'
);
expect(teamErrorMessage(err, FALLBACK)).toBe('Invite expired');
});
it('returns a clean message when there is no RPC prefix', () => {
const err = new CoreRpcError('Session expired. Please log in again.', 'auth_expired');
expect(teamErrorMessage(err, FALLBACK)).toBe('Session expired. Please log in again.');
});
it('falls back when the body is a raw HTML error page', () => {
const err = new CoreRpcError(
'POST /teams failed (404 Not Found): <!DOCTYPE html><html><body>Not Found</body></html>',
'unknown'
);
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
});
it('falls back when a JSON body has no human-readable field', () => {
const err = new CoreRpcError(
'POST /teams/join failed (400 Bad Request): {"code":"E_BAD"}',
'unknown'
);
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
});
it('falls back when nothing remains after the RPC prefix', () => {
const err = new CoreRpcError(
'DELETE /teams/t1 failed (500 Internal Server Error): ',
'unknown'
);
expect(teamErrorMessage(err, FALLBACK)).toBe(FALLBACK);
});
it('handles the legacy plain { error } rejection shape', () => {
expect(teamErrorMessage({ error: 'Team limit reached' }, FALLBACK)).toBe('Team limit reached');
});
it('surfaces a bare Error message', () => {
expect(teamErrorMessage(new Error('boom'), FALLBACK)).toBe('boom');
});
it('falls back for non-object rejections', () => {
expect(teamErrorMessage(null, FALLBACK)).toBe(FALLBACK);
expect(teamErrorMessage(undefined, FALLBACK)).toBe(FALLBACK);
expect(teamErrorMessage('nope', FALLBACK)).toBe(FALLBACK);
});
it('caps an overly long reason', () => {
const long = 'x'.repeat(500);
const out = teamErrorMessage({ error: long }, FALLBACK);
expect(out.length).toBeLessThanOrEqual(200);
expect(out.endsWith('…')).toBe(true);
});
});
@@ -0,0 +1,81 @@
import { CoreRpcError } from '../../../services/coreRpcClient';
/**
* Extract a human-readable failure reason from a team RPC rejection.
*
* Team ops call the hosted backend through `callCoreRpc`, which rejects with a
* {@link CoreRpcError}. The real reason lives in either `err.data` (structured
* JSON-RPC `error.data`) or `err.message` — the latter shaped by the Rust
* `flatten_authed_error` net as `"POST /teams/join failed (400 Bad Request):
* <body>"`. The previous TeamPanel catch checked `'error' in err`, which never
* matched a `CoreRpcError` (it has no `.error` field), so every failure fell
* back to a generic banner and the backend reason was dropped (issue #3723).
*
* This helper surfaces the backend reason when one is recoverable and falls
* back to the caller-supplied localized string otherwise. It also handles the
* legacy plain `{ error }` / `{ message }` rejection shape so existing call
* sites keep working.
*/
const RPC_PREFIX = /^(?:GET|POST|PUT|DELETE|PATCH)\s+\S+\s+failed\s*\([^)]*\):?\s*/i;
const MAX_LEN = 200;
function cap(value: string): string {
const oneLine = value.replace(/\s+/g, ' ').trim();
return oneLine.length > MAX_LEN ? `${oneLine.slice(0, MAX_LEN - 1)}` : oneLine;
}
function firstString(obj: Record<string, unknown>, keys: string[]): string | null {
for (const key of keys) {
const value = obj[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return null;
}
/** Pull a human field out of a structured error body / `error.data`. */
function reasonFromData(data: unknown): string | null {
if (data && typeof data === 'object') {
const reason = firstString(data as Record<string, unknown>, ['message', 'error', 'detail']);
if (reason) return cap(reason);
}
return null;
}
/**
* Strip the `"<VERB> <path> failed (<status>):"` prefix the core prepends and
* recover the meaningful tail. Returns `null` when nothing presentable remains
* (empty body, or a raw HTML error page that must never reach the user).
*/
function cleanRpcMessage(message: string): string | null {
const body = message.replace(RPC_PREFIX, '').trim();
if (!body) return null;
// Never surface a raw HTML 404/error page (the POST /teams 404 case).
if (/^<(?:!doctype|html)/i.test(body)) return null;
// Backend errors arrive as a JSON body — lift the human field out of it.
if (body.startsWith('{')) {
try {
const fromJson = reasonFromData(JSON.parse(body));
// Structured but no human field → let the caller fall back.
return fromJson;
} catch {
// Not valid JSON — fall through and surface the raw tail.
}
}
return cap(body);
}
export function teamErrorMessage(err: unknown, fallback: string): string {
if (err instanceof CoreRpcError) {
return reasonFromData(err.data) ?? cleanRpcMessage(err.message) ?? fallback;
}
if (err && typeof err === 'object') {
const reason = firstString(err as Record<string, unknown>, ['error', 'detail']);
if (reason) return cap(reason);
const message = (err as Record<string, unknown>).message;
if (typeof message === 'string' && message.trim()) {
return cleanRpcMessage(message) ?? cap(message);
}
}
return fallback;
}
+2
View File
@@ -1767,6 +1767,8 @@ const messages: TranslationMap = {
'team.teamName': 'اسم الفريق',
'team.creating': 'جارٍ الإنشاء...',
'team.joinExistingTeam': 'الانضمام إلى فريق موجود',
'team.personalAutoCreatedNote':
'يتم إنشاء فريقك الشخصي تلقائيًا. للتعاون، انضم إلى فريق موجود باستخدام رمز دعوة أدناه.',
'team.inviteCode': 'رمز الدعوة',
'team.joining': 'جارٍ الانضمام...',
'team.join': 'انضمام',
+2
View File
@@ -1805,6 +1805,8 @@ const messages: TranslationMap = {
'team.teamName': 'টিমের নাম',
'team.creating': 'তৈরি হচ্ছে...',
'team.joinExistingTeam': 'বিদ্যমান টিমে যোগ দিন',
'team.personalAutoCreatedNote':
'আপনার ব্যক্তিগত টিম স্বয়ংক্রিয়ভাবে তৈরি হয়। সহযোগিতার জন্য, নিচে একটি আমন্ত্রণ কোড দিয়ে বিদ্যমান টিমে যোগ দিন।',
'team.inviteCode': 'আমন্ত্রণ কোড',
'team.joining': 'যোগ দেওয়া হচ্ছে...',
'team.join': 'যোগ দিন',
+2
View File
@@ -1852,6 +1852,8 @@ const messages: TranslationMap = {
'team.teamName': 'Teamname',
'team.creating': 'Erstellen...',
'team.joinExistingTeam': 'Tritt einem bestehenden Team bei',
'team.personalAutoCreatedNote':
'Dein persönliches Team wird automatisch erstellt. Um zusammenzuarbeiten, tritt unten mit einem Einladungscode einem bestehenden Team bei.',
'team.inviteCode': 'Einladungscode',
'team.joining': 'Beitritt...',
'team.join': 'Mach mit',
+2
View File
@@ -2170,6 +2170,8 @@ const en: TranslationMap = {
'team.teamName': 'Team name',
'team.creating': 'Creating...',
'team.joinExistingTeam': 'Join Existing Team',
'team.personalAutoCreatedNote':
'Your personal team is created automatically. To collaborate, join an existing team with an invite code below.',
'team.inviteCode': 'Invite code',
'team.joining': 'Joining...',
'team.join': 'Join',
+2
View File
@@ -1840,6 +1840,8 @@ const messages: TranslationMap = {
'team.teamName': 'Nombre del equipo',
'team.creating': 'Creando...',
'team.joinExistingTeam': 'Unirse a un equipo existente',
'team.personalAutoCreatedNote':
'Tu equipo personal se crea automáticamente. Para colaborar, únete a un equipo existente con un código de invitación a continuación.',
'team.inviteCode': 'Código de invitación',
'team.joining': 'Uniéndose...',
'team.join': 'Unirse',
+2
View File
@@ -1852,6 +1852,8 @@ const messages: TranslationMap = {
'team.teamName': "Nom de l'équipe",
'team.creating': 'Création…',
'team.joinExistingTeam': 'Rejoindre une équipe existante',
'team.personalAutoCreatedNote':
'Votre équipe personnelle est créée automatiquement. Pour collaborer, rejoignez une équipe existante avec un code dinvitation ci-dessous.',
'team.inviteCode': "Code d'invitation",
'team.joining': 'Adhésion…',
'team.join': 'Rejoindre',
+2
View File
@@ -1803,6 +1803,8 @@ const messages: TranslationMap = {
'team.teamName': 'टीम का नाम',
'team.creating': 'बन रही है...',
'team.joinExistingTeam': 'मौजूदा टीम जॉइन करें',
'team.personalAutoCreatedNote':
'आपकी व्यक्तिगत टीम स्वतः बन जाती है। सहयोग के लिए, नीचे आमंत्रण कोड के साथ किसी मौजूदा टीम में शामिल हों।',
'team.inviteCode': 'इनवाइट कोड',
'team.joining': 'जॉइन हो रहे हैं...',
'team.join': 'जॉइन करें',
+2
View File
@@ -1812,6 +1812,8 @@ const messages: TranslationMap = {
'team.teamName': 'Nama tim',
'team.creating': 'Membuat...',
'team.joinExistingTeam': 'Bergabung dengan Tim yang Ada',
'team.personalAutoCreatedNote':
'Tim pribadi Anda dibuat secara otomatis. Untuk berkolaborasi, gabung ke tim yang sudah ada dengan kode undangan di bawah.',
'team.inviteCode': 'Kode undangan',
'team.joining': 'Bergabung...',
'team.join': 'Bergabung',
+2
View File
@@ -1837,6 +1837,8 @@ const messages: TranslationMap = {
'team.teamName': 'Nome team',
'team.creating': 'Creazione...',
'team.joinExistingTeam': 'Unisciti a un team esistente',
'team.personalAutoCreatedNote':
'Il tuo team personale viene creato automaticamente. Per collaborare, unisciti a un team esistente con un codice di invito qui sotto.',
'team.inviteCode': 'Codice invito',
'team.joining': 'Adesione...',
'team.join': 'Unisciti',
+2
View File
@@ -1787,6 +1787,8 @@ const messages: TranslationMap = {
'team.teamName': '팀 이름',
'team.creating': '생성 중...',
'team.joinExistingTeam': '기존 팀 참가',
'team.personalAutoCreatedNote':
'개인 팀은 자동으로 생성됩니다. 협업하려면 아래 초대 코드로 기존 팀에 참여하세요.',
'team.inviteCode': '초대 코드',
'team.joining': '참가 중...',
'team.join': '참가',
+2
View File
@@ -1825,6 +1825,8 @@ const messages: TranslationMap = {
'team.teamName': 'Nazwa zespołu',
'team.creating': 'Tworzenie...',
'team.joinExistingTeam': 'Dołącz do istniejącego zespołu',
'team.personalAutoCreatedNote':
'Twój zespół osobisty jest tworzony automatycznie. Aby współpracować, dołącz do istniejącego zespołu, używając kodu zaproszenia poniżej.',
'team.inviteCode': 'Kod zaproszenia',
'team.joining': 'Dołączanie...',
'team.join': 'Dołącz',
+2
View File
@@ -1844,6 +1844,8 @@ const messages: TranslationMap = {
'team.teamName': 'Nome da equipe',
'team.creating': 'Criando...',
'team.joinExistingTeam': 'Entrar em Equipe Existente',
'team.personalAutoCreatedNote':
'Sua equipe pessoal é criada automaticamente. Para colaborar, entre em uma equipe existente com um código de convite abaixo.',
'team.inviteCode': 'Código de convite',
'team.joining': 'Entrando...',
'team.join': 'Entrar',
+2
View File
@@ -1820,6 +1820,8 @@ const messages: TranslationMap = {
'team.teamName': 'Название команды',
'team.creating': 'Создание...',
'team.joinExistingTeam': 'Вступить в существующую команду',
'team.personalAutoCreatedNote':
'Ваша личная команда создаётся автоматически. Чтобы работать вместе, присоединитесь к существующей команде по коду приглашения ниже.',
'team.inviteCode': 'Код приглашения',
'team.joining': 'Вход...',
'team.join': 'Вступить',
+2
View File
@@ -1709,6 +1709,8 @@ const messages: TranslationMap = {
'team.teamName': '团队名称',
'team.creating': '创建中...',
'team.joinExistingTeam': '加入已有团队',
'team.personalAutoCreatedNote':
'您的个人团队会自动创建。如需协作,请使用下方的邀请码加入现有团队。',
'team.inviteCode': '邀请码',
'team.joining': '加入中...',
'team.join': '加入',