mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-21 20:06:06 +00:00
fix(tenancy): close isolation and permission gaps
This commit is contained in:
@@ -38,8 +38,8 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canManage =
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canViewAudit =
|
||||
currentWorkspace?.permissions.includes('audit.view') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -210,13 +210,13 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
@@ -303,7 +303,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Logs */}
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
@@ -313,7 +313,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
{/* Tab: Sessions */}
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
|
||||
@@ -23,9 +23,13 @@ import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { buildConversationTurns } from './utils/conversationTurns';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
import { useCurrentWorkspace } from '@/app/infra/http';
|
||||
|
||||
function MonitoringPageContent() {
|
||||
const { t } = useTranslation();
|
||||
const currentWorkspace = useCurrentWorkspace();
|
||||
const canExport =
|
||||
currentWorkspace?.permissions.includes('data.export') ?? false;
|
||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
@@ -154,7 +158,7 @@ function MonitoringPageContent() {
|
||||
onTimeRangeChange={setTimeRange}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<ExportDropdown filterState={filterState} />
|
||||
{canExport && <ExportDropdown filterState={filterState} />}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -19,8 +19,8 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
currentWorkspace?.permissions.includes('resource.manage') ?? false;
|
||||
const canOperate =
|
||||
currentWorkspace?.permissions.includes('runtime.operate') ?? false;
|
||||
const canViewAudit =
|
||||
currentWorkspace?.permissions.includes('audit.view') ?? false;
|
||||
const canViewMonitoring =
|
||||
currentWorkspace?.permissions.includes('resource.view') ?? false;
|
||||
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
@@ -129,7 +129,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsTrigger value="monitoring" className="gap-1.5">
|
||||
<BarChart3 className="size-3.5" />
|
||||
{t('pipelines.monitoring.title')}
|
||||
@@ -170,7 +170,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
)}
|
||||
|
||||
{/* Tab: Monitoring */}
|
||||
{canViewAudit && (
|
||||
{canViewMonitoring && (
|
||||
<TabsContent
|
||||
value="monitoring"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
|
||||
@@ -1165,7 +1165,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
}
|
||||
|
||||
public getWorkspaces(): Promise<{ workspaces: Workspace[] }> {
|
||||
return this.get('/api/v1/workspaces');
|
||||
return this.get('/api/v1/workspaces', undefined, { skipWorkspace: true });
|
||||
}
|
||||
|
||||
public getCurrentWorkspace(): Promise<CurrentWorkspace> {
|
||||
|
||||
@@ -35,6 +35,34 @@ type InvitationView = {
|
||||
workspace: Workspace;
|
||||
};
|
||||
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
function invitationErrorKey(
|
||||
code: string | null | undefined,
|
||||
fallback: 'workspace.invitationInvalid' | 'workspace.invitationAcceptFailed',
|
||||
) {
|
||||
switch (code) {
|
||||
case 'invitation_invalid':
|
||||
return 'workspace.invitationInvalid';
|
||||
case 'invitation_expired':
|
||||
return 'workspace.invitationExpired';
|
||||
case 'invitation_revoked':
|
||||
return 'workspace.invitationAlreadyRevoked';
|
||||
case 'invitation_used':
|
||||
return 'workspace.invitationAlreadyUsed';
|
||||
case 'invitation_email_mismatch':
|
||||
return 'workspace.invitationEmailMismatch';
|
||||
default:
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function captureInvitationTokenFromFragment(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const fragment = new URLSearchParams(window.location.hash.slice(1));
|
||||
@@ -76,6 +104,9 @@ export default function AcceptInvitationPage() {
|
||||
setView(null);
|
||||
setErrorMessage('');
|
||||
const invitationToken = captureInvitationTokenFromFragment();
|
||||
const deferredErrorCode = new URLSearchParams(window.location.search).get(
|
||||
'error',
|
||||
);
|
||||
setToken(invitationToken);
|
||||
if (!invitationToken) {
|
||||
setErrorMessage(t('workspace.invitationMissing'));
|
||||
@@ -89,20 +120,26 @@ export default function AcceptInvitationPage() {
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setView(response);
|
||||
setStatus('ready');
|
||||
if (deferredErrorCode) {
|
||||
setErrorMessage(
|
||||
t(
|
||||
invitationErrorKey(
|
||||
deferredErrorCode,
|
||||
'workspace.invitationAcceptFailed',
|
||||
),
|
||||
),
|
||||
);
|
||||
setStatus('error');
|
||||
} else {
|
||||
setStatus('ready');
|
||||
}
|
||||
})
|
||||
.catch((error: { code?: string; msg?: string }) => {
|
||||
if (cancelled) return;
|
||||
clearPendingInvitationToken();
|
||||
const key =
|
||||
error.code === 'invitation_expired'
|
||||
? 'workspace.invitationExpired'
|
||||
: error.code === 'invitation_revoked'
|
||||
? 'workspace.invitationAlreadyRevoked'
|
||||
: error.code === 'invitation_used'
|
||||
? 'workspace.invitationAlreadyUsed'
|
||||
: 'workspace.invitationInvalid';
|
||||
setErrorMessage(t(key));
|
||||
setErrorMessage(
|
||||
t(invitationErrorKey(error.code, 'workspace.invitationInvalid')),
|
||||
);
|
||||
setStatus('error');
|
||||
});
|
||||
|
||||
@@ -144,11 +181,15 @@ export default function AcceptInvitationPage() {
|
||||
setErrorMessage(t('workspace.existingAccountLoginRequired'));
|
||||
return;
|
||||
}
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
apiError.code === 'invitation_email_mismatch'
|
||||
? t('workspace.invitationEmailMismatch')
|
||||
: t('workspace.invitationAcceptFailed'),
|
||||
t(
|
||||
invitationErrorKey(apiError.code, 'workspace.invitationAcceptFailed'),
|
||||
),
|
||||
);
|
||||
setStatus(
|
||||
apiError.code && TERMINAL_INVITATION_ERROR_CODES.has(apiError.code)
|
||||
? 'error'
|
||||
: 'ready',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,14 @@ const formSchema = (t: (key: string) => string) =>
|
||||
password: z.string().min(1, t('common.emptyPassword')),
|
||||
});
|
||||
|
||||
const TERMINAL_INVITATION_ERROR_CODES = new Set([
|
||||
'invitation_invalid',
|
||||
'invitation_expired',
|
||||
'invitation_revoked',
|
||||
'invitation_used',
|
||||
'invitation_email_mismatch',
|
||||
]);
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
@@ -121,7 +129,10 @@ export default function Login() {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async function finishLogin(token: string, username?: string) {
|
||||
async function finishLogin(
|
||||
token: string,
|
||||
username?: string,
|
||||
): Promise<boolean> {
|
||||
beginAuthenticatedSession(token, username);
|
||||
|
||||
const invitationToken = getPendingInvitationToken();
|
||||
@@ -133,10 +144,24 @@ export default function Login() {
|
||||
beginAuthenticatedSession(response.token, username);
|
||||
preferredWorkspaceUuid = response.workspace_uuid;
|
||||
clearPendingInvitationToken();
|
||||
} catch {
|
||||
navigate('/invitations/accept', { replace: true });
|
||||
toast.error(t('workspace.invitationAcceptFailed'));
|
||||
return;
|
||||
} catch (error) {
|
||||
const apiError = error as { code?: string };
|
||||
const errorCode =
|
||||
typeof apiError.code === 'string'
|
||||
? apiError.code
|
||||
: 'invitation_accept_failed';
|
||||
const invitationPath = TERMINAL_INVITATION_ERROR_CODES.has(errorCode)
|
||||
? `/invitations/accept?error=${encodeURIComponent(errorCode)}`
|
||||
: '/invitations/accept';
|
||||
navigate(invitationPath, { replace: true });
|
||||
toast.error(
|
||||
t(
|
||||
errorCode === 'invitation_email_mismatch'
|
||||
? 'workspace.invitationEmailMismatch'
|
||||
: 'workspace.invitationAcceptFailed',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,12 +170,13 @@ export default function Login() {
|
||||
});
|
||||
if (result.status === 'selection-required') {
|
||||
navigate('/workspaces/select?returnTo=%2Fhome', { replace: true });
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
if (result.status === 'unavailable') {
|
||||
throw new Error('No Workspace is available for this Account');
|
||||
}
|
||||
navigate('/home');
|
||||
return true;
|
||||
}
|
||||
|
||||
function onSubmit(values: z.infer<ReturnType<typeof formSchema>>) {
|
||||
@@ -161,8 +187,9 @@ export default function Login() {
|
||||
httpClient
|
||||
.authUser(username, password)
|
||||
.then(async (res) => {
|
||||
await finishLogin(res.token, username);
|
||||
toast.success(t('common.loginSuccess'));
|
||||
if (await finishLogin(res.token, username)) {
|
||||
toast.success(t('common.loginSuccess'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.loginFailed'));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, Page, test } from '@playwright/test';
|
||||
|
||||
import { installLangBotApiMocks } from './fixtures/langbot-api';
|
||||
import {
|
||||
installLangBotApiMocks,
|
||||
makeWorkspaceEntry,
|
||||
} from './fixtures/langbot-api';
|
||||
|
||||
async function save(page: Page) {
|
||||
const button = page.getByRole('button', { name: /^Save$/ });
|
||||
@@ -20,6 +23,61 @@ async function confirmDelete(page: Page) {
|
||||
}
|
||||
|
||||
test.describe('frontend CRUD smoke flows', () => {
|
||||
test('viewer keeps ordinary bot and pipeline monitoring access', async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = makeWorkspaceEntry(
|
||||
'workspace-viewer',
|
||||
'Viewer Workspace',
|
||||
'local',
|
||||
);
|
||||
await installLangBotApiMocks(page, {
|
||||
authenticated: true,
|
||||
workspaces: [workspace],
|
||||
});
|
||||
|
||||
await page.goto('/home/bots?id=new');
|
||||
await page.locator('input[name="name"]').fill('Viewer Test Bot');
|
||||
await page
|
||||
.locator('input[name="description"]')
|
||||
.fill('Proves monitoring is ordinary resource visibility.');
|
||||
await page.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/bots\?id=bot-1$/);
|
||||
|
||||
await page.goto('/home/pipelines?id=new');
|
||||
await page.locator('input[name="basic.name"]').fill('Viewer Pipeline');
|
||||
await page
|
||||
.locator('input[name="basic.description"]')
|
||||
.fill('Viewer monitoring permission regression.');
|
||||
await submit(page);
|
||||
await expect(page).toHaveURL(/\/home\/pipelines\?id=pipeline-1$/);
|
||||
|
||||
workspace.membership.role = 'viewer';
|
||||
workspace.permissions = ['member.view', 'resource.view', 'workspace.view'];
|
||||
|
||||
await page.goto('/home/bots?id=bot-1');
|
||||
await expect(page.getByRole('tab', { name: 'Logs' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Sessions' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
await page.getByRole('tab', { name: 'Logs' }).click();
|
||||
await expect(page.getByText('No logs yet')).toBeVisible();
|
||||
|
||||
await page.goto('/home/pipelines?id=pipeline-1');
|
||||
await expect(page.getByRole('tab', { name: 'Dashboard' })).toBeVisible();
|
||||
await expect(page.getByRole('tab', { name: 'Debug Chat' })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
|
||||
|
||||
await page.goto('/home/monitoring');
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Refresh Data' }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Export Data' })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test('creates, edits, and deletes a bot', async ({ page }) => {
|
||||
await installLangBotApiMocks(page, { authenticated: true });
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ export function makeWorkspaceEntry(
|
||||
permissions: [
|
||||
'api_key.manage',
|
||||
'audit.view',
|
||||
'data.export',
|
||||
'member.invite',
|
||||
'member.remove',
|
||||
'member.update_role',
|
||||
@@ -575,7 +576,7 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
|
||||
|
||||
const botLogsMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)\/logs$/);
|
||||
if (botLogsMatch) {
|
||||
return fulfillJson(route, { logs: [], total: 0 });
|
||||
return fulfillJson(route, { logs: [], total_count: 0 });
|
||||
}
|
||||
|
||||
const botMatch = path.match(/^\/api\/v1\/platform\/bots\/([^/]+)$/);
|
||||
|
||||
@@ -53,3 +53,130 @@ test('terminal invitation errors refresh on a new fragment and allow account swi
|
||||
})),
|
||||
).toEqual({ token: null, userEmail: null });
|
||||
});
|
||||
|
||||
test('login preserves an explicit invitation email mismatch error', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: false });
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'mismatch-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 400,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'invitation_email_mismatch',
|
||||
msg: 'Invitation email does not match the Account',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=mismatch-invitation');
|
||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
||||
await page.getByPlaceholder('Enter email address').fill('other@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(
|
||||
/\/invitations\/accept\?error=invitation_email_mismatch$/,
|
||||
);
|
||||
await expect(
|
||||
page.getByText('This invitation belongs to a different email address.'),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Login successful')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('a temporary accept failure keeps the authenticated invitation retryable', async ({
|
||||
page,
|
||||
}) => {
|
||||
await installLangBotApiMocks(page, { authenticated: false });
|
||||
await page.route('**/api/v1/invitations/inspect', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
invitation: {
|
||||
uuid: 'retryable-invitation',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
normalized_email: 'invited@example.com',
|
||||
role: 'viewer',
|
||||
status: 'pending',
|
||||
},
|
||||
workspace: {
|
||||
uuid: 'workspace-playwright',
|
||||
name: 'Playwright Workspace',
|
||||
},
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
let acceptAttempts = 0;
|
||||
await page.route('**/api/v1/invitations/accept', async (route) => {
|
||||
acceptAttempts += 1;
|
||||
if (acceptAttempts === 1) {
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 'temporary_failure',
|
||||
msg: 'Please retry',
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 0,
|
||||
data: {
|
||||
token: 'accepted-token',
|
||||
workspace_uuid: 'workspace-playwright',
|
||||
},
|
||||
msg: 'ok',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto('/invitations/accept#token=retryable-invitation');
|
||||
await page.getByRole('button', { name: 'I already have an account' }).click();
|
||||
await page
|
||||
.getByPlaceholder('Enter email address')
|
||||
.fill('invited@example.com');
|
||||
await page.getByPlaceholder('Enter password').fill('password');
|
||||
await page.getByRole('button', { name: 'Login with password' }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/invitations\/accept$/);
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Accept with current account' }),
|
||||
).toBeVisible();
|
||||
await page
|
||||
.getByRole('button', { name: 'Accept with current account' })
|
||||
.click();
|
||||
await expect(page).toHaveURL(/\/home(?:\/monitoring)?$/);
|
||||
expect(acceptAttempts).toBe(2);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user