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
This commit is contained in:
+2
-2
@@ -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.
|
# Falls back to the production instance if unset.
|
||||||
POCKETBASE_URL=https://pokeshare.namarusaja.me
|
DIRECTUS_URL=https://directus.namarusaja.me
|
||||||
|
|||||||
@@ -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
|
- [Astro](https://astro.build) (SSR, Node standalone adapter) — no UI framework
|
||||||
- [Tailwind CSS v4](https://tailwindcss.com) + [basecoat-css](https://basecoatui.com)
|
- [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
|
- [TCGdex API](https://api.tcgdex.net) (French locale) for card data, with an
|
||||||
in-memory server-side cache (`src/lib/tcgdex-cache.ts`)
|
in-memory server-side cache (`src/lib/tcgdex-cache.ts`)
|
||||||
|
|
||||||
@@ -27,42 +28,40 @@ Node >= 22.12.
|
|||||||
## Environment variables
|
## Environment variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ---------------- | -------------------------------------------- | ------------------------------------ |
|
| -------------- | ------------------------------------------- | -------------------------------- |
|
||||||
| `POCKETBASE_URL` | URL of the PocketBase backend (auth + data) | `https://pokeshare.namarusaja.me` |
|
| `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
|
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.
|
`.env`. `HOST` and `PORT` are also honored by the standalone server.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
Single container (external PocketBase):
|
Single container (external Directus):
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker build -t pokeshare .
|
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)
|
Or with the compose file:
|
||||||
|
|
||||||
`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).
|
|
||||||
|
|
||||||
```sh
|
```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
|
- The app listens on `4321`; expose it on your domain (Coolify: expose the
|
||||||
`app` service, or use the plain Dockerfile build pack).
|
`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
|
## Structure
|
||||||
|
|
||||||
- `src/pages/` — routes (`/`, `/search`, `/mycards`, `/card/[id]`,
|
- `src/pages/` — routes (`/`, `/search`, `/mycards`, `/card/[id]`,
|
||||||
`/series/[id]`, `/sets/[id]`, `/friends`, `/profile`) and JSON API endpoints
|
`/series/[id]`, `/sets/[id]`, `/friends`, `/profile`, `/login`, `/register`)
|
||||||
under `src/pages/api/`
|
and JSON API endpoints under `src/pages/api/`
|
||||||
- `src/middleware.ts` — per-request PocketBase client + `pb_auth` cookie auth
|
- `src/middleware.ts` — Directus session (`directus_auth` cookie) with token
|
||||||
- `src/lib/` — TCGdex cache, collection pricing helpers, type theming
|
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
|
- `src/layouts/`, `src/components/` — base layout and nav
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
"name": "pokeshare",
|
"name": "pokeshare",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.0.2",
|
"@astrojs/node": "^11.0.2",
|
||||||
|
"@directus/sdk": "^23.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"astro": "^7.1.3",
|
"astro": "^7.1.3",
|
||||||
"basecoat-css": "^1.0.2",
|
"basecoat-css": "^1.0.2",
|
||||||
"pocketbase": "^0.27.0",
|
|
||||||
"tailwindcss": "^4.3.3",
|
"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=="],
|
"@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/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=="],
|
"@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=="],
|
"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=="],
|
"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=="],
|
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Full URL of the PocketBase instance to use (required).
|
# Full URL of the Directus instance to use (required).
|
||||||
POCKETBASE_URL: ${POCKETBASE_URL:-https://pokeshare.namarusaja.me}
|
DIRECTUS_URL: ${DIRECTUS_URL:-https://directus.namarusaja.me}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# / redirects to /login (302) — check a 200 endpoint instead
|
# / redirects to /login (302) — check a 200 endpoint instead
|
||||||
test: "wget -q -O /dev/null http://localhost:4321/login || exit 1"
|
test: "wget -q -O /dev/null http://localhost:4321/login || exit 1"
|
||||||
|
|||||||
Generated
+13
-5
@@ -9,10 +9,10 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.0.2",
|
"@astrojs/node": "^11.0.2",
|
||||||
|
"@directus/sdk": "^23.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"astro": "^7.1.3",
|
"astro": "^7.1.3",
|
||||||
"basecoat-css": "^1.0.2",
|
"basecoat-css": "^1.0.2",
|
||||||
"pocketbase": "^0.27.0",
|
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -492,6 +492,18 @@
|
|||||||
"node": ">= 20.12.0"
|
"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": {
|
"node_modules/@emnapi/core": {
|
||||||
"version": "1.11.3",
|
"version": "1.11.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||||
@@ -3676,10 +3688,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/pocketbase": {
|
|
||||||
"version": "0.27.0",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.23",
|
"version": "8.5.23",
|
||||||
"funding": [
|
"funding": [
|
||||||
|
|||||||
+1
-1
@@ -13,10 +13,10 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/node": "^11.0.2",
|
"@astrojs/node": "^11.0.2",
|
||||||
|
"@directus/sdk": "^23.0.0",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"astro": "^7.1.3",
|
"astro": "^7.1.3",
|
||||||
"basecoat-css": "^1.0.2",
|
"basecoat-css": "^1.0.2",
|
||||||
"pocketbase": "^0.27.0",
|
|
||||||
"tailwindcss": "^4.3.3"
|
"tailwindcss": "^4.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-349
@@ -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'"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -6,10 +6,10 @@
|
|||||||
<section>
|
<section>
|
||||||
<form id="login-form" class="grid gap-4">
|
<form id="login-form" class="grid gap-4">
|
||||||
<div role="group" class="field">
|
<div role="group" class="field">
|
||||||
<label for="username">Nom d'utilisateur</label>
|
<label for="username">Email</label>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="email"
|
||||||
id="username"
|
id="username"
|
||||||
name="username"
|
name="username"
|
||||||
placeholder="votre@email.com"
|
placeholder="votre@email.com"
|
||||||
|
|||||||
@@ -1,14 +1,8 @@
|
|||||||
---
|
---
|
||||||
const { pb } = Astro.locals;
|
import { displayName, initials as userInitials } from "../lib/directus";
|
||||||
const user = pb.authStore.record;
|
|
||||||
const initials = user
|
const user = Astro.locals.user;
|
||||||
? (user.name || user.email || "")
|
const initials = user ? userInitials(user) : "";
|
||||||
.split(/[\s@.]+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((s: string) => s[0].toUpperCase())
|
|
||||||
.join("")
|
|
||||||
: "";
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -130,7 +124,7 @@ const initials = user
|
|||||||
<span>{initials}</span>
|
<span>{initials}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden sm:inline ml-1 text-sm">
|
<span class="hidden sm:inline ml-1 text-sm">
|
||||||
{user.name || user.email}
|
{displayName(user)}
|
||||||
</span>
|
</span>
|
||||||
<svg
|
<svg
|
||||||
data-icon="inline-end"
|
data-icon="inline-end"
|
||||||
|
|||||||
Vendored
+2
-2
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
declare namespace App {
|
declare namespace App {
|
||||||
interface Locals {
|
interface Locals {
|
||||||
pb: import("pocketbase").default;
|
directus: import("./lib/directus").DirectusClient | null;
|
||||||
user: import("pocketbase").default["authStore"]["record"];
|
user: import("./lib/directus").AppUser | null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
import Nav from "../components/Nav.astro";
|
import Nav from "../components/Nav.astro";
|
||||||
import "../styles/global.css";
|
import "../styles/global.css";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
const { user } = Astro.locals;
|
||||||
const user = pb.authStore.record;
|
|
||||||
const themePreference = user?.theme_preference ?? "System";
|
const themePreference = user?.theme_preference ?? "System";
|
||||||
|
|
||||||
// Determine if dark mode should be active
|
// Determine if dark mode should be active
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Auth helper: a request is authenticated when the Directus session
|
||||||
|
* (directus_auth cookie, validated by the middleware) holds a user.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function isLoggedIn(locals: App.Locals): boolean {
|
||||||
|
return locals.user != null;
|
||||||
|
}
|
||||||
+8
-19
@@ -1,26 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Shared helpers for the `mycards` PocketBase collection:
|
* Shared helpers for the `mycards` collection:
|
||||||
* record typing and Cardmarket price resolution via the cached TCGdex client.
|
* record typing and Cardmarket price resolution via the cached TCGdex client.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { getCard } from "./tcgdex-cache";
|
import { getCard } from "./tcgdex-cache";
|
||||||
|
import type { MyCard } from "./directus";
|
||||||
|
|
||||||
export interface MyCardRecord {
|
export type MyCardRecord = MyCard;
|
||||||
id: string;
|
|
||||||
user: string;
|
|
||||||
card_id: string;
|
|
||||||
card_name: string;
|
|
||||||
card_image: string;
|
|
||||||
variant: string;
|
|
||||||
quantity: number;
|
|
||||||
condition: string;
|
|
||||||
notes: string;
|
|
||||||
set_name: string;
|
|
||||||
created: string;
|
|
||||||
updated: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PricedCard = MyCardRecord & { price: number; totalPrice: number };
|
export type PricedCard = MyCard & { price: number; totalPrice: number };
|
||||||
|
|
||||||
/** Collection variant label → Cardmarket avg price field. */
|
/** Collection variant label → Cardmarket avg price field. */
|
||||||
export const variantPriceMap: Record<string, string> = {
|
export const variantPriceMap: Record<string, string> = {
|
||||||
@@ -37,7 +25,7 @@ export const variantPriceMap: Record<string, string> = {
|
|||||||
* repeated page loads don't hit the TCGdex API again.
|
* repeated page loads don't hit the TCGdex API again.
|
||||||
*/
|
*/
|
||||||
export async function priceCollectionCards(
|
export async function priceCollectionCards(
|
||||||
cards: MyCardRecord[],
|
cards: MyCard[],
|
||||||
): Promise<PricedCard[]> {
|
): Promise<PricedCard[]> {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
cards.map(async (card) => {
|
cards.map(async (card) => {
|
||||||
@@ -45,10 +33,11 @@ export async function priceCollectionCards(
|
|||||||
const detail = await getCard(card.card_id);
|
const detail = await getCard(card.card_id);
|
||||||
const cardmarket = detail?.pricing?.cardmarket;
|
const cardmarket = detail?.pricing?.cardmarket;
|
||||||
if (cardmarket) {
|
if (cardmarket) {
|
||||||
const priceKey = variantPriceMap[card.variant] ?? "avg";
|
const priceKey = variantPriceMap[card.variant ?? ""] ?? "avg";
|
||||||
price = cardmarket[priceKey] ?? cardmarket.avg ?? 0;
|
price = cardmarket[priceKey] ?? cardmarket.avg ?? 0;
|
||||||
}
|
}
|
||||||
return { ...card, price, totalPrice: price * card.quantity };
|
const quantity = card.quantity ?? 1;
|
||||||
|
return { ...card, price, totalPrice: price * quantity };
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Schema>(DIRECTUS_URL)
|
||||||
|
.with(authentication("json"))
|
||||||
|
.with(rest());
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirectusClient = ReturnType<typeof createDirectusClient>;
|
||||||
|
|
||||||
|
/** 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<AppUser, "first_name" | "last_name" | "username" | "email">,
|
||||||
|
): 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<AppUser, "first_name" | "last_name" | "username" | "email">,
|
||||||
|
): string {
|
||||||
|
return displayName(user)
|
||||||
|
.split(/[\s@.]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((s) => s[0].toUpperCase())
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
+39
-21
@@ -1,34 +1,52 @@
|
|||||||
import { defineMiddleware } from "astro:middleware";
|
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
|
const AUTH_COOKIE = "directus_auth";
|
||||||
// different PocketBase instances via the POCKETBASE_URL env variable.
|
const COOKIE_OPTIONS = {
|
||||||
const POCKETBASE_URL =
|
path: "/",
|
||||||
process.env.POCKETBASE_URL ?? "https://pokeshare.namarusaja.me";
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const onRequest = defineMiddleware(async ({ cookies, locals }, next) => {
|
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(AUTH_COOKIE);
|
||||||
const authCookie = cookies.get("pb_auth");
|
|
||||||
if (authCookie) {
|
if (authCookie) {
|
||||||
pb.authStore.loadFromCookie(authCookie.value);
|
|
||||||
|
|
||||||
// Optionally refresh the token if it's close to expiring
|
|
||||||
try {
|
try {
|
||||||
if (pb.authStore.isValid) {
|
const tokens = JSON.parse(authCookie.value) as DirectusTokens;
|
||||||
await pb.collection("users").authRefresh();
|
const directus = createDirectusClient();
|
||||||
}
|
directus.setToken(tokens.access_token);
|
||||||
|
try {
|
||||||
|
locals.user = (await directus.request(readMe())) as AppUser;
|
||||||
} catch {
|
} catch {
|
||||||
// Token is invalid/expired — clear it
|
// Access token expired — refresh and renew the cookie
|
||||||
pb.authStore.clear();
|
const renewed = (await directus.request(
|
||||||
cookies.delete("pb_auth", { path: "/" });
|
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 {
|
||||||
|
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();
|
return next();
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-12
@@ -1,15 +1,25 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { createItem } from "@directus/sdk";
|
||||||
|
import { isLoggedIn } from "../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { card_id, card_name, card_image, variant, quantity, condition, notes, set_name } =
|
const {
|
||||||
await request.json();
|
card_id,
|
||||||
|
card_name,
|
||||||
|
card_image,
|
||||||
|
variant,
|
||||||
|
quantity,
|
||||||
|
condition,
|
||||||
|
notes,
|
||||||
|
set_name,
|
||||||
|
} = await request.json();
|
||||||
|
|
||||||
if (!card_id || !card_name) {
|
if (!card_id || !card_name) {
|
||||||
return new Response(JSON.stringify({ error: "Missing card info" }), {
|
return new Response(JSON.stringify({ error: "Missing card info" }), {
|
||||||
@@ -19,17 +29,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await locals.pb.collection("mycards").create({
|
const record = await locals.directus!.request(
|
||||||
user: locals.pb.authStore.record!.id,
|
createItem("mycards", {
|
||||||
|
user: locals.user!.id,
|
||||||
card_id,
|
card_id,
|
||||||
card_name,
|
card_name,
|
||||||
card_image: card_image ?? "",
|
card_image: card_image || null,
|
||||||
variant: variant ?? "Base",
|
variant: variant || "Base",
|
||||||
quantity: quantity ?? 1,
|
quantity: Number(quantity) || 1,
|
||||||
condition: condition ?? "",
|
condition: condition || null,
|
||||||
notes: notes ?? "",
|
notes: notes || null,
|
||||||
set_name: set_name ?? "",
|
set_name: set_name || null,
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true, record }), {
|
return new Response(JSON.stringify({ success: true, record }), {
|
||||||
status: 201,
|
status: 201,
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { deleteItem } from "@directus/sdk";
|
||||||
|
import { isLoggedIn } from "../../../lib/auth";
|
||||||
|
|
||||||
export const DELETE: APIRoute = async ({ params, locals }) => {
|
export const DELETE: APIRoute = async ({ params, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
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" }), {
|
return new Response(JSON.stringify({ error: "Missing record id" }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -18,16 +20,9 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Verify the record belongs to the current user before deleting
|
// Ownership is enforced by the Directus delete permission
|
||||||
const record = await locals.pb.collection("mycards").getOne(id);
|
// (user = $CURRENT_USER) — deleting someone else's card 403s there.
|
||||||
if (record.user !== locals.pb.authStore.record!.id) {
|
await locals.directus!.request(deleteItem("mycards", id));
|
||||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
|
||||||
status: 403,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await locals.pb.collection("mycards").delete(id);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { updateItem } from "@directus/sdk";
|
||||||
|
import { isLoggedIn } from "../../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { friendshipId } = await request.json();
|
const { friendshipId } = await request.json();
|
||||||
|
const id = Number(friendshipId);
|
||||||
|
|
||||||
if (!friendshipId) {
|
if (!id || Number.isNaN(id)) {
|
||||||
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing friendship id" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
|
// Only the addressee can accept — enforced by the Directus update
|
||||||
|
// permission (addressee = $CURRENT_USER, status field only).
|
||||||
// Only the addressee can accept
|
await locals.directus!.request(
|
||||||
if (friendship.addressee !== locals.pb.authStore.record!.id) {
|
updateItem("friendships", id, { status: "accepted" }),
|
||||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
);
|
||||||
status: 403,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await locals.pb.collection("friendships").update(friendshipId, {
|
|
||||||
status: "accepted",
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Accept friend failed:", err);
|
console.error("Accept friend failed:", err);
|
||||||
return new Response(JSON.stringify({ error: "Failed to accept" }), {
|
const forbidden =
|
||||||
status: 500,
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { updateItem } from "@directus/sdk";
|
||||||
|
import { isLoggedIn } from "../../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { friendshipId } = await request.json();
|
const { friendshipId } = await request.json();
|
||||||
|
const id = Number(friendshipId);
|
||||||
|
|
||||||
if (!friendshipId) {
|
if (!id || Number.isNaN(id)) {
|
||||||
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Missing friendship id" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
|
// Only the addressee can decline — enforced by the Directus update
|
||||||
|
// permission (addressee = $CURRENT_USER, status field only).
|
||||||
// Only the addressee can decline
|
await locals.directus!.request(
|
||||||
if (friendship.addressee !== locals.pb.authStore.record!.id) {
|
updateItem("friendships", id, { status: "declined" }),
|
||||||
return new Response(JSON.stringify({ error: "Forbidden" }), {
|
);
|
||||||
status: 403,
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await locals.pb.collection("friendships").update(friendshipId, {
|
|
||||||
status: "declined",
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Decline friend failed:", err);
|
console.error("Decline friend failed:", err);
|
||||||
return new Response(JSON.stringify({ error: "Failed to decline" }), {
|
const forbidden =
|
||||||
status: 500,
|
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" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { APIRoute } from "astro";
|
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 }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
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
|
// Find the target user by username (Private users are not visible)
|
||||||
const targetUser = await locals.pb.collection("public_users").getFirstListItem(
|
const matches = (await directus.request(
|
||||||
locals.pb.filter("username = {:username}", { username }),
|
readUsers({
|
||||||
);
|
filter: { username: { _eq: username } },
|
||||||
|
fields: ["id"],
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
|
)) as AppUser[];
|
||||||
|
const targetUser = matches[0];
|
||||||
|
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
return new Response(JSON.stringify({ error: "User not found" }), {
|
||||||
@@ -33,59 +42,83 @@ export const POST: APIRoute = async ({ request, locals }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (targetUser.id === requesterId) {
|
if (targetUser.id === requesterId) {
|
||||||
return new Response(JSON.stringify({ error: "Cannot add yourself" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Cannot add yourself" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if friendship already exists (in either direction)
|
// Check if a friendship already exists (in either direction)
|
||||||
const existing = await locals.pb.collection("friendships").getList(1, 1, {
|
const existing = (await directus.request(
|
||||||
filter: locals.pb.filter(
|
readItems("friendships", {
|
||||||
"(requester = {:requesterId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:requesterId})",
|
filter: {
|
||||||
{ requesterId, targetId: targetUser.id },
|
_or: [
|
||||||
),
|
{
|
||||||
});
|
_and: [
|
||||||
|
{ requester: { _eq: requesterId } },
|
||||||
|
{ addressee: { _eq: targetUser.id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
_and: [
|
||||||
|
{ requester: { _eq: targetUser.id } },
|
||||||
|
{ addressee: { _eq: requesterId } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
}),
|
||||||
|
)) as Friendship[];
|
||||||
|
|
||||||
if (existing.items.length > 0) {
|
if (existing.length > 0) {
|
||||||
const f = existing.items[0];
|
const f = existing[0];
|
||||||
if (f.status === "accepted") {
|
if (f.status === "accepted") {
|
||||||
return new Response(JSON.stringify({ error: "Already friends" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Already friends" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (f.status === "pending") {
|
if (f.status === "pending") {
|
||||||
return new Response(JSON.stringify({ error: "Invite already sent" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invite already sent" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
// Declined — allow re-request
|
// Declined — remove it and allow re-request
|
||||||
await locals.pb.collection("friendships").delete(f.id);
|
await directus.request(deleteItem("friendships", f.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
const record = await locals.pb.collection("friendships").create({
|
// Validation rule requester = $CURRENT_USER is enforced by Directus
|
||||||
|
const record = await directus.request(
|
||||||
|
createItem("friendships", {
|
||||||
requester: requesterId,
|
requester: requesterId,
|
||||||
addressee: targetUser.id,
|
addressee: targetUser.id,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true, record }), {
|
return new Response(JSON.stringify({ success: true, record }), {
|
||||||
status: 201,
|
status: 201,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err) {
|
||||||
console.error("Friend request failed:", err);
|
console.error("Friend request failed:", err);
|
||||||
if (err.message?.includes("not found")) {
|
return new Response(
|
||||||
return new Response(JSON.stringify({ error: "User not found" }), {
|
JSON.stringify({ error: "Failed to send invite" }),
|
||||||
status: 404,
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return new Response(JSON.stringify({ error: "Failed to send invite" }), {
|
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import type { APIRoute } from "astro";
|
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 }) => {
|
export const GET: APIRoute = async ({ url, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -11,23 +14,38 @@ export const GET: APIRoute = async ({ url, locals }) => {
|
|||||||
const query = url.searchParams.get("q")?.trim().replace(/^@/, "");
|
const query = url.searchParams.get("q")?.trim().replace(/^@/, "");
|
||||||
|
|
||||||
if (!query || query.length < 3 || query.length > 20) {
|
if (!query || query.length < 3 || query.length > 20) {
|
||||||
return new Response(JSON.stringify({ error: "Query must be 3-20 characters" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Query must be 3-20 characters" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Search by username (case-insensitive), exclude self
|
// Search by username, exclude self. Private users are excluded by the
|
||||||
const users = await locals.pb.collection("public_users").getList(1, 10, {
|
// users read permission (former public_users view behavior).
|
||||||
filter: locals.pb.filter("username ~ {:query} && id != {:selfId}", {
|
const users = (await locals.directus!.request(
|
||||||
query,
|
readUsers({
|
||||||
selfId: locals.pb.authStore.record!.id,
|
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,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
|
|||||||
+31
-8
@@ -1,6 +1,11 @@
|
|||||||
import type { APIRoute } from "astro";
|
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 username = "";
|
||||||
let password = "";
|
let password = "";
|
||||||
|
|
||||||
@@ -24,28 +29,46 @@ export const POST: APIRoute = async ({ request, cookies, redirect, locals }) =>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!username || !password) {
|
// Directus auth is case-sensitive on email — normalize
|
||||||
return new Response(JSON.stringify({ error: "Username and password are required" }), {
|
const email = normalizeEmail(username);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Username and password are required" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(), {
|
cookies.set(
|
||||||
|
"directus_auth",
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: tokens.access_token,
|
||||||
|
refresh_token: tokens.refresh_token,
|
||||||
|
}),
|
||||||
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
sameSite: "lax",
|
sameSite: "lax",
|
||||||
maxAge: 60 * 60 * 24 * 7, // 1 week
|
maxAge: 60 * 60 * 24 * 7, // 1 week
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
|
console.error("Login failed:", err);
|
||||||
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
|
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ cookies, redirect }) => {
|
export const POST: APIRoute = async ({ cookies, redirect }) => {
|
||||||
cookies.delete("pb_auth", { path: "/" });
|
cookies.delete("directus_auth", { path: "/" });
|
||||||
return redirect("/login");
|
return redirect("/login");
|
||||||
};
|
};
|
||||||
|
|||||||
+47
-30
@@ -1,7 +1,9 @@
|
|||||||
import type { APIRoute } from "astro";
|
import type { APIRoute } from "astro";
|
||||||
|
import { updateMe } from "@directus/sdk";
|
||||||
|
import { isLoggedIn } from "../../lib/auth";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
export const POST: APIRoute = async ({ request, locals }) => {
|
||||||
if (!locals.pb.authStore.isValid) {
|
if (!isLoggedIn(locals)) {
|
||||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -10,47 +12,65 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
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<string, string> = {};
|
const updates: Record<string, string | null> = {};
|
||||||
|
|
||||||
if (name !== undefined) {
|
if (first_name !== undefined) {
|
||||||
updates.name = String(name).trim();
|
updates.first_name = String(first_name).trim() || null;
|
||||||
|
}
|
||||||
|
if (last_name !== undefined) {
|
||||||
|
updates.last_name = String(last_name).trim() || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) {
|
||||||
const u = String(username).trim().toLowerCase();
|
const u = String(username).trim().toLowerCase();
|
||||||
if (u.length < 3 || u.length > 20) {
|
if (u.length < 3 || u.length > 20) {
|
||||||
return new Response(JSON.stringify({ error: "Username must be 3-20 characters" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Username must be 3-20 characters" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (!/^[a-z0-9_]+$/.test(u)) {
|
if (!/^[a-z0-9_]+$/.test(u)) {
|
||||||
return new Response(JSON.stringify({ error: "Username can only contain lowercase letters, numbers, and underscores" }), {
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Username can only contain lowercase letters, numbers, and underscores",
|
||||||
|
}),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updates.username = u;
|
updates.username = u;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (visibility !== undefined) {
|
if (visibility !== undefined) {
|
||||||
if (!["Public", "Friends", "Private"].includes(visibility)) {
|
if (!["Public", "Friends", "Private"].includes(visibility)) {
|
||||||
return new Response(JSON.stringify({ error: "Invalid visibility" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid visibility" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updates.visibility = visibility;
|
updates.visibility = visibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (theme_preference !== undefined) {
|
if (theme_preference !== undefined) {
|
||||||
if (!["System", "Light", "Dark"].includes(theme_preference)) {
|
if (!["System", "Light", "Dark"].includes(theme_preference)) {
|
||||||
return new Response(JSON.stringify({ error: "Invalid theme preference" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Invalid theme preference" }),
|
||||||
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updates.theme_preference = theme_preference;
|
updates.theme_preference = theme_preference;
|
||||||
}
|
}
|
||||||
@@ -62,16 +82,7 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the user record
|
await locals.directus!.request(updateMe(updates));
|
||||||
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({ success: true }), {
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -79,15 +90,21 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
|
|||||||
});
|
});
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error("Failed to update profile:", err);
|
console.error("Failed to update profile:", err);
|
||||||
if (err.message?.includes("username")) {
|
if (err.message?.toLowerCase().includes("unique")) {
|
||||||
return new Response(JSON.stringify({ error: "Username already taken" }), {
|
return new Response(
|
||||||
status: 400,
|
JSON.stringify({ error: "Username already taken" }),
|
||||||
|
{
|
||||||
|
status: 409,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify({ error: "Failed to update profile" }), {
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Failed to update profile" }),
|
||||||
|
{
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,9 +8,11 @@ import {
|
|||||||
} from "../../lib/tcg-types";
|
} from "../../lib/tcg-types";
|
||||||
import { getCard } from "../../lib/tcgdex-cache";
|
import { getCard } from "../../lib/tcgdex-cache";
|
||||||
|
|
||||||
|
import { isLoggedIn } from "../../lib/auth";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
const { pb } = Astro.locals;
|
||||||
|
|
||||||
if (!pb.authStore.isValid) {
|
if (!isLoggedIn(Astro.locals)) {
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+60
-50
@@ -1,36 +1,56 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
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");
|
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
|
// Friendships involving the current user (scoped by Directus permissions),
|
||||||
const friendships = await pb.collection("friendships").getFullList({
|
// with both sides expanded for display
|
||||||
filter: pb.filter("requester = {:userId} || addressee = {:userId}", {
|
const friendships = (await directus!.request(
|
||||||
userId,
|
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(
|
const pendingReceived = friendships.filter(
|
||||||
(f) => f.addressee === userId && f.status === "pending",
|
(f) => idOf(f.addressee) === userId && f.status === "pending",
|
||||||
);
|
);
|
||||||
const pendingSent = friendships.filter(
|
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");
|
const accepted = friendships.filter((f) => f.status === "accepted");
|
||||||
|
|
||||||
function getOtherUser(friendship: any) {
|
function getOtherUser(friendship: Friendship): AppUser | null {
|
||||||
// expand contains the full user records; requester/addressee are just IDs
|
const other =
|
||||||
if (friendship.requester === userId) {
|
idOf(friendship.requester) === userId
|
||||||
return friendship.expand?.addressee;
|
? friendship.addressee
|
||||||
}
|
: friendship.requester;
|
||||||
return friendship.expand?.requester;
|
return typeof other === "string" ? null : other;
|
||||||
}
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -95,19 +115,12 @@ function getOtherUser(friendship: any) {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="avatar" data-size="sm">
|
<span class="avatar" data-size="sm">
|
||||||
<span>
|
<span>
|
||||||
{(
|
{other ? initials(other) : "?"}
|
||||||
other?.name ||
|
|
||||||
other?.username ||
|
|
||||||
other?.email ||
|
|
||||||
"??"
|
|
||||||
)
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">
|
<div class="font-semibold text-sm">
|
||||||
{other?.name || "—"}
|
{other ? displayName(other) : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-60">
|
<div class="text-xs opacity-60">
|
||||||
@{other?.username || "—"}
|
@{other?.username || "—"}
|
||||||
@@ -154,19 +167,12 @@ function getOtherUser(friendship: any) {
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="avatar" data-size="sm">
|
<span class="avatar" data-size="sm">
|
||||||
<span>
|
<span>
|
||||||
{(
|
{other ? initials(other) : "?"}
|
||||||
other?.name ||
|
|
||||||
other?.username ||
|
|
||||||
other?.email ||
|
|
||||||
"??"
|
|
||||||
)
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">
|
<div class="font-semibold text-sm">
|
||||||
{other?.name || "—"}
|
{other ? displayName(other) : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs opacity-60">
|
<div class="text-xs opacity-60">
|
||||||
@{other?.username || "—"}
|
@{other?.username || "—"}
|
||||||
@@ -217,19 +223,16 @@ function getOtherUser(friendship: any) {
|
|||||||
<figure>
|
<figure>
|
||||||
<span class="avatar">
|
<span class="avatar">
|
||||||
<span>
|
<span>
|
||||||
{(
|
{other
|
||||||
other?.name ||
|
? initials(other)
|
||||||
other?.username ||
|
: "?"}
|
||||||
other?.email ||
|
|
||||||
"??"
|
|
||||||
)
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</figure>
|
</figure>
|
||||||
<section>
|
<section>
|
||||||
<h3>{other?.name || "—"}</h3>
|
<h3>
|
||||||
|
{other ? displayName(other) : "—"}
|
||||||
|
</h3>
|
||||||
<p class="text-sm opacity-60">
|
<p class="text-sm opacity-60">
|
||||||
@{other?.username || "—"}
|
@{other?.username || "—"}
|
||||||
</p>
|
</p>
|
||||||
@@ -297,22 +300,29 @@ function getOtherUser(friendship: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
searchResults!.innerHTML = data.users
|
searchResults!.innerHTML = data.users
|
||||||
.map(
|
.map((u: any) => {
|
||||||
(u: any) => `
|
const name =
|
||||||
|
[u.first_name, u.last_name]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ") ||
|
||||||
|
u.username ||
|
||||||
|
u.email ||
|
||||||
|
"?";
|
||||||
|
return `
|
||||||
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
|
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="avatar" data-size="sm">
|
<span class="avatar" data-size="sm">
|
||||||
<span>${(u.name || u.username || u.email || "??").slice(0, 2).toUpperCase()}</span>
|
<span>${name.slice(0, 2).toUpperCase()}</span>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-sm">${u.name || "—"}</div>
|
<div class="font-semibold text-sm">${name}</div>
|
||||||
<div class="text-xs opacity-60">@${u.username || "—"}</div>
|
<div class="text-xs opacity-60">@${u.username || "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn" data-size="sm" onclick="sendInvite('${u.username}')">Ajouter</button>
|
<button class="btn" data-size="sm" onclick="sendInvite('${u.username}')">Ajouter</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`;
|
||||||
)
|
})
|
||||||
.join("");
|
.join("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,18 @@ import {
|
|||||||
priceCollectionCards,
|
priceCollectionCards,
|
||||||
type MyCardRecord,
|
type MyCardRecord,
|
||||||
} from "../../lib/collection";
|
} 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");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,46 +25,70 @@ if (!username) {
|
|||||||
return Astro.redirect("/friends");
|
return Astro.redirect("/friends");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the target user by username (same public view the friends API uses)
|
// Find the target user by username. The users read permission already
|
||||||
let targetUser;
|
// excludes Private users (former public_users view behavior).
|
||||||
try {
|
const matches = (await directus!.request(
|
||||||
targetUser = await pb
|
readUsers({
|
||||||
.collection("public_users")
|
filter: { username: { _eq: username } },
|
||||||
.getFirstListItem(pb.filter("username = {:username}", { username }));
|
fields: ["id", "username", "first_name", "last_name", "visibility"],
|
||||||
} catch {
|
limit: 1,
|
||||||
return Astro.redirect("/friends");
|
}),
|
||||||
}
|
)) as AppUser[];
|
||||||
|
|
||||||
// Check visibility — Private users' collections are never visible
|
const targetUser = matches[0];
|
||||||
if (targetUser.visibility === "Private") {
|
if (!targetUser) {
|
||||||
return Astro.redirect("/friends");
|
return Astro.redirect("/friends");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Friends-only collections require an accepted friendship
|
// Friends-only collections require an accepted friendship
|
||||||
if (targetUser.visibility !== "Public") {
|
if (targetUser.visibility !== "Public") {
|
||||||
const userId = pb.authStore.record!.id;
|
const userId = Astro.locals.user!.id;
|
||||||
const friendship = await pb.collection("friendships").getList(1, 1, {
|
const friendships = (await directus!.request(
|
||||||
filter: pb.filter(
|
readItems("friendships", {
|
||||||
"((requester = {:userId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:userId})) && status = 'accepted'",
|
filter: {
|
||||||
{ userId, targetId: targetUser.id },
|
_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");
|
return Astro.redirect("/friends");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch their collection
|
// Fetch their collection (visibility enforced again by the mycards read
|
||||||
const myCards = (await pb.collection("mycards").getFullList({
|
// permission at the database level)
|
||||||
filter: pb.filter("user = {:userId}", { userId: targetUser.id }),
|
const myCards = (await directus!.request(
|
||||||
sort: "-created",
|
readItems("mycards", {
|
||||||
})) as unknown as MyCardRecord[];
|
filter: { user: { _eq: targetUser.id } },
|
||||||
|
sort: ["-date_created"],
|
||||||
|
limit: -1,
|
||||||
|
}),
|
||||||
|
)) as MyCardRecord[];
|
||||||
|
|
||||||
const cardsWithPrice = await priceCollectionCards(myCards);
|
const cardsWithPrice = await priceCollectionCards(myCards);
|
||||||
|
|
||||||
const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
|
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);
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
@@ -70,21 +102,10 @@ const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="avatar" data-size="lg">
|
<span class="avatar" data-size="lg">
|
||||||
<span
|
<span>{initials(targetUser)}</span>
|
||||||
>{
|
|
||||||
(
|
|
||||||
targetUser.name ||
|
|
||||||
targetUser.username ||
|
|
||||||
targetUser.email ||
|
|
||||||
"??"
|
|
||||||
)
|
|
||||||
.slice(0, 2)
|
|
||||||
.toUpperCase()
|
|
||||||
}</span
|
|
||||||
>
|
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="m-0">{targetUser.name || "—"}</h1>
|
<h1 class="m-0">{displayName(targetUser)}</h1>
|
||||||
<p class="text-sm opacity-60 m-0">
|
<p class="text-sm opacity-60 m-0">
|
||||||
@{targetUser.username || "—"}
|
@{targetUser.username || "—"}
|
||||||
</p>
|
</p>
|
||||||
@@ -118,8 +139,8 @@ const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
|
|||||||
<header>
|
<header>
|
||||||
<h3>Collection vide</h3>
|
<h3>Collection vide</h3>
|
||||||
<p>
|
<p>
|
||||||
{targetUser.name || targetUser.username} n'a pas
|
{displayName(targetUser)} n'a pas encore de cartes
|
||||||
encore de cartes dans sa collection.
|
dans sa collection.
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -5,24 +5,25 @@ import {
|
|||||||
priceCollectionCards,
|
priceCollectionCards,
|
||||||
type MyCardRecord,
|
type MyCardRecord,
|
||||||
} from "../lib/collection";
|
} 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");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = pb.authStore.record!.id;
|
// Owner-scoping is enforced by the Directus permissions
|
||||||
|
const myCards = (await directus!.request(
|
||||||
const myCards = (await pb.collection("mycards").getFullList({
|
readItems("mycards", { limit: -1 }),
|
||||||
filter: pb.filter("user = {:userId}", { userId }),
|
)) as MyCardRecord[];
|
||||||
})) as unknown as MyCardRecord[];
|
|
||||||
|
|
||||||
// Fetch prices through the 24h-cached card endpoint
|
// Fetch prices through the 24h-cached card endpoint
|
||||||
const cardsWithPrice = await priceCollectionCards(myCards);
|
const cardsWithPrice = await priceCollectionCards(myCards);
|
||||||
|
|
||||||
// Insights
|
// 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 totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
|
||||||
|
|
||||||
const bySet = new Map<string, { count: number; value: number }>();
|
const bySet = new Map<string, { count: number; value: number }>();
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
---
|
||||||
|
// The experimental Directus login was promoted to the main login page.
|
||||||
|
return Astro.redirect("/login");
|
||||||
|
---
|
||||||
@@ -1,8 +1,16 @@
|
|||||||
---
|
---
|
||||||
import LoginForm from "../components/LoginForm.astro";
|
import LoginForm from "../components/LoginForm.astro";
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
|
||||||
|
if (Astro.locals.user) {
|
||||||
|
return Astro.redirect("/");
|
||||||
|
}
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
|
<p class="text-center text-sm opacity-70 mt-4">
|
||||||
|
Pas encore de compte ?
|
||||||
|
<a href="/register" class="underline">Créer un compte</a>
|
||||||
|
</p>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
+13
-11
@@ -4,20 +4,19 @@ import {
|
|||||||
priceCollectionCards,
|
priceCollectionCards,
|
||||||
type MyCardRecord,
|
type MyCardRecord,
|
||||||
} from "../lib/collection";
|
} 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");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = pb.authStore.record!.id;
|
// Fetch all user's cards (owner-scoping enforced by Directus permissions)
|
||||||
|
const myCards = (await directus!.request(
|
||||||
// Fetch all user's cards
|
readItems("mycards", { sort: ["-date_created"], limit: -1 }),
|
||||||
const myCards = (await pb.collection("mycards").getFullList({
|
)) as MyCardRecord[];
|
||||||
filter: pb.filter("user = {:userId}", { userId }),
|
|
||||||
sort: "-created",
|
|
||||||
})) as unknown as MyCardRecord[];
|
|
||||||
|
|
||||||
const cardsWithPrice = await priceCollectionCards(myCards);
|
const cardsWithPrice = await priceCollectionCards(myCards);
|
||||||
|
|
||||||
@@ -25,14 +24,17 @@ const totalValue = cardsWithPrice.reduce(
|
|||||||
(sum, card) => sum + card.totalPrice,
|
(sum, card) => sum + card.totalPrice,
|
||||||
0,
|
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
|
// Distinct values present in the collection, for the filter dropdowns
|
||||||
const distinctSets = [
|
const distinctSets = [
|
||||||
...new Set(cardsWithPrice.map((c) => c.set_name).filter(Boolean)),
|
...new Set(cardsWithPrice.map((c) => c.set_name).filter(Boolean)),
|
||||||
].sort();
|
].sort();
|
||||||
const distinctVariants = [
|
const distinctVariants = [
|
||||||
...new Set(cardsWithPrice.map((c) => c.variant)),
|
...new Set(cardsWithPrice.map((c) => c.variant).filter(Boolean)),
|
||||||
].sort();
|
].sort();
|
||||||
const distinctConditions = [
|
const distinctConditions = [
|
||||||
...new Set(cardsWithPrice.map((c) => c.condition).filter(Boolean)),
|
...new Set(cardsWithPrice.map((c) => c.condition).filter(Boolean)),
|
||||||
|
|||||||
+29
-23
@@ -1,21 +1,15 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
import { isLoggedIn } from "../lib/auth";
|
||||||
|
import { displayName, initials } from "../lib/directus";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
if (!isLoggedIn(Astro.locals)) {
|
||||||
|
|
||||||
if (!pb.authStore.isValid) {
|
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = pb.authStore.record!;
|
const user = Astro.locals.user!;
|
||||||
const initials = (user.name || user.email || "")
|
|
||||||
.split(/[\s@.]+/)
|
|
||||||
.filter(Boolean)
|
|
||||||
.slice(0, 2)
|
|
||||||
.map((s: string) => s[0].toUpperCase())
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const themePreference = user.theme_preference ?? "System";
|
const themePreference = user.theme_preference ?? "System";
|
||||||
|
const visibility = user.visibility ?? "Friends";
|
||||||
---
|
---
|
||||||
|
|
||||||
<BaseLayout>
|
<BaseLayout>
|
||||||
@@ -64,11 +58,11 @@ const themePreference = user.theme_preference ?? "System";
|
|||||||
<section>
|
<section>
|
||||||
<div class="flex items-center gap-4 mb-6">
|
<div class="flex items-center gap-4 mb-6">
|
||||||
<span class="avatar" data-size="lg">
|
<span class="avatar" data-size="lg">
|
||||||
<span>{initials}</span>
|
<span>{initials(user)}</span>
|
||||||
</span>
|
</span>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<span class="font-semibold"
|
<span class="font-semibold"
|
||||||
>{user.name || "—"}</span
|
>{displayName(user)}</span
|
||||||
>
|
>
|
||||||
<span class="text-sm opacity-70"
|
<span class="text-sm opacity-70"
|
||||||
>{user.email}</span
|
>{user.email}</span
|
||||||
@@ -95,16 +89,27 @@ const themePreference = user.theme_preference ?? "System";
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div role="group" class="field">
|
<div role="group" class="field">
|
||||||
<label for="name">Nom complet</label>
|
<label for="first_name">Prénom</label>
|
||||||
<input
|
<input
|
||||||
class="input"
|
class="input"
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
id="first_name"
|
||||||
name="name"
|
name="first_name"
|
||||||
value={user.name ?? ""}
|
value={user.first_name ?? ""}
|
||||||
placeholder="Votre nom complet"
|
placeholder="Votre prénom"
|
||||||
/>
|
/>
|
||||||
<p>Votre nom affiché dans l'application.</p>
|
</div>
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="last_name">Nom</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
value={user.last_name ?? ""}
|
||||||
|
placeholder="Votre nom"
|
||||||
|
/>
|
||||||
|
<p>Affichés dans l'application.</p>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -229,7 +234,7 @@ const themePreference = user.theme_preference ?? "System";
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Account form (name)
|
// Account form (username, names)
|
||||||
const accountForm = document.getElementById("account-form");
|
const accountForm = document.getElementById("account-form");
|
||||||
const accountBtn = document.getElementById(
|
const accountBtn = document.getElementById(
|
||||||
"account-submit",
|
"account-submit",
|
||||||
@@ -238,7 +243,8 @@ const themePreference = user.theme_preference ?? "System";
|
|||||||
accountForm?.addEventListener("submit", async (e) => {
|
accountForm?.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(accountForm as HTMLFormElement);
|
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");
|
const username = formData.get("username");
|
||||||
|
|
||||||
accountBtn.disabled = true;
|
accountBtn.disabled = true;
|
||||||
@@ -249,14 +255,14 @@ const themePreference = user.theme_preference ?? "System";
|
|||||||
const res = await fetch("/api/profile", {
|
const res = await fetch("/api/profile", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ name, username }),
|
body: JSON.stringify({ first_name, last_name, username }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showToast(
|
showToast(
|
||||||
"success",
|
"success",
|
||||||
"Enregistré",
|
"Enregistré",
|
||||||
"Votre nom a été mis à jour.",
|
"Votre compte a été mis à jour.",
|
||||||
);
|
);
|
||||||
// Reload to reflect the new name in the nav
|
// Reload to reflect the new name in the nav
|
||||||
setTimeout(() => window.location.reload(), 1000);
|
setTimeout(() => window.location.reload(), 1000);
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
---
|
||||||
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
|
|
||||||
|
if (Astro.locals.user) {
|
||||||
|
return Astro.redirect("/");
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<BaseLayout>
|
||||||
|
<div class="card max-w-sm mx-auto mt-16">
|
||||||
|
<header>
|
||||||
|
<h2>Créer un compte</h2>
|
||||||
|
<p>Rejoignez PokeShare pour gérer votre collection.</p>
|
||||||
|
</header>
|
||||||
|
<section>
|
||||||
|
<form id="register-form" class="grid gap-4">
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="first_name">Prénom</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
id="first_name"
|
||||||
|
name="first_name"
|
||||||
|
placeholder="Sacha"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="last_name">Nom</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
id="last_name"
|
||||||
|
name="last_name"
|
||||||
|
placeholder="Ketchum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="username">Nom d'utilisateur</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
name="username"
|
||||||
|
placeholder="sacha"
|
||||||
|
minlength="3"
|
||||||
|
maxlength="20"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
placeholder="sacha@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div role="group" class="field">
|
||||||
|
<label for="password">Mot de passe</label>
|
||||||
|
<input
|
||||||
|
class="input"
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
placeholder="8 caractères minimum"
|
||||||
|
minlength="8"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<footer class="flex-col gap-2">
|
||||||
|
<button
|
||||||
|
class="btn w-full"
|
||||||
|
type="submit"
|
||||||
|
form="register-form"
|
||||||
|
id="register-submit"
|
||||||
|
>
|
||||||
|
Créer mon compte
|
||||||
|
</button>
|
||||||
|
<p class="text-center text-sm opacity-70 m-0">
|
||||||
|
Déjà un compte ?
|
||||||
|
<a href="/login" class="underline">Se connecter</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</BaseLayout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById("register-form");
|
||||||
|
const submitBtn = document.getElementById(
|
||||||
|
"register-submit",
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
const toaster = document.getElementById("toaster") as ToasterElement;
|
||||||
|
|
||||||
|
const showToast = (
|
||||||
|
category: "success" | "error",
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
) => {
|
||||||
|
if (toaster?.toast) {
|
||||||
|
toaster.toast({ category, title, description });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
form?.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(form as HTMLFormElement);
|
||||||
|
const body = {
|
||||||
|
first_name: formData.get("first_name"),
|
||||||
|
last_name: formData.get("last_name"),
|
||||||
|
username: formData.get("username"),
|
||||||
|
email: formData.get("email"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
};
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
const originalText = submitBtn.innerHTML;
|
||||||
|
submitBtn.innerHTML = `<svg aria-label="Loading" role="status" data-icon="inline-start" class="animate-spin lucide lucide-loader-circle" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56" /></svg> Création...`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
window.location.href = "/";
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
showToast(
|
||||||
|
"error",
|
||||||
|
"Erreur",
|
||||||
|
data.error ?? "Échec de la création du compte",
|
||||||
|
);
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showToast("error", "Erreur", "Erreur réseau");
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
import BaseLayout from "../layouts/BaseLayout.astro";
|
import BaseLayout from "../layouts/BaseLayout.astro";
|
||||||
import { getSets } from "../lib/tcgdex-cache";
|
import { getSets } from "../lib/tcgdex-cache";
|
||||||
|
|
||||||
|
import { isLoggedIn } from "../lib/auth";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
const { pb } = Astro.locals;
|
||||||
|
|
||||||
if (!pb.authStore.isValid) {
|
if (!isLoggedIn(Astro.locals)) {
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||||
import { getSeries, getSetsBySerie } from "../../lib/tcgdex-cache";
|
import { getSeries, getSetsBySerie } from "../../lib/tcgdex-cache";
|
||||||
|
|
||||||
|
import { isLoggedIn } from "../../lib/auth";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
const { pb } = Astro.locals;
|
||||||
|
|
||||||
if (!pb.authStore.isValid) {
|
if (!isLoggedIn(Astro.locals)) {
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
---
|
---
|
||||||
import BaseLayout from "../../layouts/BaseLayout.astro";
|
import BaseLayout from "../../layouts/BaseLayout.astro";
|
||||||
|
|
||||||
|
import { isLoggedIn } from "../../lib/auth";
|
||||||
|
|
||||||
const { pb } = Astro.locals;
|
const { pb } = Astro.locals;
|
||||||
|
|
||||||
if (!pb.authStore.isValid) {
|
if (!isLoggedIn(Astro.locals)) {
|
||||||
return Astro.redirect("/login");
|
return Astro.redirect("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user