Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/auth/AuthDebug.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {useAuth} from "./authContext.ts";

export const AuthDebug = () => {
const {user, pos, isAuthenticated} = useAuth();
const {user, currentProvider, currentPosToken, isAuthenticated} = useAuth();
return (
<pre className="text-xs">
{JSON.stringify({ isAuthenticated, user, pos }, null, 2)}
{JSON.stringify({ isAuthenticated, user, currentProvider, currentPosToken }, null, 2)}
</pre>
);
}
26 changes: 19 additions & 7 deletions src/auth/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {authMachine} from "./authMachine";
import {useAuthService} from "./useAuthService.ts";
import {useGoogleOidc} from "./google";
import {useSquareOAuth} from "./square.ts";
import {useCloverOAuth} from "./clover.ts";
import {useShopifyOAuth} from "./shopify.ts";
import Spinner from "../components/common/Spinner";
import useRetailerOnboarding from "../users/retailer/useRetailerOnboarding.ts";

export const AuthProvider = ({children}: { children: ReactNode }) => {

Expand All @@ -28,12 +29,23 @@ export const AuthProvider = ({children}: { children: ReactNode }) => {
square.authenticate();
}, [square]);

// Implicit retailer onboarding: runs once when Square is authorized
const squareToken = auth.pos.square;
const isAuthorized = !!squareToken && new Date(squareToken.expires_at).getTime() > Date.now();
const retailerId = auth.user?.user.role.id ?? null;
const merchantId = squareToken?.merchant_id ?? null;
useRetailerOnboarding({retailerId, merchantId, isAuthorized});
// Handle Clover OAuth
const clover = useCloverOAuth({auth});
useEffect(() => {
const pending = sessionStorage.getItem("clover_oauth_pending");
if (!pending) return;
clover.authenticate();
}, [clover]);

// Handle Shopify OAuth
const shopify = useShopifyOAuth({auth});
useEffect(() => {
const pending = sessionStorage.getItem("shopify_oauth_pending");
if (!pending) return;
shopify.authenticate();
}, [shopify]);

// NOTE: Do not trigger onboarding automatically on page load. Onboarding is a one-time, explicit flow.

// CORRECT: matches({ authenticated: "idle" })
if (state.matches("loading") || google.isProcessing) {
Expand Down
85 changes: 72 additions & 13 deletions src/auth/authClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const saveRole = async (
): Promise<SessionUser> => {
const token = storage.getToken();
// Backend expects RoleEnum, which is typically uppercase. Send uppercase to avoid 400.
const body = { role: role.toUpperCase() } as unknown as { role: string };
const body = {role: role.toUpperCase()} as unknown as { role: string };
const {data} = await api.patch(
"/session/user",
body,
Expand All @@ -73,28 +73,87 @@ export const saveRole = async (
return data;
};

export const startAuthorization = (userId: string): void => {
export const startAuthorization = (provider: "square" | "clover" | "shopify", userId: string, shop: string | null): void => {
if (!userId) {
console.warn("Cannot start Square OAuth: missing userId");
console.warn(`Cannot start ${provider} OAuth: missing userId`);
return;
}
sessionStorage.setItem("square_oauth_pending", "true");
window.location.assign(`${BASE_URL}/square/authorize?id=${userId}`);
const key = `${provider}_oauth_pending`;
sessionStorage.setItem(key, "true");

// For Shopify, pass the shop value as provided by the user. Backend will normalize it.
if (provider === "shopify") {
const provided = shop ?? "";
if (!provided) {
console.warn("Shopify OAuth requires a shop value");
sessionStorage.removeItem(key);
return;
}
// Swagger: /{provider}/authorize?userId=...&shop=...
window.location.assign(`${BASE_URL}/${provider}/authorize?user_id=${encodeURIComponent(userId)}&shop=${encodeURIComponent(provided)}`);
return;
}

// Square / Clover don't require extra params
// Swagger: /{provider}/authorize?userId=...
window.location.assign(`${BASE_URL}/${provider}/authorize?user_id=${encodeURIComponent(userId)}`);
};

// === POS: Only status & refresh ===
export const getPosToken = async (provider: "square" | "clover", merchantId?: string | null) => {
export const getPosToken = async (provider: "square" | "clover" | "shopify", merchantId?: string | null) => {
if (!merchantId) {
console.error("NO MERCHANT ID for:", provider);
console.warn(`[pos] Skipping ${provider} token load: missing merchantId`);
return null;
}
const {data} = await api.get(`/${provider}/status`, {params: {merchant_id: merchantId}});
return data;
// Swagger: GET /{provider}/token?merchantId=...
const {data} = await api.get(`/${provider}/token`, {params: {merchant_id: merchantId}});

if (!data) return null;
const expiry = pickExpiryMs(data);

return {
merchantId: data.merchantId ?? data.merchant_id ?? "",
expiry,
token: data.token ?? undefined,
} as { merchantId: string; expiry: number | null; token?: string };
};

export const refreshPosToken = async (provider: "square" | "clover", merchantId?: string | null) => {
export const refreshPosToken = async (provider: "square" | "clover" | "shopify", merchantId?: string | null) => {
if (!merchantId) {
console.error("NO MERCHANT ID for:", provider);
console.warn(`[pos] Skipping ${provider} token refresh: missing merchantId`);
return null;
}
// Swagger: POST /{provider}/refresh?merchantId=...
const {data} = await api.post(`/${provider}/refresh`, null, {params: {merchant_id: merchantId}});
return data;
};
if (!data) return null;
const expiry = pickExpiryMs(data);
return {
merchantId: data.merchantId ?? data.merchant_id ?? "",
expiry,
token: data.token ?? undefined,
} as { merchantId: string; expiry: number | null; token?: string };
};

// === Expiry selection: backend exposes multiple fields; prefer ms when present ===
function pickExpiryMs(data: any): number | null {
// 1) Prefer numeric epoch ms directly
const ms = data?.expiresAtMs;
if (typeof ms === "number" && Number.isFinite(ms) && ms > 0) return Math.round(ms);

// 2) If ISO string Instant exists, parse it
const iso = data?.expiresAt ?? "";
if (typeof iso === "string" && iso.trim()) {
// Trim fractional seconds beyond ms precision to avoid parse inconsistencies
const trimmed = iso.replace(/\.(\d{3})\d+(Z|[+\-]\d{2}:\d{2})$/, ".$1$2");
const parsed = Date.parse(trimmed);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}

// 3) As a fallback, if seconds TTL provided, convert relative seconds to absolute ms
const secs = data?.expiresInSeconds;
if (typeof secs === "number" && Number.isFinite(secs) && secs > 0) {
return Date.now() + Math.round(secs * 1000);
}

return null;
}
14 changes: 9 additions & 5 deletions src/auth/authContext.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import {createContext, useContext} from "react";
import type {AuthContextValue} from "./types";
import { createContext, useContext } from "react";
import { useAuthService } from "./useAuthService";

export const AuthContext = createContext<AuthContextValue>(null as never);
// Infer the exact return type from useAuthService — no duplication!
export type AuthContextValue = ReturnType<typeof useAuthService>;

export const AuthContext = createContext<AuthContextValue | null>(null);

/**
* Custom hook to access auth context values.
* Loads XState machine into the React context.
*/
export const useAuth = (): AuthContextValue => {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within an AuthProvider");
if (!ctx) {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
};
Loading