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/App.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Header from "./components/nav/Header";
import SideNavbar from "./components/nav/SideNavbar";
import Header from "./nav/Header";
import SideNavbar from "./nav/SideNavbar";
import { Outlet } from "react-router-dom";

const App = () => {
Expand Down
15 changes: 8 additions & 7 deletions src/components/common/GoogleProfile.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import SectionCard from "./SectionCard.tsx";
import {FaConnectdevelop} from "../../assets/icons.ts";
import type {User} from "../../context/authContext.ts";
import React from "react";

const GoogleProfile: React.FC<User> = (user: User) => {
type GoogleProfileProps = { name: string; picture: string; email: string };

const GoogleProfile: React.FC<GoogleProfileProps> = (profile: GoogleProfileProps) => {
return (
<SectionCard cardHeader={{icon: FaConnectdevelop, title: "Your Google Account"}}>
<div className="flex items-center gap-4 p-4">
{user?.pictureUrl ? (
{profile.picture ? (
<img
src={user.pictureUrl}
alt={user.name ?? "User"}
src={profile.picture}
alt={profile.name ?? "User"}
className="w-18 h-18 rounded-full object-cover border"
/>
) : null}
<div className="flex flex-col">
<span className="text-textPrimary text-lg font-medium">{user?.name ?? "Unknown User"}</span>
<span className="text-textSecondary text-sm">{user?.email ?? "No email on file"}</span>
<span className="text-textPrimary text-lg font-medium">{profile.name ?? "Unknown User"}</span>
<span className="text-textSecondary text-sm">{profile.email ?? "No email on file"}</span>
</div>
</div>
</SectionCard>
Expand Down
172 changes: 108 additions & 64 deletions src/context/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,127 @@
import {type ReactNode, useEffect, useMemo, useState} from "react";
import {AuthContext, type AuthContextValue, type AuthState, type User} from "./authContext";
import {getCurrentUser} from "../services/sessionClient";
import {type ReactNode, useCallback, useEffect, useMemo, useState} from "react";
import {AuthContext, type AuthContextValue, type AuthState, type SessionUser} from "./authContext";
import {completeAuth, getCurrentUser} from "../services/authClient";

export const AuthProvider = ({children}: { children: ReactNode }) => {
// Initialize from localStorage on first render
const [state, setState] = useState<AuthState>(() => {
const stored = localStorage.getItem("user");
const user = stored ? (JSON.parse(stored) as User) : null;
return {
isAuthenticated: !!user,
user,
token: null,
};
try {
const storedUserStr = localStorage.getItem("user");
const storedToken = sessionStorage.getItem("token");
const parsed = storedUserStr ? JSON.parse(storedUserStr) : null;
const isSessionUser = !!parsed && typeof parsed === "object" && "user" in parsed && "token" in parsed;
const user = isSessionUser ? (parsed as SessionUser) : null;
if (!isSessionUser && storedUserStr) {
// Clean up legacy or corrupt value that stored only the inner user
localStorage.removeItem("user");
}
return {
isAuthenticated: !!user && !!storedToken,
user,
};
} catch {
// Corrupt storage – clear it so the app can recover
localStorage.removeItem("user");
sessionStorage.removeItem("token");
return { isAuthenticated: false, user: null };
}
});

// Hydrate from backend session on mount only if we don't already have a user
// keep local and session storage in sync with the full SessionUser shape
useEffect(() => {
let cancelled = false;
if (state.user) localStorage.setItem("user", JSON.stringify(state.user));
else localStorage.removeItem("user");

const hydrate = async () => {
// Skip if we already have a user (from localStorage or previous session)
if (state.user) return;
if (state.user?.token) sessionStorage.setItem("token", state.user.token);
else sessionStorage.removeItem("token");
}, [state.user, state.user?.token]);

// Rehydrate user on refresh if we have a token but no user (e.g., from older builds that stored only inner user)
useEffect(() => {
if (state.user) return;
const token = sessionStorage.getItem("token");
if (!token) return;
(async () => {
try {
const fetched = await getCurrentUser();
let finalUser: User | null = fetched ?? null;
const fetched = await getCurrentUser(token);
setState(prev => ({ ...prev, user: fetched, isAuthenticated: true }));
} catch (e) {
console.error("Failed to rehydrate user from token:", e);
}
})();
}, [state.user]);

// ENV user role OVERRIDES
if (finalUser && import.meta.env.MODE === "visitor") {
const roles = Array.isArray(finalUser.roles) ? [...finalUser.roles] : [];
roles.pop();
roles.push(import.meta.env.VITE_VISITOR_ROLE);
finalUser = {...finalUser, roles} as User;
console.log("Overriding user role to visitor", finalUser);
}
// handle redirect back after Google OIDC (with ?state=xyz)
useEffect(() => {
const urlState = new URLSearchParams(window.location.search).get("state");
if (!urlState) return;
(async () => {
try {
console.log("redirecting back from Google OIDC with state:", urlState);
const data = await completeAuth(urlState);
setState(authState => ({...authState, user: data, isAuthenticated: true}));
// remove the query param for a clean URL
window.history.replaceState({}, document.title, "/profile");
} catch (err) {
console.error("Failed to complete auth:", err);
}
})();
}, []);

// optional: rehydrate user using token
const refreshUser = useCallback(async (): Promise<SessionUser | null> => {
const token = state.user?.token;
if (!token) throw new Error("No token to refresh user");
console.log("refreshing user");
try {
const fetched = await getCurrentUser(token);
setState(prev => ({ ...prev, user: fetched, isAuthenticated: !!fetched }));
return fetched;
} catch {
return state.user;
}
}, [state.user?.token]);

if (cancelled) return;
// Handle redirect back after Square OAuth
useEffect(() => {
// Only run if we previously initiated Square auth
const pending = sessionStorage.getItem("squareAuthPending");
if (!pending) return;

if (finalUser) {
localStorage.setItem("user", JSON.stringify(finalUser));
setState({isAuthenticated: true, user: finalUser, token: null});
console.log("User hydrated from backend session:", finalUser);
} else {
localStorage.removeItem("user");
setState({isAuthenticated: false, user: null, token: null});
console.log("No user found in backend session");
const url = new URL(window.location.href);
const hasRetailerParam = url.searchParams.has("id");
const isRetailerPath = window.location.pathname.includes("/retailer");
const isSquareCallback = isRetailerPath || hasRetailerParam; // support old and new redirects
if (!isSquareCallback) return;

(async () => {
try {
console.log("Square OAuth redirect detected; refreshing user...");
await refreshUser(); // this will also update local/session storage via the existing effect
} catch (err) {
console.error("Failed to refresh user after Square OAuth:", err);
} finally {
// Clean up the marker and the query param for a clean URL
sessionStorage.removeItem("squareAuthPending");
if (isRetailerPath) {
window.history.replaceState({}, document.title, "/retailer");
} else if (hasRetailerParam) {
// strip query string but keep current path
window.history.replaceState({}, document.title, window.location.pathname);
}
} catch {
// Leave state as-is on error
}
};
})();
}, [refreshUser]);

void hydrate();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
const login = useCallback((user?: SessionUser, token?: string) => {
setState(prev => ({
...prev,
isAuthenticated: !!user && !!token,
user: user ?? prev.user
}));
}, []);

//Expose login helper and persist to localStorage
const login = (user?: User, token?: string) => {
setState((prev) => {
const nextUser = user ?? prev.user;
console.log("Logging in:", nextUser);
if (nextUser) {
localStorage.setItem("user", JSON.stringify(nextUser));
} else {
localStorage.removeItem("user");
}
return {
...prev,
isAuthenticated: !!nextUser,
user: nextUser,
token: token ?? prev.token,
};
});
};

const value = useMemo<AuthContextValue>(() => ({...state, login}), [state]);
const value = useMemo<AuthContextValue>(() => ({...state, login, refreshUser}),
[state]);

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};
};
54 changes: 13 additions & 41 deletions src/context/RetailerProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,25 @@
import {type ReactNode, useEffect, useRef, useState} from "react";
import {type ReactNode, useEffect, useState} from "react";
import {RetailerContext, type RetailerContextValue} from "./retailerContext.ts";
import {useNavigate, useSearchParams} from "react-router-dom";
import {useAuth} from "./authContext";

export const RetailerProvider = ({children}: { children: ReactNode }) => {
const {user} = useAuth();
const [retailerId, setRetailerId] = useState<string | null>(
() => localStorage.getItem("retailerId") // hydrate from localStorage on the first render
);

const [searchParams] = useSearchParams();
const navigate = useNavigate();
const callbackRetailerId = searchParams.get("id");
const hasHandledCallback = useRef(false);

// Handle (data-adapter) Square OAuth callback (?id=...)
useEffect(() => {
if (hasHandledCallback.current) return;
const retailer = user?.user ?? null;

// only handle once and only if we have both pieces
if (callbackRetailerId && user) {
// only set retailer if none exists already
const existingId = localStorage.getItem("retailerId");
if (!existingId) {
hasHandledCallback.current = true;
console.log("Handling Square OAuth callback:", callbackRetailerId);

// persist retailer ID
console.log("Setting retailerId for user:", callbackRetailerId);
localStorage.setItem("retailerId", callbackRetailerId);
setRetailerId(callbackRetailerId);

// clean URL + navigate to retailer profile
navigate(`/retailer/${callbackRetailerId}/profile`, { replace: true });
} else {
console.log("Existing retailerId detected:", existingId);
setRetailerId(existingId);
}
}
}, [callbackRetailerId, user, navigate]);
// Derive retailerId from the authenticated user's role (no localStorage persistence)
const [retailerId, setRetailerId] = useState<string | null>(() => {
if (retailer?.role?.value === "retailer") return retailer.role.id ?? null;
return null;
});

// Optional: handle VITE_DEV_RETAILER_ID override in dev mode
// Keep retailerId in sync with user role
useEffect(() => {
if (import.meta.env.MODE === "retailer" && import.meta.env.VITE_RETAILER_ID) {
const devRetailerId = import.meta.env.VITE_RETAILER_ID;
localStorage.setItem("retailerId", devRetailerId);
setRetailerId(devRetailerId);
if (retailer?.role?.value === "retailer") {
setRetailerId(retailer?.role.id ?? null);
} else {
setRetailerId(null);
}
}, []);
}, [retailer?.role?.value, retailer?.role?.id]);

const value: RetailerContextValue = {retailerId, setRetailerId};

Expand Down
20 changes: 15 additions & 5 deletions src/context/authContext.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import {createContext, useContext} from "react";

export type SessionUser = {
user: User;
token: string | null;
};

export type User = {
id: string;
name: string;
email: string;
pictureUrl: string;
roles?: string[];
picture: string;
role: Role | null;
};

export type Role = {
value: string;
id: string;
}

export interface AuthState {
isAuthenticated: boolean;
user: User | null;
token: string | null;
user: SessionUser | null;
}

export interface AuthContextValue extends AuthState {
login: (user?: User, token?: string) => void;
login: (user?: SessionUser, token?: string) => void;
refreshUser: () => Promise<SessionUser | null>;
}

export const AuthContext = createContext<AuthContextValue | undefined>(undefined);
Expand Down
20 changes: 10 additions & 10 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,22 @@ const router = createBrowserRouter(
<Route
path=":retailerId"
element={
<RoleRoute allowedRoles={["retailer"]} redirectPath="/">
<RoleRoute allowedRole={"retailer"} redirectPath="/">
<Outlet/>
</RoleRoute>
}>
{/*<Route path="inventory" element={<RetailerInventory/>}/>*/}
<Route path="inventory" element={<RetailerInventory/>}/>
<Route path="profile" element={<RetailerProfile/>}/>
</Route>
{/* Retailer-wide routes (not tied to ID but still protected) */}
{/*<Route*/}
{/* path="producers"*/}
{/* element={*/}
{/* <RoleRoute allowedRoles={["retailer"]} redirectPath="/">*/}
{/* <ProducerMarketplace/>*/}
{/* </RoleRoute>*/}
{/* }*/}
{/*/>*/}
<Route
path="producers"
element={
<RoleRoute allowedRole={"retailer"} redirectPath="/">
<ProducerMarketplace/>
</RoleRoute>
}
/>
</Route>
{/* --- End Retailer section --- */}

Expand Down
Loading