From fc51371dade75df102082195dd2bd9007df29756 Mon Sep 17 00:00:00 2001 From: nlevesque Date: Tue, 28 Jul 2026 20:33:46 +0200 Subject: [PATCH] Migrate backend from PocketBase to Directus Full cutover: auth, users, collection and friendships now run on Directus via @directus/sdk, with owner/visibility rules enforced by Directus permissions. - middleware: Directus session (directus_auth cookie) with token refresh - /login is Directus (email normalized to lowercase), new /register page (first/last name, username) backed by public registration - collection, friends and profile pages + all API endpoints ported; ownership and addressee-only accept/decline enforced by permissions - display names use first_name/last_name everywhere (nav, friends, profile) - PocketBase SDK, POCKETBASE_URL and pb_schema.json removed - README and compose updated for DIRECTUS_URL --- .env.example | 4 +- README.md | 37 ++- bun.lock | 6 +- docker-compose.yml | 4 +- package-lock.json | 18 +- package.json | 2 +- pb_schema.json | 349 ----------------------------- src/components/LoginForm.astro | 4 +- src/components/Nav.astro | 16 +- src/env.d.ts | 4 +- src/layouts/BaseLayout.astro | 3 +- src/lib/auth.ts | 8 + src/lib/collection.ts | 27 +-- src/lib/directus.ts | 94 ++++++++ src/middleware.ts | 58 +++-- src/pages/api/collection.ts | 40 ++-- src/pages/api/collection/[id].ts | 21 +- src/pages/api/friends/accept.ts | 54 +++-- src/pages/api/friends/decline.ts | 54 +++-- src/pages/api/friends/request.ts | 125 +++++++---- src/pages/api/friends/search.ts | 44 ++-- src/pages/api/login.ts | 51 +++-- src/pages/api/logout.ts | 2 +- src/pages/api/profile.ts | 99 ++++---- src/pages/api/register.ts | 140 ++++++++++++ src/pages/card/[id].astro | 4 +- src/pages/friends.astro | 110 ++++----- src/pages/friends/[username].astro | 105 +++++---- src/pages/index.astro | 17 +- src/pages/login-directus.astro | 4 + src/pages/login.astro | 8 + src/pages/mycards.astro | 24 +- src/pages/profile.astro | 52 +++-- src/pages/register.astro | 151 +++++++++++++ src/pages/search.astro | 4 +- src/pages/series/[id].astro | 4 +- src/pages/sets/[id].astro | 4 +- 37 files changed, 986 insertions(+), 765 deletions(-) delete mode 100644 pb_schema.json create mode 100644 src/lib/auth.ts create mode 100644 src/lib/directus.ts create mode 100644 src/pages/api/register.ts create mode 100644 src/pages/login-directus.astro create mode 100644 src/pages/register.astro diff --git a/.env.example b/.env.example index f916048..94db50b 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -# URL of the PocketBase backend (auth + database). +# URL of the Directus backend (auth + database). # Falls back to the production instance if unset. -POCKETBASE_URL=https://pokeshare.namarusaja.me +DIRECTUS_URL=https://directus.namarusaja.me diff --git a/README.md b/README.md index 493934f..2970f27 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,8 @@ collection with Cardmarket price valuations, and share collections with friends. - [Astro](https://astro.build) (SSR, Node standalone adapter) — no UI framework - [Tailwind CSS v4](https://tailwindcss.com) + [basecoat-css](https://basecoatui.com) -- [PocketBase](https://pocketbase.io) for database and authentication +- [Directus](https://directus.io) for database, authentication and permissions + (via [`@directus/sdk`](https://www.npmjs.com/package/@directus/sdk)) - [TCGdex API](https://api.tcgdex.net) (French locale) for card data, with an in-memory server-side cache (`src/lib/tcgdex-cache.ts`) @@ -26,43 +27,41 @@ Node >= 22.12. ## Environment variables -| Variable | Description | Default | -| ---------------- | -------------------------------------------- | ------------------------------------ | -| `POCKETBASE_URL` | URL of the PocketBase backend (auth + data) | `https://pokeshare.namarusaja.me` | +| Variable | Description | Default | +| -------------- | ------------------------------------------- | -------------------------------- | +| `DIRECTUS_URL` | URL of the Directus backend (auth + data) | `https://directus.namarusaja.me` | Read at **runtime** (`process.env`), so the same Docker image can be deployed -against different PocketBase instances. For local dev, copy `.env.example` to +against different Directus instances. For local dev, copy `.env.example` to `.env`. `HOST` and `PORT` are also honored by the standalone server. ## Docker -Single container (external PocketBase): +Single container (external Directus): ```sh docker build -t pokeshare . -docker run -p 4321:4321 -e POCKETBASE_URL=https://pb.example.com pokeshare +docker run -p 4321:4321 -e DIRECTUS_URL=https://directus.example.com pokeshare ``` -## Full stack (app + PocketBase) - -`docker-compose.yml` runs just the app; it expects an external PocketBase -instance referenced by `POCKETBASE_URL` (see above). A schema export of the -expected collections is kept at `pb_schema.json` for reference when setting -up a new PocketBase instance (admin UI → Settings → Import collections). +Or with the compose file: ```sh -POCKETBASE_URL=https://pb.example.com docker compose up --build +DIRECTUS_URL=https://directus.example.com docker compose up --build ``` - The app listens on `4321`; expose it on your domain (Coolify: expose the `app` service, or use the plain Dockerfile build pack). -- Accounts are managed from the PocketBase admin UI (`/_/`). +- Collections, roles and permissions are managed from the Directus Data + Studio; public registration is enabled with the "App User" role. ## Structure - `src/pages/` — routes (`/`, `/search`, `/mycards`, `/card/[id]`, - `/series/[id]`, `/sets/[id]`, `/friends`, `/profile`) and JSON API endpoints - under `src/pages/api/` -- `src/middleware.ts` — per-request PocketBase client + `pb_auth` cookie auth -- `src/lib/` — TCGdex cache, collection pricing helpers, type theming + `/series/[id]`, `/sets/[id]`, `/friends`, `/profile`, `/login`, `/register`) + and JSON API endpoints under `src/pages/api/` +- `src/middleware.ts` — Directus session (`directus_auth` cookie) with token + refresh, exposes `locals.directus` and `locals.user` +- `src/lib/` — Directus client + types, TCGdex cache, collection pricing, + auth helper, type theming - `src/layouts/`, `src/components/` — base layout and nav diff --git a/bun.lock b/bun.lock index 4d11497..86b71d1 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,10 @@ "name": "pokeshare", "dependencies": { "@astrojs/node": "^11.0.2", + "@directus/sdk": "^23.0.0", "@tailwindcss/vite": "^4.3.3", "astro": "^7.1.3", "basecoat-css": "^1.0.2", - "pocketbase": "^0.27.0", "tailwindcss": "^4.3.3", }, }, @@ -78,6 +78,8 @@ "@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="], + "@directus/sdk": ["@directus/sdk@23.0.0", "", {}, "sha512-hoPSj9bhe2dxaq9PXuvqqCHzjy9sUvcik2vi5DA/xKZ7E42O2TgDfNgRjYY8gb0ZOkm3TAUeLuEKf4aJTrUYIw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], @@ -556,8 +558,6 @@ "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "pocketbase": ["pocketbase@0.27.0", "", {}, "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ=="], - "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], diff --git a/docker-compose.yml b/docker-compose.yml index 8f48892..a3d790c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,8 +6,8 @@ services: build: . restart: unless-stopped environment: - # Full URL of the PocketBase instance to use (required). - POCKETBASE_URL: ${POCKETBASE_URL:-https://pokeshare.namarusaja.me} + # Full URL of the Directus instance to use (required). + DIRECTUS_URL: ${DIRECTUS_URL:-https://directus.namarusaja.me} healthcheck: # / redirects to /login (302) — check a 200 endpoint instead test: "wget -q -O /dev/null http://localhost:4321/login || exit 1" diff --git a/package-lock.json b/package-lock.json index 43f92e7..a41c904 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,10 +9,10 @@ "version": "0.0.1", "dependencies": { "@astrojs/node": "^11.0.2", + "@directus/sdk": "^23.0.0", "@tailwindcss/vite": "^4.3.3", "astro": "^7.1.3", "basecoat-css": "^1.0.2", - "pocketbase": "^0.27.0", "tailwindcss": "^4.3.3" }, "engines": { @@ -492,6 +492,18 @@ "node": ">= 20.12.0" } }, + "node_modules/@directus/sdk": { + "version": "23.0.0", + "resolved": "https://registry.npmjs.org/@directus/sdk/-/sdk-23.0.0.tgz", + "integrity": "sha512-hoPSj9bhe2dxaq9PXuvqqCHzjy9sUvcik2vi5DA/xKZ7E42O2TgDfNgRjYY8gb0ZOkm3TAUeLuEKf4aJTrUYIw==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/directus/directus?sponsor=1" + } + }, "node_modules/@emnapi/core": { "version": "1.11.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", @@ -3676,10 +3688,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pocketbase": { - "version": "0.27.0", - "license": "MIT" - }, "node_modules/postcss": { "version": "8.5.23", "funding": [ diff --git a/package.json b/package.json index 32d7878..3cebf50 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,10 @@ }, "dependencies": { "@astrojs/node": "^11.0.2", + "@directus/sdk": "^23.0.0", "@tailwindcss/vite": "^4.3.3", "astro": "^7.1.3", "basecoat-css": "^1.0.2", - "pocketbase": "^0.27.0", "tailwindcss": "^4.3.3" } } diff --git a/pb_schema.json b/pb_schema.json deleted file mode 100644 index 3b12885..0000000 --- a/pb_schema.json +++ /dev/null @@ -1,349 +0,0 @@ -[ - { - "id": "pbc_274306238", - "listRule": "@request.auth.id = requester || @request.auth.id = addressee", - "viewRule": "@request.auth.id = requester || @request.auth.id = addressee", - "createRule": "@request.auth.id = requester && addressee != requester", - "updateRule": "@request.auth.id = addressee || @request.auth.id = requester", - "deleteRule": "@request.auth.id = addressee || @request.auth.id = requester", - "name": "friendships", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation1820765950", - "maxSelect": 0, - "minSelect": 0, - "name": "requester", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation2602483783", - "maxSelect": 0, - "minSelect": 0, - "name": "addressee", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "help": "", - "hidden": false, - "id": "select2063623452", - "maxSelect": 0, - "name": "status", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "accepted", - "pending", - "declined" - ] - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_143883864", - "listRule": "@request.auth.id = user || (user.visibility = 'Public') || (user.visibility = 'Friends' && ((@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')))", - "viewRule": "@request.auth.id = user || (@collection.friendships.requester = user && @collection.friendships.addressee = @request.auth.id && @collection.friendships.status = 'accepted') || (@collection.friendships.addressee = user && @collection.friendships.requester = @request.auth.id && @collection.friendships.status = 'accepted')", - "createRule": "@request.auth.id != \"\" && user = @request.auth.id", - "updateRule": "@request.auth.id != \"\" && user = @request.auth.id", - "deleteRule": "@request.auth.id != \"\" && user = @request.auth.id", - "name": "mycards", - "type": "base", - "fields": [ - { - "autogeneratePattern": "[a-z0-9]{15}", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 15, - "min": 15, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text1254922784", - "max": 0, - "min": 0, - "name": "card_id", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text2987424932", - "max": 0, - "min": 0, - "name": "card_name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "cascadeDelete": false, - "collectionId": "_pb_users_auth_", - "help": "", - "hidden": false, - "id": "relation1653163849", - "maxSelect": 0, - "minSelect": 0, - "name": "user", - "presentable": false, - "required": false, - "system": false, - "type": "relation" - }, - { - "exceptDomains": null, - "help": "", - "hidden": false, - "id": "url4245288345", - "name": "card_image", - "onlyDomains": null, - "presentable": false, - "required": false, - "system": false, - "type": "url" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text4047749037", - "max": 0, - "min": 0, - "name": "variant", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "number2683508278", - "max": null, - "min": null, - "name": "quantity", - "onlyInt": false, - "presentable": false, - "required": false, - "system": false, - "type": "number" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text3420757135", - "max": 0, - "min": 0, - "name": "set_name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text18589324", - "max": 0, - "min": 0, - "name": "notes", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text849699619", - "max": 0, - "min": 0, - "name": "condition", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "hidden": false, - "id": "autodate2990389176", - "name": "created", - "onCreate": true, - "onUpdate": false, - "presentable": false, - "system": false, - "type": "autodate" - }, - { - "hidden": false, - "id": "autodate3332085495", - "name": "updated", - "onCreate": true, - "onUpdate": true, - "presentable": false, - "system": false, - "type": "autodate" - } - ], - "indexes": [], - "system": false - }, - { - "id": "pbc_83919930", - "listRule": "@request.auth.id != \"\"", - "viewRule": null, - "createRule": null, - "updateRule": null, - "deleteRule": null, - "name": "public_users", - "type": "view", - "fields": [ - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "text3208210256", - "max": 0, - "min": 0, - "name": "id", - "pattern": "^[a-z0-9]+$", - "presentable": false, - "primaryKey": true, - "required": true, - "system": true, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "_clone_EOl9", - "max": 20, - "min": 3, - "name": "username", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "autogeneratePattern": "", - "help": "", - "hidden": false, - "id": "_clone_0z4L", - "max": 255, - "min": 0, - "name": "name", - "pattern": "", - "presentable": false, - "primaryKey": false, - "required": false, - "system": false, - "type": "text" - }, - { - "help": "", - "hidden": false, - "id": "_clone_3D0a", - "maxSelect": 0, - "name": "visibility", - "presentable": false, - "required": false, - "system": false, - "type": "select", - "values": [ - "Public", - "Friends", - "Private" - ] - } - ], - "indexes": [], - "system": false, - "viewQuery": "SELECT id, username, name, visibility FROM users WHERE visibility != 'Private'" - } -] \ No newline at end of file diff --git a/src/components/LoginForm.astro b/src/components/LoginForm.astro index 4aefd95..f5c9af3 100644 --- a/src/components/LoginForm.astro +++ b/src/components/LoginForm.astro @@ -6,10 +6,10 @@
- + s[0].toUpperCase()) - .join("") - : ""; +import { displayName, initials as userInitials } from "../lib/directus"; + +const user = Astro.locals.user; +const initials = user ? userInitials(user) : ""; ---
{initials} = { @@ -37,7 +25,7 @@ export const variantPriceMap: Record = { * repeated page loads don't hit the TCGdex API again. */ export async function priceCollectionCards( - cards: MyCardRecord[], + cards: MyCard[], ): Promise { return Promise.all( cards.map(async (card) => { @@ -45,10 +33,11 @@ export async function priceCollectionCards( const detail = await getCard(card.card_id); const cardmarket = detail?.pricing?.cardmarket; if (cardmarket) { - const priceKey = variantPriceMap[card.variant] ?? "avg"; + const priceKey = variantPriceMap[card.variant ?? ""] ?? "avg"; price = cardmarket[priceKey] ?? cardmarket.avg ?? 0; } - return { ...card, price, totalPrice: price * card.quantity }; + const quantity = card.quantity ?? 1; + return { ...card, price, totalPrice: price * quantity }; }), ); } diff --git a/src/lib/directus.ts b/src/lib/directus.ts new file mode 100644 index 0000000..fc21d8e --- /dev/null +++ b/src/lib/directus.ts @@ -0,0 +1,94 @@ +/** + * Directus client factory and shared types/helpers. + * URL is read at runtime (not build time) so the same Docker image can + * point to different Directus instances via DIRECTUS_URL. + */ + +import { createDirectus, rest, authentication } from "@directus/sdk"; + +export const DIRECTUS_URL = + process.env.DIRECTUS_URL ?? "https://directus.namarusaja.me"; + +export interface DirectusTokens { + access_token: string; + refresh_token: string; + expires?: number; +} + +export interface AppUser { + id: string; + email: string; + first_name: string | null; + last_name: string | null; + username: string | null; + theme_preference: string | null; + visibility: "Public" | "Friends" | "Private" | null; +} + +export interface MyCard { + id: number; + user: string; + card_id: string; + card_name: string; + card_image: string | null; + variant: string | null; + quantity: number | null; + condition: string | null; + notes: string | null; + set_name: string | null; + date_created: string; + date_updated: string; +} + +export type FriendshipStatus = "pending" | "accepted" | "declined"; + +export interface Friendship { + id: number; + requester: string | AppUser; + addressee: string | AppUser; + status: FriendshipStatus; + date_created: string; + date_updated: string; +} + +interface Schema { + mycards: MyCard[]; + friendships: Friendship[]; + directus_users: AppUser[]; +} + +export function createDirectusClient() { + return createDirectus(DIRECTUS_URL) + .with(authentication("json")) + .with(rest()); +} + +export type DirectusClient = ReturnType; + +/** Normalize an email for auth: trim + lowercase (case sensitivity bites). */ +export function normalizeEmail(email: string): string { + return email.trim().toLowerCase(); +} + +/** Display name: "First Last", falling back to username then email. */ +export function displayName( + user: Pick, +): string { + const full = [user.first_name, user.last_name] + .filter(Boolean) + .join(" ") + .trim(); + return full || user.username || user.email; +} + +/** Up-to-two-letter initials for avatars. */ +export function initials( + user: Pick, +): string { + return displayName(user) + .split(/[\s@.]+/) + .filter(Boolean) + .slice(0, 2) + .map((s) => s[0].toUpperCase()) + .join(""); +} diff --git a/src/middleware.ts b/src/middleware.ts index cd72c0f..8c1035b 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,34 +1,52 @@ import { defineMiddleware } from "astro:middleware"; -import PocketBase from "pocketbase"; +import { readMe, refresh } from "@directus/sdk"; +import { + createDirectusClient, + type AppUser, + type DirectusTokens, +} from "./lib/directus"; -// Read at runtime (not build time) so the same Docker image can point to -// different PocketBase instances via the POCKETBASE_URL env variable. -const POCKETBASE_URL = - process.env.POCKETBASE_URL ?? "https://pokeshare.namarusaja.me"; +const AUTH_COOKIE = "directus_auth"; +const COOKIE_OPTIONS = { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 1 week +} as const; export const onRequest = defineMiddleware(async ({ cookies, locals }, next) => { - const pb = new PocketBase(POCKETBASE_URL); + locals.directus = null; + locals.user = null; - // Restore auth from the cookie if it exists - const authCookie = cookies.get("pb_auth"); + const authCookie = cookies.get(AUTH_COOKIE); if (authCookie) { - pb.authStore.loadFromCookie(authCookie.value); - - // Optionally refresh the token if it's close to expiring try { - if (pb.authStore.isValid) { - await pb.collection("users").authRefresh(); + const tokens = JSON.parse(authCookie.value) as DirectusTokens; + const directus = createDirectusClient(); + directus.setToken(tokens.access_token); + try { + locals.user = (await directus.request(readMe())) as AppUser; + } catch { + // Access token expired — refresh and renew the cookie + const renewed = (await directus.request( + refresh("json", tokens.refresh_token), + )) as DirectusTokens; + directus.setToken(renewed.access_token); + cookies.set( + AUTH_COOKIE, + JSON.stringify({ + access_token: renewed.access_token, + refresh_token: renewed.refresh_token, + }), + COOKIE_OPTIONS, + ); + locals.user = (await directus.request(readMe())) as AppUser; } + locals.directus = directus; } catch { - // Token is invalid/expired — clear it - pb.authStore.clear(); - cookies.delete("pb_auth", { path: "/" }); + cookies.delete(AUTH_COOKIE, { path: "/" }); } } - // Make the PocketBase instance available to all pages and endpoints via locals - locals.pb = pb; - locals.user = pb.authStore.record as any; - return next(); }); diff --git a/src/pages/api/collection.ts b/src/pages/api/collection.ts index c357641..2d3e392 100644 --- a/src/pages/api/collection.ts +++ b/src/pages/api/collection.ts @@ -1,15 +1,25 @@ import type { APIRoute } from "astro"; +import { createItem } from "@directus/sdk"; +import { isLoggedIn } from "../../lib/auth"; export const POST: APIRoute = async ({ request, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } - const { card_id, card_name, card_image, variant, quantity, condition, notes, set_name } = - await request.json(); + const { + card_id, + card_name, + card_image, + variant, + quantity, + condition, + notes, + set_name, + } = await request.json(); if (!card_id || !card_name) { return new Response(JSON.stringify({ error: "Missing card info" }), { @@ -19,17 +29,19 @@ export const POST: APIRoute = async ({ request, locals }) => { } try { - const record = await locals.pb.collection("mycards").create({ - user: locals.pb.authStore.record!.id, - card_id, - card_name, - card_image: card_image ?? "", - variant: variant ?? "Base", - quantity: quantity ?? 1, - condition: condition ?? "", - notes: notes ?? "", - set_name: set_name ?? "", - }); + const record = await locals.directus!.request( + createItem("mycards", { + user: locals.user!.id, + card_id, + card_name, + card_image: card_image || null, + variant: variant || "Base", + quantity: Number(quantity) || 1, + condition: condition || null, + notes: notes || null, + set_name: set_name || null, + }), + ); return new Response(JSON.stringify({ success: true, record }), { status: 201, diff --git a/src/pages/api/collection/[id].ts b/src/pages/api/collection/[id].ts index a1674b6..78df678 100644 --- a/src/pages/api/collection/[id].ts +++ b/src/pages/api/collection/[id].ts @@ -1,16 +1,18 @@ import type { APIRoute } from "astro"; +import { deleteItem } from "@directus/sdk"; +import { isLoggedIn } from "../../../lib/auth"; export const DELETE: APIRoute = async ({ params, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, }); } - const { id } = params; + const id = Number(params.id); - if (!id) { + if (!id || Number.isNaN(id)) { return new Response(JSON.stringify({ error: "Missing record id" }), { status: 400, headers: { "Content-Type": "application/json" }, @@ -18,16 +20,9 @@ export const DELETE: APIRoute = async ({ params, locals }) => { } try { - // Verify the record belongs to the current user before deleting - const record = await locals.pb.collection("mycards").getOne(id); - if (record.user !== locals.pb.authStore.record!.id) { - return new Response(JSON.stringify({ error: "Forbidden" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - await locals.pb.collection("mycards").delete(id); + // Ownership is enforced by the Directus delete permission + // (user = $CURRENT_USER) — deleting someone else's card 403s there. + await locals.directus!.request(deleteItem("mycards", id)); return new Response(JSON.stringify({ success: true }), { status: 200, diff --git a/src/pages/api/friends/accept.ts b/src/pages/api/friends/accept.ts index 9ee8f8e..8954e55 100644 --- a/src/pages/api/friends/accept.ts +++ b/src/pages/api/friends/accept.ts @@ -1,7 +1,9 @@ import type { APIRoute } from "astro"; +import { updateItem } from "@directus/sdk"; +import { isLoggedIn } from "../../../lib/auth"; export const POST: APIRoute = async ({ request, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => { try { const { friendshipId } = await request.json(); + const id = Number(friendshipId); - if (!friendshipId) { - return new Response(JSON.stringify({ error: "Missing friendship id" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + if (!id || Number.isNaN(id)) { + return new Response( + JSON.stringify({ error: "Missing friendship id" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } - const friendship = await locals.pb.collection("friendships").getOne(friendshipId); - - // Only the addressee can accept - if (friendship.addressee !== locals.pb.authStore.record!.id) { - return new Response(JSON.stringify({ error: "Forbidden" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - await locals.pb.collection("friendships").update(friendshipId, { - status: "accepted", - }); + // Only the addressee can accept — enforced by the Directus update + // permission (addressee = $CURRENT_USER, status field only). + await locals.directus!.request( + updateItem("friendships", id, { status: "accepted" }), + ); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); - } catch (err) { + } catch (err: any) { console.error("Accept friend failed:", err); - return new Response(JSON.stringify({ error: "Failed to accept" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + const forbidden = + err?.errors?.[0]?.extensions?.code === "FORBIDDEN" || + err?.response?.status === 403; + return new Response( + JSON.stringify({ + error: forbidden ? "Forbidden" : "Failed to accept", + }), + { + status: forbidden ? 403 : 500, + headers: { "Content-Type": "application/json" }, + }, + ); } }; diff --git a/src/pages/api/friends/decline.ts b/src/pages/api/friends/decline.ts index 9c83759..f2df727 100644 --- a/src/pages/api/friends/decline.ts +++ b/src/pages/api/friends/decline.ts @@ -1,7 +1,9 @@ import type { APIRoute } from "astro"; +import { updateItem } from "@directus/sdk"; +import { isLoggedIn } from "../../../lib/auth"; export const POST: APIRoute = async ({ request, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => { try { const { friendshipId } = await request.json(); + const id = Number(friendshipId); - if (!friendshipId) { - return new Response(JSON.stringify({ error: "Missing friendship id" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + if (!id || Number.isNaN(id)) { + return new Response( + JSON.stringify({ error: "Missing friendship id" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } - const friendship = await locals.pb.collection("friendships").getOne(friendshipId); - - // Only the addressee can decline - if (friendship.addressee !== locals.pb.authStore.record!.id) { - return new Response(JSON.stringify({ error: "Forbidden" }), { - status: 403, - headers: { "Content-Type": "application/json" }, - }); - } - - await locals.pb.collection("friendships").update(friendshipId, { - status: "declined", - }); + // Only the addressee can decline — enforced by the Directus update + // permission (addressee = $CURRENT_USER, status field only). + await locals.directus!.request( + updateItem("friendships", id, { status: "declined" }), + ); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); - } catch (err) { + } catch (err: any) { console.error("Decline friend failed:", err); - return new Response(JSON.stringify({ error: "Failed to decline" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + const forbidden = + err?.errors?.[0]?.extensions?.code === "FORBIDDEN" || + err?.response?.status === 403; + return new Response( + JSON.stringify({ + error: forbidden ? "Forbidden" : "Failed to decline", + }), + { + status: forbidden ? 403 : 500, + headers: { "Content-Type": "application/json" }, + }, + ); } }; diff --git a/src/pages/api/friends/request.ts b/src/pages/api/friends/request.ts index 9f9ecee..1f66cee 100644 --- a/src/pages/api/friends/request.ts +++ b/src/pages/api/friends/request.ts @@ -1,7 +1,10 @@ import type { APIRoute } from "astro"; +import { createItem, readItems, readUsers, deleteItem } from "@directus/sdk"; +import { isLoggedIn } from "../../../lib/auth"; +import type { AppUser, Friendship } from "../../../lib/directus"; export const POST: APIRoute = async ({ request, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -18,12 +21,18 @@ export const POST: APIRoute = async ({ request, locals }) => { }); } - const requesterId = locals.pb.authStore.record!.id; + const requesterId = locals.user!.id; + const directus = locals.directus!; - // Find the target user by username - const targetUser = await locals.pb.collection("public_users").getFirstListItem( - locals.pb.filter("username = {:username}", { username }), - ); + // Find the target user by username (Private users are not visible) + const matches = (await directus.request( + readUsers({ + filter: { username: { _eq: username } }, + fields: ["id"], + limit: 1, + }), + )) as AppUser[]; + const targetUser = matches[0]; if (!targetUser) { return new Response(JSON.stringify({ error: "User not found" }), { @@ -33,59 +42,83 @@ export const POST: APIRoute = async ({ request, locals }) => { } if (targetUser.id === requesterId) { - return new Response(JSON.stringify({ error: "Cannot add yourself" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - } - - // Check if friendship already exists (in either direction) - const existing = await locals.pb.collection("friendships").getList(1, 1, { - filter: locals.pb.filter( - "(requester = {:requesterId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:requesterId})", - { requesterId, targetId: targetUser.id }, - ), - }); - - if (existing.items.length > 0) { - const f = existing.items[0]; - if (f.status === "accepted") { - return new Response(JSON.stringify({ error: "Already friends" }), { + return new Response( + JSON.stringify({ error: "Cannot add yourself" }), + { status: 400, headers: { "Content-Type": "application/json" }, - }); + }, + ); + } + + // Check if a friendship already exists (in either direction) + const existing = (await directus.request( + readItems("friendships", { + filter: { + _or: [ + { + _and: [ + { requester: { _eq: requesterId } }, + { addressee: { _eq: targetUser.id } }, + ], + }, + { + _and: [ + { requester: { _eq: targetUser.id } }, + { addressee: { _eq: requesterId } }, + ], + }, + ], + }, + limit: 1, + }), + )) as Friendship[]; + + if (existing.length > 0) { + const f = existing[0]; + if (f.status === "accepted") { + return new Response( + JSON.stringify({ error: "Already friends" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } if (f.status === "pending") { - return new Response(JSON.stringify({ error: "Invite already sent" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Invite already sent" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } - // Declined — allow re-request - await locals.pb.collection("friendships").delete(f.id); + // Declined — remove it and allow re-request + await directus.request(deleteItem("friendships", f.id)); } - const record = await locals.pb.collection("friendships").create({ - requester: requesterId, - addressee: targetUser.id, - status: "pending", - }); + // Validation rule requester = $CURRENT_USER is enforced by Directus + const record = await directus.request( + createItem("friendships", { + requester: requesterId, + addressee: targetUser.id, + status: "pending", + }), + ); return new Response(JSON.stringify({ success: true, record }), { status: 201, headers: { "Content-Type": "application/json" }, }); - } catch (err: any) { + } catch (err) { console.error("Friend request failed:", err); - if (err.message?.includes("not found")) { - return new Response(JSON.stringify({ error: "User not found" }), { - status: 404, + return new Response( + JSON.stringify({ error: "Failed to send invite" }), + { + status: 500, headers: { "Content-Type": "application/json" }, - }); - } - return new Response(JSON.stringify({ error: "Failed to send invite" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + }, + ); } }; diff --git a/src/pages/api/friends/search.ts b/src/pages/api/friends/search.ts index f6dc6e7..e808122 100644 --- a/src/pages/api/friends/search.ts +++ b/src/pages/api/friends/search.ts @@ -1,7 +1,10 @@ import type { APIRoute } from "astro"; +import { readUsers } from "@directus/sdk"; +import { isLoggedIn } from "../../../lib/auth"; +import type { AppUser } from "../../../lib/directus"; export const GET: APIRoute = async ({ url, locals }) => { - if (!locals.pb.authStore.isValid) { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -11,23 +14,38 @@ export const GET: APIRoute = async ({ url, locals }) => { const query = url.searchParams.get("q")?.trim().replace(/^@/, ""); if (!query || query.length < 3 || query.length > 20) { - return new Response(JSON.stringify({ error: "Query must be 3-20 characters" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Query must be 3-20 characters" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } try { - // Search by username (case-insensitive), exclude self - const users = await locals.pb.collection("public_users").getList(1, 10, { - filter: locals.pb.filter("username ~ {:query} && id != {:selfId}", { - query, - selfId: locals.pb.authStore.record!.id, + // Search by username, exclude self. Private users are excluded by the + // users read permission (former public_users view behavior). + const users = (await locals.directus!.request( + readUsers({ + filter: { + _and: [ + { username: { _contains: query } }, + { id: { _neq: locals.user!.id } }, + ], + }, + fields: [ + "id", + "username", + "first_name", + "last_name", + "visibility", + ], + limit: 10, }), - fields: "id,username,name,visibility", - }); + )) as AppUser[]; - return new Response(JSON.stringify({ users: users.items }), { + return new Response(JSON.stringify({ users }), { status: 200, headers: { "Content-Type": "application/json" }, }); diff --git a/src/pages/api/login.ts b/src/pages/api/login.ts index 20129b9..3fb63ee 100644 --- a/src/pages/api/login.ts +++ b/src/pages/api/login.ts @@ -1,6 +1,11 @@ import type { APIRoute } from "astro"; +import { + createDirectusClient, + normalizeEmail, + type DirectusTokens, +} from "../../lib/directus"; -export const POST: APIRoute = async ({ request, cookies, redirect, locals }) => { +export const POST: APIRoute = async ({ request, cookies }) => { let username = ""; let password = ""; @@ -24,28 +29,46 @@ export const POST: APIRoute = async ({ request, cookies, redirect, locals }) => }); } - if (!username || !password) { - return new Response(JSON.stringify({ error: "Username and password are required" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + // Directus auth is case-sensitive on email — normalize + const email = normalizeEmail(username); + + if (!email || !password) { + return new Response( + JSON.stringify({ error: "Username and password are required" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } try { - await locals.pb.collection("users").authWithPassword(username, password); + const client = createDirectusClient(); + const tokens = (await client.login({ + email, + password, + })) as DirectusTokens; - cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, // 1 week - }); + cookies.set( + "directus_auth", + JSON.stringify({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }), + { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, // 1 week + }, + ); return new Response(JSON.stringify({ success: true }), { status: 200, headers: { "Content-Type": "application/json" }, }); - } catch { + } catch (err) { + console.error("Login failed:", err); return new Response(JSON.stringify({ error: "Invalid credentials" }), { status: 401, headers: { "Content-Type": "application/json" }, diff --git a/src/pages/api/logout.ts b/src/pages/api/logout.ts index 83abbbb..0d5889f 100644 --- a/src/pages/api/logout.ts +++ b/src/pages/api/logout.ts @@ -1,6 +1,6 @@ import type { APIRoute } from "astro"; export const POST: APIRoute = async ({ cookies, redirect }) => { - cookies.delete("pb_auth", { path: "/" }); + cookies.delete("directus_auth", { path: "/" }); return redirect("/login"); }; diff --git a/src/pages/api/profile.ts b/src/pages/api/profile.ts index e43632a..8e2f9fd 100644 --- a/src/pages/api/profile.ts +++ b/src/pages/api/profile.ts @@ -1,7 +1,9 @@ import type { APIRoute } from "astro"; +import { updateMe } from "@directus/sdk"; +import { isLoggedIn } from "../../lib/auth"; -export const POST: APIRoute = async ({ request, cookies, locals }) => { - if (!locals.pb.authStore.isValid) { +export const POST: APIRoute = async ({ request, locals }) => { + if (!isLoggedIn(locals)) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: { "Content-Type": "application/json" }, @@ -10,47 +12,65 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => { try { const body = await request.json(); - const { name, username, theme_preference, visibility } = body; + const { first_name, last_name, username, theme_preference, visibility } = + body; - const updates: Record = {}; + const updates: Record = {}; - if (name !== undefined) { - updates.name = String(name).trim(); + if (first_name !== undefined) { + updates.first_name = String(first_name).trim() || null; + } + if (last_name !== undefined) { + updates.last_name = String(last_name).trim() || null; } if (username !== undefined) { const u = String(username).trim().toLowerCase(); if (u.length < 3 || u.length > 20) { - return new Response(JSON.stringify({ error: "Username must be 3-20 characters" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Username must be 3-20 characters" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } if (!/^[a-z0-9_]+$/.test(u)) { - return new Response(JSON.stringify({ error: "Username can only contain lowercase letters, numbers, and underscores" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ + error: "Username can only contain lowercase letters, numbers, and underscores", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } updates.username = u; } if (visibility !== undefined) { if (!["Public", "Friends", "Private"].includes(visibility)) { - return new Response(JSON.stringify({ error: "Invalid visibility" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Invalid visibility" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } updates.visibility = visibility; } if (theme_preference !== undefined) { if (!["System", "Light", "Dark"].includes(theme_preference)) { - return new Response(JSON.stringify({ error: "Invalid theme preference" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Invalid theme preference" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); } updates.theme_preference = theme_preference; } @@ -62,16 +82,7 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => { }); } - // Update the user record - await locals.pb.collection("users").update(locals.pb.authStore.record!.id, updates); - - // Re-set the auth cookie with the updated record - cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), { - path: "/", - httpOnly: true, - sameSite: "lax", - maxAge: 60 * 60 * 24 * 7, - }); + await locals.directus!.request(updateMe(updates)); return new Response(JSON.stringify({ success: true }), { status: 200, @@ -79,15 +90,21 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => { }); } catch (err: any) { console.error("Failed to update profile:", err); - if (err.message?.includes("username")) { - return new Response(JSON.stringify({ error: "Username already taken" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); + if (err.message?.toLowerCase().includes("unique")) { + return new Response( + JSON.stringify({ error: "Username already taken" }), + { + status: 409, + headers: { "Content-Type": "application/json" }, + }, + ); } - return new Response(JSON.stringify({ error: "Failed to update profile" }), { - status: 500, - headers: { "Content-Type": "application/json" }, - }); + return new Response( + JSON.stringify({ error: "Failed to update profile" }), + { + status: 500, + headers: { "Content-Type": "application/json" }, + }, + ); } }; diff --git a/src/pages/api/register.ts b/src/pages/api/register.ts new file mode 100644 index 0000000..55ddc67 --- /dev/null +++ b/src/pages/api/register.ts @@ -0,0 +1,140 @@ +import type { APIRoute } from "astro"; +import { readUsers, updateMe } from "@directus/sdk"; +import { + createDirectusClient, + normalizeEmail, + type DirectusTokens, +} from "../../lib/directus"; + +/** + * Registration via Directus public registration (enabled with the App User + * role), then auto-login and set the username (the register endpoint only + * accepts email/password/first_name/last_name). + */ +export const POST: APIRoute = async ({ request, cookies }) => { + let body: any; + try { + body = await request.json(); + } catch { + return new Response(JSON.stringify({ error: "Invalid request body" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const email = normalizeEmail(body.email ?? ""); + const password = body.password ?? ""; + const firstName = body.first_name?.trim() ?? ""; + const lastName = body.last_name?.trim() ?? ""; + const username = body.username?.trim().replace(/^@/, "") ?? ""; + + if (!email || !password || !firstName || !username) { + return new Response( + JSON.stringify({ + error: "Email, password, first name and username are required", + }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + if (password.length < 8) { + return new Response( + JSON.stringify({ error: "Password must be at least 8 characters" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + if (username.length < 3 || username.length > 20) { + return new Response( + JSON.stringify({ error: "Username must be 3-20 characters" }), + { + status: 400, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + const client = createDirectusClient(); + + // Reject duplicate usernames early with a friendly error + const existing = (await client.request( + readUsers({ + filter: { username: { _eq: username } }, + fields: ["username"], + limit: 1, + }), + )) as unknown[]; + if (existing.length > 0) { + return new Response( + JSON.stringify({ error: "Username already taken" }), + { + status: 409, + headers: { "Content-Type": "application/json" }, + }, + ); + } + + try { + // Public registration (creates the user with the App User role) + const registerRes = await fetch( + new URL("/users/register", client.url), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + password, + first_name: firstName, + last_name: lastName || null, + }), + }, + ); + if (!registerRes.ok) { + const errBody = await registerRes.json().catch(() => null); + const msg = errBody?.errors?.[0]?.message ?? "Registration failed"; + const status = + registerRes.status === 400 && + msg.toLowerCase().includes("unique") + ? 409 + : 400; + return new Response(JSON.stringify({ error: msg }), { + status, + headers: { "Content-Type": "application/json" }, + }); + } + + // Auto-login, then set the username (register doesn't accept it) + const tokens = (await client.login({ email, password })) as DirectusTokens; + client.setToken(tokens.access_token); + await client.request(updateMe({ username })); + + cookies.set( + "directus_auth", + JSON.stringify({ + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + }), + { + path: "/", + httpOnly: true, + sameSite: "lax", + maxAge: 60 * 60 * 24 * 7, + }, + ); + + return new Response(JSON.stringify({ success: true }), { + status: 201, + headers: { "Content-Type": "application/json" }, + }); + } catch (err) { + console.error("Registration failed:", err); + return new Response(JSON.stringify({ error: "Registration failed" }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } +}; diff --git a/src/pages/card/[id].astro b/src/pages/card/[id].astro index f1913ab..c1a305b 100644 --- a/src/pages/card/[id].astro +++ b/src/pages/card/[id].astro @@ -8,9 +8,11 @@ import { } from "../../lib/tcg-types"; import { getCard } from "../../lib/tcgdex-cache"; +import { isLoggedIn } from "../../lib/auth"; + const { pb } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } diff --git a/src/pages/friends.astro b/src/pages/friends.astro index 9c87d6a..db43a49 100644 --- a/src/pages/friends.astro +++ b/src/pages/friends.astro @@ -1,36 +1,56 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; +import { isLoggedIn } from "../lib/auth"; +import { + displayName, + initials, + type AppUser, + type Friendship, +} from "../lib/directus"; +import { readItems } from "@directus/sdk"; -const { pb } = Astro.locals; +const { directus, user } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } -const userId = pb.authStore.record!.id; +const userId = user!.id; +const idOf = (u: string | AppUser) => (typeof u === "string" ? u : u.id); -// Get all friendships involving this user -const friendships = await pb.collection("friendships").getFullList({ - filter: pb.filter("requester = {:userId} || addressee = {:userId}", { - userId, +// Friendships involving the current user (scoped by Directus permissions), +// with both sides expanded for display +const friendships = (await directus!.request( + readItems("friendships", { + fields: [ + "*", + "requester.id", + "requester.username", + "requester.first_name", + "requester.last_name", + "addressee.id", + "addressee.username", + "addressee.first_name", + "addressee.last_name", + ], + limit: -1, }), - expand: "requester,addressee", -}); +)) as Friendship[]; const pendingReceived = friendships.filter( - (f) => f.addressee === userId && f.status === "pending", + (f) => idOf(f.addressee) === userId && f.status === "pending", ); const pendingSent = friendships.filter( - (f) => f.requester === userId && f.status === "pending", + (f) => idOf(f.requester) === userId && f.status === "pending", ); const accepted = friendships.filter((f) => f.status === "accepted"); -function getOtherUser(friendship: any) { - // expand contains the full user records; requester/addressee are just IDs - if (friendship.requester === userId) { - return friendship.expand?.addressee; - } - return friendship.expand?.requester; +function getOtherUser(friendship: Friendship): AppUser | null { + const other = + idOf(friendship.requester) === userId + ? friendship.addressee + : friendship.requester; + return typeof other === "string" ? null : other; } --- @@ -95,19 +115,12 @@ function getOtherUser(friendship: any) {
- {( - other?.name || - other?.username || - other?.email || - "??" - ) - .slice(0, 2) - .toUpperCase()} + {other ? initials(other) : "?"}
- {other?.name || "—"} + {other ? displayName(other) : "—"}
@{other?.username || "—"} @@ -154,19 +167,12 @@ function getOtherUser(friendship: any) {
- {( - other?.name || - other?.username || - other?.email || - "??" - ) - .slice(0, 2) - .toUpperCase()} + {other ? initials(other) : "?"}
- {other?.name || "—"} + {other ? displayName(other) : "—"}
@{other?.username || "—"} @@ -217,19 +223,16 @@ function getOtherUser(friendship: any) {
- {( - other?.name || - other?.username || - other?.email || - "??" - ) - .slice(0, 2) - .toUpperCase()} + {other + ? initials(other) + : "?"}
-

{other?.name || "—"}

+

+ {other ? displayName(other) : "—"} +

@{other?.username || "—"}

@@ -297,22 +300,29 @@ function getOtherUser(friendship: any) { } searchResults!.innerHTML = data.users - .map( - (u: any) => ` + .map((u: any) => { + const name = + [u.first_name, u.last_name] + .filter(Boolean) + .join(" ") || + u.username || + u.email || + "?"; + return `
- ${(u.name || u.username || u.email || "??").slice(0, 2).toUpperCase()} + ${name.slice(0, 2).toUpperCase()}
-
${u.name || "—"}
+
${name}
@${u.username || "—"}
- `, - ) + `; + }) .join(""); }); diff --git a/src/pages/friends/[username].astro b/src/pages/friends/[username].astro index 118bf59..66c27ef 100644 --- a/src/pages/friends/[username].astro +++ b/src/pages/friends/[username].astro @@ -4,10 +4,18 @@ import { priceCollectionCards, type MyCardRecord, } from "../../lib/collection"; +import { isLoggedIn } from "../../lib/auth"; +import { + displayName, + initials, + type AppUser, + type Friendship, +} from "../../lib/directus"; +import { readItems, readUsers } from "@directus/sdk"; -const { pb } = Astro.locals; +const { directus } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } @@ -17,46 +25,70 @@ if (!username) { return Astro.redirect("/friends"); } -// Find the target user by username (same public view the friends API uses) -let targetUser; -try { - targetUser = await pb - .collection("public_users") - .getFirstListItem(pb.filter("username = {:username}", { username })); -} catch { - return Astro.redirect("/friends"); -} +// Find the target user by username. The users read permission already +// excludes Private users (former public_users view behavior). +const matches = (await directus!.request( + readUsers({ + filter: { username: { _eq: username } }, + fields: ["id", "username", "first_name", "last_name", "visibility"], + limit: 1, + }), +)) as AppUser[]; -// Check visibility — Private users' collections are never visible -if (targetUser.visibility === "Private") { +const targetUser = matches[0]; +if (!targetUser) { return Astro.redirect("/friends"); } // Friends-only collections require an accepted friendship if (targetUser.visibility !== "Public") { - const userId = pb.authStore.record!.id; - const friendship = await pb.collection("friendships").getList(1, 1, { - filter: pb.filter( - "((requester = {:userId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:userId})) && status = 'accepted'", - { userId, targetId: targetUser.id }, - ), - }); + const userId = Astro.locals.user!.id; + const friendships = (await directus!.request( + readItems("friendships", { + filter: { + _and: [ + { + _or: [ + { + _and: [ + { requester: { _eq: userId } }, + { addressee: { _eq: targetUser.id } }, + ], + }, + { + _and: [ + { requester: { _eq: targetUser.id } }, + { addressee: { _eq: userId } }, + ], + }, + ], + }, + { status: { _eq: "accepted" } }, + ], + }, + limit: 1, + }), + )) as Friendship[]; - if (friendship.items.length === 0) { + if (friendships.length === 0) { return Astro.redirect("/friends"); } } -// Fetch their collection -const myCards = (await pb.collection("mycards").getFullList({ - filter: pb.filter("user = {:userId}", { userId: targetUser.id }), - sort: "-created", -})) as unknown as MyCardRecord[]; +// Fetch their collection (visibility enforced again by the mycards read +// permission at the database level) +const myCards = (await directus!.request( + readItems("mycards", { + filter: { user: { _eq: targetUser.id } }, + sort: ["-date_created"], + limit: -1, + }), +)) as MyCardRecord[]; const cardsWithPrice = await priceCollectionCards(myCards); const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0); -const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0); +const totalCards = cardsWithPrice.reduce((s, c) => s + (c.quantity ?? 0), 0); --- @@ -70,21 +102,10 @@ const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
- { - ( - targetUser.name || - targetUser.username || - targetUser.email || - "??" - ) - .slice(0, 2) - .toUpperCase() - } + {initials(targetUser)}
-

{targetUser.name || "—"}

+

{displayName(targetUser)}

@{targetUser.username || "—"}

@@ -118,8 +139,8 @@ const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);

Collection vide

- {targetUser.name || targetUser.username} n'a pas - encore de cartes dans sa collection. + {displayName(targetUser)} n'a pas encore de cartes + dans sa collection.

diff --git a/src/pages/index.astro b/src/pages/index.astro index 94d8ebf..56eaaba 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -5,24 +5,25 @@ import { priceCollectionCards, type MyCardRecord, } from "../lib/collection"; +import { isLoggedIn } from "../lib/auth"; +import { readItems } from "@directus/sdk"; -const { pb } = Astro.locals; +const { directus } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } -const userId = pb.authStore.record!.id; - -const myCards = (await pb.collection("mycards").getFullList({ - filter: pb.filter("user = {:userId}", { userId }), -})) as unknown as MyCardRecord[]; +// Owner-scoping is enforced by the Directus permissions +const myCards = (await directus!.request( + readItems("mycards", { limit: -1 }), +)) as MyCardRecord[]; // Fetch prices through the 24h-cached card endpoint const cardsWithPrice = await priceCollectionCards(myCards); // Insights -const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0); +const totalCards = cardsWithPrice.reduce((s, c) => s + (c.quantity ?? 0), 0); const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0); const bySet = new Map(); diff --git a/src/pages/login-directus.astro b/src/pages/login-directus.astro new file mode 100644 index 0000000..7882f54 --- /dev/null +++ b/src/pages/login-directus.astro @@ -0,0 +1,4 @@ +--- +// The experimental Directus login was promoted to the main login page. +return Astro.redirect("/login"); +--- diff --git a/src/pages/login.astro b/src/pages/login.astro index 68d40bd..502da94 100644 --- a/src/pages/login.astro +++ b/src/pages/login.astro @@ -1,8 +1,16 @@ --- import LoginForm from "../components/LoginForm.astro"; import BaseLayout from "../layouts/BaseLayout.astro"; + +if (Astro.locals.user) { + return Astro.redirect("/"); +} --- +

+ Pas encore de compte ? + Créer un compte +

diff --git a/src/pages/mycards.astro b/src/pages/mycards.astro index a864931..56c7a6d 100644 --- a/src/pages/mycards.astro +++ b/src/pages/mycards.astro @@ -4,20 +4,19 @@ import { priceCollectionCards, type MyCardRecord, } from "../lib/collection"; +import { isLoggedIn } from "../lib/auth"; +import { readItems } from "@directus/sdk"; -const { pb } = Astro.locals; +const { directus } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } -const userId = pb.authStore.record!.id; - -// Fetch all user's cards -const myCards = (await pb.collection("mycards").getFullList({ - filter: pb.filter("user = {:userId}", { userId }), - sort: "-created", -})) as unknown as MyCardRecord[]; +// Fetch all user's cards (owner-scoping enforced by Directus permissions) +const myCards = (await directus!.request( + readItems("mycards", { sort: ["-date_created"], limit: -1 }), +)) as MyCardRecord[]; const cardsWithPrice = await priceCollectionCards(myCards); @@ -25,14 +24,17 @@ const totalValue = cardsWithPrice.reduce( (sum, card) => sum + card.totalPrice, 0, ); -const totalCards = cardsWithPrice.reduce((sum, card) => sum + card.quantity, 0); +const totalCards = cardsWithPrice.reduce( + (sum, card) => sum + (card.quantity ?? 0), + 0, +); // Distinct values present in the collection, for the filter dropdowns const distinctSets = [ ...new Set(cardsWithPrice.map((c) => c.set_name).filter(Boolean)), ].sort(); const distinctVariants = [ - ...new Set(cardsWithPrice.map((c) => c.variant)), + ...new Set(cardsWithPrice.map((c) => c.variant).filter(Boolean)), ].sort(); const distinctConditions = [ ...new Set(cardsWithPrice.map((c) => c.condition).filter(Boolean)), diff --git a/src/pages/profile.astro b/src/pages/profile.astro index 536f03e..b45455d 100644 --- a/src/pages/profile.astro +++ b/src/pages/profile.astro @@ -1,21 +1,15 @@ --- import BaseLayout from "../layouts/BaseLayout.astro"; +import { isLoggedIn } from "../lib/auth"; +import { displayName, initials } from "../lib/directus"; -const { pb } = Astro.locals; - -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } -const user = pb.authStore.record!; -const initials = (user.name || user.email || "") - .split(/[\s@.]+/) - .filter(Boolean) - .slice(0, 2) - .map((s: string) => s[0].toUpperCase()) - .join(""); - +const user = Astro.locals.user!; const themePreference = user.theme_preference ?? "System"; +const visibility = user.visibility ?? "Friends"; --- @@ -64,11 +58,11 @@ const themePreference = user.theme_preference ?? "System";
- {initials} + {initials(user)}
{user.name || "—"}{displayName(user)} {user.email}
- + -

Votre nom affiché dans l'application.

+
+
+ + +

Affichés dans l'application.

@@ -229,7 +234,7 @@ const themePreference = user.theme_preference ?? "System"; } }; - // Account form (name) + // Account form (username, names) const accountForm = document.getElementById("account-form"); const accountBtn = document.getElementById( "account-submit", @@ -238,7 +243,8 @@ const themePreference = user.theme_preference ?? "System"; accountForm?.addEventListener("submit", async (e) => { e.preventDefault(); const formData = new FormData(accountForm as HTMLFormElement); - const name = formData.get("name"); + const first_name = formData.get("first_name"); + const last_name = formData.get("last_name"); const username = formData.get("username"); accountBtn.disabled = true; @@ -249,14 +255,14 @@ const themePreference = user.theme_preference ?? "System"; const res = await fetch("/api/profile", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name, username }), + body: JSON.stringify({ first_name, last_name, username }), }); if (res.ok) { showToast( "success", "Enregistré", - "Votre nom a été mis à jour.", + "Votre compte a été mis à jour.", ); // Reload to reflect the new name in the nav setTimeout(() => window.location.reload(), 1000); diff --git a/src/pages/register.astro b/src/pages/register.astro new file mode 100644 index 0000000..15e4113 --- /dev/null +++ b/src/pages/register.astro @@ -0,0 +1,151 @@ +--- +import BaseLayout from "../layouts/BaseLayout.astro"; + +if (Astro.locals.user) { + return Astro.redirect("/"); +} +--- + + +
+
+

Créer un compte

+

Rejoignez PokeShare pour gérer votre collection.

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +

+ Déjà un compte ? + Se connecter +

+
+
+
+ + diff --git a/src/pages/search.astro b/src/pages/search.astro index e4a2721..b94b128 100644 --- a/src/pages/search.astro +++ b/src/pages/search.astro @@ -2,9 +2,11 @@ import BaseLayout from "../layouts/BaseLayout.astro"; import { getSets } from "../lib/tcgdex-cache"; +import { isLoggedIn } from "../lib/auth"; + const { pb } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } diff --git a/src/pages/series/[id].astro b/src/pages/series/[id].astro index c838401..bc793ec 100644 --- a/src/pages/series/[id].astro +++ b/src/pages/series/[id].astro @@ -2,9 +2,11 @@ import BaseLayout from "../../layouts/BaseLayout.astro"; import { getSeries, getSetsBySerie } from "../../lib/tcgdex-cache"; +import { isLoggedIn } from "../../lib/auth"; + const { pb } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); } diff --git a/src/pages/sets/[id].astro b/src/pages/sets/[id].astro index 0943fc9..bc0a993 100644 --- a/src/pages/sets/[id].astro +++ b/src/pages/sets/[id].astro @@ -1,9 +1,11 @@ --- import BaseLayout from "../../layouts/BaseLayout.astro"; +import { isLoggedIn } from "../../lib/auth"; + const { pb } = Astro.locals; -if (!pb.authStore.isValid) { +if (!isLoggedIn(Astro.locals)) { return Astro.redirect("/login"); }