From 1bd323bdd6e74ab4e70f12343537258f48644759 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Mon, 12 Aug 2024 16:00:29 +0200 Subject: [PATCH] Logout API and implementation --- packages/backend/src/app.ts | 23 ++++++++++++++++++++++- packages/common/src/api.ts | 25 +++++++++++++++---------- packages/frontend/src/routes/Home.tsx | 23 ++++++++++++++++++++++- packages/make-api/src/Api.ts | 10 +++++++++- 4 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 4b401ba..a7eb4db 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,8 +1,9 @@ import { Schema as S } from "@effect/schema"; import { SessionId } from "common"; -import { Login, Me } from "common/api"; +import { Login, Logout, Me } from "common/api"; import { AccessLog, SessionData } from "common/db"; import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect"; +import { constant } from "effect/Function"; import { Api } from "make-api"; import { brotliCompress } from "node:zlib"; import { RequestError } from "./RequestError"; @@ -170,6 +171,26 @@ export const app = pipe( } }); } + } else if (yield* match(Logout)) { + + const sessionId = yield* pipe( + getCookie("sessionId"), + Effect.flatten, + Effect.flatMap(S.decodeUnknown(SessionId)), + Effect.catchAll(constant(Effect.succeed(null))), + ); + + if (sessionId !== null) { + db.deleteSession(sessionId); + } + + const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`; + return new Response(null, { + status: 204, + headers: { + "Set-Cookie": setCookie, + }, + }); } else if (yield* match(Me)) { const sessionId = yield* pipe( diff --git a/packages/common/src/api.ts b/packages/common/src/api.ts index 6eab794..3404090 100644 --- a/packages/common/src/api.ts +++ b/packages/common/src/api.ts @@ -3,6 +3,21 @@ import { Schema as S } from "@effect/schema"; import { UserId } from "common"; import { pipe } from "effect"; +export const Me = pipe( + Api.make("GET", "me"), + Api.responseBodyJson(200, S.Struct({ + userId: UserId, + username: S.NonEmptyString, + admin: S.Boolean, + })), + Api.responseBodyText(401, S.String), +); + +export const Logout = pipe( + Api.make("POST", "logout"), + Api.responseBodyNone(204), +); + export const Login = pipe( Api.make("POST", "login"), Api.requestBodyJson(S.Struct({ @@ -17,13 +32,3 @@ export const Login = pipe( Api.responseBodyText(400, S.String), Api.responseBodyText(401, S.String), ); - -export const Me = pipe( - Api.make("GET", "me"), - Api.responseBodyJson(200, S.Struct({ - userId: UserId, - username: S.NonEmptyString, - admin: S.Boolean, - })), - Api.responseBodyText(401, S.String), -); diff --git a/packages/frontend/src/routes/Home.tsx b/packages/frontend/src/routes/Home.tsx index ce75e97..5d2b896 100644 --- a/packages/frontend/src/routes/Home.tsx +++ b/packages/frontend/src/routes/Home.tsx @@ -2,7 +2,7 @@ import { Schema as S } from "@effect/schema"; import { Me } from "common/api"; import { Effect, Fiber, Option as O, Option, pipe } from "effect"; import { useLocation } from "preact-iso"; -import { useEffect } from "preact/hooks"; +import { useEffect, useMemo } from "preact/hooks"; import { useStore } from "../store"; import * as style from "./Home.css"; @@ -41,6 +41,26 @@ export const Home = () => { return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true }); }, []); + const logoutEffect = useMemo(() => Effect.gen(function* () { + const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/logout", { + method: "POST", + signal, + credentials: "include", + })); + + if (!res.ok) { + yield* Effect.die(new Error("Response was not ok")); + } + + setUser(O.none()); + + route("/login"); + }), []); + + const onLogoutClick = () => { + Effect.runFork(logoutEffect); + }; + if (user === null) { return (
@@ -53,6 +73,7 @@ export const Home = () => {
Użytkownik: {user.username} +
); diff --git a/packages/make-api/src/Api.ts b/packages/make-api/src/Api.ts index 1133078..cf4a114 100644 --- a/packages/make-api/src/Api.ts +++ b/packages/make-api/src/Api.ts @@ -4,7 +4,7 @@ import type { Method } from "./Method"; import type { MimeTypePredicate } from "./MimeType"; import { QueryEmpty, type Query } from "./Query"; import { RequestBodyFile, RequestBodyJson, RequestBodyMultipart, RequestBodyNone, RequestBodyText, RequestBodyUrlEncoded, type RequestBody } from "./RequestBody"; -import { ResponseBodyFile, ResponseBodyJson, ResponseBodyText, ResponseEmpty, type ResponseBody } from "./ResponseBody"; +import { ResponseBodyFile, ResponseBodyJson, ResponseBodyNone, ResponseBodyText, ResponseEmpty, type ResponseBody } from "./ResponseBody"; import { Route, RouteLiteral, RouteParam } from "./Route"; export interface Api { @@ -109,6 +109,14 @@ export const requestBodyFile = (mimeType: MimeTyp readonly query: T["props"]["query"], }> => Api(method, { ...props, request: RequestBodyFile(mimeType) }); +export const responseBodyNone = (status: Status) => + ({ method, props: { response, ...props } }: T): Api<{ + readonly route: T["props"]["route"], + readonly request: T["props"]["request"], + readonly response: { readonly [K in keyof T["props"]["response"] | Status]: K extends Status ? ResponseBodyNone : T["props"]["response"][K] }, + readonly query: T["props"]["query"], + }> => Api(method, { ...props, response: Object.freeze({ ...response, [status]: ResponseBodyNone }) }); + export const responseBodyText = >(status: Status, schema: Schema) => ({ method, props: { response, ...props } }: T): Api<{ readonly route: T["props"]["route"],