Extend and fix session management, /me API
This commit is contained in:
@@ -3,6 +3,10 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start:backend": "bun run packages/backend/src/index.ts",
|
||||||
|
"start:frontend": "pnpm --filter frontend exec vite --open"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { Schema as S } from "@effect/schema";
|
||||||
import { Login } from "common/api";
|
import { SessionId } from "common";
|
||||||
import { AccessLog } from "common/db";
|
import { Login, 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 { DateTime, Duration, Either as E, Effect, Match, Option as O, pipe } from "effect";
|
||||||
import { Api } from "make-api";
|
import { Api } from "make-api";
|
||||||
import { brotliCompress } from "node:zlib";
|
import { brotliCompress } from "node:zlib";
|
||||||
@@ -24,7 +25,8 @@ const match = (api: Api.Api.Any) => Effect.gen(function* () {
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-])\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
|
const COOKIE_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*=\s*([!#$%&'()*+./:<=>?@[\]^_`{|}~a-z0-9-]+)/i;
|
||||||
|
const WEIGHTED_TOKEN_REGEX = /^([!#$%'*+.^_`|~a-z0-9-]+)\s*(?:;\s*q=(0(?:\.[0-9]{0,3})?|1(?:\.0{0,3})?))?$/i;
|
||||||
|
|
||||||
const acceptsBrotli = Effect.gen(function* () {
|
const acceptsBrotli = Effect.gen(function* () {
|
||||||
const req = yield* Request;
|
const req = yield* Request;
|
||||||
@@ -48,7 +50,7 @@ const acceptsBrotli = Effect.gen(function* () {
|
|||||||
.split(/\s*,\s*/)
|
.split(/\s*,\s*/)
|
||||||
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
|
.map((token) => token.match(WEIGHTED_TOKEN_REGEX))
|
||||||
.filter((match) => match !== null)
|
.filter((match) => match !== null)
|
||||||
.map((match) => Object.freeze({
|
.map((match) => ({
|
||||||
coding: match[1],
|
coding: match[1],
|
||||||
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
|
...(match[2] ? { quality: Number.parseFloat(match[2]) } : undefined),
|
||||||
}));
|
}));
|
||||||
@@ -56,6 +58,24 @@ const acceptsBrotli = Effect.gen(function* () {
|
|||||||
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
|
return codings.some(({ coding, quality = 1 }) => coding === "br" && quality > 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const getCookie = (name: string): Effect.Effect<O.Option<string>, never, Request> => Effect.gen(function* () {
|
||||||
|
const req = yield* Request;
|
||||||
|
|
||||||
|
const cookieString = req.headers["cookie"];
|
||||||
|
|
||||||
|
if (!cookieString) {
|
||||||
|
return O.none();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieValue = cookieString
|
||||||
|
.split(/\s*;\s*/)
|
||||||
|
.map((cookie) => cookie.match(COOKIE_REGEX))
|
||||||
|
.filter((match): match is RegExpMatchArray => match !== null && match[1] === name)
|
||||||
|
.map((match) => match[2])[0];
|
||||||
|
|
||||||
|
return O.fromNullable(cookieValue);
|
||||||
|
});
|
||||||
|
|
||||||
const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
||||||
const decoder = S.decodeUnknown(schema);
|
const decoder = S.decodeUnknown(schema);
|
||||||
return pipe(
|
return pipe(
|
||||||
@@ -66,7 +86,7 @@ const requestJson = <A, I, R>(schema: S.Schema<A, I, R>) => {
|
|||||||
),
|
),
|
||||||
Effect.flatMap((req) => req.json),
|
Effect.flatMap((req) => req.json),
|
||||||
Effect.flatMap(decoder),
|
Effect.flatMap(decoder),
|
||||||
Effect.mapError((error) => new RequestError({ status: 400, body: error.message }))
|
Effect.mapError((error) => new RequestError({ status: 400, body: error.message })),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,7 +107,8 @@ export const app = pipe(
|
|||||||
ip: O.getOrNull(req.ip),
|
ip: O.getOrNull(req.ip),
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log(JSON.stringify(accessLog));
|
yield* db.createAccessLog(accessLog);
|
||||||
|
console.log(`${DateTime.toDateUtc(accessLog.timestamp).toISOString()} ${accessLog.requestId} ${accessLog.method} ${accessLog.pathname}?${new URLSearchParams(accessLog.query).toString()} ${accessLog.ip}`);
|
||||||
|
|
||||||
if (yield* match(Login)) {
|
if (yield* match(Login)) {
|
||||||
|
|
||||||
@@ -103,7 +124,8 @@ export const app = pipe(
|
|||||||
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
|
return yield* new RequestError({ status: 401, body: "Invalid username or password" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionId = yield* db.createSession(user.userId);
|
const sessionData = SessionData.make({ userId: user.userId });
|
||||||
|
const sessionId = yield* db.createSession(sessionData);
|
||||||
|
|
||||||
const responseData = Login.props.response[200].schema.make({
|
const responseData = Login.props.response[200].schema.make({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -119,7 +141,91 @@ export const app = pipe(
|
|||||||
const responseArray = new TextEncoder().encode(responseJson);
|
const responseArray = new TextEncoder().encode(responseJson);
|
||||||
|
|
||||||
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
||||||
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; Secure`;
|
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||||
|
|
||||||
|
if (yield* acceptsBrotli) {
|
||||||
|
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
||||||
|
brotliCompress(responseArray, (error, result) => {
|
||||||
|
if (error !== null) {
|
||||||
|
callback(Effect.die(error));
|
||||||
|
} else {
|
||||||
|
callback(Effect.succeed(result));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return new Response(responseBrotli, {
|
||||||
|
headers: {
|
||||||
|
"Content-Encoding": "br",
|
||||||
|
"Content-Length": responseArray.byteLength.toString(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": setCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new Response(responseArray, {
|
||||||
|
headers: {
|
||||||
|
"Content-Length": responseArray.byteLength.toString(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Set-Cookie": setCookie,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (yield* match(Me)) {
|
||||||
|
|
||||||
|
const sessionId = yield* pipe(
|
||||||
|
getCookie("sessionId"),
|
||||||
|
Effect.flatten,
|
||||||
|
Effect.flatMap(S.decodeUnknown(SessionId)),
|
||||||
|
Effect.mapError(() => new RequestError({ status: 401, body: "Session invalid or expired" })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const sessionData = yield* pipe(
|
||||||
|
db.getAndRefreshSessionData(sessionId),
|
||||||
|
Effect.optionFromOptional,
|
||||||
|
Effect.map(O.getOrNull),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (sessionData === null) {
|
||||||
|
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||||
|
return new Response(null, {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": setCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = yield* pipe(
|
||||||
|
db.getUserById(sessionData.userId),
|
||||||
|
Effect.optionFromOptional,
|
||||||
|
Effect.map(O.getOrNull),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
const setCookie = `sessionId=; Expires=${new Date(0).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||||
|
return new Response(null, {
|
||||||
|
status: 401,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": setCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = Me.props.response[200].schema.make({
|
||||||
|
userId: user.userId,
|
||||||
|
username: user.username,
|
||||||
|
admin: user.admin,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseJson = yield* pipe(
|
||||||
|
responseData,
|
||||||
|
S.encode(Me.props.response[200].schema),
|
||||||
|
Effect.map(JSON.stringify),
|
||||||
|
);
|
||||||
|
const responseArray = new TextEncoder().encode(responseJson);
|
||||||
|
|
||||||
|
const expiresAt = DateTime.addDuration(yield* DateTime.now, Duration.days(7));
|
||||||
|
const setCookie = `sessionId=${sessionId}; Expires=${DateTime.toDateUtc(expiresAt).toUTCString()}; HttpOnly; SameSite=None; Secure`;
|
||||||
|
|
||||||
if (yield* acceptsBrotli) {
|
if (yield* acceptsBrotli) {
|
||||||
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
const responseBrotli = yield* Effect.async<Buffer>((callback) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { Schema as S } from "@effect/schema";
|
||||||
import { Database as SqliteDatabase } from "bun:sqlite";
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
||||||
import { SessionId, UserId } from "common";
|
import { SessionId, UserId } from "common";
|
||||||
import { User } from "common/db";
|
import { AccessLog, SessionData, User } from "common/db";
|
||||||
import { Context, Effect, Layer, pipe } from "effect";
|
import { Context, Effect, Layer, pipe } from "effect";
|
||||||
import { NoSuchElementException } from "effect/Cause";
|
import { NoSuchElementException } from "effect/Cause";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
@@ -14,8 +14,14 @@ export function generateSessionId(byteLength: number = 12): SessionId {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseInterface {
|
export interface DatabaseInterface {
|
||||||
|
readonly createAccessLog: (accessLog: AccessLog) => Effect.Effect<void>;
|
||||||
|
|
||||||
readonly getUserByUsername: (username: string) => Effect.Effect<User, NoSuchElementException>;
|
readonly getUserByUsername: (username: string) => Effect.Effect<User, NoSuchElementException>;
|
||||||
readonly createSession: (userId: UserId) => Effect.Effect<SessionId>;
|
readonly getUserById: (userId: UserId) => Effect.Effect<User, NoSuchElementException>;
|
||||||
|
|
||||||
|
readonly createSession: (sessionData: SessionData) => Effect.Effect<SessionId>;
|
||||||
|
readonly getAndRefreshSessionData: (sessionId: SessionId) => Effect.Effect<SessionData, NoSuchElementException>;
|
||||||
|
readonly deleteSession: (sessionId: SessionId) => Effect.Effect<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Database extends Context.Tag("Database")<Database, DatabaseInterface>() { }
|
export class Database extends Context.Tag("Database")<Database, DatabaseInterface>() { }
|
||||||
@@ -85,23 +91,64 @@ export const DatabaseLive = (filePath: string = "db.sqlite3") => Layer.effect(Da
|
|||||||
}
|
}
|
||||||
})),
|
})),
|
||||||
Effect.map((db) => {
|
Effect.map((db) => {
|
||||||
const getUserByUsername = db.prepare("SELECT userId, username, password, admin FROM User WHERE username = ?");
|
const createAccessLog = db.prepare<
|
||||||
const cleanupSessions = db.prepare("DELETE FROM Session WHERE expiresAt >= datetime()");
|
never,
|
||||||
const createSession = db.prepare("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))");
|
[timestamp: string, requestId: string, method: string, pathname: string, query: string, ip: string | null]
|
||||||
const updateSession = db.prepare("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ?");
|
>("INSERT INTO AccessLog (timestamp, requestId, method, pathname, query, ip) VALUES (?, ?, ?, ?, ?, ?)");
|
||||||
|
|
||||||
|
const getUserByUsername = db.prepare<
|
||||||
|
{ userId: string, username: string, password: string, admin: number },
|
||||||
|
[username: string]
|
||||||
|
>("SELECT userId, username, password, admin FROM User WHERE username = ?");
|
||||||
|
const getUserById = db.prepare<
|
||||||
|
{ userId: string, username: string, password: string, admin: number },
|
||||||
|
[userId: UserId]
|
||||||
|
>("SELECT userId, username, password, admin FROM User WHERE userId = ?");
|
||||||
|
|
||||||
|
const cleanupSessions = db.prepare<never, []>("DELETE FROM Session WHERE datetime() >= expiresAt");
|
||||||
|
const createSession = db.prepare<
|
||||||
|
never,
|
||||||
|
[sessionId: SessionId, userId: UserId]
|
||||||
|
>("INSERT INTO Session (sessionId, userId, expiresAt) VALUES (?, ?, datetime('now', '+7 days'))");
|
||||||
|
const updateSession = db.prepare<typeof SessionData.Encoded, [sessionId: SessionId]>("UPDATE Session SET expiresAt = datetime('now', '+7 days') WHERE sessionId = ? RETURNING userId");
|
||||||
|
const deleteSession = db.prepare<never, [sessionId: SessionId]>("DELETE FROM Session WHERE sessionId = ?");
|
||||||
|
|
||||||
return Object.freeze<DatabaseInterface>({
|
return Object.freeze<DatabaseInterface>({
|
||||||
|
createAccessLog: (accessLog) => Effect.sync(() => {
|
||||||
|
const { timestamp, requestId, method, pathname, query, ip } = S.encodeSync(AccessLog)(accessLog);
|
||||||
|
createAccessLog.run(timestamp, requestId, method, pathname, query, ip);
|
||||||
|
}),
|
||||||
|
|
||||||
getUserByUsername: (username) => Effect.suspend(() => {
|
getUserByUsername: (username) => Effect.suspend(() => {
|
||||||
const res = getUserByUsername.get(username);
|
const res = getUserByUsername.get(username);
|
||||||
if (res === null) return Effect.fail(new NoSuchElementException());
|
if (res === null) return Effect.fail(new NoSuchElementException());
|
||||||
return Effect.succeed(S.decodeUnknownSync(User)(res));
|
return Effect.succeed(S.decodeSync(User)(res));
|
||||||
}),
|
}),
|
||||||
|
|
||||||
createSession: (userId) => Effect.sync(() => {
|
getUserById: (userId) => Effect.suspend(() => {
|
||||||
|
const res = getUserById.get(userId);
|
||||||
|
if (res === null) return Effect.fail(new NoSuchElementException());
|
||||||
|
return Effect.succeed(S.decodeSync(User)(res));
|
||||||
|
}),
|
||||||
|
|
||||||
|
createSession: ({ userId }) => Effect.sync(() => {
|
||||||
|
cleanupSessions.run();
|
||||||
const sessionId = generateSessionId();
|
const sessionId = generateSessionId();
|
||||||
createSession.run(sessionId, userId);
|
createSession.run(sessionId, userId);
|
||||||
return sessionId;
|
return sessionId;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
getAndRefreshSessionData: (sessionId) => Effect.suspend(() => {
|
||||||
|
cleanupSessions.run();
|
||||||
|
const res = updateSession.get(sessionId);
|
||||||
|
if (res === null) return Effect.fail(new NoSuchElementException());
|
||||||
|
return Effect.succeed(S.decodeUnknownSync(SessionData)(res));
|
||||||
|
}),
|
||||||
|
|
||||||
|
deleteSession: (sessionId) => Effect.sync(() => {
|
||||||
|
cleanupSessions.run();
|
||||||
|
deleteSession.run(sessionId);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -17,3 +17,13 @@ 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),
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { Schema as S } from "@effect/schema";
|
|||||||
import { AttachmentId, BooleanFromNumber, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
|
import { AttachmentId, BooleanFromNumber, PieceId, RequestId, SessionId, Sha256, UserId } from "common";
|
||||||
import { Brand as B, pipe } from "effect";
|
import { Brand as B, pipe } from "effect";
|
||||||
|
|
||||||
|
export const SessionData = S.Struct({
|
||||||
|
userId: UserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SessionData = typeof SessionData.Type;
|
||||||
|
|
||||||
export const SystemInformation = S.Struct({
|
export const SystemInformation = S.Struct({
|
||||||
createdBy: S.Union(UserId, S.Null),
|
createdBy: S.Union(UserId, S.Null),
|
||||||
createdAt: S.DateTimeUtc,
|
createdAt: S.DateTimeUtc,
|
||||||
@@ -47,11 +53,13 @@ export const Piece = pipe(
|
|||||||
S.extend(SystemInformation),
|
S.extend(SystemInformation),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Session = S.Struct({
|
export const Session = pipe(
|
||||||
sessionId: SessionId,
|
S.Struct({
|
||||||
userId: UserId,
|
sessionId: SessionId,
|
||||||
expiresAt: S.DateTimeUtc,
|
expiresAt: S.DateTimeUtc,
|
||||||
});
|
}),
|
||||||
|
S.extend(SessionData),
|
||||||
|
);
|
||||||
|
|
||||||
export const User = S.Struct({
|
export const User = S.Struct({
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ErrorBoundary, lazy, LocationProvider, Route, Router } from "preact-iso";
|
import { ErrorBoundary, LocationProvider, Route, Router } from "preact-iso";
|
||||||
|
import { Home } from "./routes/Home";
|
||||||
const Home = lazy(() => import("./routes/Home"));
|
import { Login } from "./routes/Login";
|
||||||
const Login = lazy(() => import("./routes/Login"));
|
|
||||||
|
|
||||||
export const App = () => (
|
export const App = () => (
|
||||||
<LocationProvider>
|
<LocationProvider>
|
||||||
|
|||||||
18
packages/frontend/src/routes/Home.css.ts
Normal file
18
packages/frontend/src/routes/Home.css.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { style } from "@vanilla-extract/css";
|
||||||
|
|
||||||
|
export const container = style({
|
||||||
|
width: 800,
|
||||||
|
maxWidth: "100%",
|
||||||
|
|
||||||
|
margin: "0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const loading = style({
|
||||||
|
padding: 8,
|
||||||
|
|
||||||
|
textAlign: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
padding: 8,
|
||||||
|
});
|
||||||
@@ -1 +1,59 @@
|
|||||||
export default () => null;
|
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 { useStore } from "../store";
|
||||||
|
import * as style from "./Home.css";
|
||||||
|
|
||||||
|
export const Home = () => {
|
||||||
|
|
||||||
|
const { route } = useLocation();
|
||||||
|
|
||||||
|
const user = Option.getOrNull(useStore(state => state.user));
|
||||||
|
const setUser = useStore(state => state.setUser);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user !== null) return;
|
||||||
|
|
||||||
|
const effect = Effect.gen(function* () {
|
||||||
|
const res = yield* Effect.promise((signal) => fetch("http://localhost:3000/me", {
|
||||||
|
method: "GET",
|
||||||
|
signal,
|
||||||
|
credentials: "include",
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
route("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseData = yield* pipe(
|
||||||
|
Effect.promise(() => res.json()),
|
||||||
|
Effect.flatMap(S.decodeUnknown(Me.props.response[200].schema)),
|
||||||
|
Effect.orDie,
|
||||||
|
);
|
||||||
|
|
||||||
|
setUser(O.some(responseData));
|
||||||
|
});
|
||||||
|
|
||||||
|
const fiber = Effect.runFork(effect);
|
||||||
|
return () => Effect.runFork(Fiber.interrupt(fiber), { immediate: true });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (user === null) {
|
||||||
|
return (
|
||||||
|
<div class={style.container}>
|
||||||
|
<div class={style.loading}>Ładowanie…</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={style.container}>
|
||||||
|
<div class={style.content}>
|
||||||
|
Użytkownik: {user.username}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Schema as S } from "@effect/schema";
|
import { Schema as S } from "@effect/schema";
|
||||||
import { Login } from "common/api";
|
import { Login as LoginApi } from "common/api";
|
||||||
|
import { Effect, Fiber, Option as O, pipe } from "effect";
|
||||||
|
import { useLocation } from "preact-iso";
|
||||||
import { useId, useMemo, useRef } from "preact/hooks";
|
import { useId, useMemo, useRef } from "preact/hooks";
|
||||||
import { useStore } from "../store";
|
import { useStore } from "../store";
|
||||||
import * as style from "./Login.css";
|
import * as style from "./Login.css";
|
||||||
import { Effect, Fiber, Option as O, pipe } from "effect";
|
|
||||||
|
|
||||||
export default () => {
|
export const Login = () => {
|
||||||
|
|
||||||
|
const { route } = useLocation();
|
||||||
|
|
||||||
const loginUsername = useStore(state => state.loginUsername);
|
const loginUsername = useStore(state => state.loginUsername);
|
||||||
const loginPassword = useStore(state => state.loginPassword);
|
const loginPassword = useStore(state => state.loginPassword);
|
||||||
@@ -20,11 +23,11 @@ export default () => {
|
|||||||
|
|
||||||
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
|
const requestFiber = useRef<Fiber.RuntimeFiber<void> | null>(null);
|
||||||
const requestEffect = useMemo(() => Effect.gen(function* () {
|
const requestEffect = useMemo(() => Effect.gen(function* () {
|
||||||
const requestData = Login.props.request.schema.make({ username: loginUsername, password: loginPassword });
|
const requestData = LoginApi.props.request.schema.make({ username: loginUsername, password: loginPassword });
|
||||||
|
|
||||||
const requestJson = yield* pipe(
|
const requestJson = yield* pipe(
|
||||||
requestData,
|
requestData,
|
||||||
S.encode(Login.props.request.schema),
|
S.encode(LoginApi.props.request.schema),
|
||||||
Effect.map(JSON.stringify),
|
Effect.map(JSON.stringify),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
);
|
);
|
||||||
@@ -34,6 +37,7 @@ export default () => {
|
|||||||
body: requestJson,
|
body: requestJson,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
signal,
|
signal,
|
||||||
|
credentials: "include",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -42,7 +46,7 @@ export default () => {
|
|||||||
|
|
||||||
const responseData = yield* pipe(
|
const responseData = yield* pipe(
|
||||||
Effect.promise(() => res.json()),
|
Effect.promise(() => res.json()),
|
||||||
Effect.flatMap(S.decodeUnknown(Login.props.response[200].schema)),
|
Effect.flatMap(S.decodeUnknown(LoginApi.props.response[200].schema)),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -50,9 +54,7 @@ export default () => {
|
|||||||
setLoginPassword("");
|
setLoginPassword("");
|
||||||
setUser(O.some(responseData));
|
setUser(O.some(responseData));
|
||||||
|
|
||||||
const a = document.createElement("a");
|
route("/");
|
||||||
a.href = "/";
|
|
||||||
a.click();
|
|
||||||
}), [loginUsername, loginPassword]);
|
}), [loginUsername, loginPassword]);
|
||||||
|
|
||||||
const onSubmit = (e: SubmitEvent) => {
|
const onSubmit = (e: SubmitEvent) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user