Use Azure auth for no good reason
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
|||||||
|
.env
|
||||||
build
|
build
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@elysiajs/static": "catalog:",
|
"@elysiajs/static": "catalog:",
|
||||||
"@elysiajs/swagger": "catalog:",
|
"@elysiajs/swagger": "catalog:",
|
||||||
"common": "workspace:^",
|
"common": "workspace:^",
|
||||||
|
"effect": "catalog:",
|
||||||
"elysia": "catalog:",
|
"elysia": "catalog:",
|
||||||
"kysely": "catalog:",
|
"kysely": "catalog:",
|
||||||
"kysely-bun-sqlite": "catalog:"
|
"kysely-bun-sqlite": "catalog:"
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import cors from "@elysiajs/cors";
|
import cors from "@elysiajs/cors";
|
||||||
import { staticPlugin } from "@elysiajs/static";
|
import { staticPlugin } from "@elysiajs/static";
|
||||||
import { swagger } from "@elysiajs/swagger";
|
import { swagger } from "@elysiajs/swagger";
|
||||||
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex, UserId } from "common";
|
import { AttachmentId, PieceId, RepertoireId, RequestId, SessionId, Sha256_Bin, Sha256_Hex } from "common";
|
||||||
|
import { Effect, Option, pipe } from "effect";
|
||||||
import { Elysia, error, t } from "elysia";
|
import { Elysia, error, t } from "elysia";
|
||||||
import { sql } from "kysely";
|
import { sql } from "kysely";
|
||||||
|
import { CLIENT_ID, EXTERNAL_OAUTH_CONFIGURATION, getUser, INTERNAL_OAUTH_CONFIGURATION, makeAuthorizationUrl, REDIRECT_URI, revalidateTokens } from "./auth";
|
||||||
import * as Db from "./database";
|
import * as Db from "./database";
|
||||||
import * as Model from "./model";
|
import * as Model from "./model";
|
||||||
|
import { DbFromInstance } from "./services/db";
|
||||||
|
import { SessionFromValue } from "./services/session";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
|
|
||||||
@@ -36,37 +40,85 @@ const app = new Elysia()
|
|||||||
.where(sql`datetime()`, ">=", "expiresAt")
|
.where(sql`datetime()`, ">=", "expiresAt")
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
const sessionId = cookie.sessionId.value;
|
const sessionId = (cookie.sessionId.value as SessionId | undefined) ?? Db.generateSessionId();
|
||||||
if (sessionId === undefined) {
|
|
||||||
return { user: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await db
|
const expiresAt = new Date().getTime() + 604800000;
|
||||||
|
cookie.sessionId.set({
|
||||||
|
value: sessionId,
|
||||||
|
expires: new Date(expiresAt),
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "none",
|
||||||
|
secure: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const returning = [
|
||||||
|
"sessionId",
|
||||||
|
"accessToken",
|
||||||
|
"codeVerifier",
|
||||||
|
"external",
|
||||||
|
"idToken",
|
||||||
|
"refreshToken",
|
||||||
|
"state",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
let session = await db
|
||||||
.updateTable("Session")
|
.updateTable("Session")
|
||||||
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
.set({ expiresAt: sql`datetime('now', '+7 days') ` })
|
||||||
.where("sessionId", "=", SessionId(sessionId))
|
.where("sessionId", "=", sessionId)
|
||||||
.returning(["userId"])
|
.returning(returning)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
|
|
||||||
if (res === undefined) {
|
if (session === undefined) {
|
||||||
return { user: null };
|
session = await db
|
||||||
|
.insertInto("Session")
|
||||||
|
.values({ sessionId, expiresAt: sql`datetime('now', '+7 days')` })
|
||||||
|
.returning(returning)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await db
|
const { accessToken, idToken, refreshToken, roles, userId } = await pipe(
|
||||||
.selectFrom("User")
|
{
|
||||||
.select(["userId", "username", "admin"])
|
accessToken: Option.fromNullable(session.accessToken),
|
||||||
.where("userId", "=", res.userId)
|
idToken: Option.fromNullable(session.idToken),
|
||||||
.executeTakeFirst();
|
refreshToken: Option.fromNullable(session.refreshToken),
|
||||||
|
external: Boolean(session.external),
|
||||||
|
},
|
||||||
|
revalidateTokens,
|
||||||
|
Effect.runPromise,
|
||||||
|
);
|
||||||
|
|
||||||
if (user === undefined) {
|
await db
|
||||||
return { user: null };
|
.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),
|
||||||
|
})
|
||||||
|
.execute();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: {
|
session: {
|
||||||
userId: user.userId,
|
sessionId: session.sessionId,
|
||||||
username: user.username,
|
accessToken,
|
||||||
admin: user.admin !== 0,
|
idToken,
|
||||||
|
refreshToken,
|
||||||
|
roles,
|
||||||
|
userId,
|
||||||
|
codeVerifier: Option.fromNullable(session.codeVerifier),
|
||||||
|
external: pipe(
|
||||||
|
session.external,
|
||||||
|
Option.fromNullable,
|
||||||
|
Option.map((e) => e !== 0),
|
||||||
|
),
|
||||||
|
state: Option.fromNullable(session.state),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -100,66 +152,87 @@ const app = new Elysia()
|
|||||||
|
|
||||||
// --- MARK: AUTHENTICATION --------------------------------------------
|
// --- MARK: AUTHENTICATION --------------------------------------------
|
||||||
|
|
||||||
.get("/me", ({ user }) => {
|
.get("/me", ({ session: { idToken, roles } }) => {
|
||||||
|
return Option.match(idToken, {
|
||||||
if (user === null) {
|
onNone: () => error("Unauthorized", "Session invalid or expired"),
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
onSome: ({ payload: { oid, name } }) => ({
|
||||||
}
|
userId: oid,
|
||||||
|
username: name,
|
||||||
return user;
|
roles: roles as string[],
|
||||||
|
}),
|
||||||
|
});
|
||||||
}, {
|
}, {
|
||||||
response: {
|
response: {
|
||||||
200: Model.User,
|
200: Model.Me,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/login", async ({ db, body: { username, password }, cookie }) => {
|
.get("/login", async ({ db, query, redirect, session: { sessionId } }) => {
|
||||||
|
|
||||||
const user = await db
|
const url = await pipe(
|
||||||
.selectFrom("User")
|
makeAuthorizationUrl({ external: "external" in query }),
|
||||||
.selectAll()
|
Effect.provide([
|
||||||
.where("username", "=", username)
|
DbFromInstance(db),
|
||||||
.executeTakeFirst();
|
SessionFromValue(sessionId),
|
||||||
|
]),
|
||||||
|
Effect.runPromise,
|
||||||
|
);
|
||||||
|
|
||||||
if (user === undefined) {
|
return redirect(url, 302);
|
||||||
return error("Unauthorized", "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await Bun.password.verify(password, user.password);
|
|
||||||
if (!valid) {
|
|
||||||
return error("Unauthorized", "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sessionId = Db.generateSessionId();
|
|
||||||
await db
|
|
||||||
.insertInto("Session")
|
|
||||||
.values({ sessionId, userId: user.userId, expiresAt: sql`datetime('now', '+7 days')` })
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
const expiresAt = new Date().getTime() + 604800000;
|
|
||||||
cookie.sessionId.set({
|
|
||||||
value: sessionId,
|
|
||||||
expires: new Date(expiresAt),
|
|
||||||
httpOnly: true,
|
|
||||||
sameSite: "none",
|
|
||||||
secure: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: user.userId,
|
|
||||||
username: user.username,
|
|
||||||
admin: user.admin !== 0,
|
|
||||||
sessionId,
|
|
||||||
};
|
|
||||||
}, {
|
}, {
|
||||||
body: t.Object({
|
|
||||||
username: t.String({ minLength: 1 }),
|
|
||||||
password: t.String({ minLength: 1 }),
|
|
||||||
}),
|
|
||||||
response: {
|
response: {
|
||||||
200: Model.User,
|
302: t.Void(),
|
||||||
401: t.Literal("Invalid username or password"),
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
.post("/login", async ({ db, request, redirect, session: { sessionId, external, codeVerifier } }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
|
||||||
|
const code = data.get("code") as string | null;
|
||||||
|
const state = data.get("state") as string | null;
|
||||||
|
|
||||||
|
if (code !== null && state !== null && Option.isSome(external) && Option.isSome(codeVerifier)) {
|
||||||
|
const { tokenEndpoint } = external.value ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||||
|
|
||||||
|
const res = await fetch(tokenEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code_verifier": codeVerifier.value,
|
||||||
|
"client_secret": process.env.CLIENT_SECRET!,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: refreshToken,
|
||||||
|
id_token: idToken,
|
||||||
|
} = await res.json() as { access_token: string, refresh_token: string, id_token: string };
|
||||||
|
|
||||||
|
await db
|
||||||
|
.updateTable("Session")
|
||||||
|
.set({
|
||||||
|
accessToken,
|
||||||
|
refreshToken,
|
||||||
|
idToken,
|
||||||
|
codeVerifier: null,
|
||||||
|
state: null,
|
||||||
|
})
|
||||||
|
.where("sessionId", "=", sessionId)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(process.env.NODE_ENV === "production" ? "https://music.renati.me/" : "http://localhost:5173/", 303);
|
||||||
|
}, {
|
||||||
|
response: {
|
||||||
|
303: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -180,142 +253,29 @@ const app = new Elysia()
|
|||||||
.where("sessionId", "=", SessionId(sessionId))
|
.where("sessionId", "=", SessionId(sessionId))
|
||||||
.execute();
|
.execute();
|
||||||
}, {
|
}, {
|
||||||
|
|
||||||
response: {
|
response: {
|
||||||
204: t.Void(),
|
204: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.post("/change-password", async ({ db, body: { username, currentPassword, newPassword }, set }) => {
|
|
||||||
|
|
||||||
const user = await db
|
|
||||||
.selectFrom("User")
|
|
||||||
.selectAll()
|
|
||||||
.where("username", "=", username)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (user === undefined) {
|
|
||||||
return error("Unauthorized", "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
const valid = await Bun.password.verify(currentPassword, user.password);
|
|
||||||
if (!valid) {
|
|
||||||
return error("Unauthorized", "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
const password = await Bun.password.hash(newPassword);
|
|
||||||
|
|
||||||
const res = await db
|
|
||||||
.updateTable("User")
|
|
||||||
.set({ password })
|
|
||||||
.where("username", "=", username)
|
|
||||||
.returningAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (res.length === 0) {
|
|
||||||
return error("Unauthorized", "Invalid username or password");
|
|
||||||
}
|
|
||||||
|
|
||||||
set.status = "No Content";
|
|
||||||
}, {
|
|
||||||
body: t.Object({
|
|
||||||
username: t.String({ minLength: 1 }),
|
|
||||||
currentPassword: t.String({ minLength: 1 }),
|
|
||||||
newPassword: t.String({ minLength: 1 }),
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
204: t.Void(),
|
|
||||||
401: t.Literal("Invalid username or password"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- MARK: USER MANAGEMENT -------------------------------------------
|
// --- MARK: USER MANAGEMENT -------------------------------------------
|
||||||
|
|
||||||
.post("/user", async ({ db, body: { username, password, admin }, user }) => {
|
.get("/user/:userId", async ({ params: { userId }, session: { accessToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(accessToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user.admin) {
|
const res = await pipe(
|
||||||
return error("Forbidden", "Operation allowed only for admin users");
|
{ accessToken: accessToken.value.token, userId },
|
||||||
}
|
getUser,
|
||||||
|
Effect.runPromise,
|
||||||
|
);
|
||||||
|
|
||||||
const userId = UserId(Bun.randomUUIDv7());
|
return Option.match(res, {
|
||||||
|
onNone: () => error("Not Found", undefined),
|
||||||
const newUser = await db
|
onSome: ({ displayName }) => ({ userId, displayName }),
|
||||||
.insertInto("User")
|
});
|
||||||
.values({
|
|
||||||
userId,
|
|
||||||
username,
|
|
||||||
password: await Bun.password.hash(password),
|
|
||||||
admin: admin ? 1 : 0,
|
|
||||||
})
|
|
||||||
.returningAll()
|
|
||||||
.executeTakeFirstOrThrow();
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: newUser.userId,
|
|
||||||
username: newUser.username,
|
|
||||||
admin: newUser.admin !== 0,
|
|
||||||
};
|
|
||||||
}, {
|
|
||||||
body: Model.User_Post,
|
|
||||||
response: {
|
|
||||||
200: Model.User,
|
|
||||||
401: t.Literal("Session invalid or expired"),
|
|
||||||
403: t.Literal("Operation allowed only for admin users"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
.get("/user", async ({ db, query, user }) => {
|
|
||||||
|
|
||||||
if (user === null) {
|
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
let q = db
|
|
||||||
.selectFrom("User")
|
|
||||||
.select("userId")
|
|
||||||
.orderBy(["username", "userId"])
|
|
||||||
.offset(query.offset ?? 0)
|
|
||||||
.limit(query.limit ?? 100);
|
|
||||||
|
|
||||||
if (query.username !== undefined) {
|
|
||||||
q = q.where("username", "like", "%" + query.username + "%");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await q.execute();
|
|
||||||
return res.map(({ userId }) => userId);
|
|
||||||
}, {
|
|
||||||
query: Model.User_Query,
|
|
||||||
response: {
|
|
||||||
200: t.Array(Model.UserId),
|
|
||||||
401: t.Literal("Session invalid or expired"),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
.get("/user/:userId", async ({ db, params: { userId }, user }) => {
|
|
||||||
|
|
||||||
if (user === null) {
|
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await db
|
|
||||||
.selectFrom("User")
|
|
||||||
.select(["userId", "username", "admin"])
|
|
||||||
.where("userId", "=", userId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (res === undefined) {
|
|
||||||
return error("Not Found", undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: res.userId,
|
|
||||||
username: res.username,
|
|
||||||
admin: res.admin !== 0,
|
|
||||||
};
|
|
||||||
}, {
|
}, {
|
||||||
params: t.Object({
|
params: t.Object({
|
||||||
userId: Model.UserId,
|
userId: Model.UserId,
|
||||||
@@ -327,95 +287,23 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.patch("/user/:userId", async ({ db, body: { username, password, admin }, params: { userId }, user }) => {
|
|
||||||
|
|
||||||
if (user === null) {
|
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.admin) {
|
|
||||||
return error("Forbidden", "Operation allowed only for admin users");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await db
|
|
||||||
.updateTable("User")
|
|
||||||
.set({
|
|
||||||
...(username !== undefined ? { username } : undefined),
|
|
||||||
...(password !== undefined ? { password: await Bun.password.hash(password) } : undefined),
|
|
||||||
...(admin !== undefined ? { admin: admin ? 1 : 0 } : undefined),
|
|
||||||
})
|
|
||||||
.where("userId", "=", userId)
|
|
||||||
.returning(["userId", "username", "admin"])
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (res === undefined) {
|
|
||||||
return error("Not Found", undefined);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
userId: res.userId,
|
|
||||||
username: res.username,
|
|
||||||
admin: res.admin !== 0,
|
|
||||||
};
|
|
||||||
}, {
|
|
||||||
body: Model.User_Patch,
|
|
||||||
params: t.Object({
|
|
||||||
userId: Model.UserId,
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
200: Model.User,
|
|
||||||
401: t.Literal("Session invalid or expired"),
|
|
||||||
403: t.Literal("Operation allowed only for admin users"),
|
|
||||||
404: t.Void(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
.delete("/user/:userId", async ({ db, params: { userId }, set, user }) => {
|
|
||||||
|
|
||||||
if (user === null) {
|
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user.admin) {
|
|
||||||
return error("Forbidden", "Operation allowed only for admin users");
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await db
|
|
||||||
.deleteFrom("User")
|
|
||||||
.where("userId", "=", userId)
|
|
||||||
.returningAll()
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
if (res.length === 0) {
|
|
||||||
return error("Not Found");
|
|
||||||
}
|
|
||||||
|
|
||||||
set.status = "No Content";
|
|
||||||
}, {
|
|
||||||
params: t.Object({
|
|
||||||
userId: Model.UserId,
|
|
||||||
}),
|
|
||||||
response: {
|
|
||||||
204: t.Void(),
|
|
||||||
401: t.Literal("Session invalid or expired"),
|
|
||||||
403: t.Literal("Operation allowed only for admin users"),
|
|
||||||
404: t.Void(),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
// --- MARK: PIECE CRUD ------------------------------------------------
|
// --- MARK: PIECE CRUD ------------------------------------------------
|
||||||
|
|
||||||
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, user }) => {
|
.post("/piece", async ({ db, body: { name, composer, lyricist, arranger }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const pieceId = PieceId(Bun.randomUUIDv7());
|
const pieceId = PieceId(Bun.randomUUIDv7());
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.insertInto("Piece")
|
.insertInto("Piece")
|
||||||
.values({ pieceId, name, composer, lyricist, arranger, createdBy: user.userId, createdAt: sql`datetime()` })
|
.values({ pieceId, name, composer, lyricist, arranger, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
@@ -428,12 +316,13 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
200: Model.Piece,
|
200: Model.Piece,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/piece", async ({ db, query, user }) => {
|
.get("/piece", async ({ db, query, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,9 +355,9 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/piece/:pieceId", async ({ db, params: { pieceId }, user }) => {
|
.get("/piece/:pieceId", async ({ db, params: { pieceId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -506,15 +395,19 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, user }) => {
|
.put("/piece/:pieceId", async ({ db, body: { name, composer, lyricist, arranger }, params: { pieceId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.updateTable("Piece")
|
.updateTable("Piece")
|
||||||
.set({ name, composer, lyricist, arranger, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
.set({ name, composer, lyricist, arranger, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||||
.where("pieceId", "=", pieceId)
|
.where("pieceId", "=", pieceId)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.execute();
|
.execute();
|
||||||
@@ -549,16 +442,21 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
200: Model.Piece,
|
200: Model.Piece,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
404: t.Void(),
|
404: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, user }) => {
|
.delete("/piece/:pieceId", async ({ db, params: { pieceId }, set, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.deleteFrom("Piece")
|
.deleteFrom("Piece")
|
||||||
.where("pieceId", "=", pieceId)
|
.where("pieceId", "=", pieceId)
|
||||||
@@ -583,12 +481,16 @@ const app = new Elysia()
|
|||||||
|
|
||||||
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
// --- MARK: ATTACHMENT CRUD -------------------------------------------
|
||||||
|
|
||||||
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, user }) => {
|
.post("/piece/:pieceId/attachment", async ({ db, body: { filename, mediaType, data }, params: { pieceId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
const attachmentId = AttachmentId(Bun.randomUUIDv7());
|
||||||
const dataArray = new Uint8Array(await data.arrayBuffer());
|
const dataArray = new Uint8Array(await data.arrayBuffer());
|
||||||
|
|
||||||
@@ -603,7 +505,7 @@ const app = new Elysia()
|
|||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.insertInto("Attachment")
|
.insertInto("Attachment")
|
||||||
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: user.userId, createdAt: sql`datetime()` })
|
.values({ attachmentId, pieceId, sha256, filename, mediaType, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
@@ -623,6 +525,7 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
200: Model.Attachment,
|
200: Model.Attachment,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -633,9 +536,9 @@ const app = new Elysia()
|
|||||||
* hash). Thus, we reflect the ownership in the URLs.
|
* hash). Thus, we reflect the ownership in the URLs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, user, set }) => {
|
.get("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, session: { idToken }, set }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,15 +571,19 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, user }) => {
|
.put("/piece/:pieceId/attachment/:attachmentId", async ({ db, body: { filename }, params: { pieceId, attachmentId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.updateTable("Attachment")
|
.updateTable("Attachment")
|
||||||
.set({ filename, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
.set({ filename, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||||
.where((eb) => eb.and([
|
.where((eb) => eb.and([
|
||||||
eb("pieceId", "=", pieceId),
|
eb("pieceId", "=", pieceId),
|
||||||
eb("attachmentId", "=", attachmentId),
|
eb("attachmentId", "=", attachmentId),
|
||||||
@@ -703,16 +610,21 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
200: Model.Attachment,
|
200: Model.Attachment,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
404: t.Void(),
|
404: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, user }) => {
|
.delete("/piece/:pieceId/attachment/:attachmentId", async ({ db, params: { pieceId, attachmentId }, set, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.deleteFrom("Attachment")
|
.deleteFrom("Attachment")
|
||||||
.where((eb) => eb.and([
|
.where((eb) => eb.and([
|
||||||
@@ -735,23 +647,28 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
204: t.Void(),
|
204: t.Void(),
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
404: t.Void(),
|
404: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- MARK: REPERTOIRE CRUD -------------------------------------------
|
// --- MARK: REPERTOIRE CRUD -------------------------------------------
|
||||||
|
|
||||||
.post("/repertoire", async ({ db, body: { name, entries }, user }) => {
|
.post("/repertoire", async ({ db, body: { name, entries }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const repertoireId = RepertoireId(Bun.randomUUIDv7());
|
const repertoireId = RepertoireId(Bun.randomUUIDv7());
|
||||||
|
|
||||||
const repertoire = await db
|
const repertoire = await db
|
||||||
.insertInto("Repertoire")
|
.insertInto("Repertoire")
|
||||||
.values({ repertoireId, name, createdBy: user.userId, createdAt: sql`datetime()` })
|
.values({ repertoireId, name, createdBy: idToken.value.payload.oid, createdAt: sql`datetime()` })
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
@@ -779,12 +696,13 @@ const app = new Elysia()
|
|||||||
}),
|
}),
|
||||||
response: {
|
response: {
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/repertoire", async ({ db, query, user }) => {
|
.get("/repertoire", async ({ db, query, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -809,9 +727,9 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, user }) => {
|
.get("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,15 +765,19 @@ const app = new Elysia()
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, user }) => {
|
.put("/repertoire/:repertoireId", async ({ db, body: { name, entries }, params: { repertoireId }, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.updateTable("Repertoire")
|
.updateTable("Repertoire")
|
||||||
.set({ name, modifiedBy: user.userId, modifiedAt: sql`datetime()` })
|
.set({ name, modifiedBy: idToken.value.payload.oid, modifiedAt: sql`datetime()` })
|
||||||
.where("repertoireId", "=", repertoireId)
|
.where("repertoireId", "=", repertoireId)
|
||||||
.returningAll()
|
.returningAll()
|
||||||
.execute();
|
.execute();
|
||||||
@@ -893,16 +815,21 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
200: Model.Repertoire,
|
200: Model.Repertoire,
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
404: t.Void(),
|
404: t.Void(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, user }) => {
|
.delete("/repertoire/:repertoireId", async ({ db, params: { repertoireId }, set, session: { idToken } }) => {
|
||||||
|
|
||||||
if (user === null) {
|
if (Option.isNone(idToken)) {
|
||||||
return error("Unauthorized", "Session invalid or expired");
|
return error("Unauthorized", "Session invalid or expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!idToken.value.payload.roles.includes("Editor")) {
|
||||||
|
return error("Forbidden", "Must be an Editor");
|
||||||
|
}
|
||||||
|
|
||||||
const res = await db
|
const res = await db
|
||||||
.deleteFrom("Repertoire")
|
.deleteFrom("Repertoire")
|
||||||
.where("repertoireId", "=", repertoireId)
|
.where("repertoireId", "=", repertoireId)
|
||||||
@@ -921,6 +848,7 @@ const app = new Elysia()
|
|||||||
response: {
|
response: {
|
||||||
204: t.Void(),
|
204: t.Void(),
|
||||||
401: t.Literal("Session invalid or expired"),
|
401: t.Literal("Session invalid or expired"),
|
||||||
|
403: t.Literal("Must be an Editor"),
|
||||||
404: t.Void(),
|
404: t.Void(),
|
||||||
},
|
},
|
||||||
// eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here
|
// eslint-disable-next-line @stylistic/comma-dangle -- a comma would confuse the TS compiler here
|
||||||
|
|||||||
234
packages/backend/src/auth.ts
Normal file
234
packages/backend/src/auth.ts
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
import { UserId } from "common";
|
||||||
|
import { DateTime, Duration, Effect, Option, pipe } from "effect";
|
||||||
|
import { constant } from "effect/Function";
|
||||||
|
import * as Model from "./model";
|
||||||
|
import { Db } from "./services/db";
|
||||||
|
import { Session } from "./services/session";
|
||||||
|
|
||||||
|
export const TENANT_ID = "0817c403-92e4-4648-a9aa-f688ffc5f97a";
|
||||||
|
export const TENANT_SUBDOMAIN = "chkvoxastra";
|
||||||
|
|
||||||
|
export const CLIENT_ID = "e5948f7d-187b-44f9-80cd-63ffda86f9be";
|
||||||
|
export const OAUTH_SCOPE = "email offline_access openid profile https://graph.microsoft.com/User.Read.All";
|
||||||
|
|
||||||
|
export const REDIRECT_URI = process.env.NODE_ENV === "production" ? "https://music.renati.me/api/v1/login" : "http://localhost:3000/api/v1/login";
|
||||||
|
|
||||||
|
export const EXPIRATION_BUFFER = Duration.seconds(10);
|
||||||
|
|
||||||
|
export interface OAuthConfiguration {
|
||||||
|
readonly authorizationEndpoint: string;
|
||||||
|
readonly tokenEndpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const INTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
||||||
|
authorizationEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/authorize`,
|
||||||
|
tokenEndpoint: `https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EXTERNAL_OAUTH_CONFIGURATION: OAuthConfiguration = Object.freeze<OAuthConfiguration>({
|
||||||
|
authorizationEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/authorize`,
|
||||||
|
tokenEndpoint: `https://${TENANT_SUBDOMAIN}.ciamlogin.com/${TENANT_ID}/oauth2/v2.0/token`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export namespace makeAuthorizationUrl {
|
||||||
|
export interface Args {
|
||||||
|
readonly external: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const makeAuthorizationUrl = Effect.fn("makeAuthorizationUrl")(
|
||||||
|
function* ({ external }: makeAuthorizationUrl.Args) {
|
||||||
|
const { db, execute } = yield* Db;
|
||||||
|
const sessionId = yield* Session;
|
||||||
|
|
||||||
|
const { codeVerifier, codeChallenge } = generateCodeVerifier();
|
||||||
|
const state = generateRandomState();
|
||||||
|
|
||||||
|
yield* db
|
||||||
|
.updateTable("Session")
|
||||||
|
.set({
|
||||||
|
codeVerifier,
|
||||||
|
state,
|
||||||
|
external: external ? 1 : 0,
|
||||||
|
accessToken: null,
|
||||||
|
idToken: null,
|
||||||
|
refreshToken: null,
|
||||||
|
})
|
||||||
|
.where("sessionId", "=", sessionId)
|
||||||
|
.$call(execute);
|
||||||
|
|
||||||
|
const { authorizationEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||||
|
|
||||||
|
const url = new URL(authorizationEndpoint);
|
||||||
|
url.searchParams.set("client_id", CLIENT_ID);
|
||||||
|
url.searchParams.set("response_type", "code");
|
||||||
|
url.searchParams.set("redirect_uri", REDIRECT_URI);
|
||||||
|
url.searchParams.set("scope", OAUTH_SCOPE);
|
||||||
|
url.searchParams.set("response_mode", "form_post");
|
||||||
|
url.searchParams.set("state", state);
|
||||||
|
url.searchParams.set("prompt", "select_account");
|
||||||
|
url.searchParams.set("code_challenge", codeChallenge);
|
||||||
|
url.searchParams.set("code_challenge_method", "S256");
|
||||||
|
|
||||||
|
return url.toString();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export namespace revaildateTokens {
|
||||||
|
export interface Args {
|
||||||
|
readonly accessToken: Option.Option<string>;
|
||||||
|
readonly idToken: Option.Option<string>;
|
||||||
|
readonly refreshToken: Option.Option<string>;
|
||||||
|
readonly external: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly accessToken: Option.Option<{
|
||||||
|
readonly token: string,
|
||||||
|
readonly payload: Model.AccessTokenPayload,
|
||||||
|
}>;
|
||||||
|
readonly idToken: Option.Option<{
|
||||||
|
readonly token: string,
|
||||||
|
readonly payload: Model.IdTokenPayload,
|
||||||
|
}>;
|
||||||
|
readonly refreshToken: Option.Option<string>;
|
||||||
|
readonly userId: Option.Option<UserId>;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const revalidateTokens = Effect.fn("revaildateTokens")(
|
||||||
|
function* ({ accessToken, idToken, refreshToken, external }: revaildateTokens.Args) {
|
||||||
|
|
||||||
|
const accessTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.AccessTokenPayload>);
|
||||||
|
const idTokenPayload = Option.map(accessToken, getJwtTokenPayload<Model.IdTokenPayload>);
|
||||||
|
|
||||||
|
const expirationThreshold = yield* pipe(
|
||||||
|
DateTime.now,
|
||||||
|
Effect.map(DateTime.addDuration(EXPIRATION_BUFFER)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Token expired or missing
|
||||||
|
if (Option.match(accessTokenPayload, {
|
||||||
|
onNone: constant(false),
|
||||||
|
onSome: (atp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * atp.exp)),
|
||||||
|
}) || Option.match(idTokenPayload, {
|
||||||
|
onNone: constant(false),
|
||||||
|
onSome: (itp) => DateTime.greaterThan(expirationThreshold, DateTime.unsafeMake(1000 * itp.exp)),
|
||||||
|
})) {
|
||||||
|
|
||||||
|
accessToken = Option.none();
|
||||||
|
idToken = Option.none();
|
||||||
|
|
||||||
|
// try refreshing
|
||||||
|
if (Option.isSome(refreshToken)) {
|
||||||
|
const refreshTokenValue = refreshToken.value;
|
||||||
|
const { tokenEndpoint } = external ? EXTERNAL_OAUTH_CONFIGURATION : INTERNAL_OAUTH_CONFIGURATION;
|
||||||
|
|
||||||
|
const res = yield* Effect.promise((signal) => fetch(tokenEndpoint, {
|
||||||
|
method: "POST",
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": refreshTokenValue,
|
||||||
|
"client_secret": process.env.CLIENT_SECRET!,
|
||||||
|
}).toString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const json = (yield* Effect.promise(() => res.json())) as { access_token: string, refresh_token: string, id_token: string };
|
||||||
|
|
||||||
|
accessToken = Option.some(json.access_token);
|
||||||
|
idToken = Option.some(json.id_token);
|
||||||
|
refreshToken = Option.some(json.refresh_token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const it = Option.map(idToken, (it) => Object.freeze({
|
||||||
|
token: it,
|
||||||
|
payload: getJwtTokenPayload<Model.IdTokenPayload>(it),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const res: revaildateTokens.Result = Object.freeze<revaildateTokens.Result>({
|
||||||
|
accessToken: Option.map(accessToken, (at) => Object.freeze({
|
||||||
|
token: at,
|
||||||
|
payload: getJwtTokenPayload<Model.AccessTokenPayload>(at),
|
||||||
|
})),
|
||||||
|
idToken: it,
|
||||||
|
refreshToken,
|
||||||
|
userId: Option.map(it, ({ payload: { oid } }) => oid),
|
||||||
|
roles: Option.match(it, {
|
||||||
|
onNone: constant(Object.freeze([])),
|
||||||
|
onSome: ({ payload: { roles } }) => roles,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function getJwtTokenPayload<O extends object = object>(token: string): O {
|
||||||
|
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf-8")) as O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace generateCodeVerifier {
|
||||||
|
export interface Result {
|
||||||
|
codeVerifier: string;
|
||||||
|
codeChallenge: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateCodeVerifier(byteLength: number = 32): generateCodeVerifier.Result {
|
||||||
|
const codeVerifierBytes = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(codeVerifierBytes);
|
||||||
|
const codeVerifier = Buffer.from(codeVerifierBytes).toString("base64url");
|
||||||
|
|
||||||
|
const codeVerifierAsciiBuffer = Buffer.from(codeVerifier, "ascii");
|
||||||
|
const codeVerifierAsciiArray = new Uint8Array(
|
||||||
|
codeVerifierAsciiBuffer.buffer,
|
||||||
|
codeVerifierAsciiBuffer.byteOffset,
|
||||||
|
codeVerifierAsciiBuffer.length,
|
||||||
|
);
|
||||||
|
const codeChallenge = Bun.SHA256.hash(codeVerifierAsciiArray, "base64url");
|
||||||
|
|
||||||
|
return { codeVerifier, codeChallenge };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateRandomState(byteLength: number = 32): string {
|
||||||
|
const array = new Uint8Array(byteLength);
|
||||||
|
crypto.getRandomValues(array);
|
||||||
|
const state = Buffer.from(array).toString("base64url");
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace getUser {
|
||||||
|
export interface Args {
|
||||||
|
readonly accessToken: string;
|
||||||
|
readonly userId: UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Result {
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getUser = Effect.fn("getUser")(
|
||||||
|
function* ({ accessToken, userId }: getUser.Args) {
|
||||||
|
const res = yield* Effect.promise((signal) => fetch(`https://graph.microsoft.com/v1.0/users/${userId}?$select=displayName`, {
|
||||||
|
signal,
|
||||||
|
headers: {
|
||||||
|
"Authorization": `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (res.status === 404) {
|
||||||
|
return Option.none();
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = (yield* Effect.promise(() => res.json())) as getUser.Result;
|
||||||
|
return Option.some(json);
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -18,7 +18,6 @@ export interface Database {
|
|||||||
Repertoire: RepertoireTable;
|
Repertoire: RepertoireTable;
|
||||||
RepertoireEntry: RepertoireEntryTable;
|
RepertoireEntry: RepertoireEntryTable;
|
||||||
Session: SessionTable;
|
Session: SessionTable;
|
||||||
User: UserTable;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInformation {
|
export interface SystemInformation {
|
||||||
@@ -79,7 +78,12 @@ export interface RepertoireEntryTable extends RepertoireEntryData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionData {
|
export interface SessionData {
|
||||||
userId: UserId;
|
state: string | null;
|
||||||
|
codeVerifier: string | null;
|
||||||
|
accessToken: string | null;
|
||||||
|
idToken: string | null;
|
||||||
|
refreshToken: string | null;
|
||||||
|
external: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionTable extends SessionData {
|
export interface SessionTable extends SessionData {
|
||||||
@@ -87,13 +91,6 @@ export interface SessionTable extends SessionData {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserTable {
|
|
||||||
userId: ColumnType<UserId, UserId, never>;
|
|
||||||
username: string;
|
|
||||||
password: string;
|
|
||||||
admin: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AccessLog = Selectable<AccessLogTable>;
|
export type AccessLog = Selectable<AccessLogTable>;
|
||||||
export type Attachment = Selectable<AttachmentTable>;
|
export type Attachment = Selectable<AttachmentTable>;
|
||||||
export type File = Selectable<FileTable>;
|
export type File = Selectable<FileTable>;
|
||||||
@@ -101,13 +98,12 @@ 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>;
|
|
||||||
|
|
||||||
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
|
function systemInformation<TB extends string, C extends string>(schema: CreateTableBuilder<TB, C>) {
|
||||||
return schema
|
return schema
|
||||||
.addColumn("createdBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
|
.addColumn("createdBy", "text")
|
||||||
.addColumn("createdAt", "text", (c) => c.notNull())
|
.addColumn("createdAt", "text", (c) => c.notNull())
|
||||||
.addColumn("modifiedBy", "text", (c) => c.references("User.userId").onDelete("set null").onUpdate("cascade"))
|
.addColumn("modifiedBy", "text")
|
||||||
.addColumn("modifiedAt", "text");
|
.addColumn("modifiedAt", "text");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +195,12 @@ export async function initDatabase(filename: string = "db.sqlite3"): Promise<Kys
|
|||||||
.createTable("Session")
|
.createTable("Session")
|
||||||
.ifNotExists()
|
.ifNotExists()
|
||||||
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
.addColumn("sessionId", "text", (c) => c.notNull().primaryKey())
|
||||||
.addColumn("userId", "text", (c) => c.references("User.userId").onDelete("cascade").onUpdate("cascade"))
|
.addColumn("state", "text")
|
||||||
|
.addColumn("codeVerifier", "text")
|
||||||
|
.addColumn("accessToken", "text")
|
||||||
|
.addColumn("idToken", "text")
|
||||||
|
.addColumn("refreshToken", "text")
|
||||||
|
.addColumn("external", "boolean")
|
||||||
.addColumn("expiresAt", "text", (c) => c.notNull())
|
.addColumn("expiresAt", "text", (c) => c.notNull())
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,27 @@ import * as Common from "common";
|
|||||||
import * as Function from "common/Function";
|
import * as Function from "common/Function";
|
||||||
import { t } from "elysia";
|
import { t } from "elysia";
|
||||||
|
|
||||||
|
export interface AccessTokenPayload {
|
||||||
|
readonly aud: string;
|
||||||
|
readonly iss: string;
|
||||||
|
readonly iat: number;
|
||||||
|
readonly nbf: number;
|
||||||
|
readonly exp: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly oid: Common.UserId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IdTokenPayload {
|
||||||
|
readonly aud: string;
|
||||||
|
readonly iss: string;
|
||||||
|
readonly iat: number;
|
||||||
|
readonly nbf: number;
|
||||||
|
readonly exp: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly oid: Common.UserId;
|
||||||
|
readonly roles: readonly string[];
|
||||||
|
}
|
||||||
|
|
||||||
const brandedString = <T>() => t.Transform(t.String())
|
const brandedString = <T>() => t.Transform(t.String())
|
||||||
.Decode(Function.unsafeCoerce<string, T>)
|
.Decode(Function.unsafeCoerce<string, T>)
|
||||||
.Encode(Function.unsafeCoerce<T, string>);
|
.Encode(Function.unsafeCoerce<T, string>);
|
||||||
@@ -44,6 +65,12 @@ export const Attachment = t.Object({
|
|||||||
...SystemInformation,
|
...SystemInformation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const Me = t.Object({
|
||||||
|
userId: UserId,
|
||||||
|
username: t.String(),
|
||||||
|
roles: t.Array(t.String()),
|
||||||
|
});
|
||||||
|
|
||||||
export const Piece = t.Object({
|
export const Piece = t.Object({
|
||||||
pieceId: PieceId,
|
pieceId: PieceId,
|
||||||
name: t.String({ minLength: 1 }),
|
name: t.String({ minLength: 1 }),
|
||||||
@@ -81,35 +108,15 @@ export const Repertoire_Query = t.Object({
|
|||||||
|
|
||||||
export const User = t.Object({
|
export const User = t.Object({
|
||||||
userId: UserId,
|
userId: UserId,
|
||||||
username: t.String(),
|
displayName: t.String(),
|
||||||
admin: t.Boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const User_Patch = t.Object({
|
|
||||||
username: t.Optional(t.String()),
|
|
||||||
password: t.Optional(t.String({ minLength: 8 })),
|
|
||||||
admin: t.Optional(t.Boolean()),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const User_Post = t.Object({
|
|
||||||
username: t.String(),
|
|
||||||
password: t.String({ minLength: 8 }),
|
|
||||||
admin: t.Boolean(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const User_Query = t.Object({
|
|
||||||
username: t.Optional(t.String()),
|
|
||||||
...Pagination,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AccessLog = typeof AccessLog.static;
|
export type AccessLog = typeof AccessLog.static;
|
||||||
export type Attachment = typeof Attachment.static;
|
export type Attachment = typeof Attachment.static;
|
||||||
|
export type Me = typeof Me.static;
|
||||||
export type Piece = typeof Piece.static;
|
export type Piece = typeof Piece.static;
|
||||||
export type Piece_Post = typeof Piece_Post.static;
|
export type Piece_Post = typeof Piece_Post.static;
|
||||||
export type Piece_Query = typeof Piece_Query.static;
|
export type Piece_Query = typeof Piece_Query.static;
|
||||||
export type Repertoire = typeof Repertoire.static;
|
export type Repertoire = typeof Repertoire.static;
|
||||||
export type Repertoire_Query = typeof Repertoire_Query.static;
|
export type Repertoire_Query = typeof Repertoire_Query.static;
|
||||||
export type User = typeof User.static;
|
export type User = typeof User.static;
|
||||||
export type User_Patch = typeof User_Patch.static;
|
|
||||||
export type User_Post = typeof User_Post.static;
|
|
||||||
export type User_Query = typeof User_Query.static;
|
|
||||||
|
|||||||
31
packages/backend/src/services/db.ts
Normal file
31
packages/backend/src/services/db.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Cause, Context, Effect, Layer, pipe } from "effect";
|
||||||
|
import { Kysely } from "kysely";
|
||||||
|
import { Database } from "../database";
|
||||||
|
|
||||||
|
export interface Executable<O> {
|
||||||
|
execute(): Promise<O[]>;
|
||||||
|
executeTakeFirst(): Promise<O | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DbInterface {
|
||||||
|
readonly db: Kysely<Database>;
|
||||||
|
readonly execute: <O>(executable: Executable<O>) => Effect.Effect<readonly O[]>;
|
||||||
|
readonly executeTakeFirst: <O>(executable: Executable<O>) => Effect.Effect<O, Cause.NoSuchElementException>;
|
||||||
|
readonly executeTakeFirstOrDefect: <O>(executable: Executable<O>) => Effect.Effect<O>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Db extends Context.Tag("Db")<Db, DbInterface>() { }
|
||||||
|
|
||||||
|
export const DbFromInstance = (db: Kysely<Database>) => Layer.succeed(Db, Object.freeze<DbInterface>({
|
||||||
|
db,
|
||||||
|
execute: (executable) => Effect.promise(() => Object.freeze(executable.execute())),
|
||||||
|
executeTakeFirst: (executable) => pipe(
|
||||||
|
Effect.promise(() => executable.executeTakeFirst()),
|
||||||
|
Effect.flatMap(Effect.fromNullable),
|
||||||
|
),
|
||||||
|
executeTakeFirstOrDefect: (executable) => pipe(
|
||||||
|
Effect.promise(() => executable.executeTakeFirst()),
|
||||||
|
Effect.flatMap(Effect.fromNullable),
|
||||||
|
Effect.orDie,
|
||||||
|
),
|
||||||
|
}));
|
||||||
6
packages/backend/src/services/session.ts
Normal file
6
packages/backend/src/services/session.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { SessionId } from "common";
|
||||||
|
import { Context, Layer } from "effect";
|
||||||
|
|
||||||
|
export class Session extends Context.Tag("Session")<Session, SessionId>() { }
|
||||||
|
|
||||||
|
export const SessionFromValue = Layer.succeed(Session);
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Home } from "@/routes/Home";
|
import { Home } from "@/routes/Home";
|
||||||
import { Login } from "@/routes/Login";
|
|
||||||
import { Piece } from "@/routes/Piece";
|
import { Piece } from "@/routes/Piece";
|
||||||
import { Pieces } from "@/routes/Pieces";
|
import { Pieces } from "@/routes/Pieces";
|
||||||
import { Repertoire } from "@/routes/Repertoire";
|
import { Repertoire } from "@/routes/Repertoire";
|
||||||
@@ -63,10 +62,6 @@ const router = createBrowserRouter([
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: "/login",
|
|
||||||
Component: Login,
|
|
||||||
},
|
|
||||||
], {
|
], {
|
||||||
future: {
|
future: {
|
||||||
v7_fetcherPersist: true,
|
v7_fetcherPersist: true,
|
||||||
|
|||||||
@@ -5,8 +5,7 @@ import { client, mapResponse } from "./client";
|
|||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
readonly userId: UserId;
|
readonly userId: UserId;
|
||||||
readonly username: string;
|
readonly displayName: string;
|
||||||
readonly admin: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemInformation {
|
export interface SystemInformation {
|
||||||
@@ -108,7 +107,7 @@ export const denormalizeRepertoire = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const UserSemaphore = Effect.unsafeMakeSemaphore(1);
|
const UserSemaphore = Effect.unsafeMakeSemaphore(1);
|
||||||
const CacheSemaphore = Effect.unsafeMakeSemaphore(4);
|
const PieceSemaphore = Effect.unsafeMakeSemaphore(4);
|
||||||
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
const RepertoireSemaphore = Effect.unsafeMakeSemaphore(1);
|
||||||
|
|
||||||
export const userLookup = (userId: UserId) => pipe(
|
export const userLookup = (userId: UserId) => pipe(
|
||||||
@@ -123,7 +122,7 @@ export const pieceLookup = (pieceId: PieceId) => pipe(
|
|||||||
Effect.flatMap(mapResponse),
|
Effect.flatMap(mapResponse),
|
||||||
Effect.flatMap(denormalizePiece),
|
Effect.flatMap(denormalizePiece),
|
||||||
Effect.map((x): Piece => x), // safely coerce to interface
|
Effect.map((x): Piece => x), // safely coerce to interface
|
||||||
CacheSemaphore.withPermits(1),
|
PieceSemaphore.withPermits(1),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const repertoireLookup = (repertoireId: RepertoireId) => pipe(
|
export const repertoireLookup = (repertoireId: RepertoireId) => pipe(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { API_URL_PREFIX } from "@/client";
|
||||||
import { mapProp, Update, Updater, useStore } from "@/hooks/useStore";
|
import { mapProp, Update, Updater, useStore } from "@/hooks/useStore";
|
||||||
import { Treaty } from "@elysiajs/eden";
|
import { Treaty } from "@elysiajs/eden";
|
||||||
import { Effect, Fiber, pipe } from "effect";
|
import { Effect, Fiber, pipe } from "effect";
|
||||||
@@ -57,8 +58,7 @@ export function useLoading<R extends Record<number, unknown>>(fn: () => Promise<
|
|||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
if (error.status === 401) {
|
if (error.status === 401) {
|
||||||
setUser(null);
|
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
||||||
navigate("/login");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,17 @@ export namespace Store {
|
|||||||
export interface User {
|
export interface User {
|
||||||
readonly userId: UserId;
|
readonly userId: UserId;
|
||||||
readonly username: string;
|
readonly username: string;
|
||||||
readonly admin: boolean;
|
readonly roles: readonly string[];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Store {
|
export interface Store {
|
||||||
readonly loginUsername: string;
|
|
||||||
readonly loginPassword: string;
|
|
||||||
|
|
||||||
readonly user: Store.User | null;
|
readonly user: Store.User | null;
|
||||||
|
|
||||||
readonly setLoginUsername: Updater<string>;
|
|
||||||
readonly setLoginPassword: Updater<string>;
|
|
||||||
|
|
||||||
readonly setUser: Updater<Store.User | null>;
|
readonly setUser: Updater<Store.User | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let store: Store = Object.freeze<Store>({
|
let store: Store = Object.freeze<Store>({
|
||||||
loginUsername: "",
|
|
||||||
loginPassword: "",
|
|
||||||
|
|
||||||
user: null,
|
user: null,
|
||||||
|
|
||||||
setLoginUsername: (action) => set(mapProp("loginUsername", action)),
|
|
||||||
setLoginPassword: (action) => set(mapProp("loginPassword", action)),
|
|
||||||
|
|
||||||
setUser: (action) => set(mapProp("user", action)),
|
setUser: (action) => set(mapProp("user", action)),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
import { client } from "@/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { useStore } from "@/hooks/useStore";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { FormEventHandler, useId, useState } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
export function Login() {
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const loginUsername = useStore(state => state.loginUsername);
|
|
||||||
const loginPassword = useStore(state => state.loginPassword);
|
|
||||||
|
|
||||||
const setLoginUsername = useStore(state => state.setLoginUsername);
|
|
||||||
const setLoginPassword = useStore(state => state.setLoginPassword);
|
|
||||||
|
|
||||||
const setUser = useStore(state => state.setUser);
|
|
||||||
|
|
||||||
const usernameId = useId();
|
|
||||||
const passwordId = useId();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { data, error } = await client.login.post({
|
|
||||||
username: loginUsername,
|
|
||||||
password: loginPassword,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoginUsername("");
|
|
||||||
setLoginPassword("");
|
|
||||||
setUser(data);
|
|
||||||
|
|
||||||
navigate("/");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Repozytorium muzyczne</CardTitle>
|
|
||||||
<CardDescription>Zaloguj się, aby kontynuować</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Label htmlFor={usernameId}>Nazwa użytkownika</Label>
|
|
||||||
<Input
|
|
||||||
id={usernameId}
|
|
||||||
className="w-[32ch]"
|
|
||||||
type="text"
|
|
||||||
autoComplete="username"
|
|
||||||
value={loginUsername}
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
onChange={(e) => setLoginUsername(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={passwordId}>Hasło</Label>
|
|
||||||
<Input
|
|
||||||
id={passwordId}
|
|
||||||
className="w-[32ch]"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={loginPassword}
|
|
||||||
required
|
|
||||||
onChange={(e) => setLoginPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="animate-spin" />}
|
|
||||||
Zaloguj się
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
import { client } from "@/client";
|
import { API_URL_PREFIX, 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 { useStore } from "@/hooks/useStore";
|
import { useStore } from "@/hooks/useStore";
|
||||||
import { LogOut, Settings, User } from "lucide-react";
|
import { Settings, User } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { Link, Outlet, useNavigate } from "react-router-dom";
|
import { Link, Outlet } from "react-router-dom";
|
||||||
|
|
||||||
export function Root() {
|
export function Root() {
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const user = useStore(state => state.user);
|
const user = useStore(state => state.user);
|
||||||
const setUser = useStore(state => state.setUser);
|
const setUser = useStore(state => state.setUser);
|
||||||
|
|
||||||
@@ -19,7 +17,7 @@ export function Root() {
|
|||||||
const { data, error } = await client.me.get();
|
const { data, error } = await client.me.get();
|
||||||
|
|
||||||
if (error !== null) {
|
if (error !== null) {
|
||||||
navigate("/login");
|
window.location.href = `${API_URL_PREFIX}/api/v1/login`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,17 +27,6 @@ export function Root() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
useEffect(() => void init(), []);
|
useEffect(() => void init(), []);
|
||||||
|
|
||||||
const onLogoutClick = async () => {
|
|
||||||
const { error } = await client.logout.post();
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
setUser(null);
|
|
||||||
navigate("/login");
|
|
||||||
};
|
|
||||||
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full overflow-hidden flex items-center justify-center">
|
<div className="w-full h-full overflow-hidden flex items-center justify-center">
|
||||||
@@ -66,9 +53,6 @@ export function Root() {
|
|||||||
<Settings />Ustawienia
|
<Settings />Ustawienia
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem onClick={onLogoutClick}>
|
|
||||||
<LogOut />Wyloguj się
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,100 +1,6 @@
|
|||||||
import { client } from "@/client";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useStore } from "@/hooks/useStore";
|
|
||||||
import { Label } from "@radix-ui/react-label";
|
|
||||||
import { Loader2 } from "lucide-react";
|
|
||||||
import { FormEventHandler, useId, useState } from "react";
|
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
return (
|
return (
|
||||||
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
<div className="p-4 overflow-y-auto grow flex flex-wrap items-start gap-4">
|
||||||
<PasswordChangeCard />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PasswordChangeCard() {
|
|
||||||
|
|
||||||
const [currentPassword, setCurrentPassword] = useState("");
|
|
||||||
const [newPassword1, setNewPassword1] = useState("");
|
|
||||||
const [newPassword2, setNewPassword2] = useState("");
|
|
||||||
|
|
||||||
const currentPasswordId = useId();
|
|
||||||
const newPassword1Id = useId();
|
|
||||||
const newPassword2Id = useId();
|
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const user = useStore(store => store.user);
|
|
||||||
|
|
||||||
const onSubmit: FormEventHandler<HTMLFormElement> = async (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
const { error } = await client["change-password"].post({
|
|
||||||
username: user!.username,
|
|
||||||
currentPassword,
|
|
||||||
newPassword: newPassword1,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (error !== null) {
|
|
||||||
console.error(error.value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={onSubmit}>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Zmiana hasła</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<Label htmlFor={currentPasswordId}>Obecne hasło</Label>
|
|
||||||
<Input
|
|
||||||
id={currentPasswordId}
|
|
||||||
className="w-[32ch]"
|
|
||||||
type="password"
|
|
||||||
autoComplete="current-password"
|
|
||||||
value={currentPassword}
|
|
||||||
required
|
|
||||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={currentPasswordId}>Nowe hasło</Label>
|
|
||||||
<Input
|
|
||||||
id={newPassword1Id}
|
|
||||||
className="w-[32ch]"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPassword1}
|
|
||||||
required
|
|
||||||
onChange={(e) => setNewPassword1(e.target.value)}
|
|
||||||
/>
|
|
||||||
<Label htmlFor={currentPasswordId}>Potwierdź hasło</Label>
|
|
||||||
<Input
|
|
||||||
id={newPassword2Id}
|
|
||||||
className="w-[32ch]"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
value={newPassword2}
|
|
||||||
required
|
|
||||||
onChange={(e) => setNewPassword2(e.target.value)}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter className="flex-row justify-end">
|
|
||||||
<Button type="submit" disabled={isLoading || newPassword1 !== newPassword2}>
|
|
||||||
{isLoading && <Loader2 className="animate-spin" />}
|
|
||||||
Zmień hasło
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function created({ createdAt, createdBy }: SystemInformation): ReactNode
|
|||||||
|
|
||||||
if (Option.isSome(createdBy)) {
|
if (Option.isSome(createdBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
nodes.push(`przez ${createdBy.value.username}`);
|
nodes.push(`przez ${createdBy.value.displayName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
@@ -48,7 +48,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
|
|||||||
if (Option.isNone(modifiedBy)) {
|
if (Option.isNone(modifiedBy)) {
|
||||||
return "\u2014";
|
return "\u2014";
|
||||||
} else {
|
} else {
|
||||||
return `przez ${modifiedBy.value.username}`;
|
return `przez ${modifiedBy.value.displayName}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ export function modified({ modifiedAt, modifiedBy }: SystemInformation): ReactNo
|
|||||||
|
|
||||||
if (Option.isSome(modifiedBy)) {
|
if (Option.isSome(modifiedBy)) {
|
||||||
nodes.push(<br />);
|
nodes.push(<br />);
|
||||||
nodes.push(`przez ${modifiedBy.value.username}`);
|
nodes.push(`przez ${modifiedBy.value.displayName}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes;
|
return nodes;
|
||||||
|
|||||||
108
pnpm-lock.yaml
generated
108
pnpm-lock.yaml
generated
@@ -146,6 +146,9 @@ importers:
|
|||||||
common:
|
common:
|
||||||
specifier: workspace:^
|
specifier: workspace:^
|
||||||
version: link:../common
|
version: link:../common
|
||||||
|
effect:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 3.11.4
|
||||||
elysia:
|
elysia:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)
|
version: 1.1.25(@sinclair/typebox@0.33.7)(openapi-types@12.1.3)(typescript@5.7.2)
|
||||||
@@ -507,32 +510,42 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||||
|
|
||||||
|
'@eslint-community/eslint-utils@4.5.1':
|
||||||
|
resolution: {integrity: sha512-soEIOALTfTK6EjmKMMoLugwaP0rzkad90iIWd1hMO9ARkSAyjfMfkRRhLvD5qH7vvM0Cg72pieUfR6yh6XxC4w==}
|
||||||
|
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||||
|
peerDependencies:
|
||||||
|
eslint: ^6.0.0 || ^7.0.0 || >=8.0.0
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.1':
|
'@eslint-community/regexpp@4.12.1':
|
||||||
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
|
resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==}
|
||||||
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
|
||||||
|
|
||||||
'@eslint/config-array@0.19.1':
|
'@eslint/config-array@0.19.2':
|
||||||
resolution: {integrity: sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==}
|
resolution: {integrity: sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==}
|
||||||
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
|
'@eslint/core@0.12.0':
|
||||||
|
resolution: {integrity: sha512-cmrR6pytBuSMTaBweKoGMwu3EiHiEC+DoyupPmlZ0HxBJBtIxwe+j/E4XPIKNx+Q74c8lXKPwYawBf5glsTkHg==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/core@0.9.1':
|
'@eslint/core@0.9.1':
|
||||||
resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==}
|
resolution: {integrity: sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/eslintrc@3.2.0':
|
'@eslint/eslintrc@3.3.0':
|
||||||
resolution: {integrity: sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==}
|
resolution: {integrity: sha512-yaVPAiNAalnCZedKLdR21GOGILMLKPyqSLWaAjQFvYA2i/ciDi8ArYVr69Anohb6cH2Ukhqti4aFnYyPm8wdwQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/js@9.17.0':
|
'@eslint/js@9.17.0':
|
||||||
resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==}
|
resolution: {integrity: sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.5':
|
'@eslint/object-schema@2.1.6':
|
||||||
resolution: {integrity: sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==}
|
resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.2.4':
|
'@eslint/plugin-kit@0.2.7':
|
||||||
resolution: {integrity: sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg==}
|
resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
'@floating-ui/core@1.6.8':
|
'@floating-ui/core@1.6.8':
|
||||||
@@ -569,8 +582,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
|
resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@humanwhocodes/retry@0.4.1':
|
'@humanwhocodes/retry@0.4.2':
|
||||||
resolution: {integrity: sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==}
|
resolution: {integrity: sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==}
|
||||||
engines: {node: '>=18.18'}
|
engines: {node: '>=18.18'}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
@@ -1347,6 +1360,15 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
debug@4.4.0:
|
||||||
|
resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1440,8 +1462,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0
|
||||||
|
|
||||||
eslint-scope@8.2.0:
|
eslint-scope@8.3.0:
|
||||||
resolution: {integrity: sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==}
|
resolution: {integrity: sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
|
|
||||||
eslint-visitor-keys@3.4.3:
|
eslint-visitor-keys@3.4.3:
|
||||||
@@ -1531,8 +1553,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
flatted@3.3.2:
|
flatted@3.3.3:
|
||||||
resolution: {integrity: sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA==}
|
resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==}
|
||||||
|
|
||||||
foreground-child@3.3.0:
|
foreground-child@3.3.0:
|
||||||
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==}
|
||||||
@@ -1659,8 +1681,8 @@ packages:
|
|||||||
immediate@3.0.6:
|
immediate@3.0.6:
|
||||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
import-fresh@3.3.0:
|
import-fresh@3.3.1:
|
||||||
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
|
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
imurmurhash@0.1.4:
|
imurmurhash@0.1.4:
|
||||||
@@ -2760,28 +2782,37 @@ snapshots:
|
|||||||
eslint: 9.17.0(jiti@1.21.6)
|
eslint: 9.17.0(jiti@1.21.6)
|
||||||
eslint-visitor-keys: 3.4.3
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
|
'@eslint-community/eslint-utils@4.5.1(eslint@9.17.0(jiti@1.21.6))':
|
||||||
|
dependencies:
|
||||||
|
eslint: 9.17.0(jiti@1.21.6)
|
||||||
|
eslint-visitor-keys: 3.4.3
|
||||||
|
|
||||||
'@eslint-community/regexpp@4.12.1': {}
|
'@eslint-community/regexpp@4.12.1': {}
|
||||||
|
|
||||||
'@eslint/config-array@0.19.1':
|
'@eslint/config-array@0.19.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/object-schema': 2.1.5
|
'@eslint/object-schema': 2.1.6
|
||||||
debug: 4.3.7
|
debug: 4.4.0
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@eslint/core@0.12.0':
|
||||||
|
dependencies:
|
||||||
|
'@types/json-schema': 7.0.15
|
||||||
|
|
||||||
'@eslint/core@0.9.1':
|
'@eslint/core@0.9.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
|
|
||||||
'@eslint/eslintrc@3.2.0':
|
'@eslint/eslintrc@3.3.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 6.12.6
|
ajv: 6.12.6
|
||||||
debug: 4.3.7
|
debug: 4.4.0
|
||||||
espree: 10.3.0
|
espree: 10.3.0
|
||||||
globals: 14.0.0
|
globals: 14.0.0
|
||||||
ignore: 5.3.2
|
ignore: 5.3.2
|
||||||
import-fresh: 3.3.0
|
import-fresh: 3.3.1
|
||||||
js-yaml: 4.1.0
|
js-yaml: 4.1.0
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.2
|
||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
@@ -2790,10 +2821,11 @@ snapshots:
|
|||||||
|
|
||||||
'@eslint/js@9.17.0': {}
|
'@eslint/js@9.17.0': {}
|
||||||
|
|
||||||
'@eslint/object-schema@2.1.5': {}
|
'@eslint/object-schema@2.1.6': {}
|
||||||
|
|
||||||
'@eslint/plugin-kit@0.2.4':
|
'@eslint/plugin-kit@0.2.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@eslint/core': 0.12.0
|
||||||
levn: 0.4.1
|
levn: 0.4.1
|
||||||
|
|
||||||
'@floating-ui/core@1.6.8':
|
'@floating-ui/core@1.6.8':
|
||||||
@@ -2827,7 +2859,7 @@ snapshots:
|
|||||||
|
|
||||||
'@humanwhocodes/retry@0.3.1': {}
|
'@humanwhocodes/retry@0.3.1': {}
|
||||||
|
|
||||||
'@humanwhocodes/retry@0.4.1': {}
|
'@humanwhocodes/retry@0.4.2': {}
|
||||||
|
|
||||||
'@isaacs/cliui@8.0.2':
|
'@isaacs/cliui@8.0.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3620,6 +3652,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
debug@4.4.0:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
decompress-response@6.0.0:
|
decompress-response@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
mimic-response: 3.1.0
|
mimic-response: 3.1.0
|
||||||
@@ -3717,7 +3753,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
eslint: 9.17.0(jiti@1.21.6)
|
eslint: 9.17.0(jiti@1.21.6)
|
||||||
|
|
||||||
eslint-scope@8.2.0:
|
eslint-scope@8.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esrecurse: 4.3.0
|
esrecurse: 4.3.0
|
||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
@@ -3728,24 +3764,24 @@ snapshots:
|
|||||||
|
|
||||||
eslint@9.17.0(jiti@1.21.6):
|
eslint@9.17.0(jiti@1.21.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/eslint-utils': 4.4.1(eslint@9.17.0(jiti@1.21.6))
|
'@eslint-community/eslint-utils': 4.5.1(eslint@9.17.0(jiti@1.21.6))
|
||||||
'@eslint-community/regexpp': 4.12.1
|
'@eslint-community/regexpp': 4.12.1
|
||||||
'@eslint/config-array': 0.19.1
|
'@eslint/config-array': 0.19.2
|
||||||
'@eslint/core': 0.9.1
|
'@eslint/core': 0.9.1
|
||||||
'@eslint/eslintrc': 3.2.0
|
'@eslint/eslintrc': 3.3.0
|
||||||
'@eslint/js': 9.17.0
|
'@eslint/js': 9.17.0
|
||||||
'@eslint/plugin-kit': 0.2.4
|
'@eslint/plugin-kit': 0.2.7
|
||||||
'@humanfs/node': 0.16.6
|
'@humanfs/node': 0.16.6
|
||||||
'@humanwhocodes/module-importer': 1.0.1
|
'@humanwhocodes/module-importer': 1.0.1
|
||||||
'@humanwhocodes/retry': 0.4.1
|
'@humanwhocodes/retry': 0.4.2
|
||||||
'@types/estree': 1.0.6
|
'@types/estree': 1.0.6
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
ajv: 6.12.6
|
ajv: 6.12.6
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.3.7
|
debug: 4.4.0
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 8.2.0
|
eslint-scope: 8.3.0
|
||||||
eslint-visitor-keys: 4.2.0
|
eslint-visitor-keys: 4.2.0
|
||||||
espree: 10.3.0
|
espree: 10.3.0
|
||||||
esquery: 1.6.0
|
esquery: 1.6.0
|
||||||
@@ -3833,10 +3869,10 @@ snapshots:
|
|||||||
|
|
||||||
flat-cache@4.0.1:
|
flat-cache@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
flatted: 3.3.2
|
flatted: 3.3.3
|
||||||
keyv: 4.5.4
|
keyv: 4.5.4
|
||||||
|
|
||||||
flatted@3.3.2: {}
|
flatted@3.3.3: {}
|
||||||
|
|
||||||
foreground-child@3.3.0:
|
foreground-child@3.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -3992,7 +4028,7 @@ snapshots:
|
|||||||
|
|
||||||
immediate@3.0.6: {}
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
import-fresh@3.3.0:
|
import-fresh@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
parent-module: 1.0.1
|
parent-module: 1.0.1
|
||||||
resolve-from: 4.0.0
|
resolve-from: 4.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user