224 lines
5.2 KiB
TypeScript
224 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useState,
|
|
ReactNode,
|
|
} from "react";
|
|
|
|
type AuthUser = {
|
|
uuid: string;
|
|
email: string;
|
|
username?: string | null;
|
|
passwordSetAt?: string | null;
|
|
displayName?: string | null;
|
|
role: string;
|
|
} | null;
|
|
|
|
export type RegisterParams = {
|
|
email: string;
|
|
password: string;
|
|
displayName?: string;
|
|
acceptedTos: boolean;
|
|
tosVersion: string;
|
|
};
|
|
|
|
type AuthContextValue = {
|
|
user: AuthUser;
|
|
loading: boolean;
|
|
login: (params: { email: string; password: string }) => Promise<void>;
|
|
register: (params: RegisterParams) => Promise<void>;
|
|
logout: () => void;
|
|
refreshUser: () => Promise<void>;
|
|
};
|
|
|
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
|
|
const USER_KEY = "ballistic_auth_user";
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [user, setUser] = useState<AuthUser>(null);
|
|
const [loading, setLoading] = useState<boolean>(true);
|
|
|
|
/**
|
|
* Persist user to localStorage (for quick initial render)
|
|
*/
|
|
const persistUser = useCallback((nextUser: AuthUser) => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
if (nextUser) {
|
|
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
|
} else {
|
|
window.localStorage.removeItem(USER_KEY);
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Hydrate from localStorage and refresh from server
|
|
*/
|
|
useEffect(() => {
|
|
if (typeof window === "undefined") return;
|
|
|
|
const storedUser = window.localStorage.getItem(USER_KEY);
|
|
|
|
// Quick hydration from cached user
|
|
if (storedUser) {
|
|
try {
|
|
const parsedUser = JSON.parse(storedUser);
|
|
setUser(parsedUser);
|
|
} catch {
|
|
window.localStorage.removeItem(USER_KEY);
|
|
}
|
|
}
|
|
|
|
// 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]);
|
|
|
|
const refreshUser = useCallback(async () => {
|
|
const res = await fetch("/api/auth/me", { cache: "no-store" });
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => res.statusText);
|
|
throw new Error(text || "Failed to refresh user");
|
|
}
|
|
|
|
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/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
throw new Error(errorData.error || "Login failed");
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
const nextUser: AuthUser = data.user;
|
|
|
|
setUser(nextUser);
|
|
persistUser(nextUser);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[persistUser]
|
|
);
|
|
|
|
const register = useCallback(
|
|
async ({
|
|
email,
|
|
password,
|
|
displayName,
|
|
acceptedTos,
|
|
tosVersion,
|
|
}: RegisterParams) => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch("/api/auth/register", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
email,
|
|
password,
|
|
displayName,
|
|
acceptedTos,
|
|
tosVersion,
|
|
}),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const errorData = await res.json().catch(() => ({}));
|
|
throw new Error(errorData.error || "Registration failed");
|
|
}
|
|
|
|
const data = await res.json();
|
|
|
|
const nextUser: AuthUser = data.user;
|
|
|
|
setUser(nextUser);
|
|
persistUser(nextUser);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
},
|
|
[persistUser]
|
|
);
|
|
|
|
const logout = useCallback(() => {
|
|
setUser(null);
|
|
persistUser(null);
|
|
|
|
// Clear server session cookies
|
|
fetch("/api/auth/logout", { method: "POST" }).catch(() => {});
|
|
}, [persistUser]);
|
|
|
|
const value: AuthContextValue = {
|
|
user,
|
|
loading,
|
|
login,
|
|
register,
|
|
logout,
|
|
refreshUser,
|
|
};
|
|
|
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
|
}
|
|
|
|
export function useAuth(): AuthContextValue {
|
|
const ctx = useContext(AuthContext);
|
|
if (!ctx) {
|
|
throw new Error("useAuth must be used within an AuthProvider");
|
|
}
|
|
return ctx;
|
|
}
|