feat: customize public profile visibility
Some checks failed
CI / build-and-test (pull_request) Has been cancelled

This commit is contained in:
Vakanaut
2026-07-12 16:57:43 +03:00
parent 103e3ca209
commit d4c72115d8
19 changed files with 245 additions and 68 deletions

View File

@@ -0,0 +1,12 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_username TEXT;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_future_races_public BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_completed_races_public BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE users
SET is_future_races_public = is_profile_public,
is_completed_races_public = is_profile_public
WHERE is_profile_public = TRUE;
CREATE UNIQUE INDEX IF NOT EXISTS users_profile_username_normalized_key
ON users (LOWER(profile_username))
WHERE profile_username IS NOT NULL;

View File

@@ -1,12 +1,12 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.5.1", "version": "1.5.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.5.1", "version": "1.5.2",
"dependencies": { "dependencies": {
"argon2": "^0.44.0", "argon2": "^0.44.0",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",

View File

@@ -1,6 +1,6 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.5.1", "version": "1.5.2",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",

View File

@@ -10,7 +10,9 @@ declare global {
id: string; id: string;
email: string; email: string;
emailVerifiedAt: string | null; emailVerifiedAt: string | null;
isProfilePublic: boolean; profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
}; };
csrfTokenHash: string; csrfTokenHash: string;
sessionToken: string; sessionToken: string;

View File

@@ -18,7 +18,9 @@ export interface AuthUser {
id: string; id: string;
email: string; email: string;
emailVerifiedAt: string | null; emailVerifiedAt: string | null;
isProfilePublic: boolean; profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
} }
interface UserRow { interface UserRow {
@@ -26,7 +28,9 @@ interface UserRow {
email: string; email: string;
password_hash: string; password_hash: string;
email_verified_at: Date | string | null; email_verified_at: Date | string | null;
is_profile_public: boolean; profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
} }
interface SessionRow { interface SessionRow {
@@ -37,7 +41,9 @@ interface SessionRow {
expires_at: Date | string; expires_at: Date | string;
email: string; email: string;
email_verified_at: Date | string | null; email_verified_at: Date | string | null;
is_profile_public: boolean; profile_username: string | null;
is_future_races_public: boolean;
is_completed_races_public: boolean;
} }
interface SessionListRow { interface SessionListRow {
@@ -68,7 +74,9 @@ function userFromRow(row: UserRow): AuthUser {
id: row.id, id: row.id,
email: row.email, email: row.email,
emailVerifiedAt: toIso(row.email_verified_at), emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public, profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
}; };
} }
@@ -85,7 +93,9 @@ function isUniqueViolation(error: unknown): boolean {
export async function findUserByEmail(email: string): Promise<UserRow | null> { export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email); const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>( const { rows } = await pool.query<UserRow>(
"SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1", `SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1`,
[normalized], [normalized],
); );
return rows[0] ?? null; return rows[0] ?? null;
@@ -146,7 +156,8 @@ export async function registerUser(email: string, password: string): Promise<voi
({ rows } = await client.query<UserRow>( ({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash) `INSERT INTO users (email, password_hash)
VALUES ($1, $2) VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at, is_profile_public`, RETURNING id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public`,
[normalized, passwordHash], [normalized, passwordHash],
)); ));
} catch (error) { } catch (error) {
@@ -185,7 +196,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at, RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email, (SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at, (SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at,
(SELECT is_profile_public FROM users WHERE id = $1) AS is_profile_public`, (SELECT profile_username FROM users WHERE id = $1) AS profile_username,
(SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public,
(SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
); );
const row = rows[0]; const row = rows[0];
@@ -197,7 +210,9 @@ export async function createSession(userId: string): Promise<{ sessionToken: str
id: row.user_id, id: row.user_id,
email: row.email, email: row.email,
emailVerifiedAt: toIso(row.email_verified_at), emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public, profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
}, },
}; };
} }
@@ -312,7 +327,8 @@ export async function rotateCsrf(sessionToken: string): Promise<string | null> {
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> { export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken); const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>( const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, u.is_profile_public `SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at,
u.profile_username, u.is_future_races_public, u.is_completed_races_public
FROM sessions s FROM sessions s
JOIN users u ON u.id = s.user_id JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`, WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
@@ -331,16 +347,38 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser
id: row.user_id, id: row.user_id,
email: row.email, email: row.email,
emailVerifiedAt: toIso(row.email_verified_at), emailVerifiedAt: toIso(row.email_verified_at),
isProfilePublic: row.is_profile_public, profileUsername: row.profile_username,
isFutureRacesPublic: row.is_future_races_public,
isCompletedRacesPublic: row.is_completed_races_public,
}, },
csrfTokenHash: row.csrf_token_hash, csrfTokenHash: row.csrf_token_hash,
}; };
} }
export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise<void> { export async function updateProfile(
userId: string,
profile: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
},
): Promise<void> {
await pool.query( await pool.query(
"UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1", `UPDATE users SET
[userId, isProfilePublic], profile_username = CASE WHEN $2 THEN $3 ELSE profile_username END,
is_future_races_public = CASE WHEN $4 THEN $5 ELSE is_future_races_public END,
is_completed_races_public = CASE WHEN $6 THEN $7 ELSE is_completed_races_public END,
updated_at = NOW()
WHERE id = $1`,
[
userId,
profile.profileUsername !== undefined,
profile.profileUsername,
profile.isFutureRacesPublic !== undefined,
profile.isFutureRacesPublic,
profile.isCompletedRacesPublic !== undefined,
profile.isCompletedRacesPublic,
],
); );
} }

View File

@@ -13,7 +13,7 @@ import {
revokeSessionById, revokeSessionById,
rotateCsrf, rotateCsrf,
revokeSession, revokeSession,
setProfileVisibility, updateProfile,
verifyEmailToken, verifyEmailToken,
} from "../authService"; } from "../authService";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware"; import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
@@ -80,7 +80,12 @@ const passwordChangeSchema = z.object({
}).strict(); }).strict();
const sessionIdSchema = z.string().uuid(); const sessionIdSchema = z.string().uuid();
const profileSchema = z.object({ isProfilePublic: z.boolean() }).strict(); const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
const profileSchema = z.object({
profileUsername: profileUsernameSchema.nullable().optional(),
isFutureRacesPublic: z.boolean().optional(),
isCompletedRacesPublic: z.boolean().optional(),
}).strict().refine((profile) => Object.keys(profile).length > 0);
function validationError(res: Response): void { function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
@@ -156,9 +161,13 @@ router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, n
return; return;
} }
try { try {
await setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic); await updateProfile(req.auth!.user.id, parsed.data);
res.json({ isProfilePublic: parsed.data.isProfilePublic }); res.json({ ok: true });
} catch (error) { } catch (error) {
if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") {
res.status(409).json({ error: "username_taken", details: ["Username is already in use"] });
return;
}
next(error); next(error);
} }
}); });

View File

@@ -10,22 +10,27 @@ interface PublicRaceRow {
distance_km: string; distance_km: string;
status: "planned" | "registered" | "completed" | null; status: "planned" | "registered" | "completed" | null;
cover_image_url: string | null; cover_image_url: string | null;
finish_time: string | null;
finish_place: string | null;
} }
function raceDate(value: string | Date): string { function raceDate(value: string | Date): string {
return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10); return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10);
} }
router.get("/users/:id/races", async (req: Request, res: Response) => { router.get("/users/:username/races", async (req: Request, res: Response) => {
const parsed = z.string().uuid().safeParse(req.params.id); const parsed = z.string().trim().toLowerCase().min(3).max(30).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/).safeParse(req.params.username);
if (!parsed.success) { if (!parsed.success) {
res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] }); res.status(400).json({ error: "validation_error", details: ["username: Invalid username"] });
return; return;
} }
try { try {
const profile = await pool.query( const profile = await pool.query(
"SELECT id FROM users WHERE id = $1 AND is_profile_public = TRUE", `SELECT id, is_future_races_public, is_completed_races_public
FROM users
WHERE LOWER(profile_username) = $1
AND (is_future_races_public = TRUE OR is_completed_races_public = TRUE)`,
[parsed.data], [parsed.data],
); );
if (profile.rowCount === 0) { if (profile.rowCount === 0) {
@@ -33,9 +38,12 @@ router.get("/users/:id/races", async (req: Request, res: Response) => {
return; return;
} }
const { rows } = await pool.query<PublicRaceRow>( const { rows } = await pool.query<PublicRaceRow>(
`SELECT race_date, title, distance_km, status, cover_image_url `SELECT race_date, title, distance_km, status, cover_image_url, finish_time, finish_place
FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`, FROM races
[parsed.data], WHERE owner_user_id = $1
AND ((status = 'completed' AND $2) OR (status IS DISTINCT FROM 'completed' AND $3))
ORDER BY race_date ASC`,
[profile.rows[0].id, profile.rows[0].is_completed_races_public, profile.rows[0].is_future_races_public],
); );
res.json(rows.map((row) => ({ res.json(rows.map((row) => ({
date: raceDate(row.race_date), date: raceDate(row.race_date),
@@ -43,9 +51,11 @@ router.get("/users/:id/races", async (req: Request, res: Response) => {
distanceKm: Number(row.distance_km), distanceKm: Number(row.distance_km),
status: row.status, status: row.status,
coverImageUrl: row.cover_image_url, coverImageUrl: row.cover_image_url,
finishTime: row.status === "completed" ? row.finish_time : null,
finishPlace: row.status === "completed" ? row.finish_place : null,
}))); })));
} catch (error) { } catch (error) {
console.error("[GET /users/:id/races]", error); console.error("[GET /users/:username/races]", error);
res.status(503).json({ error: "database_unavailable" }); res.status(503).json({ error: "database_unavailable" });
} }
}); });

View File

@@ -173,46 +173,67 @@ test("GET /api/races requires authentication", async () => {
assert.equal(res.body.error, "unauthorized"); assert.equal(res.body.error, "unauthorized");
}); });
test("public profile exposes only its owner's calendar after they enable it", async () => { test("public profile uses its username and separate visibility settings", async () => {
const { agent } = await authAgent(); const { agent } = await authAgent();
const user = await agent.get("/api/auth/me").expect(200); const user = await agent.get("/api/auth/me").expect(200);
const userId = user.body.user.id as string;
await agent await agent
.post("/api/races") .post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string) .set("X-CSRF-Token", user.body.csrfToken as string)
.send({ .send({
slug: "2026-08-01-public-race", slug: "2026-08-01-planned-race",
date: "2026-08-01", date: "2026-08-01",
title: "Public Race", title: "Planned Race",
distanceKm: 10, distanceKm: 10,
notes: "Private note", })
bibNumber: "123", .expect(201);
await agent
.post("/api/races")
.set("X-CSRF-Token", user.body.csrfToken as string)
.send({
slug: "2026-07-01-completed-race",
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
finishTime: "00:40:00", finishTime: "00:40:00",
finishPlace: "12",
notes: "Private note",
}) })
.expect(201); .expect(201);
await request(app).get(`/api/users/${userId}/races`).expect(404); await request(app).get("/api/users/public-runner/races").expect(404);
await agent await agent
.patch("/api/auth/profile") .patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string) .set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: true }) .send({ profileUsername: "public-runner", isFutureRacesPublic: true, isCompletedRacesPublic: false })
.expect(200); .expect(200);
const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200); const futureRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(publicRaces.body, [{ assert.deepEqual(futureRaces.body, [{
date: "2026-08-01", date: "2026-08-01",
title: "Public Race", title: "Planned Race",
distanceKm: 10, distanceKm: 10,
status: null, status: null,
coverImageUrl: null, coverImageUrl: null,
finishTime: null,
finishPlace: null,
}]); }]);
await agent await agent
.patch("/api/auth/profile") .patch("/api/auth/profile")
.set("X-CSRF-Token", user.body.csrfToken as string) .set("X-CSRF-Token", user.body.csrfToken as string)
.send({ isProfilePublic: false }) .send({ isFutureRacesPublic: false, isCompletedRacesPublic: true })
.expect(200); .expect(200);
await request(app).get(`/api/users/${userId}/races`).expect(404); const completedRaces = await request(app).get("/api/users/public-runner/races").expect(200);
assert.deepEqual(completedRaces.body, [{
date: "2026-07-01",
title: "Completed Race",
distanceKm: 10,
status: "completed",
coverImageUrl: null,
finishTime: "00:40:00",
finishPlace: "12",
}]);
}); });
test("login uses generic response for missing user and wrong password", async () => { test("login uses generic response for missing user and wrong password", async () => {

View File

@@ -1,12 +1,12 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.8.1", "version": "0.8.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"version": "0.8.1", "version": "0.8.2",
"dependencies": { "dependencies": {
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{ {
"name": "calendar-run-frontend", "name": "calendar-run-frontend",
"private": true, "private": true,
"version": "0.8.1", "version": "0.8.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -88,9 +88,13 @@ export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" }); await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
} }
export async function updateProfileVisibility(isProfilePublic: boolean): Promise<void> { export async function updateProfile(payload: {
profileUsername?: string | null;
isFutureRacesPublic?: boolean;
isCompletedRacesPublic?: boolean;
}): Promise<void> {
await requestJson<void>("/auth/profile", { await requestJson<void>("/auth/profile", {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ isProfilePublic }), body: JSON.stringify(payload),
}); });
} }

View File

@@ -3,6 +3,7 @@ export type ApiErrorCode =
| "not_found" | "not_found"
| "database_unavailable" | "database_unavailable"
| "conflict" | "conflict"
| "username_taken"
| "unauthorized" | "unauthorized"
| "email_not_verified" | "email_not_verified"
| "csrf_error" | "csrf_error"
@@ -42,6 +43,7 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" || value === "not_found" ||
value === "database_unavailable" || value === "database_unavailable" ||
value === "conflict" || value === "conflict" ||
value === "username_taken" ||
value === "unauthorized" || value === "unauthorized" ||
value === "email_not_verified" || value === "email_not_verified" ||
value === "csrf_error" || value === "csrf_error" ||
@@ -110,6 +112,8 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже."; return "Сервис временно недоступен. Попробуйте позже.";
case "conflict": case "conflict":
return "Запись с таким идентификатором уже существует."; return "Запись с таким идентификатором уже существует.";
case "username_taken":
return "Это имя пользователя уже занято.";
case "unauthorized": case "unauthorized":
return "Нужно войти в аккаунт."; return "Нужно войти в аккаунт.";
case "email_not_verified": case "email_not_verified":

View File

@@ -16,6 +16,6 @@ export {
resetPassword, resetPassword,
revokeOtherSessions, revokeOtherSessions,
revokeSession, revokeSession,
updateProfileVisibility, updateProfile,
verifyEmail, verifyEmail,
} from "./auth"; } from "./auth";

View File

@@ -48,7 +48,9 @@ export interface AuthUser {
id: string; id: string;
email: string; email: string;
emailVerifiedAt: string | null; emailVerifiedAt: string | null;
isProfilePublic: boolean; profileUsername: string | null;
isFutureRacesPublic: boolean;
isCompletedRacesPublic: boolean;
} }
export interface PublicRace { export interface PublicRace {
@@ -57,6 +59,8 @@ export interface PublicRace {
distanceKm: number; distanceKm: number;
status: RaceStatus | null; status: RaceStatus | null;
coverImageUrl: string | null; coverImageUrl: string | null;
finishTime: string | null;
finishPlace: string | null;
} }
export interface AuthSession { export interface AuthSession {

View File

@@ -9,12 +9,14 @@ function isPublicRace(value: unknown): value is PublicRace {
typeof race?.title === "string" && typeof race?.title === "string" &&
typeof race?.distanceKm === "number" && typeof race?.distanceKm === "number" &&
(race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") && (race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") &&
(race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") (race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") &&
(race?.finishTime === null || typeof race?.finishTime === "string") &&
(race?.finishPlace === null || typeof race?.finishPlace === "string")
); );
} }
export async function getPublicRaces(userId: string, init?: RequestInit): Promise<PublicRace[]> { export async function getPublicRaces(username: string, init?: RequestInit): Promise<PublicRace[]> {
const response = await requestJson<unknown[]>(`/users/${userId}/races`, init); const response = await requestJson<unknown[]>(`/users/${encodeURIComponent(username)}/races`, init);
if (!Array.isArray(response) || !response.every(isPublicRace)) { if (!Array.isArray(response) || !response.every(isPublicRace)) {
throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." }); throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." });
} }

View File

@@ -20,7 +20,7 @@ export const appRouter = createBrowserRouter([
{ path: "verify-email", element: <VerifyEmailPage /> }, { path: "verify-email", element: <VerifyEmailPage /> },
{ path: "forgot-password", element: <ForgotPasswordPage /> }, { path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "reset-password", element: <ResetPasswordPage /> }, { path: "reset-password", element: <ResetPasswordPage /> },
{ path: "users/:userId", element: <PublicProfilePage /> }, { path: "users/:username", element: <PublicProfilePage /> },
{ {
element: <RequireAuth />, element: <RequireAuth />,
children: [ children: [

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfileVisibility } from "../api"; import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfile } from "../api";
import type { AuthSession } from "../api"; import type { AuthSession } from "../api";
import { useAuth } from "../app/auth/AuthContext"; import { useAuth } from "../app/auth/AuthContext";
@@ -19,7 +19,11 @@ export function AccountPage(): JSX.Element {
const [passwordSuccess, setPasswordSuccess] = useState(""); const [passwordSuccess, setPasswordSuccess] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false); const [passwordSaving, setPasswordSaving] = useState(false);
const [profileError, setProfileError] = useState(""); const [profileError, setProfileError] = useState("");
const [profileSuccess, setProfileSuccess] = useState("");
const [profileSaving, setProfileSaving] = useState(false); const [profileSaving, setProfileSaving] = useState(false);
const [profileUsername, setProfileUsername] = useState(user?.profileUsername ?? "");
const [futureRacesPublic, setFutureRacesPublic] = useState(user?.isFutureRacesPublic ?? false);
const [completedRacesPublic, setCompletedRacesPublic] = useState(user?.isCompletedRacesPublic ?? false);
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {
setSessionsLoading(true); setSessionsLoading(true);
@@ -37,6 +41,12 @@ export function AccountPage(): JSX.Element {
void loadSessions(); void loadSessions();
}, [loadSessions]); }, [loadSessions]);
useEffect(() => {
setProfileUsername(user?.profileUsername ?? "");
setFutureRacesPublic(user?.isFutureRacesPublic ?? false);
setCompletedRacesPublic(user?.isCompletedRacesPublic ?? false);
}, [user?.profileUsername, user?.isFutureRacesPublic, user?.isCompletedRacesPublic]);
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> { async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault(); event.preventDefault();
setPasswordError(""); setPasswordError("");
@@ -83,12 +93,19 @@ export function AccountPage(): JSX.Element {
} }
} }
async function handleProfileVisibilityChange(isProfilePublic: boolean): Promise<void> { async function handleProfileSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setProfileSaving(true); setProfileSaving(true);
setProfileError(""); setProfileError("");
setProfileSuccess("");
try { try {
await updateProfileVisibility(isProfilePublic); await updateProfile({
profileUsername: profileUsername.trim(),
isFutureRacesPublic: futureRacesPublic,
isCompletedRacesPublic: completedRacesPublic,
});
await refresh(); await refresh();
setProfileSuccess("Настройки личной страницы сохранены.");
} catch (error) { } catch (error) {
setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы."); setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы.");
} finally { } finally {
@@ -96,31 +113,74 @@ export function AccountPage(): JSX.Element {
} }
} }
async function copyProfileUrl(): Promise<void> {
if (!user?.profileUsername) {
return;
}
setProfileError("");
try {
await navigator.clipboard.writeText(`${window.location.origin}/users/${user.profileUsername}`);
setProfileSuccess("Адрес скопирован.");
} catch {
setProfileError("Не удалось скопировать адрес.");
}
}
const profileUrl = user?.profileUsername ? `${window.location.origin}/users/${user.profileUsername}` : "";
return ( return (
<section className="page page--auth"> <section className="page page--auth">
<h1 className="page__title">Аккаунт</h1> <h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p> <p className="page__subtitle">{user?.email} email подтверждён.</p>
<section className="auth-form" aria-labelledby="profile-title"> <form className="auth-form" aria-labelledby="profile-title" onSubmit={(event) => void handleProfileSubmit(event)}>
<h2 className="auth-form__title" id="profile-title">Публичная страница</h2> <h2 className="auth-form__title" id="profile-title">Публичная страница</h2>
<label className="auth-form__field"> <label className="auth-form__field">
<span className="auth-form__label">Ссылка на страницу</span> <span className="auth-form__label">Имя пользователя</span>
<input className="auth-form__input" readOnly value={`${window.location.origin}/users/${user?.id ?? ""}`} /> <input
className="auth-form__input"
required
minLength={3}
maxLength={30}
pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
value={profileUsername}
onChange={(event) => setProfileUsername(event.target.value.toLowerCase())}
/>
</label> </label>
<label className="auth-form__field"> <label className="auth-form__field">
<span> <span>
<input <input
checked={user?.isProfilePublic ?? false} checked={futureRacesPublic}
disabled={profileSaving} disabled={profileSaving}
type="checkbox" type="checkbox"
onChange={(event) => void handleProfileVisibilityChange(event.target.checked)} onChange={(event) => setFutureRacesPublic(event.target.checked)}
/>{" "} />{" "}
Открыть страницу всем Видимость будущих стартов
</span> </span>
<span className="auth-form__label">Открытая страница показывает только календарь стартов, без личных заметок и результатов.</span>
</label> </label>
<label className="auth-form__field">
<span>
<input
checked={completedRacesPublic}
disabled={profileSaving}
type="checkbox"
onChange={(event) => setCompletedRacesPublic(event.target.checked)}
/>{" "}
Видимость завершённых стартов и результатов
</span>
</label>
<div className="auth-form__field">
<span className="auth-form__label">Адрес личной страницы</span>
<div className="account__profile-link">
<input className="auth-form__input" readOnly value={profileUrl} placeholder="Сначала задайте имя пользователя" />
<button className="btn btn--secondary" disabled={!profileUrl} type="button" onClick={() => void copyProfileUrl()}>Скопировать</button>
</div>
</div>
<p className="page__subtitle">Открытая страница не показывает личные заметки и номера участников.</p>
{profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null} {profileError ? <p className="page__subtitle page__subtitle--error" role="alert">{profileError}</p> : null}
</section> {profileSuccess ? <p className="account__success" role="status">{profileSuccess}</p> : null}
<button className="btn" disabled={profileSaving} type="submit">{profileSaving ? "Сохраняем…" : "Сохранить настройки"}</button>
</form>
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}> <form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
<h2 className="auth-form__title">Сменить пароль</h2> <h2 className="auth-form__title">Сменить пароль</h2>

View File

@@ -5,19 +5,19 @@ import type { PublicRace } from "../api";
import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib"; import { formatDistance, formatRaceDate, getRaceStatusClassName, getRaceStatusLabel } from "../lib";
export function PublicProfilePage(): JSX.Element { export function PublicProfilePage(): JSX.Element {
const { userId } = useParams<{ userId: string }>(); const { username } = useParams<{ username: string }>();
const [races, setRaces] = useState<PublicRace[]>([]); const [races, setRaces] = useState<PublicRace[]>([]);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const ac = new AbortController(); const ac = new AbortController();
if (!userId) { if (!username) {
setError("Страница пользователя не найдена."); setError("Страница пользователя не найдена.");
setLoading(false); setLoading(false);
return () => ac.abort(); return () => ac.abort();
} }
void getPublicRaces(userId, { signal: ac.signal }) void getPublicRaces(username, { signal: ac.signal })
.then((items) => { .then((items) => {
if (!ac.signal.aborted) { if (!ac.signal.aborted) {
setRaces(items); setRaces(items);
@@ -34,7 +34,7 @@ export function PublicProfilePage(): JSX.Element {
} }
}); });
return () => ac.abort(); return () => ac.abort();
}, [userId]); }, [username]);
return ( return (
<section className="page page--race-day"> <section className="page page--race-day">
@@ -55,6 +55,8 @@ export function PublicProfilePage(): JSX.Element {
<span className="race-day__meta"> <span className="race-day__meta">
{formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "} {formatRaceDate(race.date)} · {formatDistance(race.distanceKm)} ·{" "}
<span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span> <span className={getRaceStatusClassName(race.status, race.date)}>{getRaceStatusLabel(race.status, race.date)}</span>
{race.finishTime ? ` · ${race.finishTime}` : ""}
{race.finishPlace ? ` · ${race.finishPlace}` : ""}
</span> </span>
</div> </div>
</li> </li>

View File

@@ -206,6 +206,15 @@ a {
font-size: var(--font-size-caption); font-size: var(--font-size-caption);
} }
.account__profile-link {
display: flex;
gap: var(--space-2);
}
.account__profile-link .auth-form__input {
min-width: 0;
}
.dashboard-grid { .dashboard-grid {
margin-top: var(--space-6); margin-top: var(--space-6);
display: grid; display: grid;