Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77389aea19 | ||
|
|
da10a4b688 | ||
|
|
0b7dc7b6de | ||
|
|
2601e5e83e | ||
|
|
fecac320d9 | ||
|
|
811e0e0887 | ||
|
|
aee6515890 | ||
|
|
54be99fa2c |
No files matched your search
@@ -23,7 +23,10 @@ ENV CI=true \
|
|||||||
S3_BUCKET=ari-shopping \
|
S3_BUCKET=ari-shopping \
|
||||||
S3_ENDPOINT=http://127.0.0.1:9000 \
|
S3_ENDPOINT=http://127.0.0.1:9000 \
|
||||||
MEILISEARCH_HOST=http://127.0.0.1:7700 \
|
MEILISEARCH_HOST=http://127.0.0.1:7700 \
|
||||||
MEILISEARCH_API_KEY=build-only-meili-key
|
MEILISEARCH_API_KEY=build-only-meili-key \
|
||||||
|
GOOGLE_CLIENT_ID=build-only-google-client-id \
|
||||||
|
GOOGLE_CLIENT_SECRET=build-only-google-client-secret \
|
||||||
|
GOOGLE_CALLBACK_URL=http://localhost:9000/auth/customer/google/callback
|
||||||
|
|
||||||
COPY package.json package-lock.json .npmrc ./
|
COPY package.json package-lock.json .npmrc ./
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,30 @@ module.exports = defineConfig({
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth",
|
||||||
|
options: {
|
||||||
|
providers: [
|
||||||
|
// emailpass se declara explicito junto con google -- una vez
|
||||||
|
// que el modulo auth se customiza, la doc no deja claro si el
|
||||||
|
// provider default (emailpass) se sigue registrando solo.
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth-emailpass",
|
||||||
|
id: "emailpass",
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resolve: "@medusajs/medusa/auth-google",
|
||||||
|
id: "google",
|
||||||
|
options: {
|
||||||
|
clientId: required("GOOGLE_CLIENT_ID"),
|
||||||
|
clientSecret: required("GOOGLE_CLIENT_SECRET"),
|
||||||
|
callbackUrl: required("GOOGLE_CALLBACK_URL"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
|
// ADR 0006 Etapa 2. Sin required(): a diferencia de S3/DB, Meilisearch
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { handleGoogleCallback } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { code, state } = await request.json();
|
||||||
|
if (!code || !state) {
|
||||||
|
return Response.json({ error: "Faltan parámetros de Google" }, { status: 400 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const customer = await handleGoogleCallback(code, state);
|
||||||
|
return Response.json(customer);
|
||||||
|
} catch (error) {
|
||||||
|
return Response.json({ error: (error as Error).message }, { status: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { getGoogleAuthUrl } from "@/lib/headless/providers/medusa-customer";
|
||||||
|
|
||||||
|
// Navegacion de pagina completa (no fetch/XHR) -- el boton "Continuar
|
||||||
|
// con Google" enlaza directo aca, el browser sigue el 302 hasta la
|
||||||
|
// pantalla real de consentimiento de Google.
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const location = await getGoogleAuthUrl();
|
||||||
|
return Response.redirect(location);
|
||||||
|
} catch (error) {
|
||||||
|
const message = encodeURIComponent((error as Error).message);
|
||||||
|
return Response.redirect(new URL(`/?googleAuthError=${message}`, request.url));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Suspense } from "react";
|
||||||
|
import { GoogleCallbackClient } from "@/components/account/google-callback-client";
|
||||||
|
|
||||||
|
export default function GoogleCallbackPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<GoogleCallbackClient />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,17 @@ import { useCustomerStore } from "@/store/customer-store";
|
|||||||
|
|
||||||
type Mode = "login" | "register";
|
type Mode = "login" | "register";
|
||||||
|
|
||||||
|
function GoogleIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 48 48" className="size-4" aria-hidden="true">
|
||||||
|
<path fill="#FFC107" d="M43.6 20.5H42V20H24v8h11.3c-1.6 4.7-6.1 8-11.3 8-6.6 0-12-5.4-12-12s5.4-12 12-12c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 12.4 3 3 12.4 3 24s9.4 21 21 21 21-9.4 21-21c0-1.4-.1-2.7-.4-3.5z" />
|
||||||
|
<path fill="#FF3D00" d="M6.3 14.7l6.6 4.8C14.6 15.9 18.9 13 24 13c3.1 0 5.8 1.1 8 3l6-6C34 5.1 29.3 3 24 3 16.1 3 9.3 7.6 6.3 14.7z" />
|
||||||
|
<path fill="#4CAF50" d="M24 45c5.2 0 9.9-2 13.4-5.2l-6.2-5.2c-2 1.5-4.6 2.4-7.2 2.4-5.2 0-9.6-3.3-11.3-8l-6.5 5C9.2 40.3 16 45 24 45z" />
|
||||||
|
<path fill="#1976D2" d="M43.6 20.5H42V20H24v8h11.3c-.8 2.3-2.2 4.3-4.1 5.6l6.2 5.2C41.4 35.9 45 30.5 45 24c0-1.4-.1-2.7-.4-3.5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
|
const { customer, hasHydrated, loading, error, login, register, logout } = useCustomerStore();
|
||||||
const [mode, setMode] = useState<Mode>("login");
|
const [mode, setMode] = useState<Mode>("login");
|
||||||
@@ -57,6 +68,14 @@ export function AccountDialog({ open, onOpenChange }: { open: boolean; onOpenCha
|
|||||||
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
|
{loading ? "Un momento…" : mode === "login" ? "Iniciar sesión" : "Crear cuenta"}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
<div className="mt-3 flex items-center gap-3 text-xs font-bold uppercase tracking-wide text-slate-400">
|
||||||
|
<span className="h-px flex-1 bg-slate-100" />o<span className="h-px flex-1 bg-slate-100" />
|
||||||
|
</div>
|
||||||
|
<Button asChild variant="outline" size="lg" className="mt-3 w-full">
|
||||||
|
<a href="/api/customer/google/start">
|
||||||
|
<GoogleIcon />Continuar con Google
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
|
className="mt-3 text-sm font-bold text-ari-marino hover:underline"
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useCustomerStore } from "@/store/customer-store";
|
||||||
|
|
||||||
|
export function GoogleCallbackClient() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const loginWithGoogle = useCustomerStore((state) => state.loginWithGoogle);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const ran = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Guard contra el doble-mount de StrictMode -- el code de Google es
|
||||||
|
// de un solo uso, un segundo intento con el mismo code fallaria.
|
||||||
|
if (ran.current) return;
|
||||||
|
ran.current = true;
|
||||||
|
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
if (!code || !state) {
|
||||||
|
setError("Faltan parámetros de Google en la URL de retorno.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loginWithGoogle(code, state).then((ok) => {
|
||||||
|
router.replace(ok ? "/" : "/?googleAuthError=1");
|
||||||
|
});
|
||||||
|
}, [searchParams, loginWithGoogle, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[60vh] flex-col items-center justify-center gap-3 text-center">
|
||||||
|
{error ? (
|
||||||
|
<>
|
||||||
|
<p className="text-lg font-black text-rose-600">No se pudo iniciar sesión con Google</p>
|
||||||
|
<p className="text-sm text-slate-600">{error}</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm font-bold text-slate-600">Conectando con Google…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,7 +25,7 @@ spec:
|
|||||||
runAsUser: 1001
|
runAsUser: 1001
|
||||||
containers:
|
containers:
|
||||||
- name: app
|
- name: app
|
||||||
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.211
|
image: gitea.cruzcloud.net/devops/ecommerce-frontend:v1.0.213
|
||||||
env:
|
env:
|
||||||
- name: HEADLESS_PROVIDER
|
- name: HEADLESS_PROVIDER
|
||||||
value: medusa
|
value: medusa
|
||||||
|
|||||||
@@ -87,6 +87,56 @@ export async function loginCustomer(email: string, password: string): Promise<Cu
|
|||||||
return getCurrentCustomer(data.token as string);
|
return getCurrentCustomer(data.token as string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Flujo Google verificado en vivo (2026-08-29) contra el pod real, no
|
||||||
|
// solo contra la doc: GET /auth/customer/google funciona igual que el
|
||||||
|
// POST documentado (sin body, no hace falta callback_url -- ya viene de
|
||||||
|
// GOOGLE_CALLBACK_URL en el modulo); la respuesta no trae Set-Cookie, o
|
||||||
|
// sea que el "state" que Google reenvia en el callback es autocontenido
|
||||||
|
// (firmado), no depende de sesion de servidor -- confirmado con un
|
||||||
|
// callback con code/state inventados: 401 "No state provided, or
|
||||||
|
// session expired" (mismo mensaje que con state ausente, no un error de
|
||||||
|
// sesion real). auth/token/refresh confirmado que exige Bearer valido
|
||||||
|
// (401 "Unauthorized" con uno invalido, no 404).
|
||||||
|
function decodeJwtPayload(token: string): Record<string, unknown> {
|
||||||
|
const payload = token.split(".")[1] ?? "";
|
||||||
|
const base64 = payload.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const json = Buffer.from(base64, "base64").toString("utf-8");
|
||||||
|
return JSON.parse(json || "{}");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGoogleAuthUrl(): Promise<string> {
|
||||||
|
const data = await medusaAuthFetch("/auth/customer/google");
|
||||||
|
return data.location as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleGoogleCallback(code: string, state: string): Promise<CustomerSummary> {
|
||||||
|
const query = new URLSearchParams({ code, state }).toString();
|
||||||
|
const data = await medusaAuthFetch(`/auth/customer/google/callback?${query}`, { method: "POST" });
|
||||||
|
const initialToken = data.token as string;
|
||||||
|
const payload = decodeJwtPayload(initialToken);
|
||||||
|
const actorId = payload.actor_id as string | undefined;
|
||||||
|
|
||||||
|
if (!actorId) {
|
||||||
|
// Primer login con Google -- todavia no existe el customer real.
|
||||||
|
const userMetadata = (payload.user_metadata as Record<string, unknown> | undefined) ?? {};
|
||||||
|
const email = userMetadata.email as string;
|
||||||
|
const firstName = (userMetadata.given_name as string) ?? (userMetadata.name as string) ?? email.split("@")[0];
|
||||||
|
const lastName = (userMetadata.family_name as string) ?? "";
|
||||||
|
await medusaAuthFetch("/store/customers", {
|
||||||
|
method: "POST",
|
||||||
|
token: initialToken,
|
||||||
|
body: { email, first_name: firstName, last_name: lastName },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// El token del callback (o el de recien crear el customer) todavia no
|
||||||
|
// sirve como sesion final -- hay que refrescarlo, tanto para usuarios
|
||||||
|
// nuevos como para los que ya existian (confirmado contra la doc: los
|
||||||
|
// dos casos convergen en el mismo paso de refresh).
|
||||||
|
const refreshed = await medusaAuthFetch("/auth/token/refresh", { method: "POST", token: initialToken });
|
||||||
|
return getCurrentCustomer(refreshed.token as string);
|
||||||
|
}
|
||||||
|
|
||||||
export async function registerCustomer(
|
export async function registerCustomer(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ interface CustomerState {
|
|||||||
setHasHydrated: (value: boolean) => void;
|
setHasHydrated: (value: boolean) => void;
|
||||||
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
|
register: (email: string, password: string, firstName: string, lastName: string) => Promise<boolean>;
|
||||||
login: (email: string, password: string) => Promise<boolean>;
|
login: (email: string, password: string) => Promise<boolean>;
|
||||||
|
loginWithGoogle: (code: string, state: string) => Promise<boolean>;
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
syncCustomer: () => Promise<void>;
|
syncCustomer: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -77,6 +78,17 @@ export const useCustomerStore = create<CustomerState>()(
|
|||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
loginWithGoogle: async (code, state) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
const result = await postJson("/api/customer/google/callback", { code, state });
|
||||||
|
if (result.error) {
|
||||||
|
set({ loading: false, error: result.error });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
set({ loading: false, token: result.token, customer: toCustomer(result) });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
logout: () => set({ token: null, customer: null, error: null }),
|
logout: () => set({ token: null, customer: null, error: null }),
|
||||||
|
|
||||||
// El token guardado en localStorage puede llevar dias o haber sido
|
// El token guardado en localStorage puede llevar dias o haber sido
|
||||||
|
|||||||
Reference in new issue
Block a user