feat: customize public profile visibility
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
12
backend/migrations/006_profile_usernames_and_visibility.sql
Normal file
12
backend/migrations/006_profile_usernames_and_visibility.sql
Normal 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;
|
||||||
4
backend/package-lock.json
generated
4
backend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -16,6 +16,6 @@ export {
|
|||||||
resetPassword,
|
resetPassword,
|
||||||
revokeOtherSessions,
|
revokeOtherSessions,
|
||||||
revokeSession,
|
revokeSession,
|
||||||
updateProfileVisibility,
|
updateProfile,
|
||||||
verifyEmail,
|
verifyEmail,
|
||||||
} from "./auth";
|
} from "./auth";
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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." });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user