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:
2026-07-28 20:33:46 +02:00
parent a3a69e0eae
commit fc51371dad
37 changed files with 986 additions and 765 deletions
+2 -2
View File
@@ -6,10 +6,10 @@
<section>
<form id="login-form" class="grid gap-4">
<div role="group" class="field">
<label for="username">Nom d'utilisateur</label>
<label for="username">Email</label>
<input
class="input"
type="text"
type="email"
id="username"
name="username"
placeholder="votre@email.com"
+5 -11
View File
@@ -1,14 +1,8 @@
---
const { pb } = Astro.locals;
const user = pb.authStore.record;
const initials = user
? (user.name || user.email || "")
.split(/[\s@.]+/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0].toUpperCase())
.join("")
: "";
import { displayName, initials as userInitials } from "../lib/directus";
const user = Astro.locals.user;
const initials = user ? userInitials(user) : "";
---
<header
@@ -130,7 +124,7 @@ const initials = user
<span>{initials}</span>
</span>
<span class="hidden sm:inline ml-1 text-sm">
{user.name || user.email}
{displayName(user)}
</span>
<svg
data-icon="inline-end"
+2 -2
View File
@@ -2,8 +2,8 @@
declare namespace App {
interface Locals {
pb: import("pocketbase").default;
user: import("pocketbase").default["authStore"]["record"];
directus: import("./lib/directus").DirectusClient | null;
user: import("./lib/directus").AppUser | null;
}
}
+1 -2
View File
@@ -2,8 +2,7 @@
import Nav from "../components/Nav.astro";
import "../styles/global.css";
const { pb } = Astro.locals;
const user = pb.authStore.record;
const { user } = Astro.locals;
const themePreference = user?.theme_preference ?? "System";
// Determine if dark mode should be active
+8
View File
@@ -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
View File
@@ -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.
*/
import { getCard } from "./tcgdex-cache";
import type { MyCard } from "./directus";
export interface MyCardRecord {
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 MyCardRecord = MyCard;
export type PricedCard = MyCardRecord & { price: number; totalPrice: number };
export type PricedCard = MyCard & { price: number; totalPrice: number };
/** Collection variant label → Cardmarket avg price field. */
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.
*/
export async function priceCollectionCards(
cards: MyCardRecord[],
cards: MyCard[],
): Promise<PricedCard[]> {
return Promise.all(
cards.map(async (card) => {
@@ -45,10 +33,11 @@ export async function priceCollectionCards(
const detail = await getCard(card.card_id);
const cardmarket = detail?.pricing?.cardmarket;
if (cardmarket) {
const priceKey = variantPriceMap[card.variant] ?? "avg";
const priceKey = variantPriceMap[card.variant ?? ""] ?? "avg";
price = cardmarket[priceKey] ?? cardmarket.avg ?? 0;
}
return { ...card, price, totalPrice: price * card.quantity };
const quantity = card.quantity ?? 1;
return { ...card, price, totalPrice: price * quantity };
}),
);
}
+94
View File
@@ -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("");
}
+38 -20
View File
@@ -1,34 +1,52 @@
import { defineMiddleware } from "astro:middleware";
import PocketBase from "pocketbase";
import { readMe, refresh } from "@directus/sdk";
import {
createDirectusClient,
type AppUser,
type DirectusTokens,
} from "./lib/directus";
// Read at runtime (not build time) so the same Docker image can point to
// different PocketBase instances via the POCKETBASE_URL env variable.
const POCKETBASE_URL =
process.env.POCKETBASE_URL ?? "https://pokeshare.namarusaja.me";
const AUTH_COOKIE = "directus_auth";
const COOKIE_OPTIONS = {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
} as const;
export const onRequest = defineMiddleware(async ({ cookies, locals }, next) => {
const pb = new PocketBase(POCKETBASE_URL);
locals.directus = null;
locals.user = null;
// Restore auth from the cookie if it exists
const authCookie = cookies.get("pb_auth");
const authCookie = cookies.get(AUTH_COOKIE);
if (authCookie) {
pb.authStore.loadFromCookie(authCookie.value);
// Optionally refresh the token if it's close to expiring
try {
if (pb.authStore.isValid) {
await pb.collection("users").authRefresh();
const tokens = JSON.parse(authCookie.value) as DirectusTokens;
const directus = createDirectusClient();
directus.setToken(tokens.access_token);
try {
locals.user = (await directus.request(readMe())) as AppUser;
} catch {
// Access token expired — refresh and renew the cookie
const renewed = (await directus.request(
refresh("json", tokens.refresh_token),
)) as DirectusTokens;
directus.setToken(renewed.access_token);
cookies.set(
AUTH_COOKIE,
JSON.stringify({
access_token: renewed.access_token,
refresh_token: renewed.refresh_token,
}),
COOKIE_OPTIONS,
);
locals.user = (await directus.request(readMe())) as AppUser;
}
locals.directus = directus;
} catch {
// Token is invalid/expired — clear it
pb.authStore.clear();
cookies.delete("pb_auth", { path: "/" });
cookies.delete(AUTH_COOKIE, { path: "/" });
}
}
// Make the PocketBase instance available to all pages and endpoints via locals
locals.pb = pb;
locals.user = pb.authStore.record as any;
return next();
});
+26 -14
View File
@@ -1,15 +1,25 @@
import type { APIRoute } from "astro";
import { createItem } from "@directus/sdk";
import { isLoggedIn } from "../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { card_id, card_name, card_image, variant, quantity, condition, notes, set_name } =
await request.json();
const {
card_id,
card_name,
card_image,
variant,
quantity,
condition,
notes,
set_name,
} = await request.json();
if (!card_id || !card_name) {
return new Response(JSON.stringify({ error: "Missing card info" }), {
@@ -19,17 +29,19 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
try {
const record = await locals.pb.collection("mycards").create({
user: locals.pb.authStore.record!.id,
card_id,
card_name,
card_image: card_image ?? "",
variant: variant ?? "Base",
quantity: quantity ?? 1,
condition: condition ?? "",
notes: notes ?? "",
set_name: set_name ?? "",
});
const record = await locals.directus!.request(
createItem("mycards", {
user: locals.user!.id,
card_id,
card_name,
card_image: card_image || null,
variant: variant || "Base",
quantity: Number(quantity) || 1,
condition: condition || null,
notes: notes || null,
set_name: set_name || null,
}),
);
return new Response(JSON.stringify({ success: true, record }), {
status: 201,
+8 -13
View File
@@ -1,16 +1,18 @@
import type { APIRoute } from "astro";
import { deleteItem } from "@directus/sdk";
import { isLoggedIn } from "../../../lib/auth";
export const DELETE: APIRoute = async ({ params, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const { id } = params;
const id = Number(params.id);
if (!id) {
if (!id || Number.isNaN(id)) {
return new Response(JSON.stringify({ error: "Missing record id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
@@ -18,16 +20,9 @@ export const DELETE: APIRoute = async ({ params, locals }) => {
}
try {
// Verify the record belongs to the current user before deleting
const record = await locals.pb.collection("mycards").getOne(id);
if (record.user !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("mycards").delete(id);
// Ownership is enforced by the Directus delete permission
// (user = $CURRENT_USER) — deleting someone else's card 403s there.
await locals.directus!.request(deleteItem("mycards", id));
return new Response(JSON.stringify({ success: true }), {
status: 200,
+30 -24
View File
@@ -1,7 +1,9 @@
import type { APIRoute } from "astro";
import { updateItem } from "@directus/sdk";
import { isLoggedIn } from "../../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
@@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
try {
const { friendshipId } = await request.json();
const id = Number(friendshipId);
if (!friendshipId) {
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
if (!id || Number.isNaN(id)) {
return new Response(
JSON.stringify({ error: "Missing friendship id" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
// Only the addressee can accept
if (friendship.addressee !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("friendships").update(friendshipId, {
status: "accepted",
});
// Only the addressee can accept — enforced by the Directus update
// permission (addressee = $CURRENT_USER, status field only).
await locals.directus!.request(
updateItem("friendships", id, { status: "accepted" }),
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
} catch (err: any) {
console.error("Accept friend failed:", err);
return new Response(JSON.stringify({ error: "Failed to accept" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
const forbidden =
err?.errors?.[0]?.extensions?.code === "FORBIDDEN" ||
err?.response?.status === 403;
return new Response(
JSON.stringify({
error: forbidden ? "Forbidden" : "Failed to accept",
}),
{
status: forbidden ? 403 : 500,
headers: { "Content-Type": "application/json" },
},
);
}
};
+30 -24
View File
@@ -1,7 +1,9 @@
import type { APIRoute } from "astro";
import { updateItem } from "@directus/sdk";
import { isLoggedIn } from "../../../lib/auth";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
@@ -10,37 +12,41 @@ export const POST: APIRoute = async ({ request, locals }) => {
try {
const { friendshipId } = await request.json();
const id = Number(friendshipId);
if (!friendshipId) {
return new Response(JSON.stringify({ error: "Missing friendship id" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
if (!id || Number.isNaN(id)) {
return new Response(
JSON.stringify({ error: "Missing friendship id" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
const friendship = await locals.pb.collection("friendships").getOne(friendshipId);
// Only the addressee can decline
if (friendship.addressee !== locals.pb.authStore.record!.id) {
return new Response(JSON.stringify({ error: "Forbidden" }), {
status: 403,
headers: { "Content-Type": "application/json" },
});
}
await locals.pb.collection("friendships").update(friendshipId, {
status: "declined",
});
// Only the addressee can decline — enforced by the Directus update
// permission (addressee = $CURRENT_USER, status field only).
await locals.directus!.request(
updateItem("friendships", id, { status: "declined" }),
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (err) {
} catch (err: any) {
console.error("Decline friend failed:", err);
return new Response(JSON.stringify({ error: "Failed to decline" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
const forbidden =
err?.errors?.[0]?.extensions?.code === "FORBIDDEN" ||
err?.response?.status === 403;
return new Response(
JSON.stringify({
error: forbidden ? "Forbidden" : "Failed to decline",
}),
{
status: forbidden ? 403 : 500,
headers: { "Content-Type": "application/json" },
},
);
}
};
+79 -46
View File
@@ -1,7 +1,10 @@
import type { APIRoute } from "astro";
import { createItem, readItems, readUsers, deleteItem } from "@directus/sdk";
import { isLoggedIn } from "../../../lib/auth";
import type { AppUser, Friendship } from "../../../lib/directus";
export const POST: APIRoute = async ({ request, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
@@ -18,12 +21,18 @@ export const POST: APIRoute = async ({ request, locals }) => {
});
}
const requesterId = locals.pb.authStore.record!.id;
const requesterId = locals.user!.id;
const directus = locals.directus!;
// Find the target user by username
const targetUser = await locals.pb.collection("public_users").getFirstListItem(
locals.pb.filter("username = {:username}", { username }),
);
// Find the target user by username (Private users are not visible)
const matches = (await directus.request(
readUsers({
filter: { username: { _eq: username } },
fields: ["id"],
limit: 1,
}),
)) as AppUser[];
const targetUser = matches[0];
if (!targetUser) {
return new Response(JSON.stringify({ error: "User not found" }), {
@@ -33,59 +42,83 @@ export const POST: APIRoute = async ({ request, locals }) => {
}
if (targetUser.id === requesterId) {
return new Response(JSON.stringify({ error: "Cannot add yourself" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
// Check if friendship already exists (in either direction)
const existing = await locals.pb.collection("friendships").getList(1, 1, {
filter: locals.pb.filter(
"(requester = {:requesterId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:requesterId})",
{ requesterId, targetId: targetUser.id },
),
});
if (existing.items.length > 0) {
const f = existing.items[0];
if (f.status === "accepted") {
return new Response(JSON.stringify({ error: "Already friends" }), {
return new Response(
JSON.stringify({ error: "Cannot add yourself" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
});
},
);
}
// Check if a friendship already exists (in either direction)
const existing = (await directus.request(
readItems("friendships", {
filter: {
_or: [
{
_and: [
{ requester: { _eq: requesterId } },
{ addressee: { _eq: targetUser.id } },
],
},
{
_and: [
{ requester: { _eq: targetUser.id } },
{ addressee: { _eq: requesterId } },
],
},
],
},
limit: 1,
}),
)) as Friendship[];
if (existing.length > 0) {
const f = existing[0];
if (f.status === "accepted") {
return new Response(
JSON.stringify({ error: "Already friends" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
if (f.status === "pending") {
return new Response(JSON.stringify({ error: "Invite already sent" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Invite already sent" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
// Declined — allow re-request
await locals.pb.collection("friendships").delete(f.id);
// Declined — remove it and allow re-request
await directus.request(deleteItem("friendships", f.id));
}
const record = await locals.pb.collection("friendships").create({
requester: requesterId,
addressee: targetUser.id,
status: "pending",
});
// Validation rule requester = $CURRENT_USER is enforced by Directus
const record = await directus.request(
createItem("friendships", {
requester: requesterId,
addressee: targetUser.id,
status: "pending",
}),
);
return new Response(JSON.stringify({ success: true, record }), {
status: 201,
headers: { "Content-Type": "application/json" },
});
} catch (err: any) {
} catch (err) {
console.error("Friend request failed:", err);
if (err.message?.includes("not found")) {
return new Response(JSON.stringify({ error: "User not found" }), {
status: 404,
return new Response(
JSON.stringify({ error: "Failed to send invite" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ error: "Failed to send invite" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
},
);
}
};
+31 -13
View File
@@ -1,7 +1,10 @@
import type { APIRoute } from "astro";
import { readUsers } from "@directus/sdk";
import { isLoggedIn } from "../../../lib/auth";
import type { AppUser } from "../../../lib/directus";
export const GET: APIRoute = async ({ url, locals }) => {
if (!locals.pb.authStore.isValid) {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
@@ -11,23 +14,38 @@ export const GET: APIRoute = async ({ url, locals }) => {
const query = url.searchParams.get("q")?.trim().replace(/^@/, "");
if (!query || query.length < 3 || query.length > 20) {
return new Response(JSON.stringify({ error: "Query must be 3-20 characters" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Query must be 3-20 characters" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
try {
// Search by username (case-insensitive), exclude self
const users = await locals.pb.collection("public_users").getList(1, 10, {
filter: locals.pb.filter("username ~ {:query} && id != {:selfId}", {
query,
selfId: locals.pb.authStore.record!.id,
// Search by username, exclude self. Private users are excluded by the
// users read permission (former public_users view behavior).
const users = (await locals.directus!.request(
readUsers({
filter: {
_and: [
{ username: { _contains: query } },
{ id: { _neq: locals.user!.id } },
],
},
fields: [
"id",
"username",
"first_name",
"last_name",
"visibility",
],
limit: 10,
}),
fields: "id,username,name,visibility",
});
)) as AppUser[];
return new Response(JSON.stringify({ users: users.items }), {
return new Response(JSON.stringify({ users }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
+37 -14
View File
@@ -1,6 +1,11 @@
import type { APIRoute } from "astro";
import {
createDirectusClient,
normalizeEmail,
type DirectusTokens,
} from "../../lib/directus";
export const POST: APIRoute = async ({ request, cookies, redirect, locals }) => {
export const POST: APIRoute = async ({ request, cookies }) => {
let username = "";
let password = "";
@@ -24,28 +29,46 @@ export const POST: APIRoute = async ({ request, cookies, redirect, locals }) =>
});
}
if (!username || !password) {
return new Response(JSON.stringify({ error: "Username and password are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
// Directus auth is case-sensitive on email — normalize
const email = normalizeEmail(username);
if (!email || !password) {
return new Response(
JSON.stringify({ error: "Username and password are required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
try {
await locals.pb.collection("users").authWithPassword(username, password);
const client = createDirectusClient();
const tokens = (await client.login({
email,
password,
})) as DirectusTokens;
cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
});
cookies.set(
"directus_auth",
JSON.stringify({
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
}),
{
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1 week
},
);
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch {
} catch (err) {
console.error("Login failed:", err);
return new Response(JSON.stringify({ error: "Invalid credentials" }), {
status: 401,
headers: { "Content-Type": "application/json" },
+1 -1
View File
@@ -1,6 +1,6 @@
import type { APIRoute } from "astro";
export const POST: APIRoute = async ({ cookies, redirect }) => {
cookies.delete("pb_auth", { path: "/" });
cookies.delete("directus_auth", { path: "/" });
return redirect("/login");
};
+58 -41
View File
@@ -1,7 +1,9 @@
import type { APIRoute } from "astro";
import { updateMe } from "@directus/sdk";
import { isLoggedIn } from "../../lib/auth";
export const POST: APIRoute = async ({ request, cookies, locals }) => {
if (!locals.pb.authStore.isValid) {
export const POST: APIRoute = async ({ request, locals }) => {
if (!isLoggedIn(locals)) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
@@ -10,47 +12,65 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
try {
const body = await request.json();
const { name, username, theme_preference, visibility } = body;
const { first_name, last_name, username, theme_preference, visibility } =
body;
const updates: Record<string, string> = {};
const updates: Record<string, string | null> = {};
if (name !== undefined) {
updates.name = String(name).trim();
if (first_name !== undefined) {
updates.first_name = String(first_name).trim() || null;
}
if (last_name !== undefined) {
updates.last_name = String(last_name).trim() || null;
}
if (username !== undefined) {
const u = String(username).trim().toLowerCase();
if (u.length < 3 || u.length > 20) {
return new Response(JSON.stringify({ error: "Username must be 3-20 characters" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Username must be 3-20 characters" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
if (!/^[a-z0-9_]+$/.test(u)) {
return new Response(JSON.stringify({ error: "Username can only contain lowercase letters, numbers, and underscores" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({
error: "Username can only contain lowercase letters, numbers, and underscores",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
updates.username = u;
}
if (visibility !== undefined) {
if (!["Public", "Friends", "Private"].includes(visibility)) {
return new Response(JSON.stringify({ error: "Invalid visibility" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Invalid visibility" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
updates.visibility = visibility;
}
if (theme_preference !== undefined) {
if (!["System", "Light", "Dark"].includes(theme_preference)) {
return new Response(JSON.stringify({ error: "Invalid theme preference" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Invalid theme preference" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
updates.theme_preference = theme_preference;
}
@@ -62,16 +82,7 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
});
}
// Update the user record
await locals.pb.collection("users").update(locals.pb.authStore.record!.id, updates);
// Re-set the auth cookie with the updated record
cookies.set("pb_auth", locals.pb.authStore.exportToCookie(), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7,
});
await locals.directus!.request(updateMe(updates));
return new Response(JSON.stringify({ success: true }), {
status: 200,
@@ -79,15 +90,21 @@ export const POST: APIRoute = async ({ request, cookies, locals }) => {
});
} catch (err: any) {
console.error("Failed to update profile:", err);
if (err.message?.includes("username")) {
return new Response(JSON.stringify({ error: "Username already taken" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
if (err.message?.toLowerCase().includes("unique")) {
return new Response(
JSON.stringify({ error: "Username already taken" }),
{
status: 409,
headers: { "Content-Type": "application/json" },
},
);
}
return new Response(JSON.stringify({ error: "Failed to update profile" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
return new Response(
JSON.stringify({ error: "Failed to update profile" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
},
);
}
};
+140
View File
@@ -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" },
});
}
};
+3 -1
View File
@@ -8,9 +8,11 @@ import {
} from "../../lib/tcg-types";
import { getCard } from "../../lib/tcgdex-cache";
import { isLoggedIn } from "../../lib/auth";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
+60 -50
View File
@@ -1,36 +1,56 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { isLoggedIn } from "../lib/auth";
import {
displayName,
initials,
type AppUser,
type Friendship,
} from "../lib/directus";
import { readItems } from "@directus/sdk";
const { pb } = Astro.locals;
const { directus, user } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
const userId = user!.id;
const idOf = (u: string | AppUser) => (typeof u === "string" ? u : u.id);
// Get all friendships involving this user
const friendships = await pb.collection("friendships").getFullList({
filter: pb.filter("requester = {:userId} || addressee = {:userId}", {
userId,
// Friendships involving the current user (scoped by Directus permissions),
// with both sides expanded for display
const friendships = (await directus!.request(
readItems("friendships", {
fields: [
"*",
"requester.id",
"requester.username",
"requester.first_name",
"requester.last_name",
"addressee.id",
"addressee.username",
"addressee.first_name",
"addressee.last_name",
],
limit: -1,
}),
expand: "requester,addressee",
});
)) as Friendship[];
const pendingReceived = friendships.filter(
(f) => f.addressee === userId && f.status === "pending",
(f) => idOf(f.addressee) === userId && f.status === "pending",
);
const pendingSent = friendships.filter(
(f) => f.requester === userId && f.status === "pending",
(f) => idOf(f.requester) === userId && f.status === "pending",
);
const accepted = friendships.filter((f) => f.status === "accepted");
function getOtherUser(friendship: any) {
// expand contains the full user records; requester/addressee are just IDs
if (friendship.requester === userId) {
return friendship.expand?.addressee;
}
return friendship.expand?.requester;
function getOtherUser(friendship: Friendship): AppUser | null {
const other =
idOf(friendship.requester) === userId
? friendship.addressee
: friendship.requester;
return typeof other === "string" ? null : other;
}
---
@@ -95,19 +115,12 @@ function getOtherUser(friendship: any) {
<div class="flex items-center gap-3">
<span class="avatar" data-size="sm">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
{other ? initials(other) : "?"}
</span>
</span>
<div>
<div class="font-semibold text-sm">
{other?.name || "—"}
{other ? displayName(other) : "—"}
</div>
<div class="text-xs opacity-60">
@{other?.username || "—"}
@@ -154,19 +167,12 @@ function getOtherUser(friendship: any) {
<div class="flex items-center gap-3">
<span class="avatar" data-size="sm">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
{other ? initials(other) : "?"}
</span>
</span>
<div>
<div class="font-semibold text-sm">
{other?.name || "—"}
{other ? displayName(other) : "—"}
</div>
<div class="text-xs opacity-60">
@{other?.username || "—"}
@@ -217,19 +223,16 @@ function getOtherUser(friendship: any) {
<figure>
<span class="avatar">
<span>
{(
other?.name ||
other?.username ||
other?.email ||
"??"
)
.slice(0, 2)
.toUpperCase()}
{other
? initials(other)
: "?"}
</span>
</span>
</figure>
<section>
<h3>{other?.name || "—"}</h3>
<h3>
{other ? displayName(other) : "—"}
</h3>
<p class="text-sm opacity-60">
@{other?.username || "—"}
</p>
@@ -297,22 +300,29 @@ function getOtherUser(friendship: any) {
}
searchResults!.innerHTML = data.users
.map(
(u: any) => `
.map((u: any) => {
const name =
[u.first_name, u.last_name]
.filter(Boolean)
.join(" ") ||
u.username ||
u.email ||
"?";
return `
<div class="flex items-center justify-between p-3 rounded-lg bg-muted">
<div class="flex items-center gap-3">
<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>
<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>
</div>
<button class="btn" data-size="sm" onclick="sendInvite('${u.username}')">Ajouter</button>
</div>
`,
)
`;
})
.join("");
});
+63 -42
View File
@@ -4,10 +4,18 @@ import {
priceCollectionCards,
type MyCardRecord,
} from "../../lib/collection";
import { isLoggedIn } from "../../lib/auth";
import {
displayName,
initials,
type AppUser,
type Friendship,
} from "../../lib/directus";
import { readItems, readUsers } from "@directus/sdk";
const { pb } = Astro.locals;
const { directus } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
@@ -17,46 +25,70 @@ if (!username) {
return Astro.redirect("/friends");
}
// Find the target user by username (same public view the friends API uses)
let targetUser;
try {
targetUser = await pb
.collection("public_users")
.getFirstListItem(pb.filter("username = {:username}", { username }));
} catch {
return Astro.redirect("/friends");
}
// Find the target user by username. The users read permission already
// excludes Private users (former public_users view behavior).
const matches = (await directus!.request(
readUsers({
filter: { username: { _eq: username } },
fields: ["id", "username", "first_name", "last_name", "visibility"],
limit: 1,
}),
)) as AppUser[];
// Check visibility — Private users' collections are never visible
if (targetUser.visibility === "Private") {
const targetUser = matches[0];
if (!targetUser) {
return Astro.redirect("/friends");
}
// Friends-only collections require an accepted friendship
if (targetUser.visibility !== "Public") {
const userId = pb.authStore.record!.id;
const friendship = await pb.collection("friendships").getList(1, 1, {
filter: pb.filter(
"((requester = {:userId} && addressee = {:targetId}) || (requester = {:targetId} && addressee = {:userId})) && status = 'accepted'",
{ userId, targetId: targetUser.id },
),
});
const userId = Astro.locals.user!.id;
const friendships = (await directus!.request(
readItems("friendships", {
filter: {
_and: [
{
_or: [
{
_and: [
{ requester: { _eq: userId } },
{ addressee: { _eq: targetUser.id } },
],
},
{
_and: [
{ requester: { _eq: targetUser.id } },
{ addressee: { _eq: userId } },
],
},
],
},
{ status: { _eq: "accepted" } },
],
},
limit: 1,
}),
)) as Friendship[];
if (friendship.items.length === 0) {
if (friendships.length === 0) {
return Astro.redirect("/friends");
}
}
// Fetch their collection
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId: targetUser.id }),
sort: "-created",
})) as unknown as MyCardRecord[];
// Fetch their collection (visibility enforced again by the mycards read
// permission at the database level)
const myCards = (await directus!.request(
readItems("mycards", {
filter: { user: { _eq: targetUser.id } },
sort: ["-date_created"],
limit: -1,
}),
)) as MyCardRecord[];
const cardsWithPrice = await priceCollectionCards(myCards);
const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
const totalCards = cardsWithPrice.reduce((s, c) => s + (c.quantity ?? 0), 0);
---
<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 items-center gap-4">
<span class="avatar" data-size="lg">
<span
>{
(
targetUser.name ||
targetUser.username ||
targetUser.email ||
"??"
)
.slice(0, 2)
.toUpperCase()
}</span
>
<span>{initials(targetUser)}</span>
</span>
<div>
<h1 class="m-0">{targetUser.name || "—"}</h1>
<h1 class="m-0">{displayName(targetUser)}</h1>
<p class="text-sm opacity-60 m-0">
@{targetUser.username || "—"}
</p>
@@ -118,8 +139,8 @@ const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
<header>
<h3>Collection vide</h3>
<p>
{targetUser.name || targetUser.username} n'a pas
encore de cartes dans sa collection.
{displayName(targetUser)} n'a pas encore de cartes
dans sa collection.
</p>
</header>
</section>
+9 -8
View File
@@ -5,24 +5,25 @@ import {
priceCollectionCards,
type MyCardRecord,
} from "../lib/collection";
import { isLoggedIn } from "../lib/auth";
import { readItems } from "@directus/sdk";
const { pb } = Astro.locals;
const { directus } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId }),
})) as unknown as MyCardRecord[];
// Owner-scoping is enforced by the Directus permissions
const myCards = (await directus!.request(
readItems("mycards", { limit: -1 }),
)) as MyCardRecord[];
// Fetch prices through the 24h-cached card endpoint
const cardsWithPrice = await priceCollectionCards(myCards);
// Insights
const totalCards = cardsWithPrice.reduce((s, c) => s + c.quantity, 0);
const totalCards = cardsWithPrice.reduce((s, c) => s + (c.quantity ?? 0), 0);
const totalValue = cardsWithPrice.reduce((s, c) => s + c.totalPrice, 0);
const bySet = new Map<string, { count: number; value: number }>();
+4
View File
@@ -0,0 +1,4 @@
---
// The experimental Directus login was promoted to the main login page.
return Astro.redirect("/login");
---
+8
View File
@@ -1,8 +1,16 @@
---
import LoginForm from "../components/LoginForm.astro";
import BaseLayout from "../layouts/BaseLayout.astro";
if (Astro.locals.user) {
return Astro.redirect("/");
}
---
<BaseLayout>
<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>
+13 -11
View File
@@ -4,20 +4,19 @@ import {
priceCollectionCards,
type MyCardRecord,
} from "../lib/collection";
import { isLoggedIn } from "../lib/auth";
import { readItems } from "@directus/sdk";
const { pb } = Astro.locals;
const { directus } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
const userId = pb.authStore.record!.id;
// Fetch all user's cards
const myCards = (await pb.collection("mycards").getFullList({
filter: pb.filter("user = {:userId}", { userId }),
sort: "-created",
})) as unknown as MyCardRecord[];
// Fetch all user's cards (owner-scoping enforced by Directus permissions)
const myCards = (await directus!.request(
readItems("mycards", { sort: ["-date_created"], limit: -1 }),
)) as MyCardRecord[];
const cardsWithPrice = await priceCollectionCards(myCards);
@@ -25,14 +24,17 @@ const totalValue = cardsWithPrice.reduce(
(sum, card) => sum + card.totalPrice,
0,
);
const totalCards = cardsWithPrice.reduce((sum, card) => sum + card.quantity, 0);
const totalCards = cardsWithPrice.reduce(
(sum, card) => sum + (card.quantity ?? 0),
0,
);
// Distinct values present in the collection, for the filter dropdowns
const distinctSets = [
...new Set(cardsWithPrice.map((c) => c.set_name).filter(Boolean)),
].sort();
const distinctVariants = [
...new Set(cardsWithPrice.map((c) => c.variant)),
...new Set(cardsWithPrice.map((c) => c.variant).filter(Boolean)),
].sort();
const distinctConditions = [
...new Set(cardsWithPrice.map((c) => c.condition).filter(Boolean)),
+29 -23
View File
@@ -1,21 +1,15 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import { isLoggedIn } from "../lib/auth";
import { displayName, initials } from "../lib/directus";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
const user = pb.authStore.record!;
const initials = (user.name || user.email || "")
.split(/[\s@.]+/)
.filter(Boolean)
.slice(0, 2)
.map((s: string) => s[0].toUpperCase())
.join("");
const user = Astro.locals.user!;
const themePreference = user.theme_preference ?? "System";
const visibility = user.visibility ?? "Friends";
---
<BaseLayout>
@@ -64,11 +58,11 @@ const themePreference = user.theme_preference ?? "System";
<section>
<div class="flex items-center gap-4 mb-6">
<span class="avatar" data-size="lg">
<span>{initials}</span>
<span>{initials(user)}</span>
</span>
<div class="flex flex-col">
<span class="font-semibold"
>{user.name || "—"}</span
>{displayName(user)}</span
>
<span class="text-sm opacity-70"
>{user.email}</span
@@ -95,16 +89,27 @@ const themePreference = user.theme_preference ?? "System";
</p>
</div>
<div role="group" class="field">
<label for="name">Nom complet</label>
<label for="first_name">Prénom</label>
<input
class="input"
type="text"
id="name"
name="name"
value={user.name ?? ""}
placeholder="Votre nom complet"
id="first_name"
name="first_name"
value={user.first_name ?? ""}
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>
</form>
</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 accountBtn = document.getElementById(
"account-submit",
@@ -238,7 +243,8 @@ const themePreference = user.theme_preference ?? "System";
accountForm?.addEventListener("submit", async (e) => {
e.preventDefault();
const formData = new FormData(accountForm as HTMLFormElement);
const name = formData.get("name");
const first_name = formData.get("first_name");
const last_name = formData.get("last_name");
const username = formData.get("username");
accountBtn.disabled = true;
@@ -249,14 +255,14 @@ const themePreference = user.theme_preference ?? "System";
const res = await fetch("/api/profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, username }),
body: JSON.stringify({ first_name, last_name, username }),
});
if (res.ok) {
showToast(
"success",
"Enregistré",
"Votre nom a été mis à jour.",
"Votre compte a été mis à jour.",
);
// Reload to reflect the new name in the nav
setTimeout(() => window.location.reload(), 1000);
+151
View File
@@ -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>
+3 -1
View File
@@ -2,9 +2,11 @@
import BaseLayout from "../layouts/BaseLayout.astro";
import { getSets } from "../lib/tcgdex-cache";
import { isLoggedIn } from "../lib/auth";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
+3 -1
View File
@@ -2,9 +2,11 @@
import BaseLayout from "../../layouts/BaseLayout.astro";
import { getSeries, getSetsBySerie } from "../../lib/tcgdex-cache";
import { isLoggedIn } from "../../lib/auth";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}
+3 -1
View File
@@ -1,9 +1,11 @@
---
import BaseLayout from "../../layouts/BaseLayout.astro";
import { isLoggedIn } from "../../lib/auth";
const { pb } = Astro.locals;
if (!pb.authStore.isValid) {
if (!isLoggedIn(Astro.locals)) {
return Astro.redirect("/login");
}