API: le foyer n'est plus créé automatiquement à l'inscription + suppression de compte (step 3/8)
- signup() ne crée plus de House — houseId démarre à null, le foyer devient une étape optionnelle de l'onboarding (créer/rejoindre/passer) - deleteAccount(): revérifie le mot de passe, transfère l'adminship ou supprime le foyer si nécessaire (leaveCurrentHouse), puis supprime le profil (cascade sur les allergies) - DELETE /auth/me — nouvelle route, gated par mot de passe - clearCookie n'envoie plus maxAge (corrige un warning de dépréciation Express, déjà latent sur /auth/logout)
This commit is contained in:
parent
7d5a6c05bb
commit
7af98756dc
2 changed files with 62 additions and 23 deletions
|
|
@ -1,10 +1,10 @@
|
||||||
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
import { wrapAsyncHandler } from "@batch-cooking/express-tools";
|
||||||
import { loginSchema, signupSchema } from "@batch-cooking/shared";
|
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
|
||||||
import { Router } from "express";
|
import { Router } from "express";
|
||||||
import type { CookieOptions, Response } from "express";
|
import type { CookieOptions, Response } from "express";
|
||||||
import { env } from "../../config/env.js";
|
import { env } from "../../config/env.js";
|
||||||
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
|
||||||
import { login, signup } from "./auth.service.js";
|
import { deleteAccount, login, signup } from "./auth.service.js";
|
||||||
|
|
||||||
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
|
|
@ -15,7 +15,7 @@ export const authRouter = Router();
|
||||||
// bounds how long the browser keeps *sending* the cookie at all.
|
// bounds how long the browser keeps *sending* the cookie at all.
|
||||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
/** Cookie options shared by every route that sets/clears the session cookie. */
|
/** Cookie options shared by every route that sets the session cookie. */
|
||||||
const cookieOptions: CookieOptions = {
|
const cookieOptions: CookieOptions = {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
// Only require HTTPS in production — local dev/CI serve over plain HTTP.
|
||||||
|
|
@ -24,6 +24,12 @@ const cookieOptions: CookieOptions = {
|
||||||
maxAge: SEVEN_DAYS_MS,
|
maxAge: SEVEN_DAYS_MS,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `res.clearCookie` sets its own expiry to clear the cookie — passing
|
||||||
|
// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so
|
||||||
|
// every clearing route reuses `cookieOptions` minus that one field rather
|
||||||
|
// than duplicating the rest by hand.
|
||||||
|
const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a profile (+ its household) and logs the new user in
|
* Creates a profile (+ its household) and logs the new user in
|
||||||
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
|
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
|
||||||
|
|
@ -52,7 +58,7 @@ authRouter.post(
|
||||||
|
|
||||||
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
|
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
|
||||||
authRouter.post("/logout", (_req, res) => {
|
authRouter.post("/logout", (_req, res) => {
|
||||||
res.clearCookie(env.AUTH_COOKIE_NAME, cookieOptions);
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
||||||
res.status(204).end();
|
res.status(204).end();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -60,3 +66,15 @@ authRouter.post("/logout", (_req, res) => {
|
||||||
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
|
||||||
res.status(200).json(res.locals.userProfile);
|
res.status(200).json(res.locals.userProfile);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */
|
||||||
|
authRouter.delete(
|
||||||
|
"/me",
|
||||||
|
requireAuth,
|
||||||
|
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
|
||||||
|
const input = deleteAccountSchema.parse(req.body);
|
||||||
|
await deleteAccount(res.locals.userProfile.id, input.password);
|
||||||
|
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
|
||||||
|
res.status(204).end();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { env } from "../../config/env.js";
|
||||||
import { prisma } from "../../db/prisma.js";
|
import { prisma } from "../../db/prisma.js";
|
||||||
import { signAuthToken } from "../../lib/jwt.js";
|
import { signAuthToken } from "../../lib/jwt.js";
|
||||||
import { toSafeProfile } from "../../lib/safe-profile.js";
|
import { toSafeProfile } from "../../lib/safe-profile.js";
|
||||||
|
import { leaveCurrentHouse } from "../house/house.service.js";
|
||||||
|
|
||||||
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
/** Result of a successful signup/login: the safe profile plus the signed session JWT to set as a cookie. */
|
||||||
interface AuthResult {
|
interface AuthResult {
|
||||||
|
|
@ -28,8 +29,8 @@ const testHashOptions = { memoryCost: 8192, timeCost: 2, parallelism: 1 };
|
||||||
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
const hashOptions = env.NODE_ENV === "test" ? testHashOptions : undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new household (`house`) and profile (`user_profiles`) together
|
* Creates a profile (`user_profiles`), hashes the password, and issues a
|
||||||
* in one transaction, hashes the password, and issues a session token.
|
* session token.
|
||||||
*
|
*
|
||||||
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
* @throws {HttpError} `409 EMAIL_ALREADY_IN_USE` if the email is already taken.
|
||||||
*/
|
*/
|
||||||
|
|
@ -41,29 +42,49 @@ export async function signup(input: SignupInput): Promise<AuthResult> {
|
||||||
|
|
||||||
const passwordHash = await argon2.hash(input.password, hashOptions);
|
const passwordHash = await argon2.hash(input.password, hashOptions);
|
||||||
|
|
||||||
// A profile always belongs to a house; signup creates one, named after
|
// No household is created here — it's now an optional step of the
|
||||||
// the new user for now — renamed via `PATCH /house/current` (the
|
// onboarding wizard (create or join one, or skip — see
|
||||||
// household step of the profile journey). Joining an existing house is a
|
// `house.service.ts`'s `createHouse`/`joinHouse`), not an implicit side
|
||||||
// separate, not-yet-built feature.
|
// effect of signing up. `houseId` starts out `null`, same as `dietId`.
|
||||||
const profile = await prisma.$transaction(async (tx) => {
|
const profile = await prisma.userProfile.create({
|
||||||
const house = await tx.house.create({
|
|
||||||
data: { name: `Foyer de ${input.firstName}` },
|
|
||||||
});
|
|
||||||
return tx.userProfile.create({
|
|
||||||
data: {
|
data: {
|
||||||
firstName: input.firstName,
|
firstName: input.firstName,
|
||||||
lastName: input.lastName,
|
lastName: input.lastName,
|
||||||
email: input.email,
|
email: input.email,
|
||||||
passwordHash,
|
passwordHash,
|
||||||
houseId: house.id,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
const token = signAuthToken({ userProfileId: profile.id, tokenVersion: profile.tokenVersion });
|
||||||
return { profile: toSafeProfile(profile), token };
|
return { profile: toSafeProfile(profile), token };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permanently deletes a profile, after re-verifying its password —
|
||||||
|
* deleting an account is irreversible, so it's gated behind proving the
|
||||||
|
* caller still is who the session says they are, same spirit as the
|
||||||
|
* password check in {@link login}.
|
||||||
|
*
|
||||||
|
* If the profile administers a household with other members, adminship is
|
||||||
|
* handed off before the profile is deleted (see `house.service.ts`'s
|
||||||
|
* `leaveCurrentHouse`); if it's the household's last member, the household
|
||||||
|
* itself is deleted along with it. `UserProfileAllergy` rows cascade via
|
||||||
|
* the schema's `onDelete: Cascade`.
|
||||||
|
*
|
||||||
|
* @throws {HttpError} `401 INVALID_CREDENTIALS` if the password is wrong.
|
||||||
|
*/
|
||||||
|
export async function deleteAccount(profileId: number, password: string): Promise<void> {
|
||||||
|
const profile = await prisma.userProfile.findUnique({ where: { id: profileId } });
|
||||||
|
if (!profile || !(await argon2.verify(profile.passwordHash, password))) {
|
||||||
|
throw new HttpError(401, ErrorCode.INVALID_CREDENTIALS, "Invalid password");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.houseId !== null) {
|
||||||
|
await leaveCurrentHouse(profile.id, profile.houseId);
|
||||||
|
}
|
||||||
|
await prisma.userProfile.delete({ where: { id: profile.id } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verifies credentials and issues a fresh session token.
|
* Verifies credentials and issues a fresh session token.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue