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
7 changes: 4 additions & 3 deletions src/app/RoleRoute.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type {ReactNode} from "react";
import {Navigate} from "react-router-dom";
import {useAuth} from "../auth";
import {FullScreenSpinner} from "../components/FullScreenSpinner.tsx";

interface RoleRouteProps {
children: ReactNode;
Expand All @@ -23,13 +24,13 @@ export const RoleRoute = ({
allowVisitor,
redirectPath = "/",
}: RoleRouteProps) => {
const {isAuthenticated, isProducer, isRetailer, isLoading} = useAuth();
const {isAuthenticated, isProducer, isRetailer, isInitializing} = useAuth();

// If roles are required, enforce auth + role check
if (allowedRole) {
// Avoid premature redirects while auth is still resolving
if (isLoading) {
return <div style={{padding: 16}}>Loading…</div>;
if (isInitializing) {
return <FullScreenSpinner/>;
}
if (!isAuthenticated) {
return <Navigate to={redirectPath} replace/>;
Expand Down
12 changes: 7 additions & 5 deletions src/app/main.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {StrictMode, Suspense, lazy} from "react";
import {lazy, StrictMode, Suspense} from "react";
import {createRoot} from "react-dom/client";
import "./index.css";
import "leaflet/dist/leaflet.css";
Expand All @@ -13,14 +13,16 @@ const DiscoverPage = lazy(() => import("../pages/Discover.tsx").then(m => ({ def
const ProfilePage = lazy(() => import("../pages/Profile.tsx").then(m => ({ default: m.ProfilePage })));
const MarketplacePage = lazy(() => import("../pages/Marketplace.tsx").then(m => ({ default: m.MarketplacePage })));
const ProducerMarketplace = lazy(() => import("../users/producer/ProducerMarketplace.tsx").then(m => ({ default: m.ProducerMarketplace })));
const RetailerInventory = lazy(() => import("../users/retailer/RetailersInventory.tsx").then(m => ({ default: m.RetailersInventory })));
const RetailerInventory = lazy(() => import("../users/retailer/inventory/RetailerInventoryPage.tsx").then(m => ({ default: m.RetailerInventoryPage })));
const RetailerProfile = lazy(() => import("../users/retailer/RetailerProfile.tsx").then(m => ({ default: m.RetailerProfile })));
const ProducerProfile = lazy(() => import("../users/producer/ProducerProfile.tsx").then(m => ({ default: m.ProducerProfile })));
const GraphFeed = lazy(() => import("../pages/GraphFeed.tsx"));
const ProducerInventory = lazy(() => import("../users/producer/ProducerInventory.tsx"));
const ProducerPage = lazy(() => import("../pages/ProducerPage.tsx"));
const WinePage = lazy(() => import("../pages/WinePage.tsx"));

const RetailerCellar = lazy(() => import("../users/retailer/RetailerCellar.tsx").then(m => ({ default: m.RetailerCellar })));

const router = createBrowserRouter(
createRoutesFromElements(
<Route path="/" element={
Expand All @@ -35,7 +37,7 @@ const router = createBrowserRouter(
{/* todo fix routes and navigate throughout */}
<Route path="marketplace" element={(<MarketplacePage/>)} />
{/* RetailerInventory should be 'nested' under Marketplace properly */}
<Route path=":retailerId/inventory" element={(<RetailerInventory/>)} />
<Route path="retailer/:retailerId/inventory" element={(<RetailerInventory/>)} />
<Route path="profile" element={(<ProfilePage/>)} />
{/* Public graph detail routes: human-readable slugs in path; IDs used for data queries */}
<Route path="producer/:slug/:id" element={(<ProducerPage/>)} />
Expand All @@ -50,7 +52,7 @@ const router = createBrowserRouter(
<Outlet/>
</RoleRoute>
}>
<Route path="inventory" element={(<RetailerInventory/>)} />
<Route path="cellar" element={(<RetailerCellar/>)} />
<Route path="profile" element={(<RetailerProfile/>)} />
</Route>
{/* Retailer-wide routes (not tied to ID but still protected) */}
Expand All @@ -77,7 +79,7 @@ const router = createBrowserRouter(
>
<Route path="profile" element={(<ProducerProfile/>)} />
{/* Producer Inventory (includes CSV Import component) */}
<Route path="inventory" element={(<ProducerInventory/>)} />
<Route path="cellar" element={(<ProducerInventory/>)} />
</Route>
<Route
path="marketplace"
Expand Down
13 changes: 6 additions & 7 deletions src/app/roleNavConfig.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import {
BarChart3,
Globe,
MessageCircleQuestion,
Package,
Store,
User
Expand All @@ -18,6 +17,7 @@ export type NavLinkDef = {
// {title: "Activity", icon: Activity, route: "/activity"},
// {title: "Analytics", icon: BarChart3, route: "/analytics"},
// {title: "Settings", icon: Settings, route: "/"},
// {title: "Help", icon: MessageCircleQuestion, route: "/"},
// ];

// Base links common to most roles
Expand All @@ -26,17 +26,16 @@ const baseLinks: NavLinkDef[] = [
{title: "Discover", icon: Globe, route: "/explore"},
{title: "Marketplace", icon: Store, route: "/marketplace"},
{title: "Profile", icon: User, route: "/profile"},
{title: "Help", icon: MessageCircleQuestion, route: "/"},
//...genericLinks,
];

// Role-specific augmentations
function retailerLinks(retailerId: string): NavLinkDef[] {
// Use dynamic retailerId paths to match router: /retailer/:retailerId/...
const cellar: NavLinkDef = {title: "Cellar", icon: Package, route: `/retailer/${retailerId}/inventory`};
const cellar: NavLinkDef = {title: "Cellar", icon: Package, route: `/retailer/${retailerId}/cellar`};
const marketplace: NavLinkDef = {title: "Marketplace", icon: Store, route: "/retailer/marketplace"};
//const profile: NavLinkDef = {title: "Profile", icon: User, route: `/retailer/${retailerId}/profile`};
return [baseLinks[0], marketplace, cellar, baseLinks[4]];
return [baseLinks[0], marketplace, cellar, baseLinks[3]];
}

function visitorLinks(): NavLinkDef[] {
Expand All @@ -48,9 +47,9 @@ function enthusiastLinks(): NavLinkDef[] {
}

function producerLinks(producerId: string): NavLinkDef[] {
const cellar: NavLinkDef = {title: "Cellar", icon: Package, route: `/producer/${producerId}/inventory`};
const marketplace: NavLinkDef = {title: "Marketplace", icon: Store, route: "/producer/marketplace"};
return [baseLinks[0], marketplace, cellar, baseLinks[4]];
const cellar: NavLinkDef = {title: "Cellar", icon: Package, route: `/producer/${producerId}/cellar`};
const marketplace: NavLinkDef = {title: "Marketplace", icon: Store, route: "/retailer/marketplace"};
return [baseLinks[0], marketplace, cellar, baseLinks[3]];
}

export function resolveNavLinksByRole(role: string, userId?: string): NavLinkDef[] {
Expand Down
56 changes: 30 additions & 26 deletions src/auth/AuthManager.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,58 @@
import {type ReactNode, useEffect} from "react";
import Spinner from "../components/Spinner.tsx";
import {useShopifyOAuth} from "./shopify.ts";
import {useCloverOAuth} from "./clover.ts";
import {useSquareOAuth} from "./square.ts";
import {useGoogleOidc} from "./google.ts";
import {useMachine} from "@xstate/react";
import {authMachine} from "./authMachine.ts";
import {AuthContext, type AuthContextValue} from "./authContext.ts";
import {useAuthService} from "./authSystem.tsx";
import {FullScreenSpinner} from "../components/FullScreenSpinner.tsx";
import {useNavigate} from "react-router-dom";
import {posOAuthMachine} from "./posOAuthMachine.ts";
import type {PosProvider} from "./types.ts";

/**
* Handles OAuth redirection side effects.
* Kept separate to prevent AuthProvider from becoming a "God Component".
*/
export const AuthManager = ({auth}: { auth: AuthContextValue }) => {
const navigate = useNavigate();
const google = useGoogleOidc({auth});
const square = useSquareOAuth({auth});
const clover = useCloverOAuth({auth});
const shopify = useShopifyOAuth({auth});
const [oauthState, oauthSend] = useMachine(posOAuthMachine, {
input: {
fetchUser: auth.fetchUser,
loadPos: auth.pos.load,
},
});

const processingPosOAuth = oauthState.matches("processing");
const redirectPath = oauthState.context.redirectPath;

useEffect(() => {
google.authorize();
}, [google]);
useEffect(() => {
if (sessionStorage.getItem("square_oauth_pending")) square.authenticate();
}, [square]);
useEffect(() => {
if (sessionStorage.getItem("clover_oauth_pending")) clover.authenticate();
}, [clover]);
const provider = (["square", "clover", "shopify"] as PosProvider[]).find((p) => {
return !!sessionStorage.getItem(`${p}_oauth_pending`);
});
if (!provider) return;
oauthSend({type: "START", provider});
}, [oauthSend]);

useEffect(() => {
if (sessionStorage.getItem("shopify_oauth_pending")) shopify.authenticate();
}, [shopify]);
if (!redirectPath) return;
navigate(redirectPath, {replace: true});
oauthSend({type: "RESET"});
}, [navigate, oauthSend, redirectPath]);

return google.isProcessing ? (
return (google.isProcessing || processingPosOAuth) ? (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-white/80">
<Spinner label="Completing login..."/>
<FullScreenSpinner label={google.isProcessing ? "Completing login..." : "Completing POS authorization..."}/>
</div>
) : null;
};

export const AuthProvider = ({children}: { children: ReactNode }) => {
const [state, , actor] = useMachine(authMachine);
const [, , actor] = useMachine(authMachine);
const auth = useAuthService(actor);

if (state.matches("loading")) {
return (
<div className="flex h-screen items-center justify-center">
<Spinner label="Authenticating…"/>
</div>
);
if (auth.isInitializing) {
return <FullScreenSpinner label="Setting up your session..."/>
}

return (
Expand Down
13 changes: 7 additions & 6 deletions src/auth/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ This module centralizes the React provider, consumer hook, and facade for auth i
| Flow | Steps |
|------|-------|
| Google Login | Click → `/session/authenticate` → Google → `/session/complete?state=xyz` → `auth.login()` |
| Square Connect | Click → `/square/authorize` → Square → callback → server sets session → redirects to `/retailer?id=123` → `useSquareOAuth` → `auth.loadPos` → `auth.fetchUser` |
| Square Connect | Click → `/square/authorize` → Square → callback → server sets session → redirects to `/retailer?id=123` → `posOAuthMachine` (via `AuthManager`) → `auth.loadPos` → `auth.fetchUser` |
| Clover Connect | Same as Square, different provider path |
| Shopify Connect | Click → shop capture → `/shopify/authorize?shop=...` → callback → server redirects → `useShopifyOAuth` → `auth.loadPos` → `auth.fetchUser` |
| Shopify Connect | Click → shop capture → `/shopify/authorize?shop=...` → callback → server redirects → `posOAuthMachine` (via `AuthManager`) → `auth.loadPos` → `auth.fetchUser` |

Notes:
- No frontend POST to provider callbacks; the backend (Quarkus) completes the OAuth flow and redirects the browser back with query params.
Expand All @@ -34,13 +34,14 @@ Notes:
| `index.ts` | Public barrel: import everything from `src/auth` |
| `authSystem.tsx` | Consolidated module: `AuthProvider`, `AuthContext`/`useAuth`, and `useAuthService` facade (selectors + actions) |
| `authMachine.ts` | XState machine — single source of truth for state logic |
| `posOAuthMachine.ts` | XState machine that completes POS OAuth callbacks and drives redirect/error outcomes |
| `types.ts` | `SessionUser`, `Role`, POS types and helpers (`deriveRole`, `hasRole`) |
| `storage.ts` | Persist user/token in web storage |
| `authClient.ts` | Axios client + auth/session/POS helpers |
| `google.ts` | Google OIDC completion hook |
| `square.ts` | Square OAuth completion hook |
| `clover.ts` | Clover OAuth completion hook |
| `shopify.ts` | Shopify OAuth completion hook |
| `square.ts` | Legacy compatibility module (not exported by barrel) |
| `clover.ts` | Legacy compatibility module (not exported by barrel) |
| `shopify.ts` | Legacy compatibility module (not exported by barrel) |
| `authContext.ts` | Re-export shim: re-exports `AuthContext` and `useAuth` from `authSystem.tsx` |
| `AuthProvider.tsx` | Re-export shim: re-exports `AuthProvider` from `authSystem.tsx` |
| `useAuthService.ts` | Re-export shim: re-exports `useAuthService` (for deep import compatibility) |
Expand All @@ -53,7 +54,7 @@ Always import from the barrel to keep call sites stable:

```ts
import { AuthProvider, useAuth } from "../auth";
import { startAuthentication, useSquareOAuth } from "../auth";
import { startAuthentication } from "../auth";
import type { Role, PosToken } from "../auth";
```

Expand Down
Loading