feat(auth): Microsoft OAuth sign-in/link + self-serve signup via Google/Microsoft
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/ unlink endpoints, ApplicationUser fields, reconciler columns) for Microsoft Entra ID + personal accounts via the multi-tenant "common" endpoint. Google/Microsoft sign-in previously only worked for accounts already linked to an existing local user -- there was no way to actually sign up via OAuth. Both exchange endpoints now create a new user when no match is found and Auth:AllowRegistration is true, same gate as email/password registration. Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no vanilla-JS equivalent to Google's Identity Services script) wired into the login page's provider tabs and the profile page's account-linking section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId config gate on the backend.
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
import { Box, Button, Chip, Paper, Typography } from "@mui/material";
|
||||
import { PublicClientApplication } from "@azure/msal-browser";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { clearAuthClientState, getAuthPersistencePreference } from "../auth";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "microsoft" | "external";
|
||||
email?: string;
|
||||
userName?: string;
|
||||
displayName?: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
microsoftLink?: {
|
||||
linked: boolean;
|
||||
email?: string | null;
|
||||
linkedAt?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
let msalInstance: PublicClientApplication | null = null;
|
||||
function getMsalInstance(clientId: string): PublicClientApplication {
|
||||
msalInstance ??= new PublicClientApplication({
|
||||
auth: { clientId, authority: "https://login.microsoftonline.com/common", redirectUri: window.location.origin },
|
||||
});
|
||||
return msalInstance;
|
||||
}
|
||||
|
||||
export default function MicrosoftAuthCard({ onSignedIn }: { onSignedIn?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [working, setWorking] = useState(false);
|
||||
|
||||
const clientId = (process.env.REACT_APP_MICROSOFT_CLIENT_ID || "").trim();
|
||||
const signedIn = Boolean(me?.provider);
|
||||
const actionLabel = !signedIn
|
||||
? t("continueWithMicrosoft")
|
||||
: me?.provider === "local" && !me?.microsoftLink?.linked
|
||||
? t("linkWithMicrosoft")
|
||||
: t("signInWithMicrosoft");
|
||||
|
||||
async function refreshMe() {
|
||||
try {
|
||||
const res = await api.get<MeResponse>("/auth/me");
|
||||
setMe(res.data);
|
||||
} catch {
|
||||
setMe(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void refreshMe();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onAuthChanged = () => { void refreshMe(); };
|
||||
window.addEventListener("auth-changed", onAuthChanged);
|
||||
return () => window.removeEventListener("auth-changed", onAuthChanged);
|
||||
}, []);
|
||||
|
||||
async function handleSignIn() {
|
||||
if (!clientId) return;
|
||||
setWorking(true);
|
||||
try {
|
||||
const msal = getMsalInstance(clientId);
|
||||
await msal.initialize();
|
||||
const result = await msal.loginPopup({ scopes: ["openid", "profile", "email"] });
|
||||
const idToken = result.idToken;
|
||||
if (!idToken) throw new Error(t("microsoftAuthFailed"));
|
||||
|
||||
if (me?.provider === "local") {
|
||||
const res = await api.post<{ linked: boolean; email?: string | null }>("/auth/microsoft/link", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
toast(res.data?.email ? t("microsoftLinkedSuccessWithEmail", { email: res.data.email }) : t("microsoftLinkedSuccess"), "success");
|
||||
await refreshMe();
|
||||
} else {
|
||||
await api.post("/auth/microsoft/exchange", { token: idToken, rememberMe: getAuthPersistencePreference() === "local" });
|
||||
window.dispatchEvent(new Event("auth-changed"));
|
||||
toast(t("microsoftSignedIn"), "success");
|
||||
onSignedIn?.();
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(getApiErrorMessage(e, t("microsoftAuthFailed")), "error");
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
}
|
||||
|
||||
const signedInName = me?.userName || me?.displayName || [me?.firstName, me?.lastName].filter(Boolean).join(" ") || me?.email || "";
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 2, p: 2 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("microsoftAccountTitle")}
|
||||
</Typography>
|
||||
|
||||
{!clientId && (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftSetupHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{clientId && (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" label={me?.microsoftLink?.linked ? t("microsoftLinked") : t("microsoftAvailableToLink")} color={me?.microsoftLink?.linked ? "success" : "default"} variant={me?.microsoftLink?.linked ? "filled" : "outlined"} />
|
||||
{me?.microsoftLink?.linkedAt ? <Chip size="small" variant="outlined" label={t("microsoftLinkedDate", { date: new Date(me.microsoftLink.linkedAt).toLocaleDateString() })} /> : null}
|
||||
</Box>
|
||||
|
||||
{!signedIn ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftSignInHint")}
|
||||
</Typography>
|
||||
) : me?.provider === "local" ? (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{me.microsoftLink?.linked
|
||||
? t("microsoftLinkedTo", { email: me.microsoftLink.email || t("microsoftLinkedToYourAccount") })
|
||||
: t("microsoftBindHint")}
|
||||
</Typography>
|
||||
) : (
|
||||
<Typography sx={{ color: "text.secondary" }}>
|
||||
{t("microsoftExchangeHint")}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "flex-start", gap: 1 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", fontWeight: 700, letterSpacing: 0.4, textTransform: "uppercase" }}>
|
||||
{actionLabel}
|
||||
</Typography>
|
||||
<Button variant="outlined" disabled={working} onClick={() => void handleSignIn()}>
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 2, flexWrap: "wrap" }}>
|
||||
{signedIn ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
void api.post("/auth/logout").catch(() => undefined).finally(() => {
|
||||
clearAuthClientState();
|
||||
setMe(null);
|
||||
toast(t("signedOut"), "info");
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("signOut")}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{me?.provider === "local" && me.microsoftLink?.linked ? (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={working}
|
||||
onClick={async () => {
|
||||
try {
|
||||
await api.delete("/auth/microsoft/link");
|
||||
toast(t("microsoftUnlinked"), "info");
|
||||
await refreshMe();
|
||||
} catch (e: any) {
|
||||
const msg = e?.response?.data || e?.message || t("microsoftUnlinkFailed");
|
||||
toast(String(msg), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("unlinkMicrosoft")}
|
||||
</Button>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
{signedIn && me?.email ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{t("signedInAs", { name: signedInName })}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -628,6 +628,26 @@ export const translations = {
|
||||
googleScriptLoadFailed: "Google auth script failed to load.",
|
||||
googleUnlinked: "Google account unlinked.",
|
||||
googleUnlinkFailed: "Failed to unlink Google account.",
|
||||
microsoftAccountTitle: "Microsoft account",
|
||||
microsoftSetupHint: "Set `REACT_APP_MICROSOFT_CLIENT_ID` in your UI environment to enable Microsoft sign-in and account linking.",
|
||||
microsoftLinked: "Linked",
|
||||
microsoftAvailableToLink: "Available to link",
|
||||
microsoftLinkedDate: "Linked {date}",
|
||||
microsoftSignInHint: "Sign in with a Microsoft account that has already been linked to your Jobbjakt user.",
|
||||
continueWithMicrosoft: "Continue with Microsoft",
|
||||
signInWithMicrosoft: "Sign in with Microsoft",
|
||||
linkWithMicrosoft: "Link with Microsoft",
|
||||
microsoftLinkedTo: "Linked to {email}.",
|
||||
microsoftLinkedToYourAccount: "Linked to your Microsoft account.",
|
||||
microsoftBindHint: "Bind a Microsoft account to this user so you can sign in with Microsoft and still keep your normal app roles and data.",
|
||||
microsoftExchangeHint: "Exchange your Microsoft sign-in for a normal Jobbjakt session.",
|
||||
microsoftSignedIn: "Signed in with Microsoft.",
|
||||
microsoftLinkedSuccess: "Microsoft account linked.",
|
||||
microsoftLinkedSuccessWithEmail: "Linked Microsoft account {email}.",
|
||||
microsoftAuthFailed: "Microsoft authentication failed.",
|
||||
microsoftUnlinked: "Microsoft account unlinked.",
|
||||
microsoftUnlinkFailed: "Failed to unlink Microsoft account.",
|
||||
unlinkMicrosoft: "Unlink Microsoft",
|
||||
signedOut: "Signed out.",
|
||||
signedInAs: "Signed in as {name}.",
|
||||
unlinkGoogle: "Unlink Google",
|
||||
@@ -663,6 +683,7 @@ export const translations = {
|
||||
authOptional: "Authentication is optional in this environment.",
|
||||
emailAndPassword: "Email & password",
|
||||
google: "Google",
|
||||
microsoft: "Microsoft",
|
||||
createAccount: "Create account",
|
||||
signedIn: "Signed in.",
|
||||
rememberMe: "Remember me",
|
||||
@@ -1572,6 +1593,26 @@ export const translations = {
|
||||
googleScriptLoadFailed: "Kunne ikke laste Google-autentiseringsskriptet.",
|
||||
googleUnlinked: "Google-konto koblet fra.",
|
||||
googleUnlinkFailed: "Kunne ikke koble fra Google-kontoen.",
|
||||
microsoftAccountTitle: "Microsoft-konto",
|
||||
microsoftSetupHint: "Sett `REACT_APP_MICROSOFT_CLIENT_ID` i UI-miljøet ditt for å aktivere Microsoft-innlogging og kontokobling.",
|
||||
microsoftLinked: "Koblet",
|
||||
microsoftAvailableToLink: "Tilgjengelig for kobling",
|
||||
microsoftLinkedDate: "Koblet {date}",
|
||||
microsoftSignInHint: "Logg inn med en Microsoft-konto som allerede er koblet til Jobbjakt-brukeren din.",
|
||||
continueWithMicrosoft: "Fortsett med Microsoft",
|
||||
signInWithMicrosoft: "Logg inn med Microsoft",
|
||||
linkWithMicrosoft: "Koble til med Microsoft",
|
||||
microsoftLinkedTo: "Koblet til {email}.",
|
||||
microsoftLinkedToYourAccount: "Koblet til Microsoft-kontoen din.",
|
||||
microsoftBindHint: "Koble en Microsoft-konto til denne brukeren slik at du kan logge inn med Microsoft og fortsatt beholde vanlige approller og data.",
|
||||
microsoftExchangeHint: "Bytt Microsoft-innloggingen din mot en vanlig Jobbjakt-økt.",
|
||||
microsoftSignedIn: "Logget inn med Microsoft.",
|
||||
microsoftLinkedSuccess: "Microsoft-konto koblet.",
|
||||
microsoftLinkedSuccessWithEmail: "Koblet Microsoft-konto {email}.",
|
||||
microsoftAuthFailed: "Microsoft-autentisering mislyktes.",
|
||||
microsoftUnlinked: "Microsoft-konto koblet fra.",
|
||||
microsoftUnlinkFailed: "Kunne ikke koble fra Microsoft-kontoen.",
|
||||
unlinkMicrosoft: "Koble fra Microsoft",
|
||||
signedOut: "Logget ut.",
|
||||
signedInAs: "Logget inn som {name}.",
|
||||
unlinkGoogle: "Koble fra Google",
|
||||
@@ -1607,6 +1648,7 @@ export const translations = {
|
||||
authOptional: "Autentisering er valgfri i dette miljøet.",
|
||||
emailAndPassword: "E-post og passord",
|
||||
google: "Google",
|
||||
microsoft: "Microsoft",
|
||||
createAccount: "Opprett konto",
|
||||
signedIn: "Logget inn.",
|
||||
rememberMe: "Husk meg",
|
||||
|
||||
@@ -7,12 +7,14 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { getRememberMePref, setAuthPersistencePreference } from "../auth";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type AuthConfig = {
|
||||
requireAuth: boolean;
|
||||
googleEnabled: boolean;
|
||||
microsoftEnabled: boolean;
|
||||
localEnabled: boolean;
|
||||
allowRegistration: boolean;
|
||||
};
|
||||
@@ -81,6 +83,7 @@ export default function LoginPage() {
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label={t("emailAndPassword")} />
|
||||
<Tab label={t("google")} />
|
||||
<Tab label={t("microsoft")} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
@@ -123,6 +126,7 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{tab === 1 && <GoogleAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
{tab === 2 && <MicrosoftAuthCard onSignedIn={() => { navigate(nextPath, { replace: true }); }} />}
|
||||
</Paper>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import ZoomInOutlinedIcon from "@mui/icons-material/ZoomInOutlined";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import GoogleAuthCard from "../components/GoogleAuthCard";
|
||||
import MicrosoftAuthCard from "../components/MicrosoftAuthCard";
|
||||
import CropImageDialog from "../components/CropImageDialog";
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -562,6 +563,7 @@ export default function ProfilePage() {
|
||||
</Box>
|
||||
|
||||
<GoogleAuthCard />
|
||||
<MicrosoftAuthCard />
|
||||
|
||||
<Box sx={{ mt: 3, display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||
<Box sx={{ gridColumn: "1 / -1" }}>
|
||||
|
||||
@@ -29,12 +29,13 @@ jest.mock('./api', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('./components/GoogleAuthCard', () => () => null);
|
||||
jest.mock('./components/MicrosoftAuthCard', () => () => null);
|
||||
|
||||
beforeEach(() => {
|
||||
const { api } = require('./api');
|
||||
api.get.mockImplementation((url: string) => {
|
||||
if (url === '/auth/config') {
|
||||
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, localEnabled: true, allowRegistration: false } });
|
||||
return Promise.resolve({ data: { requireAuth: false, googleEnabled: false, microsoftEnabled: false, localEnabled: true, allowRegistration: false } });
|
||||
}
|
||||
if (url === '/auth/me') {
|
||||
return Promise.resolve({ data: { roles: [], email: 'demo@example.com', userName: 'demo' } });
|
||||
|
||||
Reference in New Issue
Block a user