This commit is contained in:
+77
-138
@@ -9,9 +9,6 @@ import {
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
type AuthUser = {
|
||||
uuid: string;
|
||||
email: string;
|
||||
@@ -29,174 +26,132 @@ export type RegisterParams = {
|
||||
tosVersion: string;
|
||||
};
|
||||
|
||||
// 1) update the type
|
||||
type AuthContextValue = {
|
||||
user: AuthUser;
|
||||
token: string | null;
|
||||
loading: boolean;
|
||||
login: (params: { email: string; password: string }) => Promise<void>;
|
||||
register: (params: RegisterParams) => Promise<void>;
|
||||
logout: () => void;
|
||||
getAuthHeaders: () => HeadersInit;
|
||||
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
|
||||
refreshMeAndSession: () => Promise<void>;
|
||||
|
||||
refreshUser: () => Promise<void>;
|
||||
};
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = "ballistic_auth_token";
|
||||
const USER_KEY = "ballistic_auth_user";
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<AuthUser>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
/**
|
||||
* Persist auth to localStorage
|
||||
* Persist user to localStorage (for quick initial render)
|
||||
*/
|
||||
const persistAuth = useCallback(
|
||||
(nextToken: string | null, nextUser: AuthUser) => {
|
||||
if (typeof window === "undefined") return;
|
||||
const persistUser = useCallback((nextUser: AuthUser) => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
if (nextToken) {
|
||||
window.localStorage.setItem(TOKEN_KEY, nextToken);
|
||||
} else {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
if (nextUser) {
|
||||
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
||||
} else {
|
||||
window.localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
if (nextUser) {
|
||||
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
||||
} else {
|
||||
window.localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* ✅ Hydrate from localStorage ONCE
|
||||
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
|
||||
* Hydrate from localStorage and refresh from server
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
||||
const storedUser = window.localStorage.getItem(USER_KEY);
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
// Quick hydration from cached user
|
||||
if (storedUser) {
|
||||
try {
|
||||
const parsedUser = JSON.parse(storedUser);
|
||||
|
||||
setToken(storedToken);
|
||||
setUser(parsedUser);
|
||||
|
||||
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
|
||||
fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: storedToken,
|
||||
role: parsedUser?.role,
|
||||
}),
|
||||
}).catch(() => {});
|
||||
} catch {
|
||||
window.localStorage.removeItem(TOKEN_KEY);
|
||||
window.localStorage.removeItem(USER_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, []);
|
||||
// Verify session with server
|
||||
fetch("/api/auth/me")
|
||||
.then((res) => {
|
||||
if (res.ok) {
|
||||
return res.json();
|
||||
}
|
||||
throw new Error("Not authenticated");
|
||||
})
|
||||
.then((data) => {
|
||||
const nextUser: AuthUser = {
|
||||
uuid: data.uuid,
|
||||
email: data.email,
|
||||
username: data.username || null,
|
||||
displayName: data.displayName || null,
|
||||
role: data.role || "USER",
|
||||
passwordSetAt: data.passwordSetAt || null,
|
||||
};
|
||||
setUser(nextUser);
|
||||
persistUser(nextUser);
|
||||
})
|
||||
.catch(() => {
|
||||
setUser(null);
|
||||
persistUser(null);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
}, [persistUser]);
|
||||
|
||||
/**
|
||||
* Used by login/register/magic-link
|
||||
*/
|
||||
const setSession = useCallback(
|
||||
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||
setToken(nextToken);
|
||||
setUser(nextUser);
|
||||
persistAuth(nextToken, nextUser);
|
||||
const refreshUser = useCallback(async () => {
|
||||
const res = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
|
||||
// ✅ await so middleware sees cookie before navigation
|
||||
try {
|
||||
await fetch("/api/auth/session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
token: nextToken,
|
||||
role: nextUser.role,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
[persistAuth]
|
||||
);
|
||||
|
||||
const refreshMeAndSession = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || "Failed to refresh session");
|
||||
throw new Error(text || "Failed to refresh user");
|
||||
}
|
||||
|
||||
const me = await res.json();
|
||||
|
||||
const nextUser = {
|
||||
uuid: me.uuid,
|
||||
email: me.email,
|
||||
username: me.username || null,
|
||||
displayName: me.displayName || null,
|
||||
role: me.role || "USER",
|
||||
passwordSetAt: me.passwordSetAt || null,
|
||||
} as NonNullable<AuthUser>;
|
||||
|
||||
await setSession(token, nextUser);
|
||||
}, [token, setSession]);
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const nextUser: AuthUser = {
|
||||
uuid: data.uuid,
|
||||
email: data.email,
|
||||
username: data.username || null,
|
||||
displayName: data.displayName || null,
|
||||
role: data.role || "USER",
|
||||
passwordSetAt: data.passwordSetAt || null,
|
||||
};
|
||||
|
||||
setUser(nextUser);
|
||||
persistUser(nextUser);
|
||||
}, [persistUser]);
|
||||
|
||||
const login = useCallback(
|
||||
async ({ email, password }: { email: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || "Login failed");
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Login failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const nextToken: string = data.token ?? data.accessToken;
|
||||
const nextUser: AuthUser =
|
||||
data.user ??
|
||||
({
|
||||
uuid: data.uuid,
|
||||
email: data.email ?? email,
|
||||
displayName: data.displayName ?? null,
|
||||
passwordSetAt: data.passwordSetAt ?? null,
|
||||
role: data.role ?? "USER",
|
||||
} as AuthUser);
|
||||
const nextUser: AuthUser = data.user;
|
||||
|
||||
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
setUser(nextUser);
|
||||
persistUser(nextUser);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[setSession]
|
||||
[persistUser]
|
||||
);
|
||||
|
||||
const register = useCallback(
|
||||
@@ -209,7 +164,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}: RegisterParams) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
const res = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -222,54 +177,38 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => res.statusText);
|
||||
throw new Error(text || "Registration failed");
|
||||
const errorData = await res.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || "Registration failed");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const nextToken: string = data.token ?? data.accessToken;
|
||||
const nextUser: AuthUser =
|
||||
data.user ??
|
||||
({
|
||||
uuid: data.uuid,
|
||||
email: data.email ?? email,
|
||||
displayName: data.displayName ?? displayName ?? null,
|
||||
passwordSetAt: data.passwordSetAt ?? null,
|
||||
role: data.role ?? "USER",
|
||||
} as AuthUser);
|
||||
const nextUser: AuthUser = data.user;
|
||||
|
||||
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||
setUser(nextUser);
|
||||
persistUser(nextUser);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[setSession]
|
||||
[persistUser]
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
persistAuth(null, null);
|
||||
persistUser(null);
|
||||
|
||||
// Clear server session cookies
|
||||
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
|
||||
}, [persistAuth]);
|
||||
|
||||
const getAuthHeaders = useCallback((): HeadersInit => {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}, [token]);
|
||||
fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
||||
}, [persistUser]);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
user,
|
||||
token,
|
||||
loading,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
getAuthHeaders,
|
||||
setSession,
|
||||
refreshMeAndSession
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user