Files
germanandClaude Opus 4.8 a355188301 feat(club): frontend закрытого клуба + вход через Яндекс
Страница /club (уровни, форма заявки, статус, админ-модерация),
/club/gallery (закрытая галерея), /login (кнопка «Войти через
Яндекс» + email-вход, разбор #token). Хелперы lib/auth.ts,
lib/club.ts, компонент AdminRequests. Ссылки «Клуб»/«Войти» в Nav.
Эстетика по существующим токенам (paper/ink/accent, Cormorant/Inter).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-05 21:49:15 +03:00

101 lines
3.4 KiB
TypeScript

// Клиентский помощник авторизации (браузер).
// Токен храним в localStorage; редирект Яндекса приносит его в hash: /club#token=...&refresh=...
'use client';
import { API_BASE } from './api';
const TOKEN_KEY = 'nika_token';
const REFRESH_KEY = 'nika_refresh';
export interface AuthUser {
id: string;
email: string;
name: string;
role: 'owner' | 'admin' | 'buyer' | 'subscriber';
telegram_id?: number | null;
club_tier?: string | null;
avatar_url?: string | null;
created_at?: string;
}
export function getToken(): string | null {
if (typeof window === 'undefined') return null;
return window.localStorage.getItem(TOKEN_KEY);
}
export function setTokens(token: string, refresh?: string | null) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(TOKEN_KEY, token);
if (refresh) window.localStorage.setItem(REFRESH_KEY, refresh);
}
export function clearTokens() {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(REFRESH_KEY);
}
// Разбирает токен из hash после редиректа OAuth. Возвращает true, если токен найден и сохранён.
export function consumeHashToken(): boolean {
if (typeof window === 'undefined') return false;
const hash = window.location.hash.replace(/^#/, '');
if (!hash) return false;
const params = new URLSearchParams(hash);
const token = params.get('token');
if (!token) return false;
setTokens(token, params.get('refresh'));
// Чистим hash, чтобы токен не «висел» в адресной строке.
history.replaceState(null, '', window.location.pathname + window.location.search);
return true;
}
// Авторизованный fetch к нашему API. Кидает объект { status, message } при ошибке.
export async function authFetch<T = unknown>(
path: string,
init: RequestInit = {},
): Promise<T> {
const token = getToken();
const res = await fetch(`${API_BASE}${path.startsWith('/') ? path : `/${path}`}`, {
...init,
headers: {
Accept: 'application/json',
...(init.body ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init.headers || {}),
},
});
let data: unknown = null;
try {
data = await res.json();
} catch {
/* пустое тело */
}
if (!res.ok) {
const message =
(data && typeof data === 'object' && 'error' in data && (data as { error: string }).error) ||
`${res.status} ${res.statusText}`;
throw { status: res.status, message } as { status: number; message: string };
}
return data as T;
}
// Текущий пользователь (или null, если не авторизован / токен протух).
export async function fetchMe(): Promise<AuthUser | null> {
if (!getToken()) return null;
try {
const { user } = await authFetch<{ user: AuthUser }>('/auth/me');
return user;
} catch {
return null;
}
}
// Ссылка на старт входа через Яндекс (полный путь до backend).
export function yandexLoginUrl(): string {
return `${API_BASE}/auth/yandex`;
}
export function isAdmin(user: AuthUser | null): boolean {
return !!user && (user.role === 'admin' || user.role === 'owner');
}