From 2f96b55205e4e22320b118e9582f4fc5209f3129 Mon Sep 17 00:00:00 2001 From: Szymon Nowakowski Date: Thu, 9 Oct 2025 16:25:15 +0200 Subject: [PATCH] User CRUD on backend and refactors --- packages/backend/src/app.ts | 85 ++++------- packages/backend/src/config.ts | 22 ++- .../backend/src/services/Authentication.ts | 144 +++++++++--------- packages/backend/src/the_api.ts | 134 +++++++++++----- packages/common/src/index.ts | 2 +- packages/common/src/the_api.ts | 32 +++- packages/frontend/package.json | 1 + 7 files changed, 246 insertions(+), 174 deletions(-) diff --git a/packages/backend/src/app.ts b/packages/backend/src/app.ts index 37421a0..0a8c372 100644 --- a/packages/backend/src/app.ts +++ b/packages/backend/src/app.ts @@ -1,6 +1,6 @@ import * as Body from "common/Body"; import { fetch } from "common/Fetch"; -import { Cause, Effect, Layer, Option, pipe, Record, Redacted, Stream } from "effect"; +import { Cause, Effect, Layer, Match, Option, pipe, Record, Redacted, Stream } from "effect"; import * as path from "node:path"; import { config } from "./config"; import * as Authentication from "./services/Authentication"; @@ -10,6 +10,17 @@ import { handle } from "./the_api"; const FRONTEND_ROOT = "packages/frontend/build"; const FRONTEND_ASSETS_ROOT = path.join(FRONTEND_ROOT, "assets"); +const CORS_HEADERS: [string, string][] = Match.value(config.NODE_ENV).pipe( + Match.when("development", (): [string, string][] => [ + ["Access-Control-Allow-Origin", "http://localhost:5173"], + ["Access-Control-Allow-Methods", "POST, OPTIONS"], + ["Access-Control-Allow-Credentials", "true"], + ["Access-Control-Allow-Headers", "Content-Type"], + ]), + Match.when("production", () => []), + Match.exhaustive, +); + const assetRoutes = await pipe( Stream.fromAsyncIterable( new Bun.Glob("**/*").scan(FRONTEND_ASSETS_ROOT), @@ -24,81 +35,28 @@ const assetRoutes = await pipe( Effect.runPromise, ); -const CORS_HEADERS: [string, string][] = [ - ["Access-Control-Allow-Origin", "http://localhost:5173"], - ["Access-Control-Allow-Methods", "POST, OPTIONS"], - ["Access-Control-Allow-Credentials", "true"], - ["Access-Control-Allow-Headers", "Content-Type"], -]; - 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: (req) => Effect.gen(function* () { const searchParams = new URL(req.url).searchParams; + + // Callback URL with query response type if (searchParams.has("code") || searchParams.has("state")) { const code = searchParams.get("code"); const state = searchParams.get("state"); - yield* login(code, state); + yield* Authentication.getAndProcessIdToken(code, state); return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); } + // Initial login request; redirect to identity provider const res = yield* pipe( Authentication.Authentication, Effect.flatMap(({ sessionId }) => Authentication.makeAuthorizationUrl(sessionId)), @@ -111,12 +69,14 @@ Bun.serve({ Effect.runPromise, ), POST: (req) => Effect.gen(function* () { + // Callback URL with form_post response type + 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); + yield* Authentication.getAndProcessIdToken(code, state); return Response.redirect(config.NODE_ENV === "production" ? `https://${config.HOSTNAME}/` : "http://localhost:5173/", 303); }).pipe( @@ -136,6 +96,13 @@ Bun.serve({ }); } + if (req.method !== "POST") { + return new Response(null, { + status: 405, + headers: CORS_HEADERS, + }); + } + const authenticationLayer = Authentication.Live(req); const layers = Layer.provideMerge(authenticationLayer, databaseLayer); diff --git a/packages/backend/src/config.ts b/packages/backend/src/config.ts index 36e6fa2..0f0e909 100644 --- a/packages/backend/src/config.ts +++ b/packages/backend/src/config.ts @@ -4,11 +4,6 @@ import { constant } from "effect/Function"; /* NOTE I know "effect/Config" exists, but I also don't care. This works for me. */ export const Config = Schema.Struct({ - CLIENT_ID: Schema.UUID, - CLIENT_SECRET: pipe( - Schema.String, - Schema.Redacted, - ), DB_PATH: pipe( Schema.String, Schema.optionalWith({ default: constant("db.sqlite3") }), @@ -22,8 +17,25 @@ export const Config = Schema.Struct({ Schema.NumberFromString, Schema.optionalWith({ default: constant(3000) }), ), + OAUTH_CLIENT_ID: Schema.String, + OAUTH_CLIENT_SECRET: pipe( + Schema.String, + Schema.Redacted, + ), OAUTH_AUTHORIZATION_ENDPOINT: Schema.String, OAUTH_TOKEN_ENDPOINT: Schema.String, + OAUTH_USER_ID_CLAIM: pipe( + Schema.String, + Schema.optionalWith({ default: constant("sub") }), + ), + OAUTH_DISPLAY_NAME_CLAIM: pipe( + Schema.String, + Schema.optionalWith({ default: constant("display_name") }), + ), + OAUTH_AVATAR_URL_CLAIM: pipe( + Schema.String, + Schema.optionalWith({ default: constant("picture") }), + ), }); export type Config = typeof Config.Type; diff --git a/packages/backend/src/services/Authentication.ts b/packages/backend/src/services/Authentication.ts index 04ffdaa..7baff80 100644 --- a/packages/backend/src/services/Authentication.ts +++ b/packages/backend/src/services/Authentication.ts @@ -1,8 +1,10 @@ import { config } from "backend/config"; import { BunRequest } from "bun"; import { SessionId, UserId } from "common"; +import * as Body from "common/Body"; +import { fetch } from "common/Fetch"; import { NotFound, Unauthenticated, User } from "common/the_api"; -import { Context, DateTime, Duration, Effect, HashMap, HashSet, Layer, Option, pipe, Schema } from "effect"; +import { Context, DateTime, Effect, HashMap, HashSet, Layer, Option, pipe, Redacted, Schema } from "effect"; import { constant } from "effect/Function"; import { sql } from "kysely"; import * as Database from "./Database"; @@ -11,16 +13,12 @@ export interface AuthenticationInterface { readonly me: Effect.Effect; readonly logout: Effect.Effect; readonly sessionId: SessionId; - readonly getUser: (userId: UserId) => Effect.Effect; } export class Authentication extends Context.Tag("Authentication")() { } 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"; export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect.gen(function* () { @@ -51,8 +49,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect const returning = [ "sessionId", - "codeVerifier", - "state", "userId", ] as const; @@ -71,8 +67,6 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect const state = Object.freeze({ sessionId: session.sessionId, - codeVerifier: Option.fromNullable(session.codeVerifier), - state: Option.fromNullable(session.state), userId: Option.fromNullable(session.userId), }); @@ -92,67 +86,18 @@ export const Live = (request: BunRequest) => Layer.effect(Authentication, Effect .where("sessionId", "=", state.sessionId) .$call(Database.execute), sessionId: state.sessionId, - getUser: (userId) => pipe( - getUser(userId), - Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())), - Effect.provideService(Database.Database, database), - ), }); })); -export const Test = (me: Option.Option, users: HashMap.HashMap) => Layer.sync(Authentication, constant(Object.freeze({ +export const Test = (me: Option.Option) => 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: (userId) => pipe( - users, - HashMap.get(userId), - Option.match({ - onNone: () => Effect.fail(NotFound.make()), - onSome: Effect.succeed, - }), - ), }))); -export const AccessTokenPayload = Schema.Struct({ - aud: pipe( - Schema.String, - Schema.HashSet, - ), - exp: Schema.Number, - iat: Schema.Number, - iss: Schema.String, - sub: UserId, -}); - -export const IdTokenPayload = Schema.Struct({ - aud: pipe( - Schema.String, - Schema.HashSet, - ), - exp: Schema.Number, - iat: Schema.Number, - iss: Schema.String, - sub: UserId, - - name: Schema.String, - given_name: Schema.String, - family_name: Schema.String, - display_name: Schema.String, - preferred_username: Schema.String, - - email: Schema.String, - email_verified: Schema.Boolean, - - picture: Schema.String, -}); - -export type AccessTokenPayload = typeof AccessTokenPayload.Type; -export type IdTokenPayload = typeof IdTokenPayload.Type; - function generateCodeVerifier(byteLength: number = 32) { const codeVerifierBytes = new Uint8Array(byteLength); crypto.getRandomValues(codeVerifierBytes); @@ -183,7 +128,7 @@ function generateRandomState(byteLength: number = 32): string { return state; } -const getUser = (userId: UserId) => Effect.gen(function* () { +export const getUser = (userId: UserId) => Effect.gen(function* () { const database = yield* Database.Database; const user = yield* database @@ -224,12 +169,24 @@ const getOrAddUser = (userId: UserId) => pipe( )), ); -export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(function* () { +const upsertUser = (idTokenPayload: { readonly [_: string]: unknown }) => Effect.gen(function* () { const database = yield* Database.Database; - const userId = idTokenPayload.sub; - const displayName = idTokenPayload.display_name; - const avatarUrl = idTokenPayload.picture; + const userId = config.OAUTH_USER_ID_CLAIM in idTokenPayload + ? UserId.make(String(idTokenPayload[config.OAUTH_USER_ID_CLAIM])) + : null; + + const displayName = config.OAUTH_DISPLAY_NAME_CLAIM in idTokenPayload + ? String(idTokenPayload[config.OAUTH_DISPLAY_NAME_CLAIM]) + : null; + + const avatarUrl = config.OAUTH_AVATAR_URL_CLAIM in idTokenPayload + ? String(idTokenPayload[config.OAUTH_AVATAR_URL_CLAIM]) + : null; + + if (userId === null) { + return yield* Effect.fail(Unauthenticated.make()); + } const user = yield* database .insertInto("User") @@ -259,17 +216,58 @@ export const upsertUser = (idTokenPayload: IdTokenPayload) => Effect.gen(functio }); }); -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")); - return pipe( - decoder(json), - Effect.orDie, - ); - }; +const getJwtTokenPayload = (token: string) => { + const json = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")); + return json as { readonly [_: string]: unknown }; }; +export const getAndProcessIdToken = (code: string | null, state: string | null) => Effect.gen(function* () { + const { sessionId } = yield* 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 idTokenPayload = yield* pipe( + fetch(config.OAUTH_TOKEN_ENDPOINT, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams({ + "client_id": config.OAUTH_CLIENT_ID, + "code": code, + "redirect_uri": REDIRECT_URI, + "grant_type": "authorization_code", + "code_verifier": codeVerifier.value, + "client_secret": Redacted.value(config.OAUTH_CLIENT_SECRET), + }).toString(), + }), + Effect.flatMap(Body.json), + Effect.map((body) => (body as { id_token: string }).id_token), + Effect.map(getJwtTokenPayload), + ); + + const { userId } = yield* upsertUser(idTokenPayload); + + yield* db + .updateTable("Session") + .set({ + codeVerifier: null, + state: null, + userId, + }) + .where("sessionId", "=", sessionId) + .$call(Database.execute); + } +}); + export const makeAuthorizationUrl = (sessionId: SessionId) => Effect.gen(function* () { const database = yield* Database.Database; diff --git a/packages/backend/src/the_api.ts b/packages/backend/src/the_api.ts index 4d0f3d7..b1e0f23 100644 --- a/packages/backend/src/the_api.ts +++ b/packages/backend/src/the_api.ts @@ -6,32 +6,20 @@ import { implement } from "./api"; import * as Authentication from "./services/Authentication"; import * as Database from "./services/Database"; -const READ_ACCESS = HashSet.make(Role.Editor, Role.Viewer); -const WRITE_ACCESS = HashSet.make(Role.Editor); +const READ_ACCESS = HashSet.make(Role.Admin, Role.Editor, Role.Viewer); +const WRITE_ACCESS = HashSet.make(Role.Admin, Role.Editor); +const ADMIN_ACCESS = HashSet.make(Role.Admin); const requireAuthenticated = pipe( Authentication.Authentication, Effect.flatMap(({ me }) => me), ); -const requireReadAccess = pipe( +const requireOneOf = (roles: HashSet.HashSet) => pipe( Authentication.Authentication, Effect.flatMap(({ me }) => me), Effect.flatMap((user) => pipe( - HashSet.intersection(user.roles, READ_ACCESS), - HashSet.size, - Number.greaterThan(0), - ) - ? Effect.succeed(user) - : Effect.fail(Unauthorized.make()) - ), -); - -const requireWriteAccess = pipe( - Authentication.Authentication, - Effect.flatMap(({ me }) => me), - Effect.flatMap((user) => pipe( - HashSet.intersection(user.roles, WRITE_ACCESS), + HashSet.intersection(user.roles, roles), HashSet.size, Number.greaterThan(0), ) @@ -52,19 +40,93 @@ export const handle = implement(api, { Authentication.Authentication, Effect.flatMap(({ logout }) => logout), ), + + // --- User CRUD --- + getUser: (userId) => Effect.gen(function* () { yield* requireAuthenticated; - const { getUser } = yield* Authentication.Authentication; - const user = yield* getUser(userId); + const user = yield* pipe( + Authentication.getUser(userId), + Effect.catchTag("NoSuchElementException", () => Effect.fail(NotFound.make())), + ); return user; }), + queryUsers: ({ displayName, role, offset, limit }) => Effect.gen(function* () { + yield* requireAuthenticated; + const db = yield* Database.Database; + + let query = db + .selectFrom("User") + .leftJoin("UserRole", "User.userId", "UserRole.role") + .select("User.userId") + .groupBy("User.userId") + .orderBy("displayName") + .offset(offset) + .limit(limit); + + query = Option.match(displayName, { + onNone: () => query, + onSome: (displayName) => query.where("User.displayName", "like", "%" + displayName + "%"), + }); + + query = Option.match(role, { + onNone: () => query, + onSome: (role) => query.where("UserRole.role", "=", role), + }); + + const res = yield* query.$call(Database.execute); + return res.map(({ userId }) => userId); + }), + updateUser: ({ userId, ...user }) => Effect.gen(function* () { + yield* requireOneOf(ADMIN_ACCESS); + const db = yield* Database.Database; + + const res = yield* pipe( + Authentication.getUser(userId), + Effect.mapError(() => NotFound.make()), + ); + + yield* db + .deleteFrom("UserRole") + .where("userId", "=", userId) + .$call(Database.execute); + + const roles = yield* pipe( + user.roles, + Effect.forEach((role) => db + .insertInto("UserRole") + .values({ userId, role }) + .returning("role") + .$call(Database.executeTakeFirstOrDie) + .pipe(Effect.map(({ role }) => role)), + ), + ); + + return { + userId: res.userId, + displayName: res.displayName, + avatarUrl: res.avatarUrl, + roles: HashSet.fromIterable(roles), + }; + }), + deleteUser: (userId) => Effect.gen(function* () { + yield* requireOneOf(ADMIN_ACCESS); + const db = yield* Database.Database; + + yield* db + .deleteFrom("User") + .where("userId", "=", userId) + .returning("userId") + .$call(Database.executeTakeFirst) + .pipe(Effect.mapError(() => NotFound.make())); + }), // --- Piece CRUD --- createPiece: (piece) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const res = yield* db @@ -99,7 +161,7 @@ export const handle = implement(api, { }; }), getPiece: (pieceId) => Effect.gen(function* () { - yield* requireReadAccess; + yield* requireOneOf(READ_ACCESS); const db = yield* Database.Database; const piece = yield* db @@ -149,13 +211,15 @@ export const handle = implement(api, { }; }), queryPieces: ({ name, author, offset, limit }) => Effect.gen(function* () { - yield* requireReadAccess; + yield* requireOneOf(READ_ACCESS); const db = yield* Database.Database; let query = db .selectFrom("Piece") .select("pieceId") - .orderBy(["name", "composer", "arranger"]) + .orderBy("name") + .orderBy("composer") + .orderBy("arranger") .offset(offset) .limit(limit); @@ -177,7 +241,7 @@ export const handle = implement(api, { return res.map(({ pieceId }) => pieceId); }), updatePiece: ({ pieceId, ...piece }) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const res = yield* db @@ -235,7 +299,7 @@ export const handle = implement(api, { }; }), deletePiece: (pieceId) => Effect.gen(function* () { - yield* requireWriteAccess; + yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; yield* db @@ -249,7 +313,7 @@ export const handle = implement(api, { // --- Attachment CRUD --- createAttachment: (attachment) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const sha256 = Sha256.make(new Uint8Array(Bun.SHA256.byteLength)); @@ -292,7 +356,7 @@ export const handle = implement(api, { }; }), getAttachment: (attachmentId) => Effect.gen(function* () { - yield* requireReadAccess; + yield* requireOneOf(READ_ACCESS); const db = yield* Database.Database; const res = yield* db @@ -306,7 +370,7 @@ export const handle = implement(api, { return res; }), updateAttachment: ({ attachmentId, ...attachment }) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const res = yield* db @@ -337,7 +401,7 @@ export const handle = implement(api, { }; }), deleteAttachment: (attachmentId) => Effect.gen(function* () { - yield* requireWriteAccess; + yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; yield* db @@ -351,7 +415,7 @@ export const handle = implement(api, { // --- Repertoire CRUD --- createRepertoire: (repertoire) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const repertoireId = RepertoireId.make(Bun.randomUUIDv7()); @@ -393,7 +457,7 @@ export const handle = implement(api, { }; }), getRepertoire: (repertoireId) => Effect.gen(function* () { - yield* requireReadAccess; + yield* requireOneOf(READ_ACCESS); const db = yield* Database.Database; const repertoire = yield* db @@ -424,8 +488,8 @@ export const handle = implement(api, { ), }; }), - queryRepertoire: ({ name, offset, limit }) => Effect.gen(function* () { - yield* requireReadAccess; + queryRepertoires: ({ name, offset, limit }) => Effect.gen(function* () { + yield* requireOneOf(READ_ACCESS); const db = yield* Database.Database; let query = db @@ -444,7 +508,7 @@ export const handle = implement(api, { return res.map(({ repertoireId }) => repertoireId); }), updateRepertoire: ({ repertoireId, ...repertoire }) => Effect.gen(function* () { - const { userId } = yield* requireWriteAccess; + const { userId } = yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; const res = yield* db @@ -490,7 +554,7 @@ export const handle = implement(api, { }; }), deleteRepertoire: (repertoireId) => Effect.gen(function* () { - yield* requireWriteAccess; + yield* requireOneOf(WRITE_ACCESS); const db = yield* Database.Database; yield* db diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 3f14490..e61a084 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -22,5 +22,5 @@ export type RepertoireId = typeof RepertoireId.Type; export const RequestId = pipe(Schema.UUID, Schema.brand("RequestId")); export type RequestId = typeof RequestId.Type; -export const UserId = pipe(Schema.UUID, Schema.brand("UserId")); +export const UserId = pipe(Schema.String, Schema.brand("UserId")); export type UserId = typeof UserId.Type; diff --git a/packages/common/src/the_api.ts b/packages/common/src/the_api.ts index 5904c2a..8ec640a 100644 --- a/packages/common/src/the_api.ts +++ b/packages/common/src/the_api.ts @@ -8,6 +8,7 @@ import * as Api from "./Api"; export enum Role { Viewer = "Viewer", Editor = "Editor", + Admin = "Admin", } export const SystemInformation = Schema.Struct({ @@ -78,6 +79,16 @@ export const User = Schema.Struct({ roles: Schema.HashSet(Schema.Enums(Role)), }); +export const User_Query = Schema.Struct({ + displayName: pipe(Schema.NonEmptyString, Schema.optionalWith({ as: "Option", exact: true })), + role: pipe(Schema.Enums(Role), Schema.optionalWith({ as: "Option", exact: true })), +}).pipe(Schema.extend(Pagination)); + +export const User_AssignRoles = Schema.Struct({ + userId: UserId, + 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; @@ -85,6 +96,7 @@ 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; +export type User_AssignRoles = typeof User_AssignRoles.Type; // --- MARK: ERROR TYPES ------------------------------------------------------- @@ -102,11 +114,29 @@ export default Api.bundle({ me: Api.make(Schema.Void, User, Unauthenticated), logout: Api.make(Schema.Void, Schema.Void), + + // --- User CRUD --- + getUser: Api.make( UserId, User, Schema.Union(Unauthenticated, NotFound), ), + queryUsers: Api.make( + User_Query, + pipe(UserId, Schema.Array), + Schema.Union(Unauthenticated), + ), + updateUser: Api.make( + User_AssignRoles, + User, + Schema.Union(Unauthenticated, Unauthorized, NotFound), + ), + deleteUser: Api.make( + UserId, + Schema.Void, + Schema.Union(Unauthenticated, Unauthorized, NotFound), + ), // --- Piece CRUD --- @@ -183,7 +213,7 @@ export default Api.bundle({ Repertoire, Schema.Union(Unauthenticated, Unauthorized, NotFound), ), - queryRepertoire: Api.make( + queryRepertoires: Api.make( Repertoire_Query, pipe(RepertoireId, Schema.Array), Schema.Union(Unauthenticated, Unauthorized), diff --git a/packages/frontend/package.json b/packages/frontend/package.json index ee44abe..5f5dc2d 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -17,6 +17,7 @@ "vite": "catalog:" }, "dependencies": { + "@radix-ui/react-avatar": "catalog:", "@radix-ui/react-dialog": "catalog:", "@radix-ui/react-dropdown-menu": "catalog:", "@radix-ui/react-label": "catalog:",