Logout API and implementation

This commit is contained in:
2024-08-12 16:00:29 +02:00
parent 1a60ecb48a
commit 1bd323bdd6
4 changed files with 68 additions and 13 deletions

View File

@@ -1,8 +1,9 @@
import { Schema as S } from "@effect/schema"; import { Schema as S } from "@effect/schema";
import { SessionId } from "common"; import { SessionId } from "common";
import { Login, Me } from "common/api"; import { Login, Logout, Me } from "common/api";
import { AccessLog, SessionData } from "common/db"; import { AccessLog, SessionData } from "common/db";
import { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect"; 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 { Api } from "make-api";
import { brotliCompress } from "node:zlib"; import { brotliCompress } from "node:zlib";
import { RequestError } from "./RequestError"; 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)) { } else if (yield* match(Me)) {
const sessionId = yield* pipe( const sessionId = yield* pipe(

View File

@@ -3,6 +3,21 @@ import { Schema as S } from "@effect/schema";
import { UserId } from "common"; import { UserId } from "common";
import { pipe } from "effect"; 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( export const Login = pipe(
Api.make("POST", "login"), Api.make("POST", "login"),
Api.requestBodyJson(S.Struct({ Api.requestBodyJson(S.Struct({
@@ -17,13 +32,3 @@ export const Login = pipe(
Api.responseBodyText(400, S.String), Api.responseBodyText(400, S.String),
Api.responseBodyText(401, 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),
);

View File

@@ -2,7 +2,7 @@ import { Schema as S } from "@effect/schema";
import { Me } from "common/api"; import { Me } from "common/api";
import { Effect, Fiber, Option as O, Option, pipe } from "effect"; import { Effect, Fiber, Option as O, Option, pipe } from "effect";
import { useLocation } from "preact-iso"; import { useLocation } from "preact-iso";
import { useEffect } from "preact/hooks"; import { useEffect, useMemo } from "preact/hooks";
import { useStore } from "../store"; import { useStore } from "../store";
import * as style from "./Home.css"; import * as style from "./Home.css";
@@ -41,6 +41,26 @@ export const Home = () => {
return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true }); 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) { if (user === null) {
return ( return (
<div class={style.container}> <div class={style.container}>
@@ -53,6 +73,7 @@ export const Home = () => {
<div class={style.container}> <div class={style.container}>
<div class={style.content}> <div class={style.content}>
Użytkownik: {user.username} Użytkownik: {user.username}
<button type="button" onClick={onLogoutClick}>Wyloguj się</button>
</div> </div>
</div> </div>
); );

View File

@@ -4,7 +4,7 @@ import type { Method } from "./Method";
import type { MimeTypePredicate } from "./MimeType"; import type { MimeTypePredicate } from "./MimeType";
import { QueryEmpty, type Query } from "./Query"; import { QueryEmpty, type Query } from "./Query";
import { RequestBodyFile, RequestBodyJson, RequestBodyMultipart, RequestBodyNone, RequestBodyText, RequestBodyUrlEncoded, type RequestBody } from "./RequestBody"; 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"; import { Route, RouteLiteral, RouteParam } from "./Route";
export interface Api<Props extends Api.PropsAny> { export interface Api<Props extends Api.PropsAny> {
@@ -109,6 +109,14 @@ export const requestBodyFile = <const MimeType extends string>(mimeType: MimeTyp
readonly query: T["props"]["query"], readonly query: T["props"]["query"],
}> => Api(method, { ...props, request: RequestBodyFile(mimeType) }); }> => Api(method, { ...props, request: RequestBodyFile(mimeType) });
export const responseBodyNone = <const Status extends number>(status: Status) =>
<const T extends Api.Any>({ 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<any>({ ...response, [status]: ResponseBodyNone }) });
export const responseBodyText = <const Status extends number, const Schema extends S.Schema<any, string>>(status: Status, schema: Schema) => export const responseBodyText = <const Status extends number, const Schema extends S.Schema<any, string>>(status: Status, schema: Schema) =>
<const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{ <const T extends Api.Any>({ method, props: { response, ...props } }: T): Api<{
readonly route: T["props"]["route"], readonly route: T["props"]["route"],