Gids — Microsoft Entra ID aansluiten
Hoe je een Coloriginz-app laat inloggen met een Microsoft-werkaccount. Geen
component en geen @col/-item: het meeste werk zit in de tenant, niet in code,
en juist dat deel is nergens anders vastgelegd.
Deze gids gaat over de aansluiting. Voor de knop op het inlogscherm bestaat wél
een item: @col/sso-button.
Wat er draait, en wat niet
| App | Status | NextAuth | Provider-id |
|---|---|---|---|
supplier-onboarding-vercel (Onboarding Portal) |
Live sinds 28 juli 2026 | v4 | azure-ad |
floriday-app (Floriday middleware) |
Gebouwd, nooit aangesloten | Auth.js v5 | microsoft-entra-id |
voorraadbeheer (SupplyHub) |
Gebouwd, nog niet aangesloten | v4 | azure-ad |
growerportal2 |
Gebouwd, provider uit tot IT levert | Auth.js v5 | microsoft-entra-id |
Begin een nieuwe app op Auth.js v5, niet op v4. De Onboarding Portal staat op
v4 omdat hij daar al op stond; die lijn is onderhoudsmodus. Een migratie is voor
een draaiende app geen kleinigheid — getServerSession(authOptions) wordt
auth() in élke route, de middleware gaat op de schop, en het provider-id
verandert, dus IT moet eerst nieuwe redirect URI's registreren voordat er nog
iemand kan inloggen. Voor een nieuwe app is dat allemaal gratis, want je bouwt
het één keer goed.
Bewijs en voorschrift zijn hier niet dezelfde app. Alleen de Onboarding
Portal draait live, dus alles over de tenant, de redirect URI's en admin consent
komt daarvandaan. Maar de regels rond accounttoestanden (§3.3) zijn in SupplyHub
het verst uitgewerkt: daar is elke route die de toestand van een account
schrijft langsgelopen, en daarbij kwamen gaten boven die de portal ook had
(§6). Voor de code in §3 en §4 is SupplyHub daarom de referentie; waar de portal
of growerportal2 iets anders doet, staat dat erbij.
Checklist voor een nieuwe app
- App Registration aanvragen bij IT (§1) — doorlooptijd is dagen, begin hier
- Redirect URI's bepalen (§2) en meesturen met de aanvraag
- Admin consent aanvragen (§1) — aparte stap, meest voorkomende blokkade
- De vier beslissingen nemen (§3) en de
signIn-callback bouwen - Elke route die de toestand van een account schrijft langslopen (§3.3)
- Bestaande database: schema, backfill, controle — pas dán de env-vars (§3.3)
- Env-vars in Vercel op Preview én Production
- Testen op preview — lokaal kan niet (§2)
1. Wat je aan IT vraagt
Contact: systems@dfg.nl.
Eén App Registration per app. Niet per omgeving en niet per label: test en productie delen dezelfde registratie, en alle merkdomeinen ook. Ze verschillen alleen in redirect URI, en die mogen naast elkaar bestaan.
Single tenant (AzureADMyOrg). De tenant
29ebd335-b1bc-4b1d-b89b-ea6e27378762 bevat coloriginz.com,
freshfromsource.com, mypeonysociety.com én parfumflowercompany.com. Eén
registratie bedient dus alle vier de labels. col.com zit in een andere
tenant — accounts op dat domein kunnen niet via deze SSO inloggen. Een domein
controleren kan zonder IT:
https://login.microsoftonline.com/<domein>/v2.0/.well-known/openid-configuration
De issuer in het antwoord bevat de tenant-id.
Permissies: openid, profile, email, User.Read, allemaal delegated.
Meer heb je niet nodig — je vraagt alleen het profiel van de persoon die op dat
moment inlogt. Dat is ook het argument waarmee je de aanvraag onderbouwt: geen
toegang tot mail, agenda, bestanden of gegevens van anderen.
Admin consent is een aparte stap en de meest voorkomende blokkade. In deze tenant mogen gebruikers zelf geen toestemming geven aan apps. Zonder eenmalige goedkeuring door een Global Admin krijgt iedereen "Need admin approval" — de credentials zijn dan gewoon goed, de app is alleen niet toegelaten. Reken erop dat dit een tweede mail wordt: het is niet vanzelfsprekend dat IT deze stap meeneemt bij het aanmaken.
De status is niet van buitenaf te controleren. De app-credentials mogen
oauth2PermissionGrants niet uitlezen (403), dus de enige test is opnieuw
inloggen.
Mailsjablonen voor beide aanvragen staan in bijlage A en B.
2. De redirect URI's — hier gaat het mis
De vorm is altijd:
https://<host>/api/auth/callback/<provider-id>
Het provider-id verschilt per NextAuth-versie. Op v4 heet de provider
azure-ad, op Auth.js v5 microsoft-entra-id. Neem dus nooit een lijst URI's
over van een app die op de andere versie draait — de registratie is dan
syntactisch geldig en werkt alsnog niet.
Registreer het custom domein, niet de .vercel.app-URL. Gebruikers komen
binnen via onboarding-portal.apps.coloriginz.com, en dát is de host die in de
callback belandt.
Eén URI per domein per omgeving. Bij multi-label loopt dat op: de
Onboarding Portal heeft twee omgevingen × vier labeldomeinen. Voor
parfumflowercompany.com bestaat nog geen DNS-record voor het portaal, dus die
twee staan nog open.
Localhost staat er bewust niet bij. SSO is daardoor lokaal niet te testen; gebruik een preview-deployment. Houd de credentials-login werkend zolang er nog ontwikkeld wordt, anders kun je lokaal helemaal niet meer inloggen.
Multi-domein vraagt geen codewijziging. Dit is eerder verkeerd aangenomen:
NEXTAUTH_URL blokkeert het niet. Zodra process.env.VERCEL gezet is — altijd,
op Vercel — negeert NextAuth die variabele en gebruikt het de
x-forwarded-host van het request (next-auth/utils/detect-origin.js).
Client-side roept signIn() een relatief pad aan. De app genereert dus vanzelf
per domein de juiste redirect_uri. Het enige wat ontbreekt is de registratie
in Azure.
3. De vier beslissingen die je zelf moet nemen
3.1 SSO maakt nooit accounts aan
Een geslaagde Entra-login geeft toegang tot een bestaand account. Bestaat er geen gebruiker met dat adres, dan weiger je — ook al is de persoon aantoonbaar een collega.
Zonder die regel kan iedereen in de tenant binnenlopen zodra de koppeling live gaat. Beide apps hanteren hem, en het is de enige beslissing in deze lijst waar niets aan te kiezen valt.
3.2 Welke claim is de identiteit?
for (const raw of [claims.email, claims.preferred_username, claims.upn]) {
const normalized = raw?.trim().toLowerCase()
if (normalized) return normalized
}
return null
Azure vult de email-claim alleen als het mail-attribuut van het account
gevuld is. Bij accounts waar dat leeg is — en die bestaan — mislukt een login
die alleen op email leunt, met een foutmelding die nergens naar wijst. De
fallback naar UPN vangt dat op.
Schrijf die keten niet met ??. email ?? preferred_username ?? upn stopt
bij een lege string, want '' is niet null. Een account met een leeg
mail-attribuut dat als "" binnenkomt, valt dan niet door naar de UPN en wordt
geweigerd — precies het geval waar de fallback voor bestaat. Laat leeg en alleen
spaties net zo doorvallen als ontbrekend. De Onboarding Portal gebruikt nog ??;
SupplyHub (resolveSsoEmail in src/lib/sso.ts) en growerportal2 doen het
goed.
Over email_verified: Entra stuurt die claim standaard niet mee. Een
fail-closed controle erop weigert daardoor élke login. floriday-app heeft die
controle wel (decideEntraSignIn in src/features/auth/entra-linking.ts,
reason email-not-verified); dat is veilig, maar het betekent dat die app pas
kan inloggen nadat de regel is herzien. Het commentaar in auth-config.ts
benoemt dat zelf.
In een single-tenant app is die controle ook niet waar de beveiliging op rust:
de adressen worden door IT beheerd, niemand kiest zijn eigen UPN, en §3.1 zorgt
dat een onbekend adres sowieso niet binnenkomt. Wil je het harder, vraag dan
xms_edov of verified_primary_email aan in de app-registratie in plaats van
te vertrouwen op een claim die er niet is.
Vergelijk hoofdletterongevoelig. Alle apps normaliseren naar lowercase en zoeken
met Prisma's mode: 'insensitive'. Geef die findFirst wel een orderBy
(bijvoorbeeld createdAt: 'asc'): een unieke index in Postgres laat twee adressen
toe die alleen in hoofdletters verschillen, en zonder volgorde is de match dan
willekeurig.
Leg daarnaast de oid vast, ook als je er nog niet op matcht. Het
e-mailadres is de praktische sleutel, maar geen stabiele: mensen veranderen van
naam, en een adres kan opnieuw worden uitgegeven aan iemand anders. De oid
(object id) is per gebruiker per tenant onveranderlijk en is dus waar je
uiteindelijk op wilt koppelen. Schrijf hem bij elke SSO-login weg naar een
nullable, unieke kolom; dan kun je later overstappen zonder dat iedereen opnieuw
gekoppeld moet worden.
Doe dat wegschrijven wel best-effort, in een try/catch. Zolang er niets op
authenticeert mag een botsing op die unieke kolom — bijvoorbeeld een oude rij die
de oid nog vasthoudt nadat iemands adres veranderde — nooit een geslaagde login
alsnog laten mislukken.
3.3 Eén vlag kan niet twee dingen betekenen
Dit is de valkuil waar de referentie-implementatie zelf in liep, en hij is het waard om uitgeschreven te lezen.
De Onboarding Portal had één boolean, isActive, die drie toestanden dekte:
nooit geactiveerd, uitgezet door een beheerder, en soft-deleted. De SSO-callback
activeerde bij false het account onvoorwaardelijk — bedoeld voor de eerste
toestand, met als gevolg dat de andere twee er ook onder vielen. Een beheerder
kon iemand dus uitzetten of verwijderen, waarna diezelfde persoon — nog gewoon
lid van de tenant — via "Inloggen met Microsoft" weer binnenkwam met zijn oude
rollen. Het wachtwoordpad weigerde hem wel. Klassiek: een tweede inlogroute die
een controle overslaat die de eerste wél uitvoert.
Scheid de toestanden voordat je auto-activatie aanzet. De portal doet dat nu
met een deactivatedAt DateTime? naast isActive:
isActive |
deactivatedAt |
Betekenis | SSO |
|---|---|---|---|
false |
null |
uitgenodigd, nooit geactiveerd | activeren en toelaten |
false |
gezet | uitgezet of verwijderd door een beheerder | weigeren |
true |
null |
gewoon actief | toelaten |
De vierde combinatie, true met een tijdstempel, hoort niet te bestaan. Kom je
hem tegen, dan heeft een route de ene helft geschreven en de andere vergeten: het
wachtwoordpad laat zo iemand binnen en SSO weigert hem.
Elke schrijver van het paar
Het veld toevoegen is het makkelijke deel. Het werk zit in elke route die
isActive, deactivatedAt of een activatietoken schrijft, want één route die
het paar half bijwerkt, heropent de zijdeur die deactivatedAt moest sluiten.
Loop ze allemaal langs; in een gewone app zijn het er deze:
| Route | Moet doen |
|---|---|
| Uitnodigen | isActive: false, deactivatedAt blijft null, activatietoken van dagen |
| Uitzetten door beheerder | deactivatedAt alleen stempelen bij een echte overgang van true naar false, en in dezelfde update elke openstaande activatie- of resettoken wissen |
| Soft delete | Idem: een verwijdering is ook een deactivering |
| Aanzetten door beheerder | isActive: true en deactivatedAt: null |
| Uitnodiging intrekken | deactivatedAt zetten en de token wissen, zonder isActive aan te raken (zie hieronder) |
| Activatie via de link | Weigeren als deactivatedAt gezet is; bij succes isActive: true én deactivatedAt: null |
| Wachtwoord resetten | Weigeren als isActive uit staat |
| Activatiemail opnieuw sturen | Weigeren als deactivatedAt gezet is, niet alleen als er een wachtwoord is |
SSO signIn-callback |
Alleen (false, null) activeren, en daarbij de token wissen |
Vier van die regels zijn niet vanzelfsprekend, en de portal miste ze alle vier tot 26 september 2026 (zie §6 voor de stand):
- Uitzetten moet de openstaande token intrekken. Anders werkt dit: je nodigt iemand uit, bedenkt je binnen een week en zet hem uit, en hij klikt alsnog op de link uit de mail. Als de activatieroute alleen naar de vervaldatum kijkt, zet die het account gewoon aan — via het wachtwoordpad, zonder dat SSO er iets mee te maken heeft.
- De activatieroute moet zelf ook weigeren. Het intrekken van de token is de
eerste verdediging; een controle op
deactivatedAtin de activatieroute vangt de tokens die er al lagen voordat je de eerste regel invoerde. Geef dezelfde neutrale melding als bij een onbekende link. - Stempel alleen bij de overgang. Een tweede keer uitzetten, of een gewone
bewerking die
isActive: falsemeestuurt, hoort de oorspronkelijke datum niet te overschrijven. - Resend-activatie mag niet leunen op "heeft nog geen wachtwoord". Dat voelt
als de juiste test voor "nooit geactiveerd", maar een medewerker die alleen via
Microsoft inlogt heeft ook nooit een wachtwoord gekozen. Met alleen die test kan
een uitgezette collega opnieuw worden uitgenodigd, de link gebruiken, en daarna
ook weer via Microsoft binnenkomen. De terugweg voor
(false, gezet)is altijd dezelfde: eerst weer aanzetten, wat het tijdstempel wist.
Een uitnodiging intrekken vraagt een eigen actie. Een uitgenodigd account
staat al op isActive: false, dus "uitzetten" is voor dat account geen
overgang: een gewone bewerking stuurt dezelfde payload. Zonder aparte actie is
er geen weg van "uitgenodigd" naar "uitgezet", en blijft auto-activatie via SSO
openstaan voor iemand die je niet meer wilt toelaten. SupplyHub heeft daarvoor
POST /api/admin/employees/[id]/revoke-invitation.
Laat de beheer-UI die actie ook aanbieden. Een gewone aan/uit-knop biedt
bij een uitgenodigd account alleen "aanzetten". Dat is geen fout, maar het
betekent wel dat een beheerder die zich bedenkt maar één omweg heeft: eerst
aanzetten en dan weer uitzetten. Tussen die twee klikken kan de persoon echt
binnenkomen. Toon bij een uitgenodigde rij dus "uitnodiging intrekken", en na
het intrekken een rij met de status "uitgezet". Daarvoor heeft de tabel
deactivatedAt nodig: isActive en de aanwezigheid van een wachtwoord
onderscheiden "uitgenodigd" niet van "ingetrokken".
Heeft jouw app een aparte uitnodigingsflow — zoals floriday-app, dat
isActive: false alleen gebruikt voor "uitgezet" — dan is auto-activatie
sowieso niet aan de orde en weiger je gewoon.
Invoeren in een bestaande database — de volgorde is een beveiligingsregel
Bij het invoeren hiervan in een bestaande database moet je de zittende rijen
alsnog verdelen. De portal gebruikte daarvoor: wie ooit een wachtwoord heeft
gezet (passwordHash IS NOT NULL) heeft het account in gebruik gehad en is dus
uitgezet; wie er geen heeft en nog een openstaande activatietoken, is nooit
begonnen. Dat gaf op productie precies de
goede splitsing.
Een nieuwe kolom is null op elke rij, en (false, null) betekent
"uitgenodigd". Tussen de schema-push en de backfill staat dus elke uitgezette
gebruiker als uitnodiging in de database, en die activeert de SSO-callback. Een
oud-medewerker die nog in de tenant zit, kan in dat venster inloggen en zijn
account blijvend aanzetten. Per omgeving, in deze volgorde:
- Schema pushen
- Backfill als dry run draaien en élke naam lezen: iedereen onder "nooit geactiveerd" kan zichzelf via SSO activeren
- Backfill toepassen en controleren
- Pas dan de Entra-env-vars zetten en redeployen
De heuristiek zit op sommige rijen mis: iemand die is uitgenodigd en daarna uitgezet voordat hij ooit activeerde, komt aan de verkeerde kant uit. Corrigeer die met de hand vóór stap 3.
SupplyHub heeft hier een script voor met een dry run (scripts/backfill-deactivated-at.ts)
en het draaiboek in zijn CLAUDE.md. growerportal2 voegt er een vangnet aan
toe in de beslisfunctie zelf: een inactief account mét wachtwoord wordt altijd
geweigerd, ook als deactivatedAt nog leeg is. Dat maakt een vergeten of
mislukte backfill onschadelijk en kost één regel. Neem hem over.
Wat je bij een terechte auto-activatie wél doet: activationToken en
activationExpiresAt wissen. Een openstaande activatielink hoort dood te zijn
zodra het account langs een andere weg in gebruik is genomen.
Reken erop dat je gebruikers zónder wachtwoord krijgt, en regel hun terugweg.
Wie via Entra binnenkomt kiest er nooit een. Dat is prima tot Microsoft-login een
keer niet kan — een account buiten de tenant, een storing, een plek waar het
geblokkeerd is. In de Onboarding Portal liep zo iemand vast: "wachtwoord
vergeten" zoekt op een passwordHash, vond er geen, en gaf het neutrale "als het
adres bekend is ontvangt u een mail" terug zonder iets te sturen. Terecht dat die
route niet verraadt welke adressen bestaan, maar het effect was een gebruiker die
niets kon en niet kon zien waarom.
De oplossing hoeft niet groot te zijn: laat dat geval doorvallen naar de activatiemail in plaats van de resetmail. Naar buiten toe verandert er niets, dus de anti-enumeratie blijft heel. Geef die token wel de korte levensduur van een reset en niet de dagen van een uitnodiging — hij zet een wachtwoord op een account dat al in gebruik is.
Zet in dezelfde beweging beide routes op de activatiepagina zelf. Voor de meeste mensen is die pagina de eerste kennismaking met het systeem, en "log in met Microsoft of maak een wachtwoord aan" is daar een betere eerste indruk dan een formulier dat doet alsof de ene route niet bestaat.
3.4 Hoe geef je een fout terug?
Een signIn-callback kan geen foutmelding tonen; hij kan alleen true,
false, of een redirect-URL teruggeven. Het patroon is dus: redirect naar de
loginpagina met een code, en die code aan de leeskant vertalen.
Lees de code nooit rechtstreeks uit de querystring in je UI — controleer hem
tegen een vaste lijst. De Onboarding Portal doet dat met validSsoErrors in
src/app/login/page.tsx.
Kies één taal voor het contract en houd hem vast. De Onboarding Portal gebruikt
?error=AccountNotFound, floriday ?fout=no-account; beide hebben de taal in
het contract gebakken, en dat wringt zodra een derde app de andere kant op
kiest. Voor nieuwe apps: Engelse parameternaam en Engelse codes, vertalen doe je
pas in de UI.
Gebruik deze codes, zodat een later gedeeld item niet eerst twee contracten hoeft te verzoenen:
| Code | Wanneer |
|---|---|
NoEmail |
geen bruikbare claim na de hele keten uit §3.2 |
AccountNotFound |
geen gebruiker met dat adres (§3.1) |
AccountDeactivated |
deactivatedAt gezet, of het vangnet uit §3.3 |
ServerError |
de callback gooide een fout, zie hieronder |
De portal en SupplyHub gebruiken deze al. growerportal2 noemt de eerste
NoEmailClaim en heeft daarnaast AccountNotAllowed voor rollen die nooit in de
tenant zitten (leveranciers, transporteurs). Die extra code is een goede
toevoeging voor elke app met externe gebruikers.
Zet daarnaast NextAuth's eigen codes op de lijst die je wilt vertalen, in elk
geval OAuthCallback, OAuthSignin en Callback. Die gebruikt NextAuth zelf
als het inloggen onderweg misgaat, bijvoorbeeld als Azure de redirect URI
afwijst.
Vang fouten in de signIn-callback zelf af. Zonder try/catch zet NextAuth
de ruwe foutmelding in de URL van zijn eigen foutpagina — bij een
databasefout inclusief hostnaam en poort van de database — op een pagina zonder
weg terug. Log de fout en stuur /login?error=ServerError terug.
4. Valkuilen
Zet prompt: 'select_account' op de authorization-params. Zonder dat logt
Azure stil in met het laatst gebruikte account. Je merkt dat pas als je met twee
accounts wilt testen en niet begrijpt waarom je steeds dezelfde sessie krijgt.
Schakel de provider op alle drie de env-vars, niet alleen op de client-id.
De Onboarding Portal kijkt naar AZURE_AD_CLIENT_ID en geeft clientSecret en
tenantId daarna met ! door. Dat werkt zolang alle drie gezet zijn, maar
levert een half geconfigureerde provider op als er één ontbreekt.
floriday-app, SupplyHub en growerportal2 doen het goed met één
entraEnabled-constante over alle drie.
Rollen komen nooit uit Entra. De provider levert identiteit, meer niet. Rol,
label en rechten lees je uit je eigen database — in de jwt-callback, na de
lookup op e-mailadres. Zet in de profile() van de provider lege waarden neer
en vul ze daar.
Wil je dat intrekken direct werkt, lees de rollen dan bij elk request vers.
De Onboarding Portal doet een DB-lookup in elke jwt-callback en geeft een leeg
token terug zodra isActive uit staat. Dat kost een query per request; de prijs
voor deactivering die niet wacht op het verlopen van een JWT. SupplyHub kiest
een tussenweg en leest hooguit elke vijf minuten opnieuw; dat is een verdedigbare
afweging, zolang je hem opschrijft. Wat níet kan, is helemaal niet opnieuw lezen
— dan werkt uitzetten pas bij de volgende login.
Een leeg token is geen uitgelogde gebruiker, tenzij je dat zelf regelt. Dit
is de valkuil die de vorige regel ondermijnt. Een jwt-callback die {}
teruggeeft, levert nog steeds een token op dat decodeert tot een object met
iat, exp en jti — truthy dus. Twee plekken moeten daar expliciet op testen:
- De middleware test op
!token?.id, niet op!token. - De
session-callback geeftnullterug alstoken.idontbreekt. Anders bouwt NextAuth alsnog eensession.userzonder id en zonder rollen, en die komt langs elkeif (!session?.user)in je API-routes. Een route die daarnawhere: { userId: session.user.id }doet, stuurtundefinednaar Prisma, en Prisma laat eenundefinedfilter weg.
Zonder die twee regels houdt een uitgezette gebruiker een sessie, alleen zonder
identiteit — en wat de routes daarmee doen, hangt per route af. SupplyHub doet
beide (src/middleware.ts, de session-callback in src/lib/auth.ts).
Secrets nooit in git en nooit in projectmemory. Het secret hoort alleen in
Vercel en in je lokale .env.
Client-id en tenant-id mogen wél in documentatie. Ze zijn openbaar van opzet: allebei staan ze in de URL van elke inlogredirect, dus elke gebruiker die ooit heeft ingelogd heeft ze in zijn adresbalk gehad, en de tenant-id is bovendien opvraagbaar via het discovery-endpoint. De client-id zégt wie de app is, het secret bewíjst het — en dat een client-id publiek mag zijn, blijkt uit public clients (SPA's, mobiele apps), die helemaal geen secret hebben en alleen op redirect URI plus PKCE draaien.
Dat maakt de lijst met redirect URI's uit §2 een beveiligingsmaatregel en geen configuratiedetail: hij is de reden dat iemand met jouw client-id een loginflow kan starten maar de code nooit op zijn eigen domein ziet landen.
Zet de vervaldatum van het secret in je agenda. Bijlage A vraagt 24 maanden aan. Verloopt hij, dan valt de SSO om zonder dat er iets aan de app is veranderd.
5. Wat je nu overneemt
Er is bewust geen @col/-item voor de aansluiting zelf. Eén draaiende
implementatie is geen patroon, en de apps zitten op verschillende
NextAuth-majors. Een gedeeld config-object zou die verschillen moeten
verstoppen achter opties, en dat is meer bagage dan het bespaart.
Kopieer daarom uit SupplyHub (voorraadbeheer), de meest complete
implementatie van §3:
src/lib/sso.ts—resolveSsoEmailendecideSsoSignIn, zonder Prisma of NextAuth, met vaste reason-codesscripts/check-sso-decision.ts— de waarheidstabel van die functie alsnode:assert-script, bruikbaar ook zonder testframeworksrc/lib/auth.ts— de provider, designIn-callback mettry/catch, dejwt-callback en desession-callback die een leeg token afwijstsrc/middleware.ts— de test optoken?.idsrc/app/api/admin/employees/[id]/route.ts,src/app/api/admin/employees/[id]/revoke-invitation/route.tsensrc/app/api/auth/activate/route.ts— de schrijvers uit de tabel in §3.3scripts/backfill-deactivated-at.ts— de backfill met dry run- De sectie Account states in de
CLAUDE.mdvan dat project — de tabel met toestanden en schrijvers, en het draaiboek voor het aanzetten per omgeving
Uit de Onboarding Portal neem je de activatiepagina met beide routes erop en de
terugval van wachtwoord-vergeten naar de activatiemail (§3.3). Uit
growerportal2 neem je het vangnet voor een ontbrekende backfill (§3.3), en
AccountNotAllowed als je app externe gebruikers heeft (§3.4).
Neem het patroon over, niet de letterlijke code: SupplyHub staat op NextAuth v4
en een nieuwe app hoort op v5 te beginnen. growerportal2 laat zien hoe
hetzelfde er op v5 uitziet (src/lib/entra-sign-in.ts). Wat je overneemt is de
volgorde van controles in de signIn-callback — adres bepalen, account
opzoeken, weigeren als het uitgezet is, pas daarna eventueel activeren — het
feit dat rollen uit je eigen database komen, en de lijst schrijvers in §3.3.
Zodra een tweede app écht live is, wordt dit alsnog een item — dan valt er te extraheren uit twee bewezen implementaties in plaats van uit één plus een vermoeden. De vorm ligt voor de hand, want drie apps zijn er al onafhankelijk op uitgekomen: een pure beslissingsfunctie met een getypeerde uitkomst en vaste reason-codes, waar de app zijn eigen lookup in schuift. De beslissingsfunctie is het makkelijke deel. De schrijvers uit §3.3 zitten in de routes van elke app zelf, en die kan een item niet meeleveren — die lijst blijft een checklist.
6. Openstaand
Onboarding Portal, live. Op 26 september 2026 zijn deze gaten gerepareerd en naar test en productie gedeployed:
- Uitzetten en de soft delete lieten de activatietoken staan, en de
activatieroute keek niet naar
deactivatedAt. Een ingetrokken uitnodiging was zo nog zeven dagen bruikbaar. - Resend-activatie testte alleen op een ontbrekend wachtwoord.
- Er was geen manier om een uitnodiging in te trekken. Nu staat er een actie "uitnodiging intrekken" bij elke uitgenodigde rij.
- De middleware testte op
!!tokenen desession-callback bouwde ook bij een leeg token eensession.user.
Nog niet opgepakt, ook niet op test:
- De claim-keten gebruikt
??, designIn-callback heeft geentry/catch, en de provider hangt alleen aan de client-id. - Het weigeren van een uitgezet account en een ingetrokken uitnodiging is nog niet met een echte Entra-login op de testomgeving doorlopen.
- Uitzetten en de soft delete lieten de activatietoken staan, en de
activatieroute keek niet naar
SupplyHub: gebouwd volgens deze gids, nog niet aangesloten. Het vangnet uit
growerportal2zit er nog niet in. Tot het met een echte Entra-login op de testomgeving is doorlopen, is §3.3 ook daar beredeneerd en niet aangetoond.growerportal2: dejwt-callback leest de database niet opnieuw, dus uitzetten werkt pas bij de volgende login (§4).floriday-app: deemail_verified-regel moet herzien worden vóór de koppeling wordt aangezet, anders weigert elke login (§3.2).PFC: zodra
onboarding-portal.apps.parfumflowercompany.comeen DNS-record heeft, moeten de twee bijbehorende redirect URI's alsnog aangevraagd worden.
Bijlage A — mail: App Registration aanvragen
Onderwerp: App Registration voor
<naam van de app>Hoi,
Voor
<naam van de app>willen we inloggen met het Microsoft-werkaccount, zodat collega's geen apart wachtwoord meer nodig hebben. Daarvoor is een App Registration nodig in onze tenant.Gevraagd:
- Naam:
<naam van de app>- Type: single tenant (accounts alleen in deze organisatie)
- Platform: Web
- Redirect URI's:
<één regel per domein per omgeving, zie §2>- API permissions (Microsoft Graph, delegated):
openid,profile,User.Read- Client secret met een looptijd van 24 maanden
Graag de Application (client) ID, de Directory (tenant) ID en het secret terugkoppelen. Het secret ontvang ik het liefst via een kanaal waar het niet blijft staan.
De app vraagt alleen het profiel op van de gebruiker die inlogt (naam en e-mailadres), om te bepalen wie er binnenkomt. Geen toegang tot mail, agenda, bestanden of gegevens van andere gebruikers.
Groet,
Bijlage B — mail: admin consent aanvragen
Onderwerp: Admin consent nodig voor App Registration
<naam van de app>Hoi,
Bedankt voor de App Registration. De koppeling werkt technisch: client ID, secret en tenant ID kloppen en de redirect URI's staan goed geregistreerd. Bij het inloggen krijg ik alleen nog "Need admin approval" — gebruikers mogen in onze tenant zelf geen toestemming geven aan apps, dus de app moet eenmalig door een Global Admin worden goedgekeurd.
App:
<naam van de app>Application (client) ID:<client-id>Tenant ID:29ebd335-b1bc-4b1d-b89b-ea6e27378762Wat er nodig is (Azure portal):
- Entra ID > App registrations >
<naam van de app>> API permissions- Controleer dat onder Microsoft Graph (Delegated) staan:
openid,profile,User.Read- Klik op "Grant admin consent for
<tenant>"Alternatief in plaats van stap 1-3, deze URL openen als Global Admin:
https://login.microsoftonline.com/29ebd335-b1bc-4b1d-b89b-ea6e27378762/adminconsent?client_id=<client-id>Ter geruststelling: de app vraagt alleen het eigen profiel van de ingelogde gebruiker op (naam + e-mailadres) om te bepalen wie er inlogt. Geen toegang tot mail, agenda, bestanden of gegevens van andere gebruikers.
Groet,