Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified frontend/bun.lockb
Binary file not shown.
158 changes: 131 additions & 27 deletions frontend/src/routes/_authenticated/admin/integrations/microsoft.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,24 +42,25 @@ const submitOAuthForm = async (
navigate: UseNavigateResult<string>,
userRole: UserRole,
) => {
// Role-based API routing
const isAdmin =
userRole === UserRole.Admin || userRole === UserRole.SuperAdmin
// Map authType to isServiceAuth boolean
const isServiceAuth = value.authType === "appOnly"

const response = isAdmin
? await api.admin.oauth.create.$post({
console.log(isServiceAuth)

const response = isServiceAuth
? await api.admin.microsoft.service_account.$post({
form: {
clientId: value.clientId,
clientSecret: value.clientSecret,
scopes: value.scopes,
tenantId: value.tenantId,
app: Apps.MicrosoftDrive,
},
})
: await api.oauth.create.$post({
form: {
clientId: value.clientId,
clientSecret: value.clientSecret,
scopes: value.scopes,
scopes: [value.scopes, ""],
app: Apps.MicrosoftDrive,
},
})
Expand All @@ -81,28 +82,51 @@ const submitOAuthForm = async (
type OAuthFormData = {
clientId: string
clientSecret: string
scopes: string[]
scopes: string
tenantId: string
authType: "delegated" | "appOnly"
}

export const OAuthForm = ({
onSuccess,
userRole,
}: { onSuccess: any; userRole: UserRole }) => {
setOAuthIntegrationStatus,
}: {
onSuccess: any
userRole: UserRole
setOAuthIntegrationStatus: (status: OAuthIntegrationStatus) => void
}) => {
const { toast } = useToast()
const navigate = useNavigate()
const form = useForm<OAuthFormData>({
defaultValues: {
clientId: "",
clientSecret: "",
scopes: [],
tenantId: "",
scopes: "https://graph.microsoft.com/.default",
authType: "delegated",
},
onSubmit: async ({ value }) => {
try {
await submitOAuthForm(value, navigate, userRole)
toast({
title: "Microsoft OAuth integration added",
description: "Perform OAuth to add the data",
})

// Handle different flows based on authentication type
if (value.authType === "appOnly") {
// For app-only (service account), skip OAuth redirect and directly start connecting
toast({
title: "Microsoft service account integration created",
description: "Starting data ingestion...",
})
setOAuthIntegrationStatus(OAuthIntegrationStatus.OAuthConnecting)
} else {
// For delegated, show OAuth message and wait for OAuth redirect
toast({
title: "Microsoft OAuth integration added",
description: "Perform OAuth to add the data",
})
setOAuthIntegrationStatus(OAuthIntegrationStatus.OAuth)
}

onSuccess()
} catch (error) {
toast({
Expand Down Expand Up @@ -172,27 +196,106 @@ export const OAuthForm = ({
<Label htmlFor="scopes">scopes</Label>
<form.Field
name="scopes"
validators={{
onChange: ({ value }) => (!value ? "scopes are required" : undefined),
}}
children={(field) => (
<>
<Input
id="scopes"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value.split(","))}
placeholder="Enter OAuth scopes"
disabled={true}
placeholder="https://graph.microsoft.com/.default"
className="bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400"
/>
{field.state.meta.isTouched && field.state.meta.errors.length ? (
<p className="text-red-600 dark:text-red-400 text-sm">
{field.state.meta.errors.join(", ")}
</p>
) : null}
</>
)}
/>

{/* Only show Authentication Type selection for Admin/SuperAdmin users */}
{(userRole === UserRole.Admin || userRole === UserRole.SuperAdmin) && (
<>
<Label className="mt-2">Authentication Type</Label>
<form.Field
name="authType"
children={(field) => (
<div className="flex items-center space-x-8 py-3 px-2">
<div className="flex items-center space-x-3">
<input
type="radio"
id="delegated"
name="authType"
value="delegated"
checked={field.state.value === "delegated"}
onChange={(e) =>
field.handleChange(
e.target.value as "delegated" | "appOnly",
)
}
className="h-4 w-4"
/>
<Label htmlFor="delegated" className="text-sm font-normal">
Delegated
</Label>
</div>
<div className="flex items-center space-x-3">
<input
type="radio"
id="appOnly"
name="authType"
value="appOnly"
checked={field.state.value === "appOnly"}
onChange={(e) =>
field.handleChange(
e.target.value as "delegated" | "appOnly",
)
}
className="h-4 w-4"
/>
<Label htmlFor="appOnly" className="text-sm font-normal">
App-only
</Label>
</div>
</div>
)}
/>

{/* Only show Tenant ID when App-only is selected */}
<form.Field
name="authType"
children={(authTypeField) =>
authTypeField.state.value === "appOnly" && (
<>
<Label htmlFor="tenantId">Tenant ID</Label>
<form.Field
name="tenantId"
validators={{
onChange: ({ value }) =>
!value ? "Tenant ID is required" : undefined,
}}
children={(field) => (
<>
<Input
id="tenantId"
type="text"
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
placeholder="Enter tenant ID"
/>
{field.state.meta.isTouched &&
field.state.meta.errors.length ? (
<p className="text-red-600 dark:text-red-400 text-sm">
{field.state.meta.errors.join(", ")}
</p>
) : null}
</>
)}
/>
</>
)
}
/>
</>
)}

<Button type="submit">Create Integration</Button>
</form>
)
Expand Down Expand Up @@ -342,10 +445,11 @@ export const MicrosoftOAuthTab = ({
if (oauthIntegrationStatus === OAuthIntegrationStatus.Provider) {
return (
<OAuthForm
onSuccess={() =>
setOAuthIntegrationStatus(OAuthIntegrationStatus.OAuth)
}
onSuccess={() => {
// This will be overridden by the form's own logic based on authType
}}
userRole={userRole}
setOAuthIntegrationStatus={setOAuthIntegrationStatus}
/>
)
}
Expand Down
116 changes: 114 additions & 2 deletions server/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import {
MCPConnectorMode,
MCPClientConfig,
Subsystem,
type microsoftService,
type MicrosoftServiceCredentials, // Added for tool status updates
updateToolsStatusSchema, // Added for tool status updates
type userRoleChange,
} from "@/types"
Expand All @@ -74,8 +76,18 @@ import {
Slack,
MicrosoftEntraId,
} from "arctic"
import type { SelectOAuthProvider, SelectUser } from "@/db/schema"
import { users, chats, messages, agents } from "@/db/schema" // Add database schema imports
import type {
SelectConnector,
SelectOAuthProvider,
SelectUser,
} from "@/db/schema"
import {
users,
chats,
messages,
agents,
selectConnectorSchema,
} from "@/db/schema" // Add database schema imports
import {
getErrorMessage,
IsGoogleApp,
Expand Down Expand Up @@ -117,6 +129,12 @@ import {
import { zValidator } from "@hono/zod-validator"
import { handleSlackChanges } from "@/integrations/slack/sync"
import { getAgentByExternalIdWithPermissionCheck } from "@/db/agent"
import { todo } from "node:test"
import { ClientSecretCredential } from "@azure/identity"
import { Client as GraphClient } from "@microsoft/microsoft-graph-client"
import type { AuthenticationProvider } from "@microsoft/microsoft-graph-client"
import { handleMicrosoftServiceAccountIngestion } from "@/integrations/microsoft"
import { CustomServiceAuthProvider } from "@/integrations/microsoft/utils"
import { KbItemsSchema, type VespaSchema } from "@xyne/vespa-ts"
import { GetDocument } from "@/search/vespa"
import { getCollectionFilesVespaIds } from "@/db/knowledgeBase"
Expand Down Expand Up @@ -444,6 +462,100 @@ export const CreateOAuthProvider = async (c: Context) => {
})
}

export const AddServiceConnectionMicrosoft = async (c: Context) => {
const { sub, workspaceId } = c.get(JwtPayloadKey)
loggerWithChild({ email: sub }).info("AddServiceConnectionMicrosoft")
const email = sub
const userRes = await getUserByEmail(db, email)
if (!userRes || !userRes.length) {
throw new NoUserFound({})
}
const [user] = userRes
// @ts-ignore
const form: microsoftService = c.req.valid("form")

let { clientId, clientSecret, tenantId } = form
let scopes = ["https://graph.microsoft.com/.default"]
const app = Apps.MicrosoftSharepoint

if (!clientId || !clientSecret || !tenantId) {
throw new HTTPException(400, {
message: "Client ID, Client Secret, and Tenant ID are required",
})
}

try {
const authProvider = new CustomServiceAuthProvider(
tenantId,
clientId,
clientSecret,
)

const accessToken = await authProvider.getAccessTokenWithExpiry()
const expiresAt = new Date(accessToken.expiresOnTimestamp)

const credentialsData: MicrosoftServiceCredentials = {
tenantId,
clientId,
clientSecret,
scopes,
access_token: accessToken.token,
expires_at: expiresAt.toISOString(),
}

const res = await insertConnector(
db,
user.workspaceId,
user.id,
user.workspaceExternalId,
`${app}-${ConnectorType.SaaS}-${AuthType.ServiceAccount}`,
ConnectorType.SaaS,
AuthType.ServiceAccount,
app,
{},
JSON.stringify(credentialsData), // Store the validated credentials
email, // Use current user's email as subject email
null,
null,
ConnectorStatus.Connected, // Set as connected since we validated the connection
)

const connector = selectConnectorSchema.parse(res)

if (!connector) {
throw new ConnectorNotCreated({})
}
handleMicrosoftServiceAccountIngestion(email, connector)

loggerWithChild({ email: sub }).info(
`Microsoft service account connector created with ID: ${connector.externalId}`,
)

return c.json({
success: true,
message: "connection created and job enqueued",
id: connector.externalId,
expiresAt: expiresAt.toISOString(),
})
} catch (error) {
const errMessage = getErrorMessage(error)
loggerWithChild({ email: email }).error(
error,
`${new AddServiceConnectionError({
cause: error as Error,
})} \n : ${errMessage} : ${(error as Error).stack}`,
)

if (error instanceof HTTPException) {
throw error
}

throw new HTTPException(500, {
message: "Error creating Microsoft service account connection",
})
}
}

export const AddServiceConnection = async (c: Context) => {
const { sub, workspaceId } = c.get(JwtPayloadKey)
loggerWithChild({ email: sub }).info("AddServiceConnection")
Expand Down
Binary file modified server/bun.lockb
Binary file not shown.
Loading