349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import type { ReactNode } from 'react';
|
|
|
|
export type Role = 'USER' | 'ADMIN';
|
|
export type AuthProviderName = 'LOCAL' | 'GOOGLE' | 'FACEBOOK';
|
|
export type AccountType = 'PERSONAL' | 'COMPANY';
|
|
export type ContactPreference = 'EMAIL' | 'PHONE' | 'EMAIL_AND_PHONE';
|
|
export type PreferredLanguage = 'PL' | 'EN' | 'UK' | 'DE';
|
|
|
|
export type Currency = 'PLN' | 'EUR' | 'USD';
|
|
export type AreaUnit = 'M2' | 'FT2';
|
|
export type ProfileVisibility = 'PUBLIC' | 'CONTACTS' | 'PRIVATE';
|
|
|
|
/**
|
|
* Ustawienia konta trzymane na serwerze (/api/me/settings). Zgody marketingowe celowo tu nie leza -
|
|
* ich jedynym zrodlem jest /api/me/marketing, bo steruja kwalifikacja do kampanii.
|
|
*/
|
|
export type UserSettings = {
|
|
bio: string | null;
|
|
profileVisibility: ProfileVisibility;
|
|
avatarImage: string | null;
|
|
coverImage: string | null;
|
|
currency: Currency;
|
|
areaUnit: AreaUnit;
|
|
directOffersOnly: boolean;
|
|
hideInactiveOffers: boolean;
|
|
saveSearchesOnHome: boolean;
|
|
notifySavedSearches: boolean;
|
|
notifyPriceAlerts: boolean;
|
|
notifyMessages: boolean;
|
|
notifyProductNews: boolean;
|
|
searchLocations: string | null;
|
|
searchPropertyType: string | null;
|
|
searchBudgetMax: number | null;
|
|
searchAreaMin: number | null;
|
|
searchAreaMax: number | null;
|
|
searchRoomsMin: number | null;
|
|
searchRoomsMax: number | null;
|
|
};
|
|
|
|
// Zapis czesciowy: pominiete pole zostaje bez zmian, pusty tekst je czysci, liczba ujemna zeruje.
|
|
export type UserSettingsPatch = Partial<Record<keyof UserSettings, unknown>>;
|
|
|
|
export type AuthUser = {
|
|
id: number;
|
|
email: string;
|
|
fullName: string;
|
|
nick: string | null;
|
|
role: Role;
|
|
provider: AuthProviderName;
|
|
accountType: AccountType;
|
|
phone: string | null;
|
|
address: string | null;
|
|
contactPreference: ContactPreference;
|
|
preferredLanguage: PreferredLanguage;
|
|
nip: string | null;
|
|
birthDate: string | null;
|
|
verified: boolean;
|
|
phoneVerified: boolean;
|
|
blocked: boolean;
|
|
promotionCredits: number;
|
|
createdAt: string;
|
|
};
|
|
|
|
type AuthResponse = { token: string; user: AuthUser };
|
|
|
|
// Po rejestracji konto wymaga aktywacji linkiem e-mail - nie logujemy od razu.
|
|
export type RegisterPending = { email: string; phoneVerificationRequired: boolean };
|
|
|
|
export type RegisterDetails = {
|
|
accountType: AccountType;
|
|
phone?: string;
|
|
nip?: string;
|
|
address?: string;
|
|
};
|
|
|
|
type AuthContextValue = {
|
|
user: AuthUser | null;
|
|
token: string | null;
|
|
loading: boolean;
|
|
login: (email: string, password: string) => Promise<AuthUser>;
|
|
register: (email: string, password: string, fullName: string, details?: RegisterDetails) => Promise<RegisterPending>;
|
|
socialLogin: (provider: Exclude<AuthProviderName, 'LOCAL'>) => Promise<AuthUser>;
|
|
activateAccount: (token: string) => Promise<void>;
|
|
resendActivation: (email: string) => Promise<void>;
|
|
forgotPassword: (email: string) => Promise<void>;
|
|
resetPassword: (token: string, password: string) => Promise<void>;
|
|
verifyPhone: (email: string, code: string) => Promise<void>;
|
|
resendPhoneOtp: (email: string) => Promise<void>;
|
|
updateProfile: (profile: ProfileUpdate) => Promise<AuthUser>;
|
|
changePassword: (currentPassword: string, newPassword: string) => Promise<void>;
|
|
deleteAccount: (password: string) => Promise<void>;
|
|
loadSettings: () => Promise<UserSettings>;
|
|
saveSettings: (patch: UserSettingsPatch) => Promise<UserSettings>;
|
|
refreshUser: () => Promise<AuthUser | null>;
|
|
logout: () => void;
|
|
};
|
|
|
|
/**
|
|
* PUT /auth/me nadpisuje caly profil, wiec wysylamy komplet pol, a nie tylko zmienione.
|
|
* Pusty nick oznacza "zostaw dotychczasowy" - backend nie zmienia wtedy nazwy uzytkownika.
|
|
*/
|
|
export type ProfileUpdate = {
|
|
fullName: string;
|
|
nick?: string;
|
|
phone?: string;
|
|
birthDate?: string;
|
|
address?: string;
|
|
contactPreference?: ContactPreference;
|
|
preferredLanguage?: PreferredLanguage;
|
|
};
|
|
|
|
const TOKEN_KEY = 'polskalokalnie-auth-token';
|
|
const API_BASE = '/api';
|
|
|
|
export function getToken(): string | null {
|
|
return window.localStorage.getItem(TOKEN_KEY);
|
|
}
|
|
|
|
/**
|
|
* Cienki wrapper na fetch: dokleja token Bearer i zamienia bledy backendu (pole "message")
|
|
* na wyjatek z czytelnym komunikatem po polsku.
|
|
*/
|
|
export async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
const token = getToken();
|
|
const headers = new Headers(options.headers);
|
|
if (options.body && !headers.has('Content-Type')) {
|
|
headers.set('Content-Type', 'application/json');
|
|
}
|
|
if (token) {
|
|
headers.set('Authorization', `Bearer ${token}`);
|
|
}
|
|
|
|
const response = await fetch(`${API_BASE}${path}`, { ...options, headers });
|
|
|
|
if (!response.ok) {
|
|
let message = 'Wystąpił błąd. Spróbuj ponownie.';
|
|
try {
|
|
const data = await response.json();
|
|
if (data && typeof data.message === 'string' && data.message.trim()) {
|
|
message = data.message;
|
|
}
|
|
} catch {
|
|
// brak/niepoprawne cialo JSON - zostaje komunikat domyslny
|
|
}
|
|
const error = new Error(message) as Error & { status?: number };
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return undefined as T;
|
|
}
|
|
return (await response.json()) as T;
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null);
|
|
|
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
const [token, setToken] = useState<string | null>(() => getToken());
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [loading, setLoading] = useState<boolean>(() => Boolean(getToken()));
|
|
|
|
// Po odswiezeniu strony: majac token w localStorage, pobierz aktualny profil.
|
|
useEffect(() => {
|
|
if (!token) {
|
|
setUser(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
setLoading(true);
|
|
|
|
// Sesje konczymy wylacznie wtedy, gdy backend odrzuci token (401/403).
|
|
// Chwilowy blad sieci albo 5xx nie moze wylogowywac uzytkownika - taki blad
|
|
// ponawiamy raz, a token zostaje, wiec kolejne wejscie na strone go odzyska.
|
|
const loadProfile = async (canRetry: boolean): Promise<void> => {
|
|
try {
|
|
const me = await apiFetch<AuthUser>('/auth/me');
|
|
if (!cancelled) {
|
|
setUser(me);
|
|
setLoading(false);
|
|
}
|
|
} catch (error) {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
const status = (error as { status?: number }).status;
|
|
if (status === 401 || status === 403) {
|
|
window.localStorage.removeItem(TOKEN_KEY);
|
|
setToken(null);
|
|
setUser(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (canRetry) {
|
|
await new Promise((resolve) => { window.setTimeout(resolve, 400); });
|
|
if (!cancelled) {
|
|
await loadProfile(false);
|
|
}
|
|
return;
|
|
}
|
|
setUser(null);
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
void loadProfile(true);
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [token]);
|
|
|
|
const applyAuth = useCallback((auth: AuthResponse) => {
|
|
window.localStorage.setItem(TOKEN_KEY, auth.token);
|
|
setToken(auth.token);
|
|
setUser(auth.user);
|
|
return auth.user;
|
|
}, []);
|
|
|
|
const login = useCallback(
|
|
async (email: string, password: string) =>
|
|
applyAuth(await apiFetch<AuthResponse>('/auth/login', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password }),
|
|
})),
|
|
[applyAuth],
|
|
);
|
|
|
|
// Rejestracja NIE loguje od razu - konto wymaga aktywacji linkiem z e-maila.
|
|
const register = useCallback(
|
|
async (email: string, password: string, fullName: string, details?: RegisterDetails) =>
|
|
apiFetch<RegisterPending>('/auth/register', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ email, password, fullName, ...details }),
|
|
}),
|
|
[],
|
|
);
|
|
|
|
const socialLogin = useCallback(
|
|
async (provider: Exclude<AuthProviderName, 'LOCAL'>) =>
|
|
applyAuth(await apiFetch<AuthResponse>('/auth/social', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ provider }),
|
|
})),
|
|
[applyAuth],
|
|
);
|
|
|
|
const activateAccount = useCallback(async (token: string) => {
|
|
await apiFetch<void>('/auth/activate', { method: 'POST', body: JSON.stringify({ token }) });
|
|
}, []);
|
|
|
|
const resendActivation = useCallback(async (email: string) => {
|
|
await apiFetch<void>('/auth/resend-activation', { method: 'POST', body: JSON.stringify({ email }) });
|
|
}, []);
|
|
|
|
const forgotPassword = useCallback(async (email: string) => {
|
|
await apiFetch<void>('/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) });
|
|
}, []);
|
|
|
|
const resetPassword = useCallback(async (token: string, password: string) => {
|
|
await apiFetch<void>('/auth/reset-password', { method: 'POST', body: JSON.stringify({ token, password }) });
|
|
}, []);
|
|
|
|
const verifyPhone = useCallback(async (email: string, code: string) => {
|
|
await apiFetch<void>('/auth/verify-phone', { method: 'POST', body: JSON.stringify({ email, code }) });
|
|
}, []);
|
|
|
|
const resendPhoneOtp = useCallback(async (email: string) => {
|
|
await apiFetch<void>('/auth/resend-phone-otp', { method: 'POST', body: JSON.stringify({ email }) });
|
|
}, []);
|
|
|
|
const updateProfile = useCallback(async (profile: ProfileUpdate) => {
|
|
const updated = await apiFetch<AuthUser>('/auth/me', {
|
|
method: 'PUT',
|
|
body: JSON.stringify(profile),
|
|
});
|
|
setUser(updated);
|
|
return updated;
|
|
}, []);
|
|
|
|
const changePassword = useCallback(async (currentPassword: string, newPassword: string) => {
|
|
await apiFetch<void>('/auth/change-password', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ currentPassword, newPassword }),
|
|
});
|
|
}, []);
|
|
|
|
// Po usunieciu konta token jest bezuzyteczny - czyscimy sesje od razu, bez czekania na 401.
|
|
const deleteAccount = useCallback(async (password: string) => {
|
|
await apiFetch<void>('/auth/me', {
|
|
method: 'DELETE',
|
|
body: JSON.stringify({ password }),
|
|
});
|
|
window.localStorage.removeItem(TOKEN_KEY);
|
|
setToken(null);
|
|
setUser(null);
|
|
}, []);
|
|
|
|
const loadSettings = useCallback(async () => apiFetch<UserSettings>('/me/settings'), []);
|
|
|
|
const saveSettings = useCallback(
|
|
async (patch: UserSettingsPatch) =>
|
|
apiFetch<UserSettings>('/me/settings', { method: 'PUT', body: JSON.stringify(patch) }),
|
|
[],
|
|
);
|
|
|
|
// Ponowne pobranie danych uzytkownika (np. po zakupie promowania - odswieza saldo kredytow).
|
|
const refreshUser = useCallback(async () => {
|
|
if (!getToken()) {
|
|
return null;
|
|
}
|
|
try {
|
|
const fresh = await apiFetch<AuthUser>('/auth/me');
|
|
setUser(fresh);
|
|
return fresh;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}, []);
|
|
|
|
const logout = useCallback(() => {
|
|
window.localStorage.removeItem(TOKEN_KEY);
|
|
setToken(null);
|
|
setUser(null);
|
|
}, []);
|
|
|
|
const value = useMemo<AuthContextValue>(
|
|
() => ({
|
|
user, token, loading, login, register, socialLogin,
|
|
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
|
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout,
|
|
}),
|
|
[user, token, loading, login, register, socialLogin,
|
|
activateAccount, resendActivation, forgotPassword, resetPassword, verifyPhone, resendPhoneOtp,
|
|
updateProfile, changePassword, deleteAccount, loadSettings, saveSettings, refreshUser, logout],
|
|
);
|
|
|
|
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;
|
|
}
|