fix(tenancy): close isolation and permission gaps

This commit is contained in:
Junyan Qin
2026-07-20 04:02:24 +08:00
parent 64dc887b20
commit ac72563664
30 changed files with 3294 additions and 1764 deletions
+6 -6
View File
@@ -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>
+5 -1
View File
@@ -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"
+1 -1
View File
@@ -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> {
+55 -14
View File
@@ -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',
);
}
}
+35 -8
View File
@@ -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'));