woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s

This commit is contained in:
2026-01-25 13:40:10 -05:00
parent 5b73d59c2c
commit 50ef395a38
19 changed files with 696 additions and 247 deletions
+77 -138
View File
@@ -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>;