Files
ForbiddenStarsApp/frontend/src/hooks/auth.ts
T

217 lines
7.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AuthConfig, Me } from "../domain/types";
export function useMe() {
return useQuery({
queryKey: qk.me,
queryFn: async (): Promise<Me | null> => {
const r = await api.GET("/api/users/me");
if (r.response.status === 401) return null;
return unwrap(r);
},
});
}
export function useAuthConfig() {
return useQuery({
queryKey: qk.authConfig,
queryFn: async (): Promise<AuthConfig> => unwrap(await api.GET("/api/auth/config")),
});
}
export function useDevUsers(enabled: boolean) {
return useQuery({
queryKey: qk.devUsers,
enabled,
queryFn: async () => unwrap(await api.GET("/api/auth/dev/users")),
});
}
// DEV-вход по нику (только в деве; в прод-бэкенде эндпоинта нет).
export function useStubLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (nickname: string) =>
unwrap(await api.POST("/api/auth/dev/login", { body: { nickname } })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
export function useTelegramLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (payload: Record<string, unknown>) =>
unwrap(await api.POST("/api/auth/telegram", { body: payload as never })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен).
// payload — те же данные виджета + nickname; подпись проверяется заново на сервере.
export function useTelegramRegister() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (payload: Record<string, unknown>) =>
unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useCreateDevUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (nickname: string) =>
unwrap(await api.POST("/api/auth/dev/users", { body: { nickname } })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.devUsers }),
});
}
export function useLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => unwrap(await api.POST("/api/auth/logout")),
onSuccess: () => {
qc.setQueryData(qk.me, null);
qc.clear();
},
});
}
export function useUpdateNickname() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (nickname: string) =>
unwrap(await api.PATCH("/api/users/me", { body: { nickname } })),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.me });
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// «О себе».
export function useUpdateBio() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (bio: string): Promise<Me> =>
unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
async function resizeImage(file: File, max = 512): Promise<Blob> {
try {
const dataUrl = await new Promise<string>((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result as string);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(file);
});
const img = await new Promise<HTMLImageElement>((res, rej) => {
const i = new Image();
i.onload = () => res(i);
i.onerror = () => rej(new Error("image load failed"));
i.src = dataUrl;
});
let { width, height } = img;
if (width > max || height > max) {
const scale = Math.min(max / width, max / height);
width = Math.round(width * scale);
height = Math.round(height * scale);
}
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) return file;
ctx.drawImage(img, 0, 0, width, height);
const blob = await new Promise<Blob | null>((res) =>
canvas.toBlob(res, "image/jpeg", 0.85),
);
return blob ?? file;
} catch {
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
}
}
export function useUploadAvatar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File): Promise<Me> => {
const blob = await resizeImage(file);
const form = new FormData();
form.append("file", blob, "avatar.jpg");
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/users/me/avatar`, {
method: "PUT",
body: form,
credentials: "include",
headers: csrf ? { "X-CSRF-Token": csrf } : {},
});
if (!r.ok) {
let env: { code?: string; message?: string; details?: unknown } | undefined;
try {
env = ((await r.json()) as { error?: typeof env }).error;
} catch {
/* тело без JSON */
}
throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details);
}
return (await r.json()) as Me;
},
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useDeleteAvatar() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (): Promise<Me> => unwrap(await api.DELETE("/api/users/me/avatar")),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries();
},
});
}
export function useSetActiveGroup() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (group_id: number | null) =>
unwrap(await api.PUT("/api/users/me/active-group", { body: { group_id } })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
qc.invalidateQueries({ queryKey: qk.home });
},
});
}