rubis/apps/web/src/routes/login.tsx
ordinarthur 7521e1fff6 feat(auth): Microsoft 365 SSO + factorisation helper SSO partagé
Backend
- Custom Ally driver Microsoft (Oauth2Driver) — Microsoft n'est pas dans
  les providers built-in, mais le driver dérive de Oauth2Driver en quelques
  lignes. Endpoints v2.0 (Microsoft Identity Platform), Graph /me pour le
  profil, fallback userPrincipalName si mail null (comptes perso).
- Tenant configurable via MICROSOFT_TENANT (défaut 'common' — accepte
  work/school + perso ; 'organizations' pour M365 strict).
- Migration 1400 : ajout microsoft_id nullable unique sur users.
- AuthMicrosoftController : redirect + callback (même pattern que Google).
- Refacto : extraction d'un service sso_session.ts (findOrCreateUserFromSso,
  nextRouteAfterSso, emitSsoSessionAndRedirect) → AuthGoogle + AuthMicrosoft
  partagent la logique.
- Routes /api/v1/auth/microsoft/{redirect,callback}.

Frontend
- Composant SsoButton générique (provider="google"|"microsoft") avec logo
  officiel inline pour chaque. Remplace l'ancien GoogleButton.
- Login + signup : pile verticale "Continuer avec Google" + "Continuer
  avec Microsoft", puis séparateur "ou", puis form email/password.
- Route SPA renommée /auth/google/complete → /auth/sso/complete (partagée
  entre les deux providers, la callback API redirige toujours dessus).
- Erreurs SSO sur /login : ?google=... ET ?microsoft=... → toast contextuel.

K3s
- ConfigMap rubis-api-config : ajout MICROSOFT_TENANT + MICROSOFT_CALLBACK_URL.
- Secret rubis-app-secrets : ajout MICROSOFT_CLIENT_ID + MICROSOFT_CLIENT_SECRET.

Doc
- .claude/deploy-memory.md : procédure Azure / Entra ID app registration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-07 09:38:38 +02:00

237 lines
8.7 KiB
TypeScript

import { useEffect } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { ArrowRight } from "lucide-react";
import { z } from "zod";
import { loginSchema, type AuthSession, type LoginInput } from "@rubis/shared";
import { api, ApiError } from "@/lib/api";
import { authStore } from "@/lib/auth";
import { Button } from "@/components/ui/Button";
import { Input } from "@/components/ui/Input";
import { Field } from "@/components/ui/Field";
import { Eyebrow } from "@/components/ui/Eyebrow";
import { Brand } from "@/components/brand/Brand";
import { Gem } from "@/components/brand/Gem";
import { SsoButton, AuthDivider } from "@/components/auth/SsoButton";
const ssoErrorEnum = z
.enum(["denied", "state_mismatch", "error", "no_email"])
.optional();
const searchSchema = z.object({
redirect: z.string().optional(),
google: ssoErrorEnum,
microsoft: ssoErrorEnum,
});
const SSO_ERROR_MESSAGES: Record<string, Record<string, string>> = {
google: {
denied: "Connexion Google annulée.",
state_mismatch: "Session expirée, réessayez la connexion Google.",
error: "Connexion Google impossible. Réessayez dans un instant.",
no_email: "Votre compte Google n'a pas d'email associé.",
},
microsoft: {
denied: "Connexion Microsoft annulée.",
state_mismatch: "Session expirée, réessayez la connexion Microsoft.",
error: "Connexion Microsoft impossible. Réessayez dans un instant.",
no_email: "Votre compte Microsoft n'a pas d'email associé.",
},
};
export const Route = createFileRoute("/login")({
validateSearch: searchSchema,
component: LoginPage,
});
function LoginPage() {
const navigate = useNavigate();
const search = Route.useSearch();
// Toast d'erreur si on revient d'un échec SSO (?google=denied, ?microsoft=…).
useEffect(() => {
for (const provider of ["google", "microsoft"] as const) {
const code = search[provider];
if (code && SSO_ERROR_MESSAGES[provider]?.[code]) {
toast.error(SSO_ERROR_MESSAGES[provider]![code]!);
}
}
}, [search.google, search.microsoft]);
const loginMutation = useMutation({
mutationFn: async (input: LoginInput) =>
api.post<AuthSession>("/api/v1/auth/login", input, { anonymous: true }),
onSuccess: (session) => {
authStore.setSession(session.accessToken, session.user);
toast.success(`Bonjour ${session.user.fullName.split(" ")[0]}.`);
// Si on a une URL de redirection (depuis le guard d'auth), on la suit ;
// sinon / qui décide quoi faire selon l'état d'onboarding.
void navigate({ to: search.redirect ?? "/" });
},
onError: (error: unknown) => {
if (error instanceof ApiError && error.status === 401) {
toast.error("Email ou mot de passe incorrect.");
return;
}
toast.error("Connexion impossible. Réessayez dans un instant.");
},
});
const form = useForm({
defaultValues: { email: "", password: "" } satisfies LoginInput,
validators: {
onChange: loginSchema,
},
onSubmit: async ({ value }) => {
await loginMutation.mutateAsync(value);
},
});
return (
<main className="min-h-screen bg-cream relative overflow-hidden">
{/* Glow rubis discret en haut-droite — signature visuelle (cohérent landing). */}
<div
aria-hidden="true"
className="pointer-events-none absolute top-[-180px] right-[-220px] size-[680px] rounded-full"
style={{
background:
"radial-gradient(circle, rgba(251,228,234,0.55), transparent 60%)",
}}
/>
<div className="relative z-10 mx-auto grid min-h-screen w-full max-w-[1180px] grid-cols-1 gap-16 px-6 py-12 lg:grid-cols-[1.1fr_1fr] lg:items-center lg:px-8">
{/* Colonne gauche — message marketing.
Décalée, dense, du caractère. Pas une carte centrée et fade. */}
<section className="order-2 lg:order-1 max-w-[520px]">
<div className="flex flex-col gap-4">
<Link to="/login" className="inline-block">
<Brand withSuffix />
</Link>
<Eyebrow>Bon retour</Eyebrow>
</div>
<h1 className="mt-4 font-display text-[44px] font-bold leading-[1.05] tracking-tight text-ink lg:text-[52px]">
Vos factures vous <em>attendent</em>.
<br className="hidden sm:block" />
On reprend vous en étiez.
</h1>
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
Connectez-vous pour voir en sont vos relances, qui a payé,
et combien de temps Rubis vous a fait gagner cette semaine.
</p>
<ul className="mt-10 flex flex-wrap gap-x-6 gap-y-3 text-[12.5px] text-ink-3">
<li className="inline-flex items-center gap-2">
<Gem size={10} /> Hébergement souverain
</li>
<li className="inline-flex items-center gap-2">
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
Made in France
</li>
<li className="inline-flex items-center gap-2">
<span className="size-1 rounded-full bg-ink-3" aria-hidden="true" />
RGPD-friendly
</li>
</ul>
</section>
{/* Colonne droite — formulaire de connexion */}
<section className="order-1 lg:order-2">
<div className="mx-auto w-full max-w-[420px] rounded-card border border-line bg-white p-8 shadow-card">
<h2 className="font-display text-2xl font-semibold tracking-[-0.018em] text-ink">
Se connecter
</h2>
<p className="mt-1.5 text-[14px] text-ink-3">
Pas encore de compte ?{" "}
<Link
to="/signup"
className="font-medium text-rubis underline-offset-4 hover:underline"
>
Créer un compte
</Link>
</p>
<div className="mt-7 flex flex-col gap-2">
<SsoButton provider="google" />
<SsoButton provider="microsoft" />
<AuthDivider />
</div>
<form
noValidate
onSubmit={(e) => {
e.preventDefault();
void form.handleSubmit();
}}
className="flex flex-col gap-5"
>
<form.Field name="email">
{(field) => (
<Field
label="Email"
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="email"
autoComplete="email"
autoFocus
placeholder="vous@entreprise.fr"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
)}
</form.Field>
<form.Field name="password">
{(field) => (
<Field
label="Mot de passe"
htmlFor={field.name}
error={field.state.meta.errors[0]?.message}
>
<Input
id={field.name}
name={field.name}
type="password"
autoComplete="current-password"
placeholder="••••••••"
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
aria-invalid={field.state.meta.errors.length > 0}
/>
</Field>
)}
</form.Field>
<Button
type="submit"
size="md"
loading={loginMutation.isPending}
className="mt-1 w-full"
>
Continuer <ArrowRight size={16} aria-hidden="true" />
</Button>
<p className="text-center text-[12.5px] text-ink-3">
<Link to="/login" className="hover:text-rubis hover:underline">
Mot de passe oublié ?
</Link>
</p>
</form>
</div>
</section>
</div>
</main>
);
}