Switch to internally managed roles, adapt frontend
This commit is contained in:
@@ -35,53 +35,20 @@ const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html")));
|
|||||||
|
|
||||||
const databaseLayer = Database.FromPath(config.DB_PATH);
|
const databaseLayer = Database.FromPath(config.DB_PATH);
|
||||||
|
|
||||||
Bun.serve({
|
const login = (code: string | null, state: string | null) => Effect.gen(function* () {
|
||||||
routes: {
|
|
||||||
...assetRoutes,
|
|
||||||
"/login": {
|
|
||||||
GET: async (req) => {
|
|
||||||
const res = await pipe(
|
|
||||||
Authentication.Authentication,
|
|
||||||
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl({
|
|
||||||
external: new URL(req.url).searchParams.has("external"),
|
|
||||||
sessionId,
|
|
||||||
})),
|
|
||||||
Effect.map((url) => Response.redirect(url)),
|
|
||||||
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
|
||||||
Effect.runPromise,
|
|
||||||
);
|
|
||||||
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
POST: (req) => Effect.gen(function* () {
|
|
||||||
const { sessionId } = yield* Authentication.Authentication;
|
const { sessionId } = yield* Authentication.Authentication;
|
||||||
const db = yield* Database.Database;
|
const db = yield* Database.Database;
|
||||||
|
|
||||||
const data = yield* Body.formData(req);
|
|
||||||
|
|
||||||
const code = data.get("code") as string | null;
|
|
||||||
const state = data.get("state") as string | null;
|
|
||||||
|
|
||||||
const session = yield* db
|
const session = yield* db
|
||||||
.selectFrom("Session")
|
.selectFrom("Session")
|
||||||
.select(["external", "codeVerifier"])
|
.select(["codeVerifier"])
|
||||||
.where("sessionId", "=", sessionId)
|
.where("sessionId", "=", sessionId)
|
||||||
.$call(Database.executeTakeFirst);
|
.$call(Database.executeTakeFirst);
|
||||||
|
|
||||||
const external = pipe(
|
|
||||||
session.external,
|
|
||||||
Option.fromNullable,
|
|
||||||
Option.map((external) => external !== 0),
|
|
||||||
);
|
|
||||||
|
|
||||||
const codeVerifier = Option.fromNullable(session.codeVerifier);
|
const codeVerifier = Option.fromNullable(session.codeVerifier);
|
||||||
|
|
||||||
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
if (code !== null && state !== null && Option.isSome(codeVerifier)) {
|
||||||
const { tokenEndpoint } = external.value
|
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
|
||||||
? Authentication.EXTERNAL_OAUTH_CONFIGURATION
|
|
||||||
: Authentication.INTERNAL_OAUTH_CONFIGURATION;
|
|
||||||
|
|
||||||
const res = yield* fetch(tokenEndpoint, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
@@ -96,28 +63,60 @@ Bun.serve({
|
|||||||
}).toString(),
|
}).toString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const body = yield* Body.json(res);
|
||||||
access_token: accessToken,
|
const { id_token: idToken } = body as { id_token: string };
|
||||||
refresh_token: refreshToken,
|
const idTokenPayload = yield* pipe(
|
||||||
id_token: idToken,
|
idToken,
|
||||||
} = (yield* Body.json(res)) as {
|
Authentication.getJwtTokenPayload(Authentication.IdTokenPayload),
|
||||||
access_token: string,
|
);
|
||||||
refresh_token: string,
|
|
||||||
id_token: string,
|
const { userId } = yield* Authentication.upsertUser(idTokenPayload);
|
||||||
};
|
|
||||||
|
|
||||||
yield* db
|
yield* db
|
||||||
.updateTable("Session")
|
.updateTable("Session")
|
||||||
.set({
|
.set({
|
||||||
accessToken,
|
|
||||||
refreshToken,
|
|
||||||
idToken,
|
|
||||||
codeVerifier: null,
|
codeVerifier: null,
|
||||||
state: null,
|
state: null,
|
||||||
|
userId,
|
||||||
})
|
})
|
||||||
.where("sessionId", "=", sessionId)
|
.where("sessionId", "=", sessionId)
|
||||||
.$call(Database.execute);
|
.$call(Database.execute);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Bun.serve({
|
||||||
|
routes: {
|
||||||
|
...assetRoutes,
|
||||||
|
"/login": {
|
||||||
|
GET: (req) => Effect.gen(function* () {
|
||||||
|
const searchParams = new URL(req.url).searchParams;
|
||||||
|
if (searchParams.has("code") || searchParams.has("state")) {
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const state = searchParams.get("state");
|
||||||
|
|
||||||
|
yield* login(code, state);
|
||||||
|
|
||||||
|
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = yield* pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)),
|
||||||
|
Effect.map((url) => Response.redirect(url)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}).pipe(
|
||||||
|
Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)),
|
||||||
|
Effect.runPromise,
|
||||||
|
),
|
||||||
|
POST: (req) => Effect.gen(function* () {
|
||||||
|
const data = yield* Body.formData(req);
|
||||||
|
|
||||||
|
const code = data.get("code") as string | null;
|
||||||
|
const state = data.get("state") as string | null;
|
||||||
|
|
||||||
|
yield* login(code, state);
|
||||||
|
|
||||||
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303);
|
||||||
}).pipe(
|
}).pipe(
|
||||||
|
|||||||
@@ -1,31 +1,28 @@
|
|||||||
import { config } from "backend/config";
|
import { config } from "backend/config";
|
||||||
import { BunRequest } from "bun";
|
import { BunRequest } from "bun";
|
||||||
import { SessionId, UserId } from "common";
|
import { SessionId, UserId } from "common";
|
||||||
import { fetch, FetchError } from "common/Fetch";
|
import { NotFound, Unauthenticated, User } from "common/the_api";
|
||||||
import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api";
|
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect";
|
||||||
import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect";
|
|
||||||
import { constant } from "effect/Function";
|
import { constant } from "effect/Function";
|
||||||
import { sql } from "kysely";
|
import { sql } from "kysely";
|
||||||
import * as Database from "./Database";
|
import * as Database from "./Database";
|
||||||
|
|
||||||
export interface AuthenticationInterface {
|
export interface AuthenticationInterface {
|
||||||
readonly me: Effect.Effect<Me, Unauthenticated>;
|
readonly me: Effect.Effect<User, Unauthenticated>;
|
||||||
readonly logout: Effect.Effect<void>;
|
readonly logout: Effect.Effect<void>;
|
||||||
readonly sessionId: SessionId;
|
readonly sessionId: SessionId;
|
||||||
readonly getUser: (userId: UserId) => Effect.Effect<Other, FetchError | Unauthenticated | NotFound>;
|
readonly getUser: (userId: UserId) => Effect.Effect<User, NotFound>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
|
export class Authentication extends Context.Tag("Authentication")<Authentication, AuthenticationInterface>() { }
|
||||||
|
|
||||||
export const OAUTH_SCOPE = "email openid profile offline_access";
|
export const OAUTH_SCOPE = "email openid profile";
|
||||||
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
export const REDIRECT_URI = config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/login` : "http://localhost:3000/login";
|
||||||
|
|
||||||
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||||
|
|
||||||
export const SESSION_COOKIE_NAME = "sessionId";
|
export const SESSION_COOKIE_NAME = "sessionId";
|
||||||
|
|
||||||
const ALL_ROLES = HashSet.fromIterable(Object.values(Role));
|
|
||||||
|
|
||||||
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () {
|
||||||
const database = yield* Database.Database;
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
@@ -54,11 +51,9 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
|
|||||||
|
|
||||||
const returning = [
|
const returning = [
|
||||||
"sessionId",
|
"sessionId",
|
||||||
"accessToken",
|
|
||||||
"codeVerifier",
|
"codeVerifier",
|
||||||
"idToken",
|
|
||||||
"refreshToken",
|
|
||||||
"state",
|
"state",
|
||||||
|
"userId",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const session = yield* database
|
const session = yield* database
|
||||||
@@ -74,92 +69,52 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect
|
|||||||
.$call(Database.executeTakeFirstOrDie)
|
.$call(Database.executeTakeFirstOrDie)
|
||||||
));
|
));
|
||||||
|
|
||||||
const { accessToken, idToken, refreshToken } = yield* revalidateTokens({
|
|
||||||
accessToken: Option.fromNullable(session.accessToken),
|
|
||||||
idToken: Option.fromNullable(session.idToken),
|
|
||||||
refreshToken: Option.fromNullable(session.refreshToken),
|
|
||||||
});
|
|
||||||
|
|
||||||
yield* database
|
|
||||||
.updateTable("Session")
|
|
||||||
.set({
|
|
||||||
accessToken: pipe(
|
|
||||||
accessToken,
|
|
||||||
Option.map((at) => at.token),
|
|
||||||
Option.getOrNull,
|
|
||||||
),
|
|
||||||
idToken: pipe(
|
|
||||||
idToken,
|
|
||||||
Option.map((it) => it.token),
|
|
||||||
Option.getOrNull,
|
|
||||||
),
|
|
||||||
refreshToken: Option.getOrNull(refreshToken),
|
|
||||||
})
|
|
||||||
.$call(Database.execute);
|
|
||||||
|
|
||||||
const state = Object.freeze({
|
const state = Object.freeze({
|
||||||
sessionId: session.sessionId,
|
sessionId: session.sessionId,
|
||||||
accessToken,
|
|
||||||
idToken,
|
|
||||||
refreshToken,
|
|
||||||
codeVerifier: Option.fromNullable(session.codeVerifier),
|
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||||
state: Option.fromNullable(session.state),
|
state: Option.fromNullable(session.state),
|
||||||
|
userId: Option.fromNullable(session.userId),
|
||||||
});
|
});
|
||||||
|
|
||||||
return Object.freeze<AuthenticationInterface>({
|
return Object.freeze<AuthenticationInterface>({
|
||||||
me: pipe(
|
me: pipe(
|
||||||
state.idToken,
|
state.userId,
|
||||||
Option.map(({ payload }) => {
|
Effect.flatMap(getOrAddUser),
|
||||||
const userData: Me = Object.freeze<Me>({
|
Effect.catchTag("NoSuchElementException", () => Effect.fail(Unauthenticated.make())),
|
||||||
userId: payload.sub,
|
Effect.provideService(Database.Database, database),
|
||||||
displayName: payload.display_name,
|
|
||||||
roles: ALL_ROLES,
|
|
||||||
});
|
|
||||||
return userData;
|
|
||||||
}),
|
|
||||||
Option.match({
|
|
||||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
|
||||||
onSome: Effect.succeed,
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
logout: database
|
logout: database
|
||||||
.updateTable("Session")
|
.updateTable("Session")
|
||||||
.set({
|
.set({
|
||||||
accessToken: null,
|
|
||||||
codeVerifier: null,
|
|
||||||
expiresAt: sql`datetime('now', '+7 days')`,
|
expiresAt: sql`datetime('now', '+7 days')`,
|
||||||
idToken: null,
|
userId: null,
|
||||||
refreshToken: null,
|
|
||||||
state: null,
|
|
||||||
})
|
})
|
||||||
.where("sessionId", "=", state.sessionId)
|
.where("sessionId", "=", state.sessionId)
|
||||||
.$call(Database.execute),
|
.$call(Database.execute),
|
||||||
sessionId: state.sessionId,
|
sessionId: state.sessionId,
|
||||||
getUser: (userId) => pipe(
|
getUser: (userId) => pipe(
|
||||||
getDisplayName(userId),
|
getUser(userId),
|
||||||
Effect.map((displayName) => Object.freeze<Other>({ userId, displayName })),
|
Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())),
|
||||||
|
Effect.provideService(Database.Database, database),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const Test = (me: Option.Option<Me>, other: HashMap.HashMap<UserId, Other>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
|
export const Test = (me: Option.Option<User>, users: HashMap.HashMap<UserId, User>) => Layer.sync(Authentication, constant(Object.freeze<AuthenticationInterface>({
|
||||||
me: Option.match(me, {
|
me: Option.match(me, {
|
||||||
onNone: () => Effect.fail(Unauthenticated.make()),
|
onNone: () => Effect.fail(Unauthenticated.make()),
|
||||||
onSome: (me) => Effect.succeed(me),
|
onSome: (me) => Effect.succeed(me),
|
||||||
}),
|
}),
|
||||||
logout: Effect.void,
|
logout: Effect.void,
|
||||||
sessionId: generateSessionId(),
|
sessionId: generateSessionId(),
|
||||||
getUser: Option.match(me, {
|
getUser: (userId) => pipe(
|
||||||
onNone: () => constant(Effect.fail(Unauthenticated.make())),
|
users,
|
||||||
onSome: () => (userId) => pipe(
|
|
||||||
other,
|
|
||||||
HashMap.get(userId),
|
HashMap.get(userId),
|
||||||
Option.match({
|
Option.match({
|
||||||
onNone: () => Effect.fail(NotFound.make()),
|
onNone: () => Effect.fail(NotFound.make()),
|
||||||
onSome: Effect.succeed,
|
onSome: Effect.succeed,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}),
|
|
||||||
})));
|
})));
|
||||||
|
|
||||||
export const AccessTokenPayload = Schema.Struct({
|
export const AccessTokenPayload = Schema.Struct({
|
||||||
@@ -167,8 +122,8 @@ export const AccessTokenPayload = Schema.Struct({
|
|||||||
Schema.String,
|
Schema.String,
|
||||||
Schema.HashSet,
|
Schema.HashSet,
|
||||||
),
|
),
|
||||||
exp: Schema.DateTimeUtc,
|
exp: Schema.Number,
|
||||||
iat: Schema.DateTimeUtc,
|
iat: Schema.Number,
|
||||||
iss: Schema.String,
|
iss: Schema.String,
|
||||||
sub: UserId,
|
sub: UserId,
|
||||||
});
|
});
|
||||||
@@ -178,8 +133,8 @@ export const IdTokenPayload = Schema.Struct({
|
|||||||
Schema.String,
|
Schema.String,
|
||||||
Schema.HashSet,
|
Schema.HashSet,
|
||||||
),
|
),
|
||||||
exp: Schema.DateTimeUtc,
|
exp: Schema.Number,
|
||||||
iat: Schema.DateTimeUtc,
|
iat: Schema.Number,
|
||||||
iss: Schema.String,
|
iss: Schema.String,
|
||||||
sub: UserId,
|
sub: UserId,
|
||||||
|
|
||||||
@@ -228,7 +183,83 @@ function generateRandomState(byteLength: number = 32): string {
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
const getUser = (userId: UserId) => Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
|
const user = yield* database
|
||||||
|
.selectFrom("User")
|
||||||
|
.selectAll()
|
||||||
|
.where("userId", "=", userId)
|
||||||
|
.$call(Database.executeTakeFirst);
|
||||||
|
|
||||||
|
const roles = yield* database
|
||||||
|
.selectFrom("UserRole")
|
||||||
|
.select("role")
|
||||||
|
.where("userId", "=", userId)
|
||||||
|
.$call(Database.execute);
|
||||||
|
|
||||||
|
return Object.freeze<User>({
|
||||||
|
userId,
|
||||||
|
displayName: Option.fromNullable(user.displayName),
|
||||||
|
avatarUrl: Option.fromNullable(user.avatarUrl),
|
||||||
|
roles: HashSet.fromIterable(roles.map(({ role }) => role)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const getOrAddUser = (userId: UserId) => pipe(
|
||||||
|
getUser(userId),
|
||||||
|
Effect.catchTag("NoSuchElementException", () => pipe(
|
||||||
|
Database.Database,
|
||||||
|
Effect.flatMap((database) => database
|
||||||
|
.insertInto("User")
|
||||||
|
.values({ userId })
|
||||||
|
.$call(Database.execute)
|
||||||
|
),
|
||||||
|
Effect.as(Object.freeze<User>({
|
||||||
|
userId,
|
||||||
|
displayName: Option.none(),
|
||||||
|
avatarUrl: Option.none(),
|
||||||
|
roles: HashSet.empty(),
|
||||||
|
})),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(function* () {
|
||||||
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
|
const userId = idTokenPayload.sub;
|
||||||
|
const displayName = idTokenPayload.display_name;
|
||||||
|
const avatarUrl = idTokenPayload.picture;
|
||||||
|
|
||||||
|
const user = yield* database
|
||||||
|
.insertInto("User")
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
displayName,
|
||||||
|
avatarUrl,
|
||||||
|
})
|
||||||
|
.returningAll()
|
||||||
|
.onConflict((oc) => oc
|
||||||
|
.column("userId")
|
||||||
|
.doUpdateSet({ displayName, avatarUrl })
|
||||||
|
)
|
||||||
|
.$call(Database.executeTakeFirstOrDie);
|
||||||
|
|
||||||
|
const roles = yield* database
|
||||||
|
.selectFrom("UserRole")
|
||||||
|
.select("role")
|
||||||
|
.where("userId", "=", userId)
|
||||||
|
.$call(Database.execute);
|
||||||
|
|
||||||
|
return Object.freeze<User>({
|
||||||
|
userId,
|
||||||
|
displayName: Option.fromNullable(user.displayName),
|
||||||
|
avatarUrl: Option.fromNullable(user.avatarUrl),
|
||||||
|
roles: HashSet.fromIterable(roles.map(({ role }) => role)),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
||||||
const decoder = Schema.decodeUnknown(schema);
|
const decoder = Schema.decodeUnknown(schema);
|
||||||
return (token: string) => {
|
return (token: string) => {
|
||||||
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8"));
|
||||||
@@ -239,23 +270,6 @@ const getJwtTokenPayload = <A, I, R>(schema: Schema.Schema<A, I, R>) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDisplayName = (userId: UserId) => Effect.gen(function* () {
|
|
||||||
const url = new URL(`/api/users/${userId}`, config.POCKET_ID_API_ORIGIN);
|
|
||||||
const res = yield* fetch(url, {
|
|
||||||
headers: {
|
|
||||||
"X-API-KEY": config.POCKET_ID_API_KEY,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
return yield* Effect.fail(NotFound.make());
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = yield* Effect.promise(() => res.json());
|
|
||||||
const displayName: string = json.displayName;
|
|
||||||
return displayName;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
|
export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () {
|
||||||
const database = yield* Database.Database;
|
const database = yield* Database.Database;
|
||||||
|
|
||||||
@@ -267,9 +281,6 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio
|
|||||||
.set({
|
.set({
|
||||||
codeVerifier,
|
codeVerifier,
|
||||||
state,
|
state,
|
||||||
accessToken: null,
|
|
||||||
idToken: null,
|
|
||||||
refreshToken: null,
|
|
||||||
})
|
})
|
||||||
.where("sessionId", "=", sessionId)
|
.where("sessionId", "=", sessionId)
|
||||||
.$call(Database.execute);
|
.$call(Database.execute);
|
||||||
@@ -286,112 +297,3 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio
|
|||||||
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
});
|
});
|
||||||
|
|
||||||
namespace revaildateTokens {
|
|
||||||
export interface Args {
|
|
||||||
readonly accessToken: Option.Option<string>;
|
|
||||||
readonly idToken: Option.Option<string>;
|
|
||||||
readonly refreshToken: Option.Option<string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Result {
|
|
||||||
readonly accessToken: Option.Option<{
|
|
||||||
readonly token: string,
|
|
||||||
readonly payload: AccessTokenPayload,
|
|
||||||
}>;
|
|
||||||
readonly idToken: Option.Option<{
|
|
||||||
readonly token: string,
|
|
||||||
readonly payload: IdTokenPayload,
|
|
||||||
}>;
|
|
||||||
readonly refreshToken: Option.Option<string>;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const revalidateTokens = ({ accessToken, idToken, refreshToken }: revaildateTokens.Args) => Effect.gen(function* () {
|
|
||||||
|
|
||||||
const accessTokenPayload = yield* pipe(
|
|
||||||
accessToken,
|
|
||||||
Effect.transposeMapOption(getJwtTokenPayload(AccessTokenPayload)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const idTokenPayload = yield* pipe(
|
|
||||||
idToken,
|
|
||||||
Effect.transposeMapOption(getJwtTokenPayload(IdTokenPayload)),
|
|
||||||
);
|
|
||||||
|
|
||||||
const expirationThreshold = DateTime.addDuration(
|
|
||||||
DateTime.unsafeNow(),
|
|
||||||
EXPIRATION_BUFFER,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Token expired or missing
|
|
||||||
if (Option.match(accessTokenPayload, {
|
|
||||||
onNone: constant(false),
|
|
||||||
onSome: (atp) => DateTime.greaterThan(expirationThreshold, atp.exp),
|
|
||||||
}) || Option.match(idTokenPayload, {
|
|
||||||
onNone: constant(false),
|
|
||||||
onSome: (itp) => DateTime.greaterThan(expirationThreshold, itp.exp),
|
|
||||||
})) {
|
|
||||||
|
|
||||||
accessToken = Option.none();
|
|
||||||
idToken = Option.none();
|
|
||||||
|
|
||||||
// try refreshing
|
|
||||||
if (Option.isSome(refreshToken)) {
|
|
||||||
const refreshTokenValue = refreshToken.value;
|
|
||||||
|
|
||||||
const res = yield* fetch(config.OAUTH_TOKEN_ENDPOINT, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
"client_id": config.CLIENT_ID,
|
|
||||||
"grant_type": "refresh_token",
|
|
||||||
"refresh_token": refreshTokenValue,
|
|
||||||
"client_secret": Redacted.value(config.CLIENT_SECRET),
|
|
||||||
}).toString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const json: {
|
|
||||||
access_token: string,
|
|
||||||
refresh_token: string,
|
|
||||||
id_token: string,
|
|
||||||
} = yield* Effect.promise(() => res.json());
|
|
||||||
|
|
||||||
accessToken = Option.some(json.access_token);
|
|
||||||
idToken = Option.some(json.id_token);
|
|
||||||
refreshToken = Option.some(json.refresh_token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const it = yield* pipe(
|
|
||||||
idToken,
|
|
||||||
Option.map((it) => pipe(
|
|
||||||
Effect.Do,
|
|
||||||
Effect.let("token", () => it),
|
|
||||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(IdTokenPayload)(token)),
|
|
||||||
)),
|
|
||||||
Effect.transposeOption,
|
|
||||||
Effect.map(Option.map((it) => Object.freeze(it))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const at = yield* pipe(
|
|
||||||
accessToken,
|
|
||||||
Option.map((at) => pipe(
|
|
||||||
Effect.Do,
|
|
||||||
Effect.let("token", () => at),
|
|
||||||
Effect.bind("payload", ({ token }) => getJwtTokenPayload(AccessTokenPayload)(token)),
|
|
||||||
)),
|
|
||||||
Effect.transposeOption,
|
|
||||||
Effect.map(Option.map((at) => Object.freeze(at))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
|
||||||
accessToken: at,
|
|
||||||
idToken: it,
|
|
||||||
refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res;
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
import { Database as BunSqliteDatabase } from "bun:sqlite";
|
||||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256, UserId } from "common";
|
import { AttachmentId, PieceId, RepertoireId, SessionId, Sha256, UserId } from "common";
|
||||||
|
import { Role } from "common/the_api";
|
||||||
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
import { Cause, Context, Effect, Either, HashSet, Layer, pipe, Runtime } from "effect";
|
||||||
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
import { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely";
|
||||||
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
import { BunSqliteDialect } from "kysely-bun-sqlite";
|
||||||
@@ -14,6 +15,8 @@ export interface DatabaseSchema {
|
|||||||
Repertoire: RepertoireTable;
|
Repertoire: RepertoireTable;
|
||||||
RepertoireEntry: RepertoireEntryTable;
|
RepertoireEntry: RepertoireEntryTable;
|
||||||
Session: SessionTable;
|
Session: SessionTable;
|
||||||
|
User: UserTable;
|
||||||
|
UserRole: UserRoleTable;
|
||||||
sqlite_schema: SqliteSchemaTable;
|
sqlite_schema: SqliteSchemaTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,9 +76,7 @@ export interface RepertoireEntryTable extends RepertoireEntryData {
|
|||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
state: string | null;
|
state: string | null;
|
||||||
codeVerifier: string | null;
|
codeVerifier: string | null;
|
||||||
accessToken: string | null;
|
userId: UserId | null;
|
||||||
idToken: string | null;
|
|
||||||
refreshToken: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionTable extends SessionData {
|
export interface SessionTable extends SessionData {
|
||||||
@@ -83,6 +84,17 @@ export interface SessionTable extends SessionData {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserTable {
|
||||||
|
userId: ColumnType<UserId, UserId, never>;
|
||||||
|
displayName: string | null;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserRoleTable {
|
||||||
|
userId: ColumnType<UserId, UserId, never>;
|
||||||
|
role: ColumnType<Role, Role, never>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SqliteSchemaTable {
|
export interface SqliteSchemaTable {
|
||||||
type: ColumnType<string, never, never>;
|
type: ColumnType<string, never, never>;
|
||||||
name: ColumnType<string, never, never>;
|
name: ColumnType<string, never, never>;
|
||||||
@@ -98,6 +110,8 @@ export type Piece = Selectable<PieceTable>;
|
|||||||
export type Repertoire = Selectable<RepertoireTable>;
|
export type Repertoire = Selectable<RepertoireTable>;
|
||||||
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
export type RepertoireEntry = Selectable<RepertoireEntryTable>;
|
||||||
export type Session = Selectable<SessionTable>;
|
export type Session = Selectable<SessionTable>;
|
||||||
|
export type User = Selectable<UserTable>;
|
||||||
|
export type UserRole = Selectable<UserRoleTable>;
|
||||||
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
export type SqliteSchema = Selectable<SqliteSchemaTable>;
|
||||||
|
|
||||||
// --- MARK: EFFECT LAYERS -----------------------------------------------------
|
// --- MARK: EFFECT LAYERS -----------------------------------------------------
|
||||||
@@ -196,15 +210,6 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
|
|||||||
.addColumn("data", "blob", (c) => c.notNull())
|
.addColumn("data", "blob", (c) => c.notNull())
|
||||||
.$call(execute);
|
.$call(execute);
|
||||||
|
|
||||||
yield* db.schema
|
|
||||||
.createTable("User")
|
|
||||||
.ifNotExists()
|
|
||||||
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
|
||||||
.addColumn("username", "text", (c) => c.notNull().unique())
|
|
||||||
.addColumn("password", "text", (c) => c.notNull())
|
|
||||||
.addColumn("admin", "boolean", (c) => c.notNull())
|
|
||||||
.$call(execute);
|
|
||||||
|
|
||||||
yield* db.schema
|
yield* db.schema
|
||||||
.createTable("Piece")
|
.createTable("Piece")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
@@ -253,12 +258,18 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
|
|||||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("state", "text")
|
.addColumn("state", "text")
|
||||||
.addColumn("codeVerifier", "text")
|
.addColumn("codeVerifier", "text")
|
||||||
.addColumn("accessToken", "text")
|
.addColumn("userId", "text")
|
||||||
.addColumn("idToken", "text")
|
|
||||||
.addColumn("refreshToken", "text")
|
|
||||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||||
.$call(execute);
|
.$call(execute);
|
||||||
|
|
||||||
|
yield* db.schema
|
||||||
|
.createTable("User")
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn("userId", "text", (c) => c.notNull().primaryKey())
|
||||||
|
.addColumn("displayName", "text")
|
||||||
|
.addColumn("avatarUrl", "text")
|
||||||
|
.$call(execute);
|
||||||
|
|
||||||
yield* db.schema
|
yield* db.schema
|
||||||
.createTable("Attachment")
|
.createTable("Attachment")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
@@ -277,6 +288,14 @@ const initDatabase = (filename: string) => Effect.gen(function* () {
|
|||||||
.columns(["pieceId", "filename"])
|
.columns(["pieceId", "filename"])
|
||||||
.$call(execute);
|
.$call(execute);
|
||||||
|
|
||||||
|
yield* db.schema
|
||||||
|
.createTable("UserRole")
|
||||||
|
.ifNotExists()
|
||||||
|
.addColumn("userId", "text", (c) => c.notNull().references("User.userId").onDelete("cascade").onUpdate("cascade"))
|
||||||
|
.addColumn("role", "text", (c) => c.notNull())
|
||||||
|
.addPrimaryKeyConstraint("pk_UserRole", ["userId", "role"])
|
||||||
|
.$call(execute);
|
||||||
|
|
||||||
yield* db.schema
|
yield* db.schema
|
||||||
.createTable("Option")
|
.createTable("Option")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ import * as Database from "./services/Database";
|
|||||||
const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer);
|
const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer);
|
||||||
const WRITE_ACCESS = HashSet.make(Role.Editor);
|
const WRITE_ACCESS = HashSet.make(Role.Editor);
|
||||||
|
|
||||||
|
const requireAuthenticated = pipe(
|
||||||
|
Authentication.Authentication,
|
||||||
|
Effect.flatMap(({ me }) => me),
|
||||||
|
);
|
||||||
|
|
||||||
const requireReadAccess = pipe(
|
const requireReadAccess = pipe(
|
||||||
Authentication.Authentication,
|
Authentication.Authentication,
|
||||||
Effect.flatMap(({ me }) => me),
|
Effect.flatMap(({ me }) => me),
|
||||||
@@ -39,10 +44,14 @@ export const handle = implement(api, {
|
|||||||
Authentication.Authentication,
|
Authentication.Authentication,
|
||||||
Effect.flatMap(({ logout }) => logout),
|
Effect.flatMap(({ logout }) => logout),
|
||||||
),
|
),
|
||||||
getUser: (userId) => pipe(
|
getUser: (userId) => Effect.gen(function* () {
|
||||||
Authentication.Authentication,
|
yield* requireAuthenticated;
|
||||||
Effect.flatMap(({ getUser }) => getUser(userId)),
|
|
||||||
),
|
const { getUser } = yield* Authentication.Authentication;
|
||||||
|
const user = yield* getUser(userId);
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}),
|
||||||
|
|
||||||
// --- Piece CRUD ---
|
// --- Piece CRUD ---
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { pipe, Schema } from "effect";
|
|
||||||
import * as Api from "./Api";
|
|
||||||
import { AttachmentId, PieceId, RepertoireId, Sha256, UserId } from "common";
|
import { AttachmentId, PieceId, RepertoireId, Sha256, UserId } from "common";
|
||||||
|
import { pipe, Schema } from "effect";
|
||||||
import { constant } from "effect/Function";
|
import { constant } from "effect/Function";
|
||||||
|
import * as Api from "./Api";
|
||||||
|
|
||||||
// --- MARK: COMMON TYPES ------------------------------------------------------
|
// --- MARK: COMMON TYPES ------------------------------------------------------
|
||||||
|
|
||||||
@@ -27,17 +27,6 @@ export type Pagination = typeof Pagination.Type;
|
|||||||
|
|
||||||
// --- MARK: RESPONSE TYPES ----------------------------------------------------
|
// --- MARK: RESPONSE TYPES ----------------------------------------------------
|
||||||
|
|
||||||
export const Me = Schema.Struct({
|
|
||||||
userId: UserId,
|
|
||||||
displayName: Schema.NonEmptyString,
|
|
||||||
roles: Schema.HashSet(Schema.Enums(Role)),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Other = Schema.Struct({
|
|
||||||
userId: UserId,
|
|
||||||
displayName: Schema.NonEmptyString,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Attachment = Schema.Struct({
|
export const Attachment = Schema.Struct({
|
||||||
attachmentId: AttachmentId,
|
attachmentId: AttachmentId,
|
||||||
pieceId: PieceId,
|
pieceId: PieceId,
|
||||||
@@ -82,14 +71,20 @@ export const Repertoire_Query = Schema.Struct({
|
|||||||
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
}).pipe(Schema.extend(Pagination));
|
}).pipe(Schema.extend(Pagination));
|
||||||
|
|
||||||
export type Me = typeof Me.Type;
|
export const User = Schema.Struct({
|
||||||
export type Other = typeof Other.Type;
|
userId: UserId,
|
||||||
|
displayName: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
avatarUrl: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })),
|
||||||
|
roles: Schema.HashSet(Schema.Enums(Role)),
|
||||||
|
});
|
||||||
|
|
||||||
export type Attachment = typeof Attachment.Type;
|
export type Attachment = typeof Attachment.Type;
|
||||||
export type Piece = typeof Piece.Type;
|
export type Piece = typeof Piece.Type;
|
||||||
export type Piece_Create = typeof Piece_Create.Type;
|
export type Piece_Create = typeof Piece_Create.Type;
|
||||||
export type Piece_Query = typeof Piece_Query.Type;
|
export type Piece_Query = typeof Piece_Query.Type;
|
||||||
export type Repertoire = typeof Repertoire.Type;
|
export type Repertoire = typeof Repertoire.Type;
|
||||||
export type Repertoire_Query = typeof Repertoire_Query.Type;
|
export type Repertoire_Query = typeof Repertoire_Query.Type;
|
||||||
|
export type User = typeof User.Type;
|
||||||
|
|
||||||
// --- MARK: ERROR TYPES -------------------------------------------------------
|
// --- MARK: ERROR TYPES -------------------------------------------------------
|
||||||
|
|
||||||
@@ -105,11 +100,11 @@ export default Api.bundle({
|
|||||||
|
|
||||||
// --- Authentication ---
|
// --- Authentication ---
|
||||||
|
|
||||||
me: Api.make(Schema.Void, Me, Unauthenticated),
|
me: Api.make(Schema.Void, User, Unauthenticated),
|
||||||
logout: Api.make(Schema.Void, Schema.Void),
|
logout: Api.make(Schema.Void, Schema.Void),
|
||||||
getUser: Api.make(
|
getUser: Api.make(
|
||||||
UserId,
|
UserId,
|
||||||
Other,
|
User,
|
||||||
Schema.Union(Unauthenticated, NotFound),
|
Schema.Union(Unauthenticated, NotFound),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { type Me } from "common/the_api";
|
import { type User } from "common/the_api";
|
||||||
import { identity } from "effect";
|
import { identity } from "effect";
|
||||||
import { useLayoutEffect, useState } from "react";
|
import { useLayoutEffect, useState } from "react";
|
||||||
|
|
||||||
@@ -10,14 +10,14 @@ export const mapProp = <const K extends string, T>(prop: K, action: Update<T>) =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export interface Store {
|
export interface Store {
|
||||||
readonly user: Me | null;
|
readonly user: User | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: Store = Object.freeze<Store>({
|
let store: Store = Object.freeze<Store>({
|
||||||
user: null,
|
user: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export function setUser(action: Update<Me | null>) {
|
export function setUser(action: Update<User | null>) {
|
||||||
set(mapProp("user", action));
|
set(mapProp("user", action));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg id="uuid-f8d4d392-7c12-4bd9-baff-66fbf7814b91" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 18 18">
|
|
||||||
<path d="m3.802,14.032c.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128v4.073c-.286,0-.574-.078-.824-.234l-4.374-2.734Z" fill="#225086"/>
|
|
||||||
<path d="m7.853,1.507L.353,9.967c-.579.654-.428,1.642.323,2.111,0,0,2.776,1.735,3.126,1.954.388.242,1.033.511,1.715.511.621,0,1.198-.18,1.676-.487,0,0,.001,0,.002-.001l1.805-1.128-4.364-2.728,4.365-4.924V1s0,0,0,0c-.424,0-.847.169-1.147.507Z" fill="#6df"/>
|
|
||||||
<polygon points="4.636 10.199 4.688 10.231 9 12.927 9.001 12.927 9.001 12.927 9.001 5.276 9 5.275 4.636 10.199" fill="#cbf8ff"/>
|
|
||||||
<path d="m17.324,12.078c.751-.469.902-1.457.323-2.111l-4.921-5.551c-.397-.185-.842-.291-1.313-.291-.925,0-1.752.399-2.302,1.026l-.109.123h0s4.364,4.924,4.364,4.924h0s0,0,0,0l-4.365,2.728v4.073c.287,0,.573-.078.823-.234l7.5-4.688Z" fill="#074793"/>
|
|
||||||
<path d="m9.001,1v4.275s.109-.123.109-.123c.55-.627,1.377-1.026,2.302-1.026.472,0,.916.107,1.313.291l-2.579-2.909c-.299-.338-.723-.507-1.146-.507Z" fill="#0294e4"/>
|
|
||||||
<polygon points="13.365 10.199 13.365 10.199 13.365 10.199 9.001 5.276 9.001 12.926 13.365 10.199" fill="#96bcc2"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,13 +1,8 @@
|
|||||||
import { API_URL_PREFIX } from "@/client";
|
import { API_URL_PREFIX } from "@/client";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import microsoftEntraId from "@/icons/microsoft-entra-id.svg";
|
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
|
|
||||||
const internalUrl = `${API_URL_PREFIX}/login`;
|
|
||||||
const externalUrl = `${API_URL_PREFIX}/login?external`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -16,22 +11,8 @@ export function Login() {
|
|||||||
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
|
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-col gap-2 content-stretch max-w-sm">
|
<CardContent className="flex flex-col gap-2 content-stretch max-w-sm">
|
||||||
<div
|
<a className={buttonVariants()} href={`${API_URL_PREFIX}/login`}>
|
||||||
className="text-sm text-stone-500 dark:text-stone-400"
|
Zaloguj się
|
||||||
>
|
|
||||||
Użyj emaila i hasła, konta Microsoft lub konta Google.
|
|
||||||
</div>
|
|
||||||
<a className={buttonVariants()} href={externalUrl}>
|
|
||||||
Konto zewnętrzne
|
|
||||||
</a>
|
|
||||||
<div
|
|
||||||
className="text-sm text-stone-500 dark:text-stone-400 mt-4"
|
|
||||||
>
|
|
||||||
Użyj konta firmowego.
|
|
||||||
</div>
|
|
||||||
<a className={buttonVariants()} href={internalUrl}>
|
|
||||||
<img src={microsoftEntraId} />
|
|
||||||
<div>Konto firmowe</div>
|
|
||||||
</a>
|
</a>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -326,7 +326,8 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = yield* Body.bytes(file);
|
// NOTE Apparently, file.bytes is not a thing in this context
|
||||||
|
const data = new Uint8Array(yield* Body.arrayBuffer(file));
|
||||||
|
|
||||||
const exit = yield* Effect.exit(client.createAttachment({
|
const exit = yield* Effect.exit(client.createAttachment({
|
||||||
pieceId: props.pieceId,
|
pieceId: props.pieceId,
|
||||||
@@ -340,7 +341,7 @@ function AttachmentForm(props: AttachmentForm.Props) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attachment = yield* denormalizeSystemInformation(exit.exitValue);
|
const attachment = yield* denormalizeSystemInformation(exit.value);
|
||||||
|
|
||||||
props.setAttachments((prev) => {
|
props.setAttachments((prev) => {
|
||||||
const next = [...prev, attachment];
|
const next = [...prev, attachment];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { client } from "@/client";
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { setUser, useStore } from "@/hooks/useStore";
|
import { setUser, useStore } from "@/hooks/useStore";
|
||||||
import { Effect, pipe } from "effect";
|
import { Effect, Option, pipe } from "effect";
|
||||||
import { LogOut, Settings, User } from "lucide-react";
|
import { LogOut, Settings, User } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Link, Outlet, useNavigate } from "react-router-dom";
|
import { Link, Outlet, useNavigate } from "react-router-dom";
|
||||||
@@ -53,7 +53,7 @@ export function Root() {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button type="button" variant="outline">
|
<Button type="button" variant="outline">
|
||||||
<User />{user.displayName}
|
<User />{Option.getOrElse(user.displayName, () => user.userId)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ export function created({ createdAt, createdBy }: DenormalizedSystemInformation)
|
|||||||
|
|
||||||
if (Option.isSome(createdBy)) {
|
if (Option.isSome(createdBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
if (createdBy.value !== null) {
|
if (Option.isSome(createdBy.value.displayName)) {
|
||||||
nodes.push(`przez ${createdBy.value.displayName}`);
|
nodes.push(`przez ${createdBy.value.displayName.value}`);
|
||||||
} else {
|
} else {
|
||||||
nodes.push("przez nieznanego użytkownika");
|
nodes.push("przez nieznanego użytkownika");
|
||||||
}
|
}
|
||||||
@@ -53,8 +53,8 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati
|
|||||||
if (Option.isNone(modifiedBy)) {
|
if (Option.isNone(modifiedBy)) {
|
||||||
return "\u2014";
|
return "\u2014";
|
||||||
} else {
|
} else {
|
||||||
if (modifiedBy.value !== null) {
|
if (Option.isSome(modifiedBy.value.displayName)) {
|
||||||
return `przez ${modifiedBy.value.displayName}`;
|
return `przez ${modifiedBy.value.displayName.value}`;
|
||||||
} else {
|
} else {
|
||||||
return "przez nieznanego użytkownika";
|
return "przez nieznanego użytkownika";
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,8 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati
|
|||||||
|
|
||||||
if (Option.isSome(modifiedBy)) {
|
if (Option.isSome(modifiedBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
if (modifiedBy.value !== null) {
|
if (Option.isSome(modifiedBy.value.displayName)) {
|
||||||
nodes.push(`przez ${modifiedBy.value.displayName}`);
|
nodes.push(`przez ${modifiedBy.value.displayName.value}`);
|
||||||
} else {
|
} else {
|
||||||
nodes.push("przez nieznanego użytkownika");
|
nodes.push("przez nieznanego użytkownika");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user