From 90729736ca14e7354a7320a185aef1fa39c016ce Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Tue, 7 Oct 2025 02:04:35 +0200 Subject: [PATCH] Switch to internally managed roles, adapt frontend --- packages/backend/src/app.ts | 135 ++++---- .../backend/src/services/Authentication.ts | 308 ++++++------------ packages/backend/src/services/Database.ts | 51 ++- packages/backend/src/the_api.ts | 17 +- packages/common/src/the_api.ts | 29 +- packages/frontend/src/hooks/useStore.ts | 6 +- .../frontend/src/icons/microsoft-entra-id.svg | 9 - packages/frontend/src/routes/Login.tsx | 23 +- packages/frontend/src/routes/Piece.tsx | 5 +- packages/frontend/src/routes/Root.tsx | 4 +- packages/frontend/src/snippets.tsx | 12 +- 11 files changed, 248 insertions(+), 351 deletions(-) delete mode 100644 packages/frontend/src/icons/microsoft-entra-id.svg diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 38fe96a..37421a0 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -35,89 +35,88 @@ const homepage = new Response(Bun.file(path.join(FRONTEND_ROOT, "index.html"))); const databaseLayer = Database.FromPath(config.DB_PATH); +const login = (code: string | null, state: string | null) => Effect.gen(function* () { + const { sessionId } = yield* Authentication.Authentication; + const db = yield* Database.Database; + + const session = yield* db + .selectFrom("Session") + .select(["codeVerifier"]) + .where("sessionId", "=", sessionId) + .$call(Database.executeTakeFirst); + + const codeVerifier = Option.fromNullable(session.codeVerifier); + + if (code !== null && state !== null && Option.isSome(codeVerifier)) { + 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, + "code": code, + "redirect_uri": Authentication.REDIRECT_URI, + "grant_type": "authorization_code", + "code_verifier": codeVerifier.value, + "client_secret": Redacted.value(config.CLIENT_SECRET), + }).toString(), + }); + + const body = yield* Body.json(res); + const { id_token: idToken } = body as { id_token: string }; + const idTokenPayload = yield* pipe( + idToken, + Authentication.getJwtTokenPayload(Authentication.IdTokenPayload), + ); + + const { userId } = yield* Authentication.upsertUser(idTokenPayload); + + yield* db + .updateTable("Session") + .set({ + codeVerifier: null, + state: null, + userId, + }) + .where("sessionId", "=", sessionId) + .$call(Database.execute); + } +}); + Bun.serve({ routes: { ...assetRoutes, "/login": { - GET: async (req) => { - const res = await pipe( + 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({ - external: new URL(req.url).searchParams.has("external"), - sessionId, - })), + Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)), Effect.map((url) => Response.redirect(url)), - Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)), - Effect.runPromise, ); return res; - }, + }).pipe( + Effect.provide(Layer.provideMerge(Authentication.Live(req), databaseLayer)), + Effect.runPromise, + ), POST: (req) => Effect.gen(function* () { - const { sessionId } = yield* Authentication.Authentication; - 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 - .selectFrom("Session") - .select(["external", "codeVerifier"]) - .where("sessionId", "=", sessionId) - .$call(Database.executeTakeFirst); - - const external = pipe( - session.external, - Option.fromNullable, - Option.map((external) => external !== 0), - ); - - const codeVerifier = Option.fromNullable(session.codeVerifier); - - if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) { - const { tokenEndpoint } = external.value - ? Authentication.EXTERNAL_OAUTH_CONFIGURATION - : Authentication.INTERNAL_OAUTH_CONFIGURATION; - - const res = yield* fetch(tokenEndpoint, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: new URLSearchParams({ - "client_id": config.CLIENT_ID, - "code": code, - "redirect_uri": Authentication.REDIRECT_URI, - "grant_type": "authorization_code", - "code_verifier": codeVerifier.value, - "client_secret": Redacted.value(config.CLIENT_SECRET), - }).toString(), - }); - - const { - access_token: accessToken, - refresh_token: refreshToken, - id_token: idToken, - } = (yield* Body.json(res)) as { - access_token: string, - refresh_token: string, - id_token: string, - }; - - yield* db - .updateTable("Session") - .set({ - accessToken, - refreshToken, - idToken, - codeVerifier: null, - state: null, - }) - .where("sessionId", "=", sessionId) - .$call(Database.execute); - } + yield* login(code, state); return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); }).pipe( diff --git a/packages/backend/src/services/Authentication.ts b/packages/backend/src/services/Authentication.ts index e906593..04ffdaa 100644 --- a/packages/backend/src/services/Authentication.ts +++ b/packages/backend/src/services/Authentication.ts @@ -1,31 +1,28 @@ import { config } from "backend/config"; import { BunRequest } from "bun"; import { SessionId, UserId } from "common"; -import { fetch, FetchError } from "common/Fetch"; -import { Me, NotFound, Other, Role, Unauthenticated } from "common/the_api"; -import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect"; +import { NotFound, Unauthenticated, User } from "common/the_api"; +import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect"; import { constant } from "effect/Function"; import { sql } from "kysely"; import * as Database from "./Database"; export interface AuthenticationInterface { - readonly me: Effect.Effect; + readonly me: Effect.Effect; readonly logout: Effect.Effect; readonly sessionId: SessionId; - readonly getUser: (userId: UserId) => Effect.Effect; + readonly getUser: (userId: UserId) => Effect.Effect; } export class Authentication extends Context.Tag("Authentication")() { } -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 EXPIRATION_BUFFER = Duration.seconds(10); 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* () { const database = yield* Database.Database; @@ -54,11 +51,9 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect const returning = [ "sessionId", - "accessToken", "codeVerifier", - "idToken", - "refreshToken", "state", + "userId", ] as const; const session = yield* database @@ -74,92 +69,52 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect .$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({ sessionId: session.sessionId, - accessToken, - idToken, - refreshToken, codeVerifier: Option.fromNullable(session.codeVerifier), state: Option.fromNullable(session.state), + userId: Option.fromNullable(session.userId), }); return Object.freeze({ me: pipe( - state.idToken, - Option.map(({ payload }) => { - const userData: Me = Object.freeze({ - userId: payload.sub, - displayName: payload.display_name, - roles: ALL_ROLES, - }); - return userData; - }), - Option.match({ - onNone: () => Effect.fail(Unauthenticated.make()), - onSome: Effect.succeed, - }), + state.userId, + Effect.flatMap(getOrAddUser), + Effect.catchTag("NoSuchElementException", () => Effect.fail(Unauthenticated.make())), + Effect.provideService(Database.Database, database), ), logout: database .updateTable("Session") .set({ - accessToken: null, - codeVerifier: null, expiresAt: sql`datetime('now', '+7 days')`, - idToken: null, - refreshToken: null, - state: null, + userId: null, }) .where("sessionId", "=", state.sessionId) .$call(Database.execute), sessionId: state.sessionId, getUser: (userId) => pipe( - getDisplayName(userId), - Effect.map((displayName) => Object.freeze({ userId, displayName })), + getUser(userId), + Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())), + Effect.provideService(Database.Database, database), ), }); })); -export const Test = (me: Option.Option, other: HashMap.HashMap) => Layer.sync(Authentication, constant(Object.freeze({ +export const Test = (me: Option.Option, users: HashMap.HashMap) => Layer.sync(Authentication, constant(Object.freeze({ me: Option.match(me, { onNone: () => Effect.fail(Unauthenticated.make()), onSome: (me) => Effect.succeed(me), }), logout: Effect.void, sessionId: generateSessionId(), - getUser: Option.match(me, { - onNone: () => constant(Effect.fail(Unauthenticated.make())), - onSome: () => (userId) => pipe( - other, - HashMap.get(userId), - Option.match({ - onNone: () => Effect.fail(NotFound.make()), - onSome: Effect.succeed, - }), - ), - }), + getUser: (userId) => pipe( + users, + HashMap.get(userId), + Option.match({ + onNone: () => Effect.fail(NotFound.make()), + onSome: Effect.succeed, + }), + ), }))); export const AccessTokenPayload = Schema.Struct({ @@ -167,8 +122,8 @@ export const AccessTokenPayload = Schema.Struct({ Schema.String, Schema.HashSet, ), - exp: Schema.DateTimeUtc, - iat: Schema.DateTimeUtc, + exp: Schema.Number, + iat: Schema.Number, iss: Schema.String, sub: UserId, }); @@ -178,8 +133,8 @@ export const IdTokenPayload = Schema.Struct({ Schema.String, Schema.HashSet, ), - exp: Schema.DateTimeUtc, - iat: Schema.DateTimeUtc, + exp: Schema.Number, + iat: Schema.Number, iss: Schema.String, sub: UserId, @@ -228,7 +183,83 @@ function generateRandomState(byteLength: number = 32): string { return state; } -const getJwtTokenPayload = (schema: Schema.Schema) => { +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({ + 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({ + 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({ + userId, + displayName: Option.fromNullable(user.displayName), + avatarUrl: Option.fromNullable(user.avatarUrl), + roles: HashSet.fromIterable(roles.map(({ role }) => role)), + }); +}); + +export const getJwtTokenPayload = (schema: Schema.Schema) => { const decoder = Schema.decodeUnknown(schema); return (token: string) => { const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")); @@ -239,23 +270,6 @@ const getJwtTokenPayload = (schema: Schema.Schema) => { }; }; -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* () { const database = yield* Database.Database; @@ -267,9 +281,6 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio .set({ codeVerifier, state, - accessToken: null, - idToken: null, - refreshToken: null, }) .where("sessionId", "=", sessionId) .$call(Database.execute); @@ -286,112 +297,3 @@ export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(functio return url.toString(); }); - -namespace revaildateTokens { - export interface Args { - readonly accessToken: Option.Option; - readonly idToken: Option.Option; - readonly refreshToken: Option.Option; - } - - 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; - } -} - -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({ - accessToken: at, - idToken: it, - refreshToken, - }); - - return res; -}); diff --git a/packages/backend/src/services/Database.ts b/packages/backend/src/services/Database.ts index b12dfe9..c61c8ae 100644 --- a/packages/backend/src/services/Database.ts +++ b/packages/backend/src/services/Database.ts @@ -1,5 +1,6 @@ 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 { ColumnType, CompiledQuery, CreateTableBuilder, Insertable, Kysely, Selectable, Transaction } from "kysely"; import { BunSqliteDialect } from "kysely-bun-sqlite"; @@ -14,6 +15,8 @@ export interface DatabaseSchema { Repertoire: RepertoireTable; RepertoireEntry: RepertoireEntryTable; Session: SessionTable; + User: UserTable; + UserRole: UserRoleTable; sqlite_schema: SqliteSchemaTable; } @@ -73,9 +76,7 @@ export interface RepertoireEntryTable extends RepertoireEntryData { export interface SessionData { state: string | null; codeVerifier: string | null; - accessToken: string | null; - idToken: string | null; - refreshToken: string | null; + userId: UserId | null; } export interface SessionTable extends SessionData { @@ -83,6 +84,17 @@ export interface SessionTable extends SessionData { expiresAt: string; } +export interface UserTable { + userId: ColumnType; + displayName: string | null; + avatarUrl: string | null; +} + +export interface UserRoleTable { + userId: ColumnType; + role: ColumnType; +} + export interface SqliteSchemaTable { type: ColumnType; name: ColumnType; @@ -98,6 +110,8 @@ export type Piece = Selectable; export type Repertoire = Selectable; export type RepertoireEntry = Selectable; export type Session = Selectable; +export type User = Selectable; +export type UserRole = Selectable; export type SqliteSchema = Selectable; // --- MARK: EFFECT LAYERS ----------------------------------------------------- @@ -196,15 +210,6 @@ const initDatabase = (filename: string) => Effect.gen(function* () { .addColumn("data", "blob", (c) => c.notNull()) .$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 .createTable("Piece") .ifNotExists() @@ -253,12 +258,18 @@ const initDatabase = (filename: string) => Effect.gen(function* () { .addColumn("sessionId", "text", (c) => c.notNull().primaryKey()) .addColumn("state", "text") .addColumn("codeVerifier", "text") - .addColumn("accessToken", "text") - .addColumn("idToken", "text") - .addColumn("refreshToken", "text") + .addColumn("userId", "text") .addColumn("expiresAt", "text", (c) => c.notNull()) .$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 .createTable("Attachment") .ifNotExists() @@ -277,6 +288,14 @@ const initDatabase = (filename: string) => Effect.gen(function* () { .columns(["pieceId", "filename"]) .$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 .createTable("Option") .ifNotExists() diff --git a/packages/backend/src/the_api.ts b/packages/backend/src/the_api.ts index c3b24d0..05875e9 100644 --- a/packages/backend/src/the_api.ts +++ b/packages/backend/src/the_api.ts @@ -9,6 +9,11 @@ import * as Database from "./services/Database"; const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer); const WRITE_ACCESS = HashSet.make(Role.Editor); +const requireAuthenticated = pipe( + Authentication.Authentication, + Effect.flatMap(({ me }) => me), +); + const requireReadAccess = pipe( Authentication.Authentication, Effect.flatMap(({ me }) => me), @@ -39,10 +44,14 @@ export const handle = implement(api, { Authentication.Authentication, Effect.flatMap(({ logout }) => logout), ), - getUser: (userId) => pipe( - Authentication.Authentication, - Effect.flatMap(({ getUser }) => getUser(userId)), - ), + getUser: (userId) => Effect.gen(function* () { + yield* requireAuthenticated; + + const { getUser } = yield* Authentication.Authentication; + const user = yield* getUser(userId); + + return user; + }), // --- Piece CRUD --- diff --git a/packages/common/src/the_api.ts b/packages/common/src/the_api.ts index fa65f0f..5904c2a 100644 --- a/packages/common/src/the_api.ts +++ b/packages/common/src/the_api.ts @@ -1,7 +1,7 @@ -import { pipe, Schema } from "effect"; -import * as Api from "./Api"; import { AttachmentId, PieceId, RepertoireId, Sha256, UserId } from "common"; +import { pipe, Schema } from "effect"; import { constant } from "effect/Function"; +import * as Api from "./Api"; // --- MARK: COMMON TYPES ------------------------------------------------------ @@ -27,17 +27,6 @@ export type Pagination = typeof Pagination.Type; // --- 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({ attachmentId: AttachmentId, pieceId: PieceId, @@ -82,14 +71,20 @@ export const Repertoire_Query = Schema.Struct({ name: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })), }).pipe(Schema.extend(Pagination)); -export type Me = typeof Me.Type; -export type Other = typeof Other.Type; +export const User = Schema.Struct({ + 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 Piece = typeof Piece.Type; export type Piece_Create = typeof Piece_Create.Type; export type Piece_Query = typeof Piece_Query.Type; export type Repertoire = typeof Repertoire.Type; export type Repertoire_Query = typeof Repertoire_Query.Type; +export type User = typeof User.Type; // --- MARK: ERROR TYPES ------------------------------------------------------- @@ -105,11 +100,11 @@ export default Api.bundle({ // --- Authentication --- - me: Api.make(Schema.Void, Me, Unauthenticated), + me: Api.make(Schema.Void, User, Unauthenticated), logout: Api.make(Schema.Void, Schema.Void), getUser: Api.make( UserId, - Other, + User, Schema.Union(Unauthenticated, NotFound), ), diff --git a/packages/frontend/src/hooks/useStore.ts b/packages/frontend/src/hooks/useStore.ts index 9764bf2..728719f 100644 --- a/packages/frontend/src/hooks/useStore.ts +++ b/packages/frontend/src/hooks/useStore.ts @@ -1,4 +1,4 @@ -import { type Me } from "common/the_api"; +import { type User } from "common/the_api"; import { identity } from "effect"; import { useLayoutEffect, useState } from "react"; @@ -10,14 +10,14 @@ export const mapProp = (prop: K, action: Update) = }; export interface Store { - readonly user: Me | null; + readonly user: User | null; } let store: Store = Object.freeze({ user: null, }); -export function setUser(action: Update) { +export function setUser(action: Update) { set(mapProp("user", action)); } diff --git a/packages/frontend/src/icons/microsoft-entra-id.svg b/packages/frontend/src/icons/microsoft-entra-id.svg deleted file mode 100644 index 198adab..0000000 --- a/packages/frontend/src/icons/microsoft-entra-id.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/packages/frontend/src/routes/Login.tsx b/packages/frontend/src/routes/Login.tsx index b337b23..358652a 100644 --- a/packages/frontend/src/routes/Login.tsx +++ b/packages/frontend/src/routes/Login.tsx @@ -1,13 +1,8 @@ import { API_URL_PREFIX } from "@/client"; import { buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import microsoftEntraId from "@/icons/microsoft-entra-id.svg"; export function Login() { - - const internalUrl = `${API_URL_PREFIX}/login`; - const externalUrl = `${API_URL_PREFIX}/login?external`; - return (
@@ -16,22 +11,8 @@ export function Login() { Zaloguj się, aby kontynuować -
- Użyj emaila i hasła, konta Microsoft lub konta Google. -
- - Konto zewnętrzne - -
- Użyj konta firmowego. -
- - -
Konto firmowe
+
+ Zaloguj się
diff --git a/packages/frontend/src/routes/Piece.tsx b/packages/frontend/src/routes/Piece.tsx index 0874d6b..e780905 100644 --- a/packages/frontend/src/routes/Piece.tsx +++ b/packages/frontend/src/routes/Piece.tsx @@ -326,7 +326,8 @@ function AttachmentForm(props: AttachmentForm.Props) { 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({ pieceId: props.pieceId, @@ -340,7 +341,7 @@ function AttachmentForm(props: AttachmentForm.Props) { continue; } - const attachment = yield* denormalizeSystemInformation(exit.exitValue); + const attachment = yield* denormalizeSystemInformation(exit.value); props.setAttachments((prev) => { const next = [...prev, attachment]; diff --git a/packages/frontend/src/routes/Root.tsx b/packages/frontend/src/routes/Root.tsx index 1953e65..9cd8850 100644 --- a/packages/frontend/src/routes/Root.tsx +++ b/packages/frontend/src/routes/Root.tsx @@ -2,7 +2,7 @@ import { client } from "@/client"; import { Button, buttonVariants } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; 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 { useEffect } from "react"; import { Link, Outlet, useNavigate } from "react-router-dom"; @@ -53,7 +53,7 @@ export function Root() { diff --git a/packages/frontend/src/snippets.tsx b/packages/frontend/src/snippets.tsx index 0c2f792..13c0267 100644 --- a/packages/frontend/src/snippets.tsx +++ b/packages/frontend/src/snippets.tsx @@ -37,8 +37,8 @@ export function created({ createdAt, createdBy }: DenormalizedSystemInformation) if (Option.isSome(createdBy)) { nodes.push(
); - if (createdBy.value !== null) { - nodes.push(`przez ${createdBy.value.displayName}`); + if (Option.isSome(createdBy.value.displayName)) { + nodes.push(`przez ${createdBy.value.displayName.value}`); } else { nodes.push("przez nieznanego użytkownika"); } @@ -53,8 +53,8 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati if (Option.isNone(modifiedBy)) { return "\u2014"; } else { - if (modifiedBy.value !== null) { - return `przez ${modifiedBy.value.displayName}`; + if (Option.isSome(modifiedBy.value.displayName)) { + return `przez ${modifiedBy.value.displayName.value}`; } else { return "przez nieznanego użytkownika"; } @@ -65,8 +65,8 @@ export function modified({ modifiedAt, modifiedBy }: DenormalizedSystemInformati if (Option.isSome(modifiedBy)) { nodes.push(
); - if (modifiedBy.value !== null) { - nodes.push(`przez ${modifiedBy.value.displayName}`); + if (Option.isSome(modifiedBy.value.displayName)) { + nodes.push(`przez ${modifiedBy.value.displayName.value}`); } else { nodes.push("przez nieznanego użytkownika"); }