@extends('layouts.app') @section('header')

Microsoft integration — {{ $company->name }}

This company's own Entra tenant. Nothing here is shared with any other company.
@if ($company->ms_verified_at) Credentials OK · {{ $company->ms_verified_at->diffForHumans() }} @elseif ($configured) Not tested yet @else Incomplete @endif
@endsection @section('content') @foreach (['success' => true, 'warning' => false] as $key => $good) @if (session($key))
{{ session($key) }}
@endif @endforeach {{-- Step-by-step result of the last ping, held in the flash so it survives the redirect back. Absent on a normal page load, which is why the card is not rendered as an empty shell. --}} @if ($ping = session('msPing'))

Last ping

{{ number_format($ping['ms']) }}ms
@endif
@csrf @method('PUT')

App registration

@error('ms_client_id')
{{ $message }}
@enderror
The company's own tenant GUID, or common for a multi-tenant app. @error('ms_tenant_id')
{{ $message }}
@enderror
The secret value, not the secret ID. Encrypted at rest. Leaving this blank keeps the one already stored. @error('ms_client_secret')
{{ $message }}
@enderror
The account Teams messages appear to come from. Optional — the ping checks it resolves in the directory when it is set. @error('ms_bot_email')
{{ $message }}
@enderror
Must match a URI registered on the app registration exactly, host and spelling included. Either of these is handled here: {{ route('microsoft.callback.legacy') }} (the shape Obvizio uses) or {{ route('admin.microsoft.callback') }}. @error('ms_redirect_uri')
{{ $message }}
@enderror

Test the connection

Checks the client ID, secret and tenant against Entra and throws the token away. Read-only: nothing is sent and no message is posted.

It cannot check Graph access. Every call this integration makes is delegated — made as the sending account — so those permissions only exist once that account has signed in and consented.

@csrf
@unless ($configured) Client ID, tenant ID and secret must all be saved first. @endunless
{{-- The sending account. Nothing can actually be sent until someone has signed in here, because every Graph call is made as them. --}}

Sending account

@if ($connected) Connected @else Not connected @endif
@if ($connected)
Sending as
{{ $company->ms_bot_email ?: '—' }}
Connected
{{ $company->ms_bot_connected_at?->diffForHumans() ?: '—' }}
@csrf
@else

Sign in as the account the messages should come from — not as yourself, unless that is genuinely the sender. It grants the delegated scopes once and Worklance keeps the connection alive from there.

@csrf
@if ($configured && blank($company->ms_redirect_uri)) Set the redirect URI above first. @endif @endif
{{-- A real send, not a simulated one: consent, a recipient outside the tenant and a chat that will not open are all things that only fail on the real call. --}}

Send test message

@csrf
Any account in the same tenant. @error('test_email')
{{ $message }}
@enderror
@error('test_message')
{{ $message }}
@enderror
@unless ($connected) Connect the sending account first — a message is posted as that person. @endunless

What the tenant needs

On the customer's app registration, under API permissions — all delegated, none needing admin consent:

  • openid, profile, email — sign the account in
  • offline_access — keep it signed in without a person present
  • User.Read — read the sending account's own profile
  • User.ReadBasic.All — resolve a recipient by email address
  • Chat.Create, Chat.ReadWrite, ChatMessage.Send — open a 1:1 chat and post to it

No application permissions, and specifically not User.Read.All. An app-only token cannot post to a 1:1 chat as a person, which is the entire point of sending as the bot account.

A new registration needs admin consent once. These permissions say "admin consent required: no", but most tenants let a non-admin consent only to a short low-impact list, which Chat.* and User.ReadBasic.All are not on. Until someone presses Grant admin consent on the registration, signing in shows "Need admin approval" rather than a consent prompt. A registration that already had it granted never shows either.

Sign-in asks for exactly these nine and nothing else. A registration may well list more — ChatMessage.Read often is — but asking for one beyond what an admin already consented to reopens consent, and most tenants only let a non-admin consent to a short list, so the extra scope shows up as Need admin approval instead of signing in.

@endsection