diff --git a/.github/workflows/changesets.yml b/.github/workflows/changesets.yml index ea8c27c17c..ebe5ce53ec 100644 --- a/.github/workflows/changesets.yml +++ b/.github/workflows/changesets.yml @@ -1,8 +1,8 @@ name: changesets -on: - push: - branches: - - main +# on: +# push: +# branches: +# - main env: CI: true PNPM_CACHE_FOLDER: .pnpm-store diff --git a/README.md b/README.md index 1f3dcf16bc..feb8c883b6 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ root └── ... ``` -- `core` holds the primary app that is hosted on `www.pubpub.org`. +- `core` holds the primary app that is hosted on `v7.pubpub.org`. - `integrations` holds the integrations developed by the PubPub team. 3rd party integrations may be developed and hosted elsewhere. - `packages` holds libraries and npm packages that are used by `core`, `integrations`, and 3rd party integration developers. diff --git a/core/app/(user)/forgot/ForgotForm.tsx b/core/app/(user)/forgot/ForgotForm.tsx index d2d7554dc8..3e0ed6cd7a 100644 --- a/core/app/(user)/forgot/ForgotForm.tsx +++ b/core/app/(user)/forgot/ForgotForm.tsx @@ -14,7 +14,7 @@ export default function ForgotForm() { setIsLoading(true); setFailure(false); const { error } = await supabase.auth.resetPasswordForEmail(email, { - redirectTo: "https://www.pubpub.org/reset", + redirectTo: `${process.env.NEXT_PUBLIC_PUBPUB_URL}/reset`, }); if (error) { console.error(error); diff --git a/core/app/(user)/reset/ResetForm.tsx b/core/app/(user)/reset/ResetForm.tsx index 662d82b01c..ed252cfda9 100644 --- a/core/app/(user)/reset/ResetForm.tsx +++ b/core/app/(user)/reset/ResetForm.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, FormEvent } from "react"; import { Button } from "ui"; -import { supabase } from "lib/supabase"; +import { formatSupabaseError, supabase } from "lib/supabase"; import { useRouter } from "next/navigation"; export default function ResetForm() { @@ -9,18 +9,18 @@ export default function ResetForm() { const [password, setPassword] = useState(""); const [isLoading, setIsLoading] = useState(false); const [success, setSuccess] = useState(false); - const [failure, setFailure] = useState(false); + const [error, setError] = useState(""); const handleSubmit = async (evt: FormEvent) => { setIsLoading(true); - setFailure(false); + setError(""); evt.preventDefault(); const { data, error } = await supabase.auth.updateUser({ password, }); if (error) { setIsLoading(false); - setFailure(true); + setError(formatSupabaseError(error)); } else if (data) { setIsLoading(false); setSuccess(true); @@ -46,8 +46,8 @@ export default function ResetForm() { - {failure && ( -
Error reseting password
+ {error && ( +
Error resetting password: {error}
)} diff --git a/core/app/(user)/settings/SettingsForm.tsx b/core/app/(user)/settings/SettingsForm.tsx index 1c6ba5cf0a..20fa7247db 100644 --- a/core/app/(user)/settings/SettingsForm.tsx +++ b/core/app/(user)/settings/SettingsForm.tsx @@ -88,7 +88,7 @@ export default function SettingsForm({ const resetPassword = async () => { setResetIsLoading(true); const { error } = await supabase.auth.resetPasswordForEmail(initEmail, { - redirectTo: "https://www.pubpub.org/reset", + redirectTo: `${process.env.NEXT_PUBLIC_PUBPUB_URL}/reset`, }); if (error) { console.error(error); diff --git a/core/app/api/supabase-webhooks/user/route.ts b/core/app/api/supabase-webhooks/user/route.ts index 2eb3437821..a5a41ab536 100644 --- a/core/app/api/supabase-webhooks/user/route.ts +++ b/core/app/api/supabase-webhooks/user/route.ts @@ -31,7 +31,7 @@ export async function POST(req: NextRequest) { try { await prisma.user.update({ where: { - id: body.record.id + supabaseId: body.record.id }, data: { email: newEmail diff --git a/core/app/api/user/route.ts b/core/app/api/user/route.ts index e49ae216e7..75cfa99c09 100644 --- a/core/app/api/user/route.ts +++ b/core/app/api/user/route.ts @@ -3,8 +3,9 @@ import { NextRequest, NextResponse } from "next/server"; import prisma from "prisma/db"; import { getServerSupabase } from "lib/supabaseServer"; import { generateHash, getSlugSuffix, slugifyString } from "lib/string"; -import { getLoginId } from "lib/auth/loginId"; +import { getSupabaseId } from "lib/auth/loginId"; import { BadRequestError, ForbiddenError, UnauthorizedError, handleErrors } from "~/lib/server"; +import { captureException } from "@sentry/nextjs"; export type UserPostBody = { firstName: string; @@ -23,11 +24,22 @@ export async function POST(req: NextRequest) { const submittedData: UserPostBody = await req.json(); const { firstName, lastName, email, password } = submittedData; const supabase = getServerSupabase(); + + const existingUser = await prisma.user.findUnique({ + where: { + email, + }, + }); + + if (existingUser?.supabaseId) { + throw new ForbiddenError("User already exists"); + } + const { data, error } = await supabase.auth.signUp({ email, password, options: { - emailRedirectTo: "https://www.pubpub.org/confirm", + emailRedirectTo: `${process.env.NEXT_PUBLIC_PUBPUB_URL}/login`, }, }); /* Supabase returns: @@ -48,46 +60,59 @@ export async function POST(req: NextRequest) { session: null } */ + + console.log("Supabase user", data); if (error || !data.user) { - console.error("Supabase createUser error:"); - console.error(error); + console.error("Supabase createUser error: ", error); + captureException(error); return NextResponse.json({ message: "Supabase createUser error" }, { status: 500 }); } - await prisma.user.create({ - data: { - id: data.user.id, - slug: `${slugifyString(firstName)}${ - lastName ? `-${slugifyString(lastName)}` : "" - }-${generateHash(4, "0123456789")}`, - firstName, - lastName: lastName || undefined, - email, - }, - }); - return NextResponse.json({}, { status: 201 }); + if (existingUser) { + // TODO: create community membership here, update name, slug etc. + // await prisma.user.update({ + // where: { + // email, + // }, + // data: { + // supabaseId: data.user.id, + // }, + // }); + return NextResponse.json({ message: "Existing account claimed" }, { status: 200 }); + } else { + const newUser = await prisma.user.create({ + data: { + supabaseId: data.user.id, + slug: `${slugifyString(firstName)}${ + lastName ? `-${slugifyString(lastName)}` : "" + }-${generateHash(4, "0123456789")}`, + firstName, + lastName: lastName || undefined, + email, + }, + }); + return NextResponse.json({message: "New user created"}, { status: 201 }); + } }); } export async function PUT(req: NextRequest) { return await handleErrors(async () => { - const loginId = await getLoginId(req); - if (!loginId) { + const supabaseId = await getSupabaseId(req); + if (!supabaseId) { throw new UnauthorizedError(); } const submittedData: UserPutBody = await req.json(); const { firstName, lastName } = submittedData; const currentData = await prisma.user.findUnique({ - where: { id: loginId }, + where: { supabaseId }, }); if (!currentData) { throw new BadRequestError("Unable to find user"); } const slugSuffix = getSlugSuffix(currentData.slug); await prisma.user.update({ - where: { - id: loginId, - }, + where: { supabaseId }, data: { slug: `${slugifyString(firstName)}-${slugSuffix}`, firstName, @@ -101,8 +126,8 @@ export async function PUT(req: NextRequest) { // Used to determine if an email is available when a user attempts to change theirs export async function GET(req: NextRequest) { return await handleErrors(async () => { - const loginId = await getLoginId(req); - if (!loginId) { + const supabaseId = await getSupabaseId(req); + if (!supabaseId) { throw new UnauthorizedError(); } diff --git a/core/lib/auth/loginData.ts b/core/lib/auth/loginData.ts index a0deada984..f39c5a5f78 100644 --- a/core/lib/auth/loginData.ts +++ b/core/lib/auth/loginData.ts @@ -3,7 +3,8 @@ import { cache } from "react"; import { cookies } from "next/headers"; import prisma from "~/prisma/db"; import { REFRESH_NAME, TOKEN_NAME } from "~/lib/auth/cookies"; -import { getIdFromJWT } from "~/lib/auth/loginId"; +import { getUserInfoFromJWT } from "~/lib/auth/loginId"; +import { generateHash, slugifyString } from "../string"; /* This is only called from Server Component functions */ /* When in the API, use getLoginId from loginId.ts */ @@ -11,11 +12,51 @@ export const getLoginData = cache(async () => { const nextCookies = cookies(); const sessionJWTCookie = nextCookies.get(TOKEN_NAME) || { value: "" }; const sessionRefreshCookie = nextCookies.get(REFRESH_NAME) || { value: "" }; - const loginId = await getIdFromJWT(sessionJWTCookie.value, sessionRefreshCookie.value); - if (!loginId) { + const supabaseUser = await getUserInfoFromJWT( + sessionJWTCookie.value, + sessionRefreshCookie.value + ); + if (!supabaseUser?.id) { return undefined; } - return prisma.user.findUnique({ - where: { id: loginId }, + let user = await prisma.user.findUnique({ + where: { supabaseId: supabaseUser.id }, }); + + if (!user) { + // They successfully logged in via supabase, but no corresponding record was found in the + // app database + + if (!supabaseUser.email) { + throw new Error( + `Unable to create corresponding local record for supabase user ${supabaseUser.id}` + ); + } + + // TODO: Instead of this, we should force invited users to visit the settings screen and set + // a name before progressing + const firstName = supabaseUser.user_metadata.firstName ?? ""; + const lastName = supabaseUser.user_metadata.lastName ?? null; + const communityId = supabaseUser.user_metadata.communityId; + const canAdmin = supabaseUser.user_metadata.canAdmin ?? false; + + user = await prisma.user.create({ + data: { + email: supabaseUser.email, + supabaseId: supabaseUser.id, + firstName, + lastName, + slug: `${slugifyString(firstName)}${ + lastName ? `-${slugifyString(lastName)}` : "" + }-${generateHash(4, "0123456789")}`, + memberships: { + create: { + communityId, + canAdmin, + } + } + }, + }); + } + return user; }); diff --git a/core/lib/auth/loginId.ts b/core/lib/auth/loginId.ts index 48d6b11b42..60e79263af 100644 --- a/core/lib/auth/loginId.ts +++ b/core/lib/auth/loginId.ts @@ -3,47 +3,71 @@ import jwt from "jsonwebtoken"; import { getRefreshCookie, getTokenCookie } from "~/lib/auth/cookies"; import { getServerSupabase } from "~/lib/supabaseServer"; +import type { UserAppMetadata, UserMetadata } from "@supabase/supabase-js"; const JWT_SECRET: string = process.env.JWT_SECRET || ""; -const DATABASE_URL: string = process.env.DATABASE_URL || ""; /* This is only called from API calls */ /* When rendering server components, use getLoginData from loginData.ts */ -export async function getLoginId(req: NextRequest): Promise { +export async function getSupabaseId(req: NextRequest): Promise { const sessionJWT = getTokenCookie(req); if (!sessionJWT) { return "" } const refreshToken = getRefreshCookie(req); - return await getIdFromJWT(sessionJWT, refreshToken); + return await getSupabaseIdFromJWT(sessionJWT, refreshToken); } -export async function getIdFromJWT(sessionJWT?: string, refreshToken?: string): Promise { +export async function getSupabaseIdFromJWT(sessionJWT?: string, refreshToken?: string) { if (!sessionJWT) { return ""; } - try { - const { sub: userId } = await jwt.verify(sessionJWT, JWT_SECRET); - if (typeof userId !== "string") { - throw new Error("userId is not a string"); - } - return userId; - } catch (jwtError) { - console.error("In getLoginSession", jwtError); - /* We may get a jwtError if it has expired. In which case, */ - /* we try to use the refreshToken to sign back in before */ - /* waiting for the client to that after initial page load. */ - const supabase = getServerSupabase(); - const { data, error } = await supabase.auth.refreshSession({ - refresh_token: refreshToken || "", - }); - if (error) { - console.error("Error refreshing session:", error.message) - return ""; + + const user = await getUserInfoFromJWT(sessionJWT, refreshToken) + + if (!user?.id) { + return "" + } + + return user.id; +} + +type jwtUser = { + id: string, + email?: string, + aud: string, + app_metadata: UserAppMetadata, + user_metadata: UserMetadata, + role?: string, +} + +export async function getUserInfoFromJWT(sessionJWT: string, refreshToken?: string): Promise { + try { + const decoded = await jwt.verify(sessionJWT, JWT_SECRET); + if (typeof decoded === 'string' || !decoded.sub) { + throw new Error('Invalid jwt payload') + } + // TODO: actually validate the JWT payload! + // Rename `sub` to `id` for a consistent return type with the User that the + // refreshSession method below returns + return { id: decoded.sub, ...decoded} as jwtUser } - if (!data?.user?.id) { - return ""; + catch (jwtError) { + console.error("Error verifying jwt", jwtError); + /* We may get a jwtError if it has expired. In which case, */ + /* we try to use the refreshToken to sign back in before */ + /* waiting for the client to that after initial page load. */ + const supabase = getServerSupabase(); + const { data, error } = await supabase.auth.refreshSession({ + refresh_token: refreshToken || "", + }); + if (error) { + console.error("Error refreshing session:", error.message) + return null; + } + if (!data.user?.id) { + return null; + } + return data.user; } - return data.user.id; - } } diff --git a/core/lib/supabase.ts b/core/lib/supabase.ts index 9386a73a28..9c1bfa0f1b 100644 --- a/core/lib/supabase.ts +++ b/core/lib/supabase.ts @@ -1,4 +1,4 @@ -import { createClient, SupabaseClient } from "@supabase/supabase-js"; +import { AuthError, createClient, SupabaseClient } from "@supabase/supabase-js"; export let supabase: SupabaseClient; @@ -13,3 +13,5 @@ export const createBrowserSupabase = () => { }, }); }; + +export const formatSupabaseError = (error: AuthError) => `${error.name} ${error.status}: ${error.message}` \ No newline at end of file diff --git a/core/package.json b/core/package.json index 89ff0c3ba5..edb8a137a2 100644 --- a/core/package.json +++ b/core/package.json @@ -5,6 +5,7 @@ "scripts": { "dev": "next dev -p 3000", "build": "next build", + "invite-users": "dotenv -e .env.local ts-node scripts/invite.ts", "migrate-dev": "dotenv -e .env.local prisma migrate dev", "migrate-deploy": "dotenv -e .env.local prisma migrate deploy", "prisma-studio": "dotenv -e .env.local prisma studio", @@ -15,7 +16,7 @@ "type-check-watch": "tsc --watch" }, "prisma": { - "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts" + "seed": "ts-node prisma/seed.ts" }, "dependencies": { "@faker-js/faker": "^8.0.2", @@ -56,6 +57,7 @@ "@types/react-dom": "18.2.5", "@types/uuid": "^9.0.2", "autoprefixer": "^10.4.14", + "csv-parse": "^5.5.2", "dotenv-cli": "^7.2.1", "postcss": "^8.4.27", "prisma": "^5.2.0", @@ -63,6 +65,7 @@ "tailwindcss": "^3.3.3", "ts-node": "^10.9.1", "tsconfig": "workspace:*", - "typescript": "5.1.3" + "typescript": "5.1.3", + "yargs": "^17.7.2" } } diff --git a/core/prisma/exampleCommunitySeeds/unjournal.ts b/core/prisma/exampleCommunitySeeds/unjournal.ts index dffde8d07d..9c9109e8df 100644 --- a/core/prisma/exampleCommunitySeeds/unjournal.ts +++ b/core/prisma/exampleCommunitySeeds/unjournal.ts @@ -2,6 +2,8 @@ import { PrismaClient } from "@prisma/client"; import { v4 as uuidv4 } from "uuid"; import { faker } from "@faker-js/faker"; +export const unJournalId = "03e7a5fd-bdca-4682-9221-3a69992c1f3b"; + export default async function main(prisma: PrismaClient, communityUUID: string) { await prisma.community.create({ data: { @@ -12,86 +14,107 @@ export default async function main(prisma: PrismaClient, communityUUID: string) }, }); - const confidenceObject = { - rating: { - title: "Rating", - type: "number", - minimum: 0, - maximum: 100, - default: 0, - }, - confidence: { - title: "90% Confidence Interval", - description: "E.g. for a 50 rating, you might give a CI of 42, 61", - type: "object", - properties: { - low: { - title: "Low", - type: "number", - minimum: 0, - maximum: 100, - default: 0, - }, - high: { - title: "High", - type: "number", - minimum: 0, - maximum: 100, - default: 0, - }, - comments: { - title: "Additional Comments", - type: "string", - minLength: 0, - }, - }, + const confidenceCommentsObject = { + confidence2: { + title: "Additional Comments", + type: "string", + minLength: 0, }, }; + const HundredConfidenceDef = { + $id: "unjournal:100confidence", + title: "90% Confidence Interval Rating", + description: + "Provide three numbers: your rating, then the 90% confidence bounds for your rating. E.g. for a 50 rating, you might give bounds of 42 and 61.", + type: "array", + maxItems: 3, + minItems: 3, + default: [20, 30, 40], + items: { type: "integer", minimum: 0, maximum: 100 }, + }; + + const FiveConfidenceDef = { + $id: "unjournal:5confidence", + title: "90% Confidence Interval Rating", + description: + "Provide three numbers: your rating, then the 90% confidence bounds for your rating. E.g. for a 50 rating, you might give bounds of 42 and 61.", + type: "array", + maxItems: 3, + minItems: 3, + default: [2, 3, 4], + items: { type: "number", minimum: 0, maximum: 5 }, + }; + const metricsSchema = await prisma.pubFieldSchema.create({ data: { name: "metrics", namespace: "unjournal", schema: { $id: "unjournal:metrics", - title: "Metrics and Predictions", - description: "Responses will be public. See here for details on the categories.", + title: "Metrics", + description: + "Responses will be public. See here for details on the categories.", type: "object", + $defs: { + confidence: HundredConfidenceDef, + }, properties: { - assessment: { + metrics1: { title: "Overall assessment", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - advancing: { + metrics2: { title: "Advancing knowledge and practice", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - methods: { + metrics3: { title: "Methods: Justification, reasonableness, validity, robustness", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - logic: { - title: "Logic & Communication", + metrics4: { + title: "Logic & communication", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - open: { + metrics5: { title: "Open, collaborative, replicable", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - real: { + metrics6: { title: "Engaging with real-world, impact quantification; practice, realism, and relevance", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, - relevance: { - title: "Engaging with real-world, impact quantification; practice, realism, and relevance", + metrics7: { + title: "Relevance to global priorities", type: "object", - properties: confidenceObject, + properties: { + confidence1: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, }, }, }, @@ -112,35 +135,201 @@ export default async function main(prisma: PrismaClient, communityUUID: string) }, }); - const fieldIds = [...Array(12)].map(() => uuidv4()); + const predictionsSchema = await prisma.pubFieldSchema.create({ + data: { + name: "predictions", + namespace: "unjournal", + schema: { + $id: "unjournal:predictions", + title: "Prediction metric", + description: + "Responses will be public. See here for details on the metrics.", + type: "object", + $defs: { + confidence: FiveConfidenceDef, + }, + properties: { + qualityJournal: { + title: "What 'quality journal' do you expect this work will this be published in?", + type: "object", + properties: { + confidence: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, + }, + qualityLevel: { + title: "Overall assessment on 'scale of journals'; i.e., quality-level of journal it should be published in.", + type: "object", + properties: { + confidence: { $ref: "#/$defs/confidence" }, + ...confidenceCommentsObject, + }, + }, + }, + }, + }, + }); + + const confidentialCommentsSchema = await prisma.pubFieldSchema.create({ + data: { + name: "confidential-comments", + namespace: "unjournal", + schema: { + $id: "unjournal:confidential-comments", + title: "Please write confidential comments here", + description: + "Response will not be public or seen by authors, please use this section only for comments that are personal/sensitivity in nature and place most of your evaluation in the public section).", + type: "string", + }, + }, + }); + + const surveySchema = await prisma.pubFieldSchema.create({ + data: { + name: "survey", + namespace: "unjournal", + schema: { + $id: "unjournal:survey", + title: "Survey questions", + description: "Responses will be public unless you ask us to keep them private.", + type: "object", + properties: { + field: { + title: "How long have you been in this field?", + type: "string", + }, + papers: { + title: "How many proposals, papers, and projects have you evaluated/reviewed (for journals, grants, or other peer-review)?", + type: "string", + }, + }, + }, + }, + }); + + const feedbackSchema = await prisma.pubFieldSchema.create({ + data: { + name: "feedback", + namespace: "unjournal", + schema: { + $id: "unjournal:feedback", + title: "Feedback", + description: "Responses will not be public or seen by authors.", + type: "object", + properties: { + rating: { + title: "How would you rate this template and process?", + type: "string", + }, + suggestions: { + title: "Do you have any suggestions or questions about this process or the Unjournal? (We will try to respond, and incorporate your suggestions.)", + type: "string", + }, + time: { + title: "Approximately how long did you spend completing this evaluation?", + type: "string", + }, + revision: { + title: "Would you be willing to consider evaluating a revised version of this work?", + type: "boolean", + default: false, + }, + }, + }, + }, + }); + + const anonymitySchema = await prisma.pubFieldSchema.create({ + data: { + name: "anonymity", + namespace: "unjournal", + schema: { + $id: "unjournal:anonymity", + title: "Would you like to publicly sign your review?", + description: + "If no, the public sections of your review will be published anonymously.", + type: "boolean", + default: false, + }, + }, + }); + + const evaluationSchema = await prisma.pubFieldSchema.create({ + data: { + name: "evaluation", + namespace: "unjournal", + schema: { + $id: "unjournal:evaluation", + title: "Please write your evaluation here", + description: + "Remember that your responses will be made public. Please consult our criteria. We are essentially asking for a 'standard high-quality referee report' here, with some specific considerations (mentioned in the above link). We welcome detail, elaboration, and technical discussion. If you prefer to link or submit your evaluation content in a different format, please link it here or send it to the corresponding/managing editor. Length and time spent: This is of course, up to you. The Econometrics society recommends a 2-3 page referee report. In a recent survey (Charness et al, 2022), economists report spending (median and mean) about one day per report, with substantial shares reporting ‘half a day’ and ‘two days’. We expect that that reviewers tend to spend more time on papers for high-status journals, and when reviewing work closely tied to their own agenda.", + type: "string", + }, + }, + }); + + const fieldIds = [...Array(15)].map(() => uuidv4()); await prisma.pubField.createMany({ data: [ { id: fieldIds[0], name: "Title", slug: "unjournal:title" }, { id: fieldIds[1], name: "Description", slug: "unjournal:description" }, { id: fieldIds[2], name: "Manager's Notes", slug: "unjournal:managers-notes" }, - { id: fieldIds[3], name: "Anonymity", slug: "unjournal:anonymity" }, - { id: fieldIds[4], name: "Metrics", slug: "unjournal:metrics" }, - { id: fieldIds[5], name: "Content", slug: "unjournal:content" }, + { + id: fieldIds[3], + name: "Anonymity", + pubFieldSchemaId: anonymitySchema.id, + slug: "unjournal:anonymity", + }, + { + id: fieldIds[4], + name: "Please enter your 'salted hashtag' here if you know it. Otherwise please enter an anonymous psuedonym here", + slug: "unjournal:hashtag", + }, + { + id: fieldIds[5], + name: "Evaluation", + pubFieldSchemaId: evaluationSchema.id, + slug: "unjournal:evaluation", + }, { id: fieldIds[6], name: "Evaluated Paper", slug: "unjournal:evaluated-paper" }, { id: fieldIds[7], name: "Tags", slug: "unjournal:tags" }, { id: fieldIds[8], name: "DOI", slug: "unjournal:doi" }, { id: fieldIds[9], - name: "Submission Evaluator", - pubFieldSchemaId: evaluator.id, - slug: "unjournal:evaluator", + name: "Metrics", + pubFieldSchemaId: metricsSchema.id, + slug: "unjournal:metrics", }, { id: fieldIds[10], - name: "Metrics and Predictions", - pubFieldSchemaId: metricsSchema.id, - slug: "unjournal:metrics-predictions", + name: "Predictions", + slug: "unjournal:predictions", + pubFieldSchemaId: predictionsSchema.id, }, { id: fieldIds[11], + name: "Confidential Comments", + slug: "unjournal:confidential-comments", + pubFieldSchemaId: confidentialCommentsSchema.id, + }, + { + id: fieldIds[12], name: "Survey Questions", - slug: "unjournal:survey-questions", + slug: "unjournal:survey", + pubFieldSchemaId: surveySchema.id, + }, + { + id: fieldIds[13], + name: "Feedback", + slug: "unjournal:feedback", + pubFieldSchemaId: feedbackSchema.id, + }, + { + id: fieldIds[14], + name: "Submission Evaluator", + pubFieldSchemaId: evaluator.id, + slug: "unjournal:evaluator", }, ], }); @@ -189,9 +378,15 @@ export default async function main(prisma: PrismaClient, communityUUID: string) fields: { connect: [ { id: fieldIds[0] }, - { id: fieldIds[1] }, - { id: fieldIds[9] }, // Submission Evaluator - { id: fieldIds[10] }, // Metrics and Predictions + { id: fieldIds[3] }, + { id: fieldIds[4] }, + { id: fieldIds[5] }, + { id: fieldIds[9] }, + { id: fieldIds[10] }, + { id: fieldIds[11] }, + { id: fieldIds[12] }, + { id: fieldIds[13] }, + { id: fieldIds[14] }, // evaluator ], }, }, diff --git a/core/prisma/migrations/20231010215802_add_supabase_id/migration.sql b/core/prisma/migrations/20231010215802_add_supabase_id/migration.sql new file mode 100644 index 0000000000..3ce72bec82 --- /dev/null +++ b/core/prisma/migrations/20231010215802_add_supabase_id/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[supabaseId]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- AlterTable +ALTER TABLE "users" ADD COLUMN "supabaseId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "users_supabaseId_key" ON "users"("supabaseId"); diff --git a/core/prisma/schema.prisma b/core/prisma/schema.prisma index 357e757c21..198c964d77 100644 --- a/core/prisma/schema.prisma +++ b/core/prisma/schema.prisma @@ -12,15 +12,16 @@ generator client { } model User { - id String @id @default(uuid()) - slug String @unique - email String @unique - firstName String - lastName String? - orcid String? - avatar String? - createdAt DateTime @default(now()) @map(name: "created_at") - updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") + id String @id @default(uuid()) + supabaseId String? @unique + slug String @unique + email String @unique + firstName String + lastName String? + orcid String? + avatar String? + createdAt DateTime @default(now()) @map(name: "created_at") + updatedAt DateTime @default(now()) @updatedAt @map(name: "updated_at") claims ActionClaim[] moves ActionMove[] diff --git a/core/prisma/seed.ts b/core/prisma/seed.ts index 035d7c0d16..c7553113ce 100644 --- a/core/prisma/seed.ts +++ b/core/prisma/seed.ts @@ -1,6 +1,6 @@ import { PrismaClient } from "@prisma/client"; import { SupabaseClient } from "@supabase/supabase-js"; -import buildUnjournal from "./exampleCommunitySeeds/unjournal"; +import { default as buildUnjournal, unJournalId } from "./exampleCommunitySeeds/unjournal"; const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY; @@ -33,11 +33,12 @@ async function createUserMembers( } else { user = data.user; } + await prisma.user.create({ data: { - id: user ? user.id : undefined, slug, email: user ? user.email : email, + supabaseId: user.id, firstName, lastName, avatar: "/demo/person.png", @@ -47,7 +48,6 @@ async function createUserMembers( } async function main() { - const unJournalId = "03e7a5fd-bdca-4682-9221-3a69992c1f3b"; const prismaCommunityIds = [{ communityId: unJournalId, canAdmin: true }]; await buildUnjournal(prisma, unJournalId); diff --git a/core/scripts/invite.ts b/core/scripts/invite.ts new file mode 100644 index 0000000000..7c099825ca --- /dev/null +++ b/core/scripts/invite.ts @@ -0,0 +1,72 @@ +import { parse } from "csv-parse"; +import fs from "fs"; +import yargs from "yargs"; +import { hideBin } from "yargs/helpers"; +import { formatSupabaseError } from "../lib/supabase"; +import { createClient } from "@supabase/supabase-js"; +import { randomUUID } from "crypto"; +import { unJournalId } from "../prisma/exampleCommunitySeeds/unjournal"; + +const getServerSupabase = () => { + const url = process.env.NEXT_PUBLIC_SUPABASE_URL; + const key = process.env.SUPABASE_SERVICE_ROLE_KEY; + if (!url || !key) { + throw new Error("Missing Supabase parameters"); + } + return createClient(url, key, { + auth: { + persistSession: false, + }, + }); +}; +const client = getServerSupabase(); + +const inviteUser = async (email, firstName, lastName) => { + const { error } = await client.auth.signUp({ + email, + password: randomUUID(), + options: { + emailRedirectTo: `${process.env.NEXT_PUBLIC_PUBPUB_URL}/reset`, + data: { + firstName, + lastName, + communityId: unJournalId, + canAdmin: true, + }, + }, + }); + if (error) { + throw new Error(formatSupabaseError(error)); + } +}; + +const inviteUsersFromCsv = async (path) => { + const parser = fs.createReadStream(path).pipe(parse({ columns: true })); + for await (const row of parser) { + if (!row.firstName || !row.email) { + console.log("Unable to invite user without firstname or email: ", row); + continue; + } + try { + await inviteUser(row.email, row.firstName, row.lastName); + } catch (err) { + console.log(err); + console.log(`Failed to invite ${row.firstName} ${row.lastName} ${row.email}`); + continue; + } + console.log(`Invited ${row.firstName} ${row.lastName} ${row.email}`); + } +}; + +const usage = () => { + console.log("Usage:\npnpm --filter core invite-users "); + process.exit(); +}; + +const { _: args } = yargs(hideBin(process.argv)).argv; +if (args.length !== 1) { + console.log(args); + usage(); +} + +inviteUsersFromCsv(args[0]); diff --git a/core/tsconfig.json b/core/tsconfig.json index 96e887d424..1b50f2e372 100644 --- a/core/tsconfig.json +++ b/core/tsconfig.json @@ -15,6 +15,11 @@ }, "strictNullChecks": true }, + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + }, "include": [ "next-env.d.ts", "**/*.ts", diff --git a/integrations/evaluations/app/actions/evaluate/evaluate.tsx b/integrations/evaluations/app/actions/evaluate/evaluate.tsx index 424ff15132..02dd7739fd 100644 --- a/integrations/evaluations/app/actions/evaluate/evaluate.tsx +++ b/integrations/evaluations/app/actions/evaluate/evaluate.tsx @@ -1,4 +1,5 @@ "use client"; +import Ajv from "ajv"; import { ajvResolver } from "@hookform/resolvers/ajv"; import { GetPubResponseBody, GetPubTypeResponseBody, PubValues } from "@pubpub/sdk"; import { buildFormFieldsFromSchema, buildFormSchemaFromFields } from "@pubpub/sdk/react"; @@ -14,6 +15,7 @@ import { CardTitle, Form, Icon, + Separator, useLocalStorage, useToast, } from "ui"; @@ -30,7 +32,10 @@ export function Evaluate(props: Props) { const { pub, pubType } = props; const { toast } = useToast(); - const generatedSchema = buildFormSchemaFromFields(pubType); + const generatedSchema = useMemo(() => { + const exclude = ["unjournal:title", "unjournal:evaluator"]; + return buildFormSchemaFromFields(pubType, exclude); + }, [pubType]); const form = useForm({ mode: "onChange", @@ -70,39 +75,72 @@ export function Evaluate(props: Props) { persist(values); }, [values]); - const formFieldsFromSchema = useMemo( - () => buildFormFieldsFromSchema(generatedSchema, form.control), - [form.control] - ); + const formFieldsFromSchema = useMemo(() => { + // we need to use an uncompiled schema for validation, but compiled for building the form + // "Schema" is a key later used to retrieve this schema (we could later pass multiple for dereferencing, for example) + const ajv = new Ajv(); + const schemaKey = "schema"; + const compiledSchema = ajv.addSchema(generatedSchema, schemaKey); + return buildFormFieldsFromSchema(compiledSchema, schemaKey, form.control); + }, [form.control, pubType, generatedSchema]); return ( -
- - - - {pubType.name} - {pubType.description} - - {formFieldsFromSchema} - - - - - -
- + <> + + + + Thanks for your interest in evaluating research for the Unjournal! Your + evaluation will be made public and given a DOI, but you have the option to + remain anonymous or 'sign your review' and take credit. You will be + compensated a minimum of $250 for your evaluation work, and will be eligible + for financial 'most informative evaluation' prizes. See the full guidelines + on our wiki. + + +

To evaluate:

+

{`${pub.values["unjournal:title"]}`}

+

+ {pub.values["unjournal:description"] && + `${pub.values["unjournal:description"]}`} +

+

+ View Article +

+

Manager Notes:

+

+ {pub.values["unjournal:managers-notes"] && + `${pub.values["unjournal:managers-notes"]}`} +

+
+
+
+ + + + {pubType.name} + {pubType.description} + + {formFieldsFromSchema} + + + + + +
+ + ); } diff --git a/packages/sdk/src/react/generateFormFields.tsx b/packages/sdk/src/react/generateFormFields.tsx index 0736452db9..a443dba130 100644 --- a/packages/sdk/src/react/generateFormFields.tsx +++ b/packages/sdk/src/react/generateFormFields.tsx @@ -1,6 +1,6 @@ import * as React from "react"; // this import causes a cyclic dependency in pnpm but here we are -import { JSONSchemaType } from "ajv"; +import Ajv, { JSONSchemaType } from "ajv"; import { GetPubTypeResponseBody } from "contracts"; import { Control, ControllerRenderProps } from "react-hook-form"; @@ -9,6 +9,8 @@ import { CardDescription, CardHeader, CardTitle, + Checkbox, + Confidence, FormControl, FormDescription, FormField, @@ -16,6 +18,7 @@ import { FormLabel, FormMessage, Input, + Separator, } from "ui"; import { cn } from "utils"; @@ -23,7 +26,8 @@ import { cn } from "utils"; type AnySchema = {}; export const buildFormSchemaFromFields = ( - pubType: GetPubTypeResponseBody + pubType: GetPubTypeResponseBody, + exclude: String[] ): JSONSchemaType => { const schema = { $id: `urn:uuid:${pubType.id}`, @@ -33,15 +37,18 @@ export const buildFormSchemaFromFields = ( } as JSONSchemaType; if (pubType.fields) { for (const field of pubType.fields) { - if (field.schema) { - schema.properties[field.slug] = field.schema.schema as JSONSchemaType; - } else { - schema.properties[field.slug] = { - type: "string", - title: `${field.name}`, - $id: `urn:uuid:${field.id}`, - default: "", - }; + if (!exclude.includes(field.slug)) { + if (field.schema) { + schema.properties[field.slug] = field.schema + .schema as JSONSchemaType; + } else { + schema.properties[field.slug] = { + type: "string", + title: `${field.name}`, + $id: `urn:uuid:${field.id}`, + default: "", + }; + } } } } @@ -49,18 +56,57 @@ export const buildFormSchemaFromFields = ( }; // todo: array, and more complex types that we might want to handle -export const getFormField = (schemaType: "string" | "number", field: ControllerRenderProps) => { - switch (schemaType) { +export const getFormField = (schema: JSONSchemaType, field: ControllerRenderProps) => { + const { title, description, type } = schema; + const descriptionComponentWithHtml = ( + + ); + switch (type) { case "number": return ( - field.onChange(+event.target.value)} - /> + + {title} + {descriptionComponentWithHtml} + + field.onChange(+event.target.value)} + /> + + + + ); + case "boolean": + return ( + + + { + field.onChange(checked); + }} + /> + +
+ {title} + {descriptionComponentWithHtml} + +
+
); default: - return ; + return ( + + {schema.title} + {descriptionComponentWithHtml} + + + + + + ); } }; @@ -76,55 +122,186 @@ const ScalarField = (props: ScalarFieldProps) => { control={props.control} name={props.title} defaultValue={props.schema.default ?? ""} - render={({ field }) => ( - - {props.schema.title} - {getFormField(props.schema.type, field)} - {props.schema.description} - - - )} + render={({ field }) => getFormField(props.schema, field)} /> ); }; +const customScalars = ["unjournal:100confidence", "unjournal:5confidence"]; + +const hasCustomRenderer = (id: string) => { + return customScalars.includes(id); +}; + +type CustomRendererProps = { + control: Control; + fieldSchema: JSONSchemaType; + fieldName: string; +}; +// todo: don't just use if statements, make more dynamic +const CustomRenderer = (props: CustomRendererProps) => { + const { control, fieldSchema, fieldName } = props; + if ( + fieldSchema.$id === "unjournal:100confidence" || + fieldSchema.$id === "unjournal:5confidence" + ) { + // not sure why, but these need to be set outside of the render in FormField? + const min = fieldSchema.items.minimum; + const max = fieldSchema.items.maximum; + return ( + + ( + + {fieldSchema.title} + + + field.onChange(event)} + className="confidence" + /> + + + + )} + /> + + ); + } +}; + const isObjectSchema = ( schema: JSONSchemaType ): schema is JSONSchemaType & { properties: JSONSchemaType[] } => { return schema.properties && Object.keys(schema.properties).length > 0; }; -export const buildFormFieldsFromSchema = ( +const hasRef = (schema: JSONSchemaType) => { + return schema.$ref; +}; + +const hasResolvedSchema = (compiledSchema: Ajv, schemaKey: string) => { + const resolvedSchema = compiledSchema.getSchema(schemaKey); + return resolvedSchema && resolvedSchema.schema; +}; + +const getDereferencedSchema = ( schema: JSONSchemaType, - control: Control, + compiledSchema: Ajv, path?: string ) => { - const fields: React.ReactNode[] = []; if (isObjectSchema(schema)) { for (const [fieldKey, fieldSchema] of Object.entries(schema.properties)) { + const fieldPath = path + ? schema.$id + ? `${path}/${schema.$id}` + : path + : `${schema.$id}#/properties`; + const dereffedField = getDereferencedSchema(fieldSchema, compiledSchema, fieldPath); + } + } else { + if (schema.$ref) { + const fieldPath = path + schema.$ref.split("#")[1]; + return compiledSchema.getSchema(fieldPath)!.schema; + } + } +}; + +export const buildFormFieldsFromSchema = ( + compiledSchema: Ajv, + compiledSchemaKey: string, + control: Control, + path?: string, + fieldSchema?: JSONSchemaType, + schemaPath?: string +) => { + const fields: React.ReactNode[] = []; + + // probably should refactor into function and throw an error if the schema can't be resolved from the compiled schema + const resolvedSchema = fieldSchema + ? fieldSchema + : (compiledSchema.getSchema("schema")!.schema as JSONSchemaType); + + if (isObjectSchema(resolvedSchema)) { + for (const [fieldKey, fieldSchema] of Object.entries(resolvedSchema.properties)) { const fieldPath = path ? `${path}.${fieldKey}` : fieldKey; + + // for querying the compiled schema later -- pretty robust, but does assume defs are not at top level + // may be better way to query just at last schema id, for example + const fieldSchemaPath = schemaPath + ? resolvedSchema.$id + ? `${schemaPath}/${resolvedSchema.$id}` + : schemaPath + : `${resolvedSchema.$id}#/properties`; + const fieldContent = isObjectSchema(fieldSchema) ? ( - +
+ {!path && } - {fieldSchema.title} - {fieldSchema.description} + + {fieldSchema.title} + + {fieldSchema.description && ( + + )} - {buildFormFieldsFromSchema(fieldSchema, control, fieldPath)} - + {buildFormFieldsFromSchema( + compiledSchema, + compiledSchemaKey, + control, + fieldPath, + fieldSchema, + fieldSchemaPath + )} +
) : ( - buildFormFieldsFromSchema(fieldSchema, control, fieldPath) + buildFormFieldsFromSchema( + compiledSchema, + compiledSchemaKey, + control, + fieldPath, + fieldSchema, + fieldSchemaPath + ) ); fields.push(fieldContent); } } else { + const scalarSchema = + hasRef(resolvedSchema) && hasResolvedSchema(compiledSchema, compiledSchemaKey) + ? (compiledSchema.getSchema(`${schemaPath}${resolvedSchema.$ref!.split("#")[1]}`)! + .schema as JSONSchemaType) + : resolvedSchema; fields.push( - - - + ) : ( + + + + ) ); } return fields; diff --git a/packages/ui/package.json b/packages/ui/package.json index 774af13cea..61870a002a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -14,12 +14,15 @@ "dependencies": { "@hookform/resolvers": "^3.3.1", "@radix-ui/react-avatar": "^1.0.3", + "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.0.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-popover": "^1.0.6", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", "@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-toast": "^1.1.4", "@radix-ui/react-tooltip": "^1.0.6", diff --git a/packages/ui/src/checkbox.tsx b/packages/ui/src/checkbox.tsx new file mode 100644 index 0000000000..d03b1c7552 --- /dev/null +++ b/packages/ui/src/checkbox.tsx @@ -0,0 +1,30 @@ +"use client"; + +import * as React from "react"; +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; +import { Check } from "lucide-react"; + +import { cn } from "utils"; + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)); +Checkbox.displayName = CheckboxPrimitive.Root.displayName; + +export { Checkbox }; diff --git a/packages/ui/src/customRenderers/confidence.css b/packages/ui/src/customRenderers/confidence.css new file mode 100644 index 0000000000..4ae64eac2e --- /dev/null +++ b/packages/ui/src/customRenderers/confidence.css @@ -0,0 +1,6 @@ +.slider-thumb:after { + position: absolute; + top: 1.5rem; + content: attr(aria-valuenow); + font-size: 0.8rem; +} diff --git a/packages/ui/src/customRenderers/confidence.tsx b/packages/ui/src/customRenderers/confidence.tsx new file mode 100644 index 0000000000..60a5fa9f75 --- /dev/null +++ b/packages/ui/src/customRenderers/confidence.tsx @@ -0,0 +1,29 @@ +"use client"; + +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import "./confidence.css"; + +import { cn } from "utils"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider as Confidence }; diff --git a/packages/ui/src/index.tsx b/packages/ui/src/index.tsx index d0c3a95369..44c79715fb 100644 --- a/packages/ui/src/index.tsx +++ b/packages/ui/src/index.tsx @@ -6,17 +6,22 @@ export * from "./badge"; export * from "./button"; export * from "./card"; export * from "./collapsible"; +export * from "./checkbox"; export * from "./dialog"; export * from "./dropdown-menu"; export * from "./form"; export * from "./input"; export * from "./label"; export * from "./popover"; +export * from "./separator"; export * from "./textarea"; export * from "./toast"; export * from "./toaster"; export * from "./tooltip"; export * from "./use-toast"; +/* Renderers */ +export * from "./customRenderers/confidence"; + /* Hooks */ export * from "./hooks"; diff --git a/packages/ui/src/separator.tsx b/packages/ui/src/separator.tsx new file mode 100644 index 0000000000..f3f40f2e6b --- /dev/null +++ b/packages/ui/src/separator.tsx @@ -0,0 +1,26 @@ +"use client"; + +import * as React from "react"; +import * as SeparatorPrimitive from "@radix-ui/react-separator"; + +import { cn } from "utils"; + +const Separator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => ( + +)); +Separator.displayName = SeparatorPrimitive.Root.displayName; + +export { Separator }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b086f2a567..23b03c15cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -40,7 +40,7 @@ importers: version: 0.1.11(prettier@2.8.8) turbo: specifier: latest - version: 1.10.15 + version: 1.10.13 core: dependencies: @@ -55,7 +55,7 @@ importers: version: 7.72.0(next@13.5.2)(react@18.2.0) '@stoplight/elements': specifier: ^7.12.2 - version: 7.12.2(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0) + version: 7.12.2(react-dom@18.2.0)(react@18.2.0) '@supabase/supabase-js': specifier: ^2.33.2 version: 2.33.2 @@ -88,7 +88,7 @@ importers: version: 9.0.0 next: specifier: 13.5.2 - version: 13.5.2(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0) + version: 13.5.2(react-dom@18.2.0)(react@18.2.0) next-connect: specifier: ^1.0.0 version: 1.0.0 @@ -153,6 +153,9 @@ importers: autoprefixer: specifier: ^10.4.14 version: 10.4.14(postcss@8.4.27) + csv-parse: + specifier: ^5.5.2 + version: 5.5.2 dotenv-cli: specifier: ^7.2.1 version: 7.2.1 @@ -177,6 +180,9 @@ importers: typescript: specifier: 5.1.3 version: 5.1.3 + yargs: + specifier: ^17.7.2 + version: 17.7.2 integrations/evaluations: dependencies: @@ -413,6 +419,9 @@ importers: '@radix-ui/react-avatar': specifier: ^1.0.3 version: 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.0.4 + version: 1.0.4(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-collapsible': specifier: ^1.0.3 version: 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) @@ -431,6 +440,12 @@ importers: '@radix-ui/react-popover': specifier: ^1.0.6 version: 1.0.6(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-separator': + specifier: ^1.0.3 + version: 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slider': + specifier: ^1.1.2 + version: 1.1.2(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': specifier: ^1.0.2 version: 1.0.2(@types/react@18.2.12)(react@18.2.0) @@ -457,7 +472,7 @@ importers: version: 3.3.3(ts-node@10.9.1) tailwindcss-animate: specifier: ^1.0.6 - version: 1.0.6(tailwindcss@3.3.3) + version: 1.0.6 utils: specifier: workspace:* version: link:../utils @@ -2487,6 +2502,12 @@ packages: resolution: {integrity: sha512-dT7FOLUCdZmq+AunLqB1Iz+ZH/IIS1Fz2THmKZQ6aFONrQD/BQ5ecJ7g2wGS2OgyUFf4OaLam6/bxmgdOBDqig==} requiresBuild: true + /@radix-ui/number@1.0.1: + resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} + dependencies: + '@babel/runtime': 7.22.10 + dev: false + /@radix-ui/primitive@1.0.1: resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} dependencies: @@ -2536,6 +2557,33 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-checkbox@1.0.4(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@types/react': 18.2.12 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-collapsible@1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} peerDependencies: @@ -2979,6 +3027,56 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-separator@1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@radix-ui/react-primitive': 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.12 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-slider@1.1.2(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react@18.2.12)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.12)(react@18.2.0) + '@types/react': 18.2.12 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-slot@1.0.2(@types/react@18.2.12)(react@18.2.0): resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} peerDependencies: @@ -3114,6 +3212,20 @@ packages: react: 18.2.0 dev: false + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.12)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@types/react': 18.2.12 + react: 18.2.0 + dev: false + /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.12)(react@18.2.0): resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} peerDependencies: @@ -3586,7 +3698,7 @@ packages: '@sentry/vercel-edge': 7.72.0 '@sentry/webpack-plugin': 1.20.0 chalk: 3.0.0 - next: 13.5.2(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0) + next: 13.5.2(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 rollup: 2.78.0 stacktrace-parser: 0.1.10 @@ -3697,7 +3809,7 @@ packages: - supports-color dev: false - /@stoplight/elements-core@7.12.3(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0): + /@stoplight/elements-core@7.12.3(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-SMhV9rCJk8FIOBuSZrcCa9vVfz9Bgjbj0Btxuv/E7pieIVP+7tkTfXILvTHG3n+D/abpK5rJ2VYGv7vH6MlL+w==} engines: {node: '>=14.13'} peerDependencies: @@ -3708,7 +3820,7 @@ packages: '@stoplight/json': 3.21.0 '@stoplight/json-schema-ref-parser': 9.2.7 '@stoplight/json-schema-sampler': 0.2.3 - '@stoplight/json-schema-viewer': 4.12.1(@babel/core@7.22.17)(@stoplight/markdown-viewer@5.6.0)(@stoplight/mosaic-code-viewer@1.44.3)(@stoplight/mosaic@1.44.3)(react-dom@18.2.0)(react@18.2.0) + '@stoplight/json-schema-viewer': 4.12.1(@stoplight/markdown-viewer@5.6.0)(@stoplight/mosaic-code-viewer@1.44.3)(@stoplight/mosaic@1.44.3)(react-dom@18.2.0)(react@18.2.0) '@stoplight/markdown-viewer': 5.6.0(@stoplight/mosaic-code-viewer@1.44.3)(@stoplight/mosaic@1.44.3)(react-dom@18.2.0)(react@18.2.0) '@stoplight/mosaic': 1.44.3(react-dom@18.2.0)(react@18.2.0) '@stoplight/mosaic-code-editor': 1.44.3(react-dom@18.2.0)(react@18.2.0) @@ -3719,7 +3831,7 @@ packages: '@stoplight/yaml': 4.2.3 classnames: 2.3.2 httpsnippet-lite: 3.0.5 - jotai: 1.3.9(@babel/core@7.22.17)(react-query@3.39.3)(react@18.2.0) + jotai: 1.3.9(react-query@3.39.3)(react@18.2.0) json-schema: 0.4.0 lodash: 4.17.21 nanoid: 3.3.6 @@ -3756,14 +3868,14 @@ packages: - xstate dev: false - /@stoplight/elements@7.12.2(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0): + /@stoplight/elements@7.12.2(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-lyzY3wnQp8LoEX7BM+ygvLn1WlaoQTuKzVxWq7HgvU21Gt2ZD7Tc/tLAs7bbyXVBbn11A+MyqfOTZzAkiEnIUw==} engines: {node: '>=14.13'} peerDependencies: react: '>=16.8' react-dom: '>=16.8' dependencies: - '@stoplight/elements-core': 7.12.3(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0) + '@stoplight/elements-core': 7.12.3(react-dom@18.2.0)(react@18.2.0) '@stoplight/http-spec': 5.13.0 '@stoplight/json': 3.21.0 '@stoplight/mosaic': 1.44.3(react-dom@18.2.0)(react@18.2.0) @@ -3874,7 +3986,7 @@ packages: magic-error: 0.0.1 dev: false - /@stoplight/json-schema-viewer@4.12.1(@babel/core@7.22.17)(@stoplight/markdown-viewer@5.6.0)(@stoplight/mosaic-code-viewer@1.44.3)(@stoplight/mosaic@1.44.3)(react-dom@18.2.0)(react@18.2.0): + /@stoplight/json-schema-viewer@4.12.1(@stoplight/markdown-viewer@5.6.0)(@stoplight/mosaic-code-viewer@1.44.3)(@stoplight/mosaic@1.44.3)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-oMx0WrggjDPbIUcoqO7gx8dx1QA9bs7Y2jYNGAWnDe1wmsGcOOevkJ5LaruoCbuYAvsoGsBfqyJv8Ec8ATHBGQ==} engines: {node: '>=16'} peerDependencies: @@ -3893,7 +4005,7 @@ packages: '@types/json-schema': 7.0.12 classnames: 2.3.2 fnv-plus: 1.3.1 - jotai: 1.13.1(@babel/core@7.22.17)(react@18.2.0) + jotai: 1.13.1(react@18.2.0) lodash: 4.17.21 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -4241,7 +4353,7 @@ packages: optional: true dependencies: '@ts-rest/core': 3.28.0(zod@3.21.4) - next: 13.5.2(@babel/core@7.22.17)(react-dom@18.2.0)(react@18.2.0) + next: 13.5.2(react-dom@18.2.0)(react@18.2.0) zod: 3.21.4 dev: false @@ -5048,7 +5160,7 @@ packages: dev: false /concat-map@0.0.1: - resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} @@ -5143,6 +5255,10 @@ packages: resolution: {integrity: sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==} dev: true + /csv-parse@5.5.2: + resolution: {integrity: sha512-YRVtvdtUNXZCMyK5zd5Wty1W6dNTpGKdqQd4EQ8tl/c6KW1aMBB1Kg1ppky5FONKmEqGJ/8WjLlTNLPne4ioVA==} + dev: true + /csv-stringify@5.6.5: resolution: {integrity: sha512-PjiQ659aQ+fUTQqSrd1XEDnOr52jh30RBurfzkscaE2tPaFsDH5wOAHJiw8XAHphRknCwMUE9KRayc4K/NbO8A==} dev: true @@ -6419,7 +6535,7 @@ packages: resolution: {integrity: sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==} hasBin: true - /jotai@1.13.1(@babel/core@7.22.17)(react@18.2.0): + /jotai@1.13.1(react@18.2.0): resolution: {integrity: sha512-RUmH1S4vLsG3V6fbGlKzGJnLrDcC/HNb5gH2AeA9DzuJknoVxSGvvg8OBB7lke+gDc4oXmdVsaKn/xDUhWZ0vw==} engines: {node: '>=12.20.0'} peerDependencies: @@ -6459,11 +6575,10 @@ packages: jotai-zustand: optional: true dependencies: - '@babel/core': 7.22.17 react: 18.2.0 dev: false - /jotai@1.3.9(@babel/core@7.22.17)(react-query@3.39.3)(react@18.2.0): + /jotai@1.3.9(react-query@3.39.3)(react@18.2.0): resolution: {integrity: sha512-b6DvH9gf+7TfjaboCO54g+C0yhaakIaUBtjLf0dk1p15FWCzNw/93sezdXy9cCaZ8qcEdMLJcjBwQlORmIq29g==} engines: {node: '>=12.7.0'} peerDependencies: @@ -6497,7 +6612,6 @@ packages: xstate: optional: true dependencies: - '@babel/core': 7.22.17 react: 18.2.0 react-query: 3.39.3(react-dom@18.2.0)(react@18.2.0) dev: false @@ -7283,6 +7397,46 @@ packages: - babel-plugin-macros dev: false + /next@13.5.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-vog4UhUaMYAzeqfiAAmgB/QWLW7p01/sg+2vn6bqc/CxHFYizMzLv6gjxKzl31EVFkfl/F+GbxlKizlkTE9RdA==} + engines: {node: '>=16.14.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + react: ^18.2.0 + react-dom: ^18.2.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + sass: + optional: true + dependencies: + '@next/env': 13.5.2 + '@swc/helpers': 0.5.2 + busboy: 1.6.0 + caniuse-lite: 1.0.30001519 + postcss: 8.4.14 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + styled-jsx: 5.1.1(react@18.2.0) + watchpack: 2.4.0 + zod: 3.21.4 + optionalDependencies: + '@next/swc-darwin-arm64': 13.5.2 + '@next/swc-darwin-x64': 13.5.2 + '@next/swc-linux-arm64-gnu': 13.5.2 + '@next/swc-linux-arm64-musl': 13.5.2 + '@next/swc-linux-x64-gnu': 13.5.2 + '@next/swc-linux-x64-musl': 13.5.2 + '@next/swc-win32-arm64-msvc': 13.5.2 + '@next/swc-win32-ia32-msvc': 13.5.2 + '@next/swc-win32-x64-msvc': 13.5.2 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + dev: false + /node-abort-controller@3.1.1: resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} dev: false @@ -8777,6 +8931,23 @@ packages: react: 18.2.0 dev: false + /styled-jsx@5.1.1(react@18.2.0): + resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + dependencies: + client-only: 0.0.1 + react: 18.2.0 + dev: false + /stylis@4.3.0: resolution: {integrity: sha512-E87pIogpwUsUwXw7dNyU4QDjdgVMy52m+XEOPEKUn161cCzWjjhPSQhByfd1CcNvrOLnXQ6OnnZDwnJrz/Z4YQ==} dev: false @@ -8825,12 +8996,10 @@ packages: resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} dev: false - /tailwindcss-animate@1.0.6(tailwindcss@3.3.3): + /tailwindcss-animate@1.0.6: resolution: {integrity: sha512-4WigSGMvbl3gCCact62ZvOngA+PRqhAn7si3TQ3/ZuPuQZcIEtVap+ENSXbzWhpojKB8CpvnIsrwBu8/RnHtuw==} peerDependencies: tailwindcss: '>=3.0.0 || insiders' - dependencies: - tailwindcss: 3.3.3(ts-node@10.9.1) dev: false /tailwindcss@3.3.3(ts-node@10.9.1): @@ -9031,64 +9200,64 @@ packages: yargs: 17.7.2 dev: true - /turbo-darwin-64@1.10.15: - resolution: {integrity: sha512-Sik5uogjkRTe1XVP9TC2GryEMOJCaKE2pM/O9uLn4koQDnWKGcLQv+mDU+H+9DXvKLnJnKCD18OVRkwK5tdpoA==} + /turbo-darwin-64@1.10.13: + resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.15: - resolution: {integrity: sha512-xwqyFDYUcl2xwXyGPmHkmgnNm4Cy0oNzMpMOBGRr5x64SErS7QQLR4VHb0ubiR+VAb8M+ECPklU6vD1Gm+wekg==} + /turbo-darwin-arm64@1.10.13: + resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.15: - resolution: {integrity: sha512-dM07SiO3RMAJ09Z+uB2LNUSkPp3I1IMF8goH5eLj+d8Kkwoxd/+qbUZOj9RvInyxU/IhlnO9w3PGd3Hp14m/nA==} + /turbo-linux-64@1.10.13: + resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.15: - resolution: {integrity: sha512-MkzKLkKYKyrz4lwfjNXH8aTny5+Hmiu4SFBZbx+5C0vOlyp6fV5jZANDBvLXWiDDL4DSEAuCEK/2cmN6FVH1ow==} + /turbo-linux-arm64@1.10.13: + resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.15: - resolution: {integrity: sha512-3TdVU+WEH9ThvQGwV3ieX/XHebtYNHv9HARHauPwmVj3kakoALkpGxLclkHFBLdLKkqDvmHmXtcsfs6cXXRHJg==} + /turbo-windows-64@1.10.13: + resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.15: - resolution: {integrity: sha512-l+7UOBCbfadvPMYsX08hyLD+UIoAkg6ojfH+E8aud3gcA1padpjCJTh9gMpm3QdMbKwZteT5uUM+wyi6Rbbyww==} + /turbo-windows-arm64@1.10.13: + resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.15: - resolution: {integrity: sha512-mKKkqsuDAQy1wCCIjCdG+jOCwUflhckDMSRoeBPcIL/CnCl7c5yRDFe7SyaXloUUkt4tUR0rvNIhVCcT7YeQpg==} + /turbo@1.10.13: + resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.15 - turbo-darwin-arm64: 1.10.15 - turbo-linux-64: 1.10.15 - turbo-linux-arm64: 1.10.15 - turbo-windows-64: 1.10.15 - turbo-windows-arm64: 1.10.15 + turbo-darwin-64: 1.10.13 + turbo-darwin-arm64: 1.10.13 + turbo-linux-64: 1.10.13 + turbo-linux-arm64: 1.10.13 + turbo-windows-64: 1.10.13 + turbo-windows-arm64: 1.10.13 dev: true /type-fest@0.13.1: