diff --git a/docker-compose.yml b/docker-compose.yml index 46739f0..baf9c11 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,10 @@ +# Stack complet Skori, à lancer sur l'hôte 192.168.10.174 : +# docker compose up -d --build +# Accès : http://192.168.10.174:8080 +# +# Simplifié pour l'instant : mots de passe en dur (pas de .env), déploiement HTTP +# sur le LAN. nginx sert le frontend et proxifie /api vers le backend (même +# origine), donc le navigateur ne contacte que 192.168.10.174:8080. version: '3.8' services: @@ -21,8 +28,10 @@ services: environment: NODE_ENV: production PORT: 3001 - DATABASE_URL: postgres://skori:${POSTGRES_PASSWORD:-skori}@postgres:5432/skori - JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + DATABASE_URL: postgres://skori:skori@postgres:5432/skori + JWT_SECRET: skori-lan-secret-please-change-later + # HTTP sur le LAN : le cookie refresh ne doit pas être en Secure + COOKIE_SECURE: "false" depends_on: postgres: condition: service_healthy @@ -33,7 +42,7 @@ services: restart: unless-stopped environment: POSTGRES_USER: skori - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-skori} + POSTGRES_PASSWORD: skori POSTGRES_DB: skori volumes: - skori-pgdata:/var/lib/postgresql/data diff --git a/server/src/app.ts b/server/src/app.ts index 6bc13d5..23102ec 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,6 +6,7 @@ import { errorHandler } from "./middleware/errorHandler.js"; import { authRouter } from "./modules/auth/routes.js"; import { usersRouter } from "./modules/users/routes.js"; import { syncRouter } from "./modules/sync/routes.js"; +import { friendsRouter } from "./modules/friends/routes.js"; export function createApp() { const app = express(); @@ -24,6 +25,7 @@ export function createApp() { app.use("/api/v1/auth", authRouter); app.use("/api/v1", usersRouter); app.use("/api/v1/sync", syncRouter); + app.use("/api/v1/friends", friendsRouter); app.use(errorHandler); diff --git a/server/src/config/env.ts b/server/src/config/env.ts index cfddb19..c614464 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -3,6 +3,12 @@ export const env = { databaseUrl: process.env.DATABASE_URL || "", jwtSecret: process.env.JWT_SECRET || "dev-secret-change-me", isProduction: process.env.NODE_ENV === "production", + // Whether the refresh cookie is flagged Secure (HTTPS only). Defaults to + // production, but can be forced off for a plain-HTTP LAN deployment. + cookieSecure: + process.env.COOKIE_SECURE !== undefined + ? process.env.COOKIE_SECURE === "true" + : process.env.NODE_ENV === "production", corsOrigins: (process.env.CORS_ORIGINS || "") .split(",") .map((s) => s.trim()) diff --git a/server/src/db/init.ts b/server/src/db/init.ts index 134574a..2c31e02 100644 --- a/server/src/db/init.ts +++ b/server/src/db/init.ts @@ -61,6 +61,19 @@ const STATEMENTS = [ deleted_at bigint )`, `CREATE INDEX IF NOT EXISTS sessions_owner_updated_idx ON sessions (owner_id, updated_at)`, + `CREATE TABLE IF NOT EXISTS friendships ( + id uuid PRIMARY KEY, + requester_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + addressee_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status text NOT NULL, + created_at bigint NOT NULL, + updated_at bigint NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS friendships_requester_idx ON friendships (requester_id)`, + `CREATE INDEX IF NOT EXISTS friendships_addressee_idx ON friendships (addressee_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS friendships_pair_uidx ON friendships ( + LEAST(requester_id, addressee_id), GREATEST(requester_id, addressee_id) + )`, ]; export async function ensureSchema(db: AppDatabase) { diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 67a986f..0521b0b 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -74,6 +74,27 @@ export const players = pgTable( }), ); +export const friendships = pgTable( + "friendships", + { + id: uuid("id").primaryKey(), + requesterId: uuid("requester_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + addresseeId: uuid("addressee_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + // "pending" | "accepted" | "blocked" + status: text("status").notNull(), + createdAt: ms("created_at").notNull(), + updatedAt: ms("updated_at").notNull(), + }, + (t) => ({ + requesterIdx: index("friendships_requester_idx").on(t.requesterId), + addresseeIdx: index("friendships_addressee_idx").on(t.addresseeId), + }), +); + export const sessions = pgTable( "sessions", { diff --git a/server/src/modules/auth/controller.ts b/server/src/modules/auth/controller.ts index 4a2f96b..b5c124f 100644 --- a/server/src/modules/auth/controller.ts +++ b/server/src/modules/auth/controller.ts @@ -29,7 +29,7 @@ const loginSchema = z.object({ function setRefreshCookie(res: Response, token: string) { res.cookie(REFRESH_COOKIE_NAME, token, { httpOnly: true, - secure: env.isProduction, + secure: env.cookieSecure, sameSite: "strict", maxAge: REFRESH_TOKEN_TTL_MS, path: "/api/v1/auth", diff --git a/server/src/modules/friends/controller.ts b/server/src/modules/friends/controller.ts new file mode 100644 index 0000000..130a53d --- /dev/null +++ b/server/src/modules/friends/controller.ts @@ -0,0 +1,44 @@ +import type { Request, Response } from "express"; +import { z } from "zod"; +import * as friends from "./service.js"; + +const sendSchema = z.object({ username: z.string().min(1) }); + +export async function list(req: Request, res: Response) { + res.json({ friends: await friends.listFriends(req.userId!) }); +} + +export async function requests(req: Request, res: Response) { + res.json(await friends.listRequests(req.userId!)); +} + +export async function search(req: Request, res: Response) { + const q = String(req.query.q ?? ""); + res.json({ results: await friends.searchUsers(req.userId!, q) }); +} + +export async function sendRequest(req: Request, res: Response) { + const { username } = sendSchema.parse(req.body); + const result = await friends.sendRequest(req.userId!, username); + res.status(201).json(result); +} + +export async function accept(req: Request, res: Response) { + await friends.acceptRequest(req.userId!, req.params.id); + res.json({ ok: true }); +} + +export async function decline(req: Request, res: Response) { + await friends.declineRequest(req.userId!, req.params.id); + res.json({ ok: true }); +} + +export async function unfriend(req: Request, res: Response) { + await friends.removeFriend(req.userId!, req.params.id); + res.json({ ok: true }); +} + +export async function block(req: Request, res: Response) { + await friends.blockFriend(req.userId!, req.params.id); + res.json({ ok: true }); +} diff --git a/server/src/modules/friends/routes.ts b/server/src/modules/friends/routes.ts new file mode 100644 index 0000000..5567ca3 --- /dev/null +++ b/server/src/modules/friends/routes.ts @@ -0,0 +1,17 @@ +import { Router } from "express"; +import { requireAuth } from "../../middleware/auth.js"; +import { asyncHandler } from "../../middleware/errorHandler.js"; +import * as controller from "./controller.js"; + +export const friendsRouter = Router(); + +friendsRouter.use(requireAuth); + +friendsRouter.get("/", asyncHandler(controller.list)); +friendsRouter.get("/requests", asyncHandler(controller.requests)); +friendsRouter.get("/search", asyncHandler(controller.search)); +friendsRouter.post("/requests", asyncHandler(controller.sendRequest)); +friendsRouter.post("/requests/:id/accept", asyncHandler(controller.accept)); +friendsRouter.post("/requests/:id/decline", asyncHandler(controller.decline)); +friendsRouter.delete("/:id", asyncHandler(controller.unfriend)); +friendsRouter.post("/:id/block", asyncHandler(controller.block)); diff --git a/server/src/modules/friends/service.ts b/server/src/modules/friends/service.ts new file mode 100644 index 0000000..70a41aa --- /dev/null +++ b/server/src/modules/friends/service.ts @@ -0,0 +1,220 @@ +import { and, eq, ilike, inArray, or, ne } from "drizzle-orm"; +import { getDb } from "../../db/client.js"; +import { users, friendships } from "../../db/schema.js"; +import { HttpError } from "../../middleware/errorHandler.js"; +import { newId } from "../../utils/tokens.js"; + +export interface FriendUser { + id: string; + username: string | null; + displayName: string; + avatarUrl: string | null; +} + +function toFriendUser(u: typeof users.$inferSelect): FriendUser { + return { + id: u.id, + username: u.username, + displayName: u.displayName, + avatarUrl: u.avatarUrl, + }; +} + +async function usersByIds(ids: string[]): Promise> { + if (ids.length === 0) return new Map(); + const db = getDb(); + const rows = await db.select().from(users).where(inArray(users.id, ids)); + return new Map(rows.map((u) => [u.id, toFriendUser(u)])); +} + +// All friendship rows involving `meId`, in either direction. +async function myFriendships(meId: string) { + const db = getDb(); + return db + .select() + .from(friendships) + .where( + or(eq(friendships.requesterId, meId), eq(friendships.addresseeId, meId)), + ); +} + +export async function listFriends(meId: string) { + const rows = (await myFriendships(meId)).filter( + (f) => f.status === "accepted", + ); + const otherIds = rows.map((f) => + f.requesterId === meId ? f.addresseeId : f.requesterId, + ); + const userMap = await usersByIds(otherIds); + + return rows + .map((f) => { + const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId; + const user = userMap.get(otherId); + return user ? { friendshipId: f.id, user } : null; + }) + .filter(Boolean); +} + +export async function listRequests(meId: string) { + const rows = (await myFriendships(meId)).filter( + (f) => f.status === "pending", + ); + const otherIds = rows.map((f) => + f.requesterId === meId ? f.addresseeId : f.requesterId, + ); + const userMap = await usersByIds(otherIds); + + const incoming: any[] = []; + const outgoing: any[] = []; + for (const f of rows) { + const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId; + const user = userMap.get(otherId); + if (!user) continue; + if (f.addresseeId === meId) incoming.push({ friendshipId: f.id, user }); + else outgoing.push({ friendshipId: f.id, user }); + } + return { incoming, outgoing }; +} + +export async function searchUsers(meId: string, query: string) { + const db = getDb(); + const q = query.trim(); + if (q.length < 2) return []; + + const found = await db + .select() + .from(users) + .where(and(ilike(users.username, `${q}%`), ne(users.id, meId))) + .limit(10); + + // Annotate each result with the current relationship (for button state). + const rels = await myFriendships(meId); + const relByOther = new Map(); + for (const f of rels) { + const otherId = f.requesterId === meId ? f.addresseeId : f.requesterId; + const label = + f.status === "accepted" + ? "friends" + : f.status === "blocked" + ? "blocked" + : f.requesterId === meId + ? "pending_out" + : "pending_in"; + relByOther.set(otherId, label); + } + + return found.map((u) => ({ + ...toFriendUser(u), + relationship: relByOther.get(u.id) ?? "none", + })); +} + +export async function sendRequest(meId: string, username: string) { + const db = getDb(); + const [target] = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + + if (!target) throw new HttpError(404, "user_not_found"); + if (target.id === meId) throw new HttpError(400, "cannot_add_self"); + + const [existing] = await db + .select() + .from(friendships) + .where( + or( + and( + eq(friendships.requesterId, meId), + eq(friendships.addresseeId, target.id), + ), + and( + eq(friendships.requesterId, target.id), + eq(friendships.addresseeId, meId), + ), + ), + ) + .limit(1); + + if (existing) { + if (existing.status === "accepted") + throw new HttpError(409, "already_friends"); + if (existing.status === "blocked") throw new HttpError(403, "blocked"); + throw new HttpError(409, "request_already_exists"); + } + + const now = Date.now(); + const [row] = await db + .insert(friendships) + .values({ + id: newId(), + requesterId: meId, + addresseeId: target.id, + status: "pending", + createdAt: now, + updatedAt: now, + }) + .returning(); + + return { friendshipId: row.id, user: toFriendUser(target) }; +} + +export async function acceptRequest(meId: string, friendshipId: string) { + const db = getDb(); + const [f] = await db + .select() + .from(friendships) + .where(eq(friendships.id, friendshipId)) + .limit(1); + if (!f || f.addresseeId !== meId || f.status !== "pending") { + throw new HttpError(404, "request_not_found"); + } + await db + .update(friendships) + .set({ status: "accepted", updatedAt: Date.now() }) + .where(eq(friendships.id, friendshipId)); +} + +export async function declineRequest(meId: string, friendshipId: string) { + const db = getDb(); + const [f] = await db + .select() + .from(friendships) + .where(eq(friendships.id, friendshipId)) + .limit(1); + if (!f || f.addresseeId !== meId || f.status !== "pending") { + throw new HttpError(404, "request_not_found"); + } + await db.delete(friendships).where(eq(friendships.id, friendshipId)); +} + +export async function removeFriend(meId: string, friendshipId: string) { + const db = getDb(); + const [f] = await db + .select() + .from(friendships) + .where(eq(friendships.id, friendshipId)) + .limit(1); + if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) { + throw new HttpError(404, "friendship_not_found"); + } + await db.delete(friendships).where(eq(friendships.id, friendshipId)); +} + +export async function blockFriend(meId: string, friendshipId: string) { + const db = getDb(); + const [f] = await db + .select() + .from(friendships) + .where(eq(friendships.id, friendshipId)) + .limit(1); + if (!f || (f.requesterId !== meId && f.addresseeId !== meId)) { + throw new HttpError(404, "friendship_not_found"); + } + await db + .update(friendships) + .set({ status: "blocked", updatedAt: Date.now() }) + .where(eq(friendships.id, friendshipId)); +} diff --git a/server/src/smoke-test.ts b/server/src/smoke-test.ts index cf5572f..5222119 100644 --- a/server/src/smoke-test.ts +++ b/server/src/smoke-test.ts @@ -160,6 +160,78 @@ async function main() { // --- H. Auth required on sync --- const noAuth = await req("GET", "/sync?since=0"); check("sync without token is 401", noAuth.status === 401); + + // --- I. Friends: two accounts befriend each other --- + const emailF = `friend${Date.now()}@test.dev`; + const regF = await req("POST", "/auth/register", { + email: emailF, + password: "supersecret", + displayName: "Zoe", + username: `zoe_${Date.now().toString(36)}`, + }); + check("second account registers with username", regF.status === 201); + const tokenF = regF.json.accessToken; + const zoeUsername = regF.json.user.username; + + // Alice needs a username too so Zoe could find her back (not required here) + await req("PATCH", "/me", { username: `alice_${Date.now().toString(36)}` }, tokenA); + + // Alice searches for Zoe by username + const searchRes = await req( + "GET", + `/friends/search?q=${zoeUsername}`, + undefined, + tokenA, + ); + check("search finds the user by username", searchRes.json?.results?.some((u: any) => u.id === regF.json.user.id)); + + // Alice sends a friend request to Zoe + const sendRes = await req( + "POST", + "/friends/requests", + { username: zoeUsername }, + tokenA, + ); + check("friend request created", sendRes.status === 201); + const friendshipId = sendRes.json.friendshipId; + + // Duplicate request is rejected + const dupRes = await req( + "POST", + "/friends/requests", + { username: zoeUsername }, + tokenA, + ); + check("duplicate friend request rejected", dupRes.status === 409); + + // Zoe sees the incoming request + const zoeReqs = await req("GET", "/friends/requests", undefined, tokenF); + check("addressee sees incoming request", zoeReqs.json?.incoming?.some((r: any) => r.friendshipId === friendshipId)); + + // Alice sees it as outgoing + const aliceReqs = await req("GET", "/friends/requests", undefined, tokenA); + check("requester sees outgoing request", aliceReqs.json?.outgoing?.some((r: any) => r.friendshipId === friendshipId)); + + // Zoe accepts + const acceptRes = await req( + "POST", + `/friends/requests/${friendshipId}/accept`, + undefined, + tokenF, + ); + check("accept returns ok", acceptRes.status === 200); + + // Both now list each other as friends + const aliceFriends = await req("GET", "/friends", undefined, tokenA); + const zoeFriends = await req("GET", "/friends", undefined, tokenF); + check("requester now has the friend", aliceFriends.json?.friends?.some((f: any) => f.user.id === regF.json.user.id)); + check("addressee now has the friend", zoeFriends.json?.friends?.some((f: any) => f.friendshipId === friendshipId)); + + // Unfriend + const unfriendRes = await req("DELETE", `/friends/${friendshipId}`, undefined, tokenA); + check("unfriend returns ok", unfriendRes.status === 200); + const aliceAfter = await req("GET", "/friends", undefined, tokenA); + check("friend removed after unfriend", !aliceAfter.json?.friends?.some((f: any) => f.friendshipId === friendshipId)); } finally { server.close(); } diff --git a/src/App.tsx b/src/App.tsx index 404a331..8324bab 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,6 +10,7 @@ import History from "./pages/History"; import Players from "./pages/Players"; import Locations from "./pages/Locations"; import Profiles from "./pages/Profiles"; +import Friends from "./pages/Friends"; import Auth from "./pages/Auth"; import Settings from "./pages/Settings"; import Statistics from "./pages/Stats"; @@ -41,6 +42,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/NavigationMenu.tsx b/src/components/NavigationMenu.tsx index 2dafee8..feb350d 100644 --- a/src/components/NavigationMenu.tsx +++ b/src/components/NavigationMenu.tsx @@ -9,6 +9,7 @@ import { MapPin, UserCircle, ChevronRight, + Users2, } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { motion, AnimatePresence } from "framer-motion"; @@ -101,6 +102,12 @@ export function NavigationMenu() { > Emplacements + + + + + ); + } + + const handleSearch = async () => { + setError(null); + setSearching(true); + try { + setResults(await search(query)); + } catch { + setError("Recherche impossible."); + } finally { + setSearching(false); + } + }; + + const handleSend = async (username: string) => { + setError(null); + try { + await sendRequest(username); + setResults((prev) => + prev.map((r) => + r.username === username ? { ...r, relationship: "pending_out" } : r, + ), + ); + } catch (e) { + const code = e instanceof ApiError ? e.message : ""; + setError( + code === "already_friends" + ? "Vous êtes déjà amis." + : code === "request_already_exists" + ? "Demande déjà envoyée." + : "Impossible d'envoyer la demande.", + ); + } + }; + + const handleSaveUsername = async () => { + const u = usernameInput.trim(); + if (!u) return; + setSavingUsername(true); + setError(null); + try { + await updateAccount({ username: u }); + } catch (e) { + const code = e instanceof ApiError ? e.message : ""; + setError( + code === "username_taken" + ? "Ce pseudo est déjà pris." + : "Impossible d'enregistrer le pseudo.", + ); + } finally { + setSavingUsername(false); + } + }; + + return ( +
+
+ +

+ Amis +

+
+ + {error && ( +

+ {error} +

+ )} + + {/* Prompt to set a username so others can find you */} + {!user?.username ? ( + + +

+ + Choisissez un pseudo pour que vos amis vous trouvent +

+
+ setUsernameInput(e.target.value)} + placeholder="votre_pseudo" + className="h-12" + /> + +
+
+
+ ) : ( +

+ Votre pseudo : @{user.username} +

+ )} + + {/* Search */} +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="Rechercher un pseudo…" + className="h-12" + /> + +
+ + {results.length > 0 && ( +
+ {results.map((r) => ( + + +
+ +
+

{r.displayName}

+ {r.username && ( +

+ @{r.username} +

+ )} +
+
+ {r.relationship === "friends" ? ( + Ami + ) : r.relationship === "pending_out" ? ( + + Envoyée + + ) : r.relationship === "pending_in" ? ( + + Vous a ajouté + + ) : ( + + )} +
+
+ ))} +
+ )} + + {/* Incoming requests */} + {incoming.length > 0 && ( +
+

Demandes reçues

+ {incoming.map((r) => ( + + +
+ +
+

{r.user.displayName}

+ {r.user.username && ( +

+ @{r.user.username} +

+ )} +
+
+
+ + +
+
+
+ ))} +
+ )} + + {/* Outgoing requests */} + {outgoing.length > 0 && ( +
+

Demandes envoyées

+ {outgoing.map((r) => ( + + +
+ +

{r.user.displayName}

+
+ + + En attente + +
+
+ ))} +
+ )} + + {/* Friends list */} +
+

+ Mes amis {friends.length > 0 && `(${friends.length})`} +

+ {friends.length === 0 ? ( +
+ +

+ Aucun ami pour l'instant. Recherchez un pseudo ci-dessus. +

+
+ ) : ( + friends.map((f) => ( + + +
+ +
+

{f.user.displayName}

+ {f.user.username && ( +

+ @{f.user.username} +

+ )} +
+
+ +
+
+ )) + )} +
+
+ ); +} diff --git a/src/pages/NewGame.tsx b/src/pages/NewGame.tsx index 85ccd11..bcca862 100644 --- a/src/pages/NewGame.tsx +++ b/src/pages/NewGame.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { ChevronLeft, @@ -17,6 +17,8 @@ import { db } from "../database/db"; import { getGameConfig } from "../games"; import { useGameStore } from "../stores/gameStore"; import { useProfileStore } from "../stores/profileStore"; +import { useAuthStore } from "../stores/authStore"; +import { useFriendsStore, FriendEntry } from "../stores/friendsStore"; import { useGameTheme } from "../hooks/useGameTheme"; import { Player, SavedPlayer } from "../types"; import { Button } from "../components/ui/button"; @@ -85,6 +87,12 @@ export default function NewGame() { const startNewGame = useGameStore((state) => state.startNewGame); const activeProfileId = useProfileStore((state) => state.activeProfileId); + const authStatus = useAuthStore((state) => state.status); + const { friends, load: loadFriends } = useFriendsStore(); + + useEffect(() => { + if (authStatus === "authenticated") loadFriends(); + }, [authStatus, loadFriends]); const [players, setPlayers] = useState([ { id: generateId(), name: "" }, @@ -123,6 +131,9 @@ export default function NewGame() { (p) => p.name.trim().toLowerCase() === sp.name.trim().toLowerCase(), ), ); + const availableFriends = friends.filter( + (f) => !players.some((p) => p.linkedUserId === f.user.id), + ); if (!gameConfig) { return
Jeu introuvable
; @@ -158,6 +169,25 @@ export default function NewGame() { } }; + const handleQuickAddFriend = (friend: FriendEntry) => { + // Snapshot the friend's name/avatar into the session, tagged with their + // server account id (linkedUserId). + const newPlayer: Player = { + id: generateId(), + name: friend.user.displayName, + avatar: friend.user.avatarUrl ?? undefined, + linkedUserId: friend.user.id, + }; + const emptyIndex = players.findIndex((p) => p.name.trim() === ""); + if (emptyIndex !== -1) { + const newPlayers = [...players]; + newPlayers[emptyIndex] = newPlayer; + setPlayers(newPlayers); + } else if (players.length < gameConfig.maxPlayers) { + setPlayers([...players, newPlayer]); + } + }; + const handleRemovePlayer = (id: string) => { if (players.length > gameConfig.minPlayers) { setPlayers(players.filter((p) => p.id !== id)); @@ -359,6 +389,35 @@ export default function NewGame() { )} + + {availableFriends.length > 0 && ( +
+

+ Amis +

+
+ {availableFriends.map((f) => ( +
handleQuickAddFriend(f)} + > + {f.user.avatarUrl ? ( + + ) : ( + + )} + {f.user.displayName} +
+ ))} +
+
+ )} {locations.length > 0 && ( diff --git a/src/stores/authStore.ts b/src/stores/authStore.ts index 47989a8..10c0d4d 100644 --- a/src/stores/authStore.ts +++ b/src/stores/authStore.ts @@ -35,6 +35,11 @@ interface AuthState { login: (email: string, password: string) => Promise; logout: () => Promise; restore: () => Promise; + updateAccount: (changes: { + username?: string; + displayName?: string; + avatarUrl?: string | null; + }) => Promise; } async function linkActiveProfileToUser(userId: string) { @@ -93,6 +98,14 @@ export const useAuthStore = create((set) => ({ set({ user: null, status: "anonymous" }); }, + updateAccount: async (changes) => { + const res = await apiJson<{ user: AuthUser }>("/me", { + method: "PATCH", + body: JSON.stringify(changes), + }); + set({ user: res.user }); + }, + // On boot: try to silently resume a session via the refresh cookie. restore: async () => { try { diff --git a/src/stores/friendsStore.ts b/src/stores/friendsStore.ts new file mode 100644 index 0000000..a570965 --- /dev/null +++ b/src/stores/friendsStore.ts @@ -0,0 +1,101 @@ +import { create } from "zustand"; +import { apiJson, getAccessToken } from "../lib/apiClient"; + +export interface FriendUser { + id: string; + username: string | null; + displayName: string; + avatarUrl: string | null; +} + +export interface FriendEntry { + friendshipId: string; + user: FriendUser; +} + +export interface SearchResult extends FriendUser { + relationship: + | "none" + | "pending_out" + | "pending_in" + | "friends" + | "blocked"; +} + +interface FriendsState { + friends: FriendEntry[]; + incoming: FriendEntry[]; + outgoing: FriendEntry[]; + loading: boolean; + load: () => Promise; + search: (q: string) => Promise; + sendRequest: (username: string) => Promise; + accept: (friendshipId: string) => Promise; + decline: (friendshipId: string) => Promise; + unfriend: (friendshipId: string) => Promise; +} + +export const useFriendsStore = create((set, get) => ({ + friends: [], + incoming: [], + outgoing: [], + loading: false, + + load: async () => { + if (!getAccessToken()) { + set({ friends: [], incoming: [], outgoing: [] }); + return; + } + set({ loading: true }); + try { + const [friendsRes, requestsRes] = await Promise.all([ + apiJson<{ friends: FriendEntry[] }>("/friends"), + apiJson<{ incoming: FriendEntry[]; outgoing: FriendEntry[] }>( + "/friends/requests", + ), + ]); + set({ + friends: friendsRes.friends, + incoming: requestsRes.incoming, + outgoing: requestsRes.outgoing, + }); + } finally { + set({ loading: false }); + } + }, + + search: async (q) => { + if (!getAccessToken() || q.trim().length < 2) return []; + const res = await apiJson<{ results: SearchResult[] }>( + `/friends/search?q=${encodeURIComponent(q.trim())}`, + ); + return res.results; + }, + + sendRequest: async (username) => { + await apiJson("/friends/requests", { + method: "POST", + body: JSON.stringify({ username }), + }); + await get().load(); + }, + + accept: async (friendshipId) => { + await apiJson(`/friends/requests/${friendshipId}/accept`, { + method: "POST", + }); + await get().load(); + }, + + decline: async (friendshipId) => { + await apiJson(`/friends/requests/${friendshipId}/decline`, { + method: "POST", + }); + await get().load(); + }, + + unfriend: async (friendshipId) => { + await apiJson(`/friends/${friendshipId}`, { method: "DELETE" }); + await get().load(); + }, +})); diff --git a/src/types/index.ts b/src/types/index.ts index 6aeac49..dd11e71 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -47,6 +47,7 @@ export interface Player { name: string; color?: string; avatar?: string; + linkedUserId?: string; // compte serveur de l'ami tagué comme co-joueur (Phase 4) } export interface RoundScore {