Files
polskalokalnie/frontend/src/auth.tsx
T
2026-08-12 17:03:22 +02:00

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;
}