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>
237 lines
8.7 KiB
TypeScript
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 où vous en étiez.
|
|
</h1>
|
|
<p className="mt-5 max-w-md text-[16px] leading-[1.6] text-ink-2">
|
|
Connectez-vous pour voir où 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>
|
|
);
|
|
}
|