Merge branch 'main' into surveyBg

This commit is contained in:
Anjy Gupta
2023-11-11 01:05:38 +05:30
committed by GitHub
39 changed files with 413 additions and 224 deletions

View File

@@ -122,7 +122,7 @@ GOOGLE_SHEETS_CLIENT_SECRET=
GOOGLE_SHEETS_REDIRECT_URL=
# Oauth credentials for Airtable integration
AIR_TABLE_CLIENT_ID=
AIRTABLE_CLIENT_ID=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=

View File

@@ -10,13 +10,13 @@ jobs:
cron-reportUsageToStripe:
env:
APP_URL: ${{ secrets.APP_URL }}
API_KEY: ${{ secrets.API_KEY }}
CRON_SECRET: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.API_KEY }}
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ env.APP_URL }}/api/cron/report-usage \
-X GET \
-H 'x-api-key: ${{ env.API_KEY }}' \
-X POST \
-H 'x-api-key: ${{ env.CRON_SECRET }}' \
--fail

View File

@@ -160,8 +160,8 @@ Enabling the Airtable Integration in a self-hosted environment requires creating
### By now, your environment variables should include the below ones:
- `AIR_TABLE_CLIENT_ID`
- `AIR_TABLE_REDIRECT_URL`
- `AIRTABLE_CLIENT_ID`
- `AIRTABLE_REDIRECT_URL`
Voila! You have successfully enabled the Airtable integration in your self-hosted Formbricks instance. Now you can follow the steps mentioned in the [Formbricks Cloud](#formbricks-cloud) section to link an Airtable with Formbricks.

View File

@@ -0,0 +1,66 @@
export const metadata = {
title: "External auth providers",
description:
"Set up and integrate multiple external authentication providers with Formbricks. Our step-by-step guide covers Google OAuth and more, ensuring a seamless login experience for your users.",
};
## Google OAuth Authentication
Integrating Google OAuth with your Formbricks instance allows users to log in using their Google credentials, ensuring a secure and streamlined user experience. This guide will walk you through the process of setting up Google OAuth for your Formbricks instance.
### Requirements
- A Google Cloud Platform (GCP) account.
- A Formbricks instance running and accessible.
### Steps
1. **Create a GCP Project**:
- Navigate to the [GCP Console](https://console.cloud.google.com/).
- From the projects list, select a project or create a new one.
2. **Setting up OAuth 2.0**:
- If the **APIs & services** page isn't already open, open the console left side menu and select **APIs & services**.
- On the left, click **Credentials**.
- Click **Create Credentials**, then select **OAuth client ID**.
3. **Configure OAuth Consent Screen**:
- If this is your first time creating a client ID, configure your consent screen by clicking **Consent Screen**.
- Fill in the necessary details and under **Authorized domains**, add the domain where your Formbricks instance is hosted.
4. **Create OAuth 2.0 Client IDs**:
- Select the application type **Web application** for your project and enter any additional information required.
- Ensure to specify authorized JavaScript origins and authorized redirect URIs.
```
Authorized JavaScript origins: {WEBAPP_URL}
Authorized redirect URIs: {WEBAPP_URL}/api/auth/callback/google
```
5. **Update Environment Variables in Docker**:
- To integrate the Google OAuth, you have two options: either update the environment variables in the docker-compose file or directly add them to the running container.
- In your Docker setup directory, open the `.env` file, and add or update the following lines with the `Client ID` and `Client Secret` obtained from Google Cloud Platform:
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
```
docker exec -it container_id /bin/bash
export GOOGLE_AUTH_ENABLED=1
export GOOGLE_CLIENT_ID=your-client-id-here
export GOOGLE_CLIENT_SECRET=your-client-secret-here
exit
```
```
GOOGLE_AUTH_ENABLED=1
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
```
6. **Restart Your Formbricks Instance**:
- **Note:** Restarting your Docker containers may cause a brief period of downtime. Plan accordingly.
- Once the environment variables have been updated, it's crucial to restart your Docker containers to apply the changes. This ensures that your Formbricks instance can utilize the new Google OAuth configuration for user authentication. Here's how you can do it:
- Navigate to your Docker setup directory where your `docker-compose.yml` file is located.
- Run the following command to bring down your current Docker containers and then bring them back up with the updated environment configuration:

View File

@@ -249,6 +249,7 @@ export const navigation: Array<NavGroup> = [
{ title: "Production", href: "/docs/self-hosting/production" },
{ title: "Docker", href: "/docs/self-hosting/docker" },
{ title: "Migration Guide", href: "/docs/self-hosting/migration-guide" },
{ title: "External auth providers", href: "/docs/self-hosting/external-auth-providers" },
],
},
{

View File

@@ -1,3 +1,5 @@
export const dynamic = "force-dynamic";
import ConfirmationPage from "./components/ConfirmationPage";
export default function BillingConfirmation({ searchParams }) {

View File

@@ -6,8 +6,8 @@ export const fetchTables = async (environmentId: string, baseId: string) => {
headers: { environmentId: environmentId },
cache: "no-store",
});
return res.json() as Promise<TIntegrationAirtableTables>;
const resJson = await res.json();
return resJson.data as Promise<TIntegrationAirtableTables>;
};
export const authorize = async (environmentId: string, apiHost: string): Promise<string> => {
@@ -21,6 +21,6 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.authUrl;
const authUrl = resJSON.data.authUrl;
return authUrl;
};

View File

@@ -1,6 +1,6 @@
import AirtableWrapper from "@/app/(app)/environments/[environmentId]/integrations/airtable/components/AirtableWrapper";
import { getAirtableTables } from "@formbricks/lib/airtable/service";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getIntegrations } from "@formbricks/lib/integration/service";
import { getSurveys } from "@formbricks/lib/survey/service";
@@ -9,7 +9,7 @@ import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import GoBackButton from "@formbricks/ui/GoBackButton";
export default async function Airtable({ params }) {
const enabled = !!AIR_TABLE_CLIENT_ID;
const enabled = !!AIRTABLE_CLIENT_ID;
const [surveys, integrations, environment] = await Promise.all([
getSurveys(params.environmentId),
getIntegrations(params.environmentId),

View File

@@ -9,6 +9,6 @@ export const authorize = async (environmentId: string, apiHost: string): Promise
throw new Error("Could not create response");
}
const resJSON = await res.json();
const authUrl = resJSON.authUrl;
const authUrl = resJSON.data.authUrl;
return authUrl;
};

View File

@@ -157,7 +157,7 @@ export default function EditWelcomeCard({
</div>
</div>
</div>
{/* <div className="mt-8 flex items-center">
<div className="mt-8 flex items-center">
<div className="mr-2">
<Switch
id="timeToFinish"
@@ -176,7 +176,7 @@ export default function EditWelcomeCard({
Display an estimate of completion time for survey
</div>
</div>
</div> */}
</div>
</form>
</Collapsible.CollapsibleContent>
</Collapsible.Root>

View File

@@ -18,7 +18,7 @@ const welcomeCardDefault: TSurveyWelcomeCard = {
enabled: false,
headline: "Welcome!",
html: "Thanks for providing your feedback - let's go!",
timeToFinish: false,
timeToFinish: true,
};
export const templates: TTemplate[] = [

View File

@@ -3,6 +3,7 @@
import { Button } from "@formbricks/ui/Button";
import type { Session } from "next-auth";
import Link from "next/link";
import { useEffect, useRef } from "react";
type Greeting = {
next: () => void;
@@ -13,6 +14,27 @@ type Greeting = {
const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
const legacyUser = !session ? false : new Date(session?.user?.createdAt) < new Date("2023-05-03T00:00:00"); // if user is created before onboarding deployment
const buttonRef = useRef<HTMLButtonElement | null>(null);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
next();
}
};
const button = buttonRef.current;
if (button) {
button.focus();
button.addEventListener("keydown", handleKeyDown);
}
return () => {
if (button) {
button.removeEventListener("keydown", handleKeyDown);
}
};
}, []);
return (
<div className="flex h-full w-full max-w-xl flex-col justify-around gap-8 px-8">
@@ -30,7 +52,7 @@ const Greeting: React.FC<Greeting> = ({ next, skip, name, session }) => {
<Button size="lg" variant="minimal" onClick={skip}>
I&apos;ll do it later
</Button>
<Button size="lg" variant="darkCTA" onClick={next}>
<Button size="lg" variant="darkCTA" onClick={next} ref={buttonRef} tabIndex={0}>
Begin (1 min)
</Button>
</div>

View File

@@ -7,8 +7,9 @@ import { cn } from "@formbricks/lib/cn";
import { TProfileObjective } from "@formbricks/types/profile";
import { TProfile } from "@formbricks/types/profile";
import { Button } from "@formbricks/ui/Button";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { handleTabNavigation } from "../utils";
type ObjectiveProps = {
next: () => void;
@@ -35,18 +36,26 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isProfileUpdating, setIsProfileUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const handleNextClick = async () => {
if (selectedChoice) {
const selectedObjective = objectives.find((objective) => objective.label === selectedChoice);
if (selectedObjective) {
try {
setIsProfileUpdating(true);
const updatedProfile = {
...profile,
await updateProfileAction({
objective: selectedObjective.id,
name: profile.name ?? undefined,
};
await updateProfileAction(updatedProfile);
});
setIsProfileUpdating(false);
} catch (e) {
setIsProfileUpdating(false);
@@ -73,14 +82,14 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What do you want to achieve?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
We have 85+ templates, help us select the best for your need.
</label>
<div className="mt-4">
<fieldset>
<fieldset id="choices" aria-label="What do you want to achieve?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{objectives.map((choice) => (
@@ -103,6 +112,11 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId,
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}

View File

@@ -88,12 +88,7 @@ export default function Onboarding({ session, environmentId, profile, product }:
<Greeting next={next} skip={doLater} name={profile.name ? profile.name : ""} session={session} />
)}
{currentStep === 2 && (
<Role
next={next}
skip={skipStep}
setFormbricksResponseId={setFormbricksResponseId}
profile={profile}
/>
<Role next={next} skip={skipStep} setFormbricksResponseId={setFormbricksResponseId} />
)}
{currentStep === 3 && (
<Objective

View File

@@ -96,6 +96,7 @@ const Product: React.FC<Product> = ({ done, isLoading, environmentId, product })
placeholder="e.g. Formbricks"
value={name}
onChange={handleNameChange}
aria-label="Your product name"
/>
</div>
</div>

View File

@@ -1,19 +1,18 @@
"use client";
import { cn } from "@formbricks/lib/cn";
import { updateProfileAction } from "@/app/(app)/onboarding/actions";
import { env } from "@/env.mjs";
import { createResponse, formbricksEnabled } from "@/app/lib/formbricks";
import { TProfile } from "@formbricks/types/profile";
import { env } from "@/env.mjs";
import { cn } from "@formbricks/lib/cn";
import { Button } from "@formbricks/ui/Button";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast";
import { handleTabNavigation } from "../utils";
type RoleProps = {
next: () => void;
skip: () => void;
setFormbricksResponseId: (id: string) => void;
profile: TProfile;
};
type RoleChoice = {
@@ -21,9 +20,18 @@ type RoleChoice = {
id: "project_manager" | "engineer" | "founder" | "marketing_specialist" | "other";
};
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profile }) => {
const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
const fieldsetRef = useRef<HTMLFieldSetElement>(null);
useEffect(() => {
const onKeyDown = handleTabNavigation(fieldsetRef, setSelectedChoice);
window.addEventListener("keydown", onKeyDown);
return () => {
window.removeEventListener("keydown", onKeyDown);
};
}, [fieldsetRef, setSelectedChoice]);
const roles: Array<RoleChoice> = [
{ label: "Project Manager", id: "project_manager" },
@@ -39,8 +47,7 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
if (selectedRole) {
try {
setIsUpdating(true);
const updatedProfile = { ...profile, role: selectedRole.id };
await updateProfileAction(updatedProfile);
await updateProfileAction({ role: selectedRole.id });
setIsUpdating(false);
} catch (e) {
setIsUpdating(false);
@@ -66,19 +73,20 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
return (
<div className="flex w-full max-w-xl flex-col gap-8 px-8">
<div className="px-4">
<label className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
<label htmlFor="choices" className="mb-1.5 block text-base font-semibold leading-6 text-slate-900">
What is your role?
</label>
<label className="block text-sm font-normal leading-6 text-slate-500">
Make your Formbricks experience more personalised.
</label>
<div className="mt-4">
<fieldset>
<fieldset id="choices" aria-label="What is your role?" ref={fieldsetRef}>
<legend className="sr-only">Choices</legend>
<div className=" relative space-y-2 rounded-md">
{roles.map((choice) => (
<label
key={choice.id}
htmlFor={choice.id}
className={cn(
selectedChoice === choice.label
? "z-10 border-slate-400 bg-slate-100"
@@ -90,12 +98,18 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId, profil
type="radio"
id={choice.id}
value={choice.label}
name="role"
checked={choice.label === selectedChoice}
className="checked:text-brand-dark focus:text-brand-dark h-4 w-4 border border-gray-300 focus:ring-0 focus:ring-offset-0"
aria-labelledby={`${choice.id}-label`}
onChange={(e) => {
setSelectedChoice(e.currentTarget.value);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleNextClick();
}
}}
/>
<span id={`${choice.id}-label`} className="ml-3 font-medium">
{choice.label}

View File

@@ -0,0 +1,34 @@
// util.js
export const handleTabNavigation = (fieldsetRef, setSelectedChoice) => (event) => {
if (event.key !== "Tab") {
return;
}
event.preventDefault();
const radioButtons = fieldsetRef.current?.querySelectorAll('input[type="radio"]');
if (!radioButtons || radioButtons.length === 0) {
return;
}
const focusedRadioButton = fieldsetRef.current?.querySelector(
'input[type="radio"]:focus'
) as HTMLInputElement;
if (!focusedRadioButton) {
// If no radio button is focused, then it will focus on the first one by default
const firstRadioButton = radioButtons[0] as HTMLInputElement;
firstRadioButton.focus();
setSelectedChoice(firstRadioButton.value);
return;
}
const focusedIndex = Array.from(radioButtons).indexOf(focusedRadioButton);
const lastIndex = radioButtons.length - 1;
// Calculating the next index, considering wrapping from the last to the first element
const nextIndex = focusedIndex === lastIndex ? 0 : focusedIndex + 1;
const nextRadioButton = radioButtons[nextIndex] as HTMLInputElement;
nextRadioButton.focus();
setSelectedChoice(nextRadioButton.value);
};

View File

@@ -51,7 +51,7 @@ async function reportTeamUsage(team: TTeam) {
}
}
export async function GET(): Promise<NextResponse> {
export async function POST(): Promise<NextResponse> {
const headersList = headers();
const apiKey = headersList.get("x-api-key");

View File

@@ -1,10 +1,11 @@
import { prisma } from "@formbricks/database";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
WEBAPP_URL,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { createOrUpdateIntegration } from "@formbricks/lib/integration/service";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
@@ -15,19 +16,19 @@ export async function GET(req: NextRequest) {
const code = queryParams.get("code");
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
return responses.badRequestResponse("Invalid environmentId");
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
return responses.badRequestResponse("`code` must be a string");
}
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
let key;
@@ -61,22 +62,7 @@ export async function GET(req: NextRequest) {
},
};
const result = await prisma.integration.upsert({
where: {
type_environmentId: {
environmentId,
type: "googleSheets",
},
},
update: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
create: {
...googleSheetIntegration,
environment: { connect: { id: environmentId } },
},
});
const result = await createOrUpdateIntegration(environmentId, googleSheetIntegration);
if (result) {
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/google-sheets`);

View File

@@ -1,11 +1,12 @@
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
GOOGLE_SHEETS_CLIENT_SECRET,
GOOGLE_SHEETS_REDIRECT_URL,
} from "@formbricks/lib/constants";
import { google } from "googleapis";
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
@@ -20,24 +21,24 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
return responses.badRequestResponse("environmentId is missing");
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
return responses.unauthorizedResponse();
}
const client_id = GOOGLE_SHEETS_CLIENT_ID;
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
if (!client_id) return NextResponse.json({ Error: "Google client id is missing" }, { status: 400 });
if (!client_secret) return NextResponse.json({ Error: "Google client secret is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Google redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Google client id is missing");
if (!client_secret) return responses.internalServerErrorResponse("Google client secret is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
const authUrl = oAuth2Client.generateAuthUrl({
@@ -47,5 +48,5 @@ export async function GET(req: NextRequest) {
state: environmentId!,
});
return NextResponse.json({ authUrl }, { status: 200 });
return responses.successResponse({ authUrl });
}

View File

@@ -9,6 +9,7 @@ import { getTeamDetails } from "@formbricks/lib/teamDetail/service";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
import { TSurvey } from "@formbricks/types/surveys";
export async function OPTIONS(): Promise<NextResponse> {
return responses.successResponse({}, true);
@@ -27,10 +28,13 @@ export async function POST(request: Request): Promise<NextResponse> {
);
}
let survey;
let survey: TSurvey | null;
try {
survey = await getSurvey(responseInput.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId);
}
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);

View File

@@ -1,9 +1,10 @@
import { authOptions } from "@formbricks/lib/authOptions";
import { connectAirtable, fetchAirtableAuthToken } from "@formbricks/lib/airtable/service";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { responses } from "@/app/lib/api/response";
import * as z from "zod";
async function getEmail(token: string) {
@@ -26,27 +27,27 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ error: "Invalid environmentId" });
return responses.badRequestResponse("Invalid environmentId");
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
return responses.notAuthenticatedResponse();
}
if (code && typeof code !== "string") {
return NextResponse.json({ message: "`code` must be a string" }, { status: 400 });
return responses.badRequestResponse("`code` must be a string");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
return responses.unauthorizedResponse();
}
const client_id = AIR_TABLE_CLIENT_ID;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
const code_verifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const formData = {
grant_type: "authorization_code",
@@ -59,7 +60,7 @@ export async function GET(req: NextRequest) {
try {
const key = await fetchAirtableAuthToken(formData);
if (!key) {
return NextResponse.json({ Error: "Failed to fetch Airtable auth token" }, { status: 500 });
return responses.notFoundResponse("airtable auth token", key);
}
const email = await getEmail(key.access_token);
@@ -71,8 +72,7 @@ export async function GET(req: NextRequest) {
return NextResponse.redirect(`${WEBAPP_URL}/environments/${environmentId}/integrations/airtable`);
} catch (error) {
console.error(error);
NextResponse.json({ Error: error }, { status: 500 });
responses.internalServerErrorResponse(error);
}
NextResponse.json({ Error: "unknown error occurred" }, { status: 400 });
responses.badRequestResponse("unknown error occurred");
}

View File

@@ -1,10 +1,11 @@
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { responses } from "@/app/lib/api/response";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import crypto from "crypto";
import { AIR_TABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
import { AIRTABLE_CLIENT_ID, WEBAPP_URL } from "@formbricks/lib/constants";
const scope = `data.records:read data.records:write schema.bases:read schema.bases:write user.email:read`;
@@ -13,23 +14,22 @@ export async function GET(req: NextRequest) {
const session = await getServerSession(authOptions);
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
return responses.badRequestResponse("environmentId is missing");
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
return responses.notAuthenticatedResponse();
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
return responses.unauthorizedResponse();
}
const client_id = AIR_TABLE_CLIENT_ID;
const client_id = AIRTABLE_CLIENT_ID;
const redirect_uri = WEBAPP_URL + "/api/v1/integrations/airtable/callback";
if (!client_id) return NextResponse.json({ Error: "Airtable client id is missing" }, { status: 400 });
if (!redirect_uri) return NextResponse.json({ Error: "Airtable redirect url is missing" }, { status: 400 });
if (!client_id) return responses.internalServerErrorResponse("Airtable client id is missing");
if (!redirect_uri) return responses.internalServerErrorResponse("Airtable redirect url is missing");
const codeVerifier = Buffer.from(environmentId + session.user.id + environmentId).toString("base64");
const codeChallengeMethod = "S256";
@@ -51,5 +51,5 @@ export async function GET(req: NextRequest) {
authUrl.searchParams.append("code_challenge_method", codeChallengeMethod);
authUrl.searchParams.append("code_challenge", codeChallenge);
return NextResponse.json({ authUrl: authUrl.toString() }, { status: 200 });
return responses.successResponse({ authUrl: authUrl.toString() });
}

View File

@@ -4,7 +4,8 @@ import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { getIntegrationByType } from "@formbricks/lib/integration/service";
import { TIntegrationAirtable } from "@formbricks/types/integration/airtable";
import { getServerSession } from "next-auth";
import { NextRequest, NextResponse } from "next/server";
import { NextRequest } from "next/server";
import { responses } from "@/app/lib/api/response";
import * as z from "zod";
export async function GET(req: NextRequest) {
@@ -15,30 +16,28 @@ export async function GET(req: NextRequest) {
const baseId = z.string().safeParse(queryParams.get("baseId"));
if (!baseId.success) {
return NextResponse.json({ Error: "Base Id is Required" }, { status: 400 });
return responses.missingFieldResponse("Base Id is Required");
}
if (!session) {
return NextResponse.json({ Error: "Invalid session" }, { status: 400 });
return responses.notAuthenticatedResponse();
}
if (!environmentId) {
return NextResponse.json({ Error: "environmentId is missing" }, { status: 400 });
return responses.badRequestResponse("environmentId is missing");
}
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session?.user.id, environmentId);
if (!canUserAccessEnvironment || !environmentId) {
return NextResponse.json({ Error: "You dont have access to environment" }, { status: 401 });
return responses.unauthorizedResponse();
}
const integration = (await getIntegrationByType(environmentId, "airtable")) as TIntegrationAirtable;
console.log(integration);
if (!integration) {
return NextResponse.json({ Error: "integration not found" }, { status: 401 });
return responses.notFoundResponse("Integration not found", environmentId);
}
const tables = await getTables(integration.config.key, baseId.data);
return NextResponse.json(tables, { status: 200 });
return responses.successResponse(tables);
}

View File

@@ -54,7 +54,7 @@ export const env = createEnv({
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
AIR_TABLE_CLIENT_ID: z.string().optional(),
AIRTABLE_CLIENT_ID: z.string().optional(),
AWS_ACCESS_KEY: z.string().optional(),
AWS_SECRET_KEY: z.string().optional(),
S3_ACCESS_KEY: z.string().optional(),
@@ -139,7 +139,7 @@ export const env = createEnv({
AZUREAD_CLIENT_ID: process.env.AZUREAD_CLIENT_ID,
AZUREAD_CLIENT_SECRET: process.env.AZUREAD_CLIENT_SECRET,
AZUREAD_TENANT_ID: process.env.AZUREAD_TENANT_ID,
AIR_TABLE_CLIENT_ID: process.env.AIR_TABLE_CLIENT_ID,
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
},
});

View File

@@ -27,7 +27,7 @@
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@react-email/components": "^0.0.9",
"@sentry/nextjs": "^7.76.0",
"@sentry/nextjs": "^7.77.0",
"@t3-oss/env-nextjs": "^0.7.1",
"@vercel/og": "^0.5.20",
"bcryptjs": "^2.4.3",

View File

@@ -1,10 +1,7 @@
import "server-only";
import { env } from "../../../apps/web/env.mjs";
import { unstable_cache } from "next/cache";
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
import { ENTERPRISE_LICENSE_KEY } from "@formbricks/lib/constants";
export const getIsEnterpriseEdition = () =>
unstable_cache(

View File

@@ -1,7 +1,7 @@
{
"name": "@formbricks/js",
"license": "MIT",
"version": "1.1.4",
"version": "1.1.5",
"description": "Formbricks-js allows you to connect your app to Formbricks, display surveys and trigger events.",
"keywords": [
"Formbricks",

View File

@@ -13,7 +13,7 @@ import {
ZIntegrationAirtableTokenSchema,
} from "@formbricks/types/integration/airtable";
import { Prisma } from "@prisma/client";
import { AIR_TABLE_CLIENT_ID } from "../constants";
import { AIRTABLE_CLIENT_ID } from "../constants";
import { createOrUpdateIntegration, deleteIntegration, getIntegrationByType } from "../integration/service";
interface ConnectAirtableOptions {
@@ -122,7 +122,7 @@ export const getAirtableToken = async (environmentId: string) => {
const currentDate = new Date();
if (currentDate >= expiryDate) {
const client_id = AIR_TABLE_CLIENT_ID;
const client_id = AIRTABLE_CLIENT_ID;
const newToken = await fetchAirtableAuthToken({
grant_type: "refresh_token",

View File

@@ -1,7 +1,5 @@
import "server-only";
import path from "path";
import { env } from "@/env.mjs";
import { unstable_cache } from "next/cache";
export const IS_FORMBRICKS_CLOUD = process.env.IS_FORMBRICKS_CLOUD === "1";
export const REVALIDATION_INTERVAL = 0; //TODO: find a good way to cache and revalidate data when it changes
@@ -47,7 +45,7 @@ export const GOOGLE_SHEETS_CLIENT_ID = process.env.GOOGLE_SHEETS_CLIENT_ID;
export const GOOGLE_SHEETS_CLIENT_SECRET = process.env.GOOGLE_SHEETS_CLIENT_SECRET;
export const GOOGLE_SHEETS_REDIRECT_URL = process.env.GOOGLE_SHEETS_REDIRECT_URL;
export const AIR_TABLE_CLIENT_ID = process.env.AIR_TABLE_CLIENT_ID;
export const AIRTABLE_CLIENT_ID = process.env.AIRTABLE_CLIENT_ID;
export const SMTP_HOST = process.env.SMTP_HOST;
export const SMTP_PORT = process.env.SMTP_PORT;
@@ -84,20 +82,6 @@ export const LOCAL_UPLOAD_URL = {
// Pricing
export const PRICING_USERTARGETING_FREE_MTU = 2500;
export const PRICING_APPSURVEYS_FREE_RESPONSES = 250;
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
export const getIsEnterpriseEdition = () =>
unstable_cache(
async () => {
if (ENTERPRISE_LICENSE_KEY) {
return ENTERPRISE_LICENSE_KEY?.length > 0;
}
return false;
},
["isEE"],
{ revalidate: 60 * 60 * 24 }
)();
export const colours = [
"#FFF2D8",
@@ -125,3 +109,7 @@ export const colours = [
"#F6FDC3",
"#CDFAD5",
];
// Enterprise License constant
export const ENTERPRISE_LICENSE_KEY = process.env.ENTERPRISE_LICENSE_KEY;

View File

@@ -1,6 +1,7 @@
import { TSurveyWithTriggers } from "@formbricks/types/js";
import { useEffect, useState } from "preact/hooks";
import Progress from "./Progress";
import { calculateElementIdx } from "../lib/utils";
interface ProgressBarProps {
survey: TSurveyWithTriggers;
@@ -13,6 +14,7 @@ const PROGRESS_INCREMENT = 0.1;
export default function ProgressBar({ survey, questionId, brandColor }: ProgressBarProps) {
const [progress, setProgress] = useState(0); // [0, 1]
const [prevQuestionIdx, setPrevQuestionIdx] = useState(0); // [0, survey.questions.length
const [prevQuestionId, setPrevQuestionId] = useState(""); // [0, survey.questions.length
useEffect(() => {
// calculate progress
@@ -20,28 +22,10 @@ export default function ProgressBar({ survey, questionId, brandColor }: Progress
function calculateProgress(questionId: string, survey: TSurveyWithTriggers, progress: number) {
if (survey.questions.length === 0) return 0;
if (questionId === "end") return 1;
let currentQustionIdx = survey.questions.findIndex((e) => e.id === questionId);
if (progress > 0 && currentQustionIdx === prevQuestionIdx) return progress;
if (progress > 0 && questionId === prevQuestionId) return progress;
if (currentQustionIdx === -1) currentQustionIdx = 0;
const currentQuestion = survey.questions[currentQustionIdx];
const surveyLength = survey.questions.length;
const middleIdx = Math.floor(surveyLength / 2);
const possibleNextQuestions = currentQuestion.logic?.map((l) => l.destination) || [];
const getLastQuestionIndex = () => {
const lastQuestion = survey.questions
.filter((q) => possibleNextQuestions.includes(q.id))
.sort((a, b) => survey.questions.indexOf(a) - survey.questions.indexOf(b))
.pop();
return survey.questions.findIndex((e) => e.id === lastQuestion?.id);
};
let elementIdx = currentQustionIdx || 0.5;
const lastprevQuestionIdx = getLastQuestionIndex();
if (lastprevQuestionIdx > 0) elementIdx = Math.min(middleIdx, lastprevQuestionIdx - 1);
if (possibleNextQuestions.includes("end")) elementIdx = middleIdx;
const elementIdx = calculateElementIdx(survey, currentQustionIdx);
const newProgress = elementIdx / survey.questions.length;
@@ -57,7 +41,7 @@ export default function ProgressBar({ survey, questionId, brandColor }: Progress
} else if (newProgress <= progress && progress + PROGRESS_INCREMENT <= 1) {
updatedProgress = progress + PROGRESS_INCREMENT;
}
setPrevQuestionId(questionId);
setPrevQuestionIdx(currentQustionIdx);
return updatedProgress;
}

View File

@@ -131,6 +131,7 @@ export function Survey({
timeToFinish={survey.welcomeCard.timeToFinish}
brandColor={brandColor}
onSubmit={onSubmit}
survey={survey}
/>
);
} else if (questionId === "end" && survey.thankYouCard.enabled) {

View File

@@ -1,6 +1,8 @@
import Headline from "./Headline";
import HtmlBody from "./HtmlBody";
import SubmitButton from "./SubmitButton";
import { calculateElementIdx } from "../lib/utils";
import { TSurveyWithTriggers } from "@formbricks/types/js";
interface WelcomeCardProps {
headline?: string;
@@ -10,8 +12,26 @@ interface WelcomeCardProps {
timeToFinish?: boolean;
brandColor: string;
onSubmit: (data: { [x: string]: any }) => void;
survey: TSurveyWithTriggers;
}
const TimerIcon = () => {
return (
<div className="mr-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="currentColor"
class="bi bi-stopwatch"
viewBox="0 0 16 16">
<path d="M8.5 5.6a.5.5 0 1 0-1 0v2.9h-3a.5.5 0 0 0 0 1H8a.5.5 0 0 0 .5-.5V5.6z" />
<path d="M6.5 1A.5.5 0 0 1 7 .5h2a.5.5 0 0 1 0 1v.57c1.36.196 2.594.78 3.584 1.64a.715.715 0 0 1 .012-.013l.354-.354-.354-.353a.5.5 0 0 1 .707-.708l1.414 1.415a.5.5 0 1 1-.707.707l-.353-.354-.354.354a.512.512 0 0 1-.013.012A7 7 0 1 1 7 2.071V1.5a.5.5 0 0 1-.5-.5zM8 3a6 6 0 1 0 .001 12A6 6 0 0 0 8 3z" />
</svg>
</div>
);
};
export default function WelcomeCard({
headline,
html,
@@ -20,7 +40,36 @@ export default function WelcomeCard({
timeToFinish,
brandColor,
onSubmit,
survey,
}: WelcomeCardProps) {
const calculateTimeToComplete = () => {
let idx = calculateElementIdx(survey, 0);
if (idx === 0.5) {
idx = 1;
}
const timeInSeconds = (survey.questions.length / idx) * 15; //15 seconds per question.
if (timeInSeconds > 360) {
// If it's more than 6 minutes
return "6+ minutes";
}
// Calculate minutes, if there are any seconds left, add a minute
const minutes = Math.floor(timeInSeconds / 60);
const remainingSeconds = timeInSeconds % 60;
if (remainingSeconds > 0) {
// If there are any seconds left, we'll need to round up to the next minute
if (minutes === 0) {
// If less than 1 minute, return 'less than 1 minute'
return "less than 1 minute";
} else {
// If more than 1 minute, return 'less than X minutes', where X is minutes + 1
return `less than ${minutes + 1} minutes`;
}
}
// If there are no remaining seconds, just return the number of minutes
return `${minutes} minutes`;
};
return (
<div>
{fileUrl && (
@@ -46,7 +95,12 @@ export default function WelcomeCard({
<div className="flex items-center text-xs text-slate-600">Press Enter </div>
</div>
</div>
{timeToFinish && <></>}
{timeToFinish && (
<div className="item-center mt-4 flex text-slate-500">
<TimerIcon />
<p className="text-xs">Takes {calculateTimeToComplete()}</p>
</div>
)}
</div>
);
}

View File

@@ -47,7 +47,6 @@ export function evaluateCondition(logic: TSurveyLogic, responseValue: any): bool
(Array.isArray(responseValue) && responseValue.length === 0) ||
responseValue === "" ||
responseValue === null ||
responseValue === undefined ||
responseValue === "dismissed"
);
default:

View File

@@ -1,3 +1,5 @@
import { TSurveyWithTriggers } from "@formbricks/types/js";
export const cn = (...classes: string[]) => {
return classes.filter(Boolean).join(" ");
};
@@ -45,3 +47,25 @@ export const shuffleQuestions = (array: any[], shuffleOption: string) => {
return arrayCopy;
};
export const calculateElementIdx = (survey: TSurveyWithTriggers, currentQustionIdx: number): number => {
const currentQuestion = survey.questions[currentQustionIdx];
const surveyLength = survey.questions.length;
const middleIdx = Math.floor(surveyLength / 2);
const possibleNextQuestions = currentQuestion?.logic?.map((l) => l.destination) || [];
const getLastQuestionIndex = () => {
const lastQuestion = survey.questions
.filter((q) => possibleNextQuestions.includes(q.id))
.sort((a, b) => survey.questions.indexOf(a) - survey.questions.indexOf(b))
.pop();
return survey.questions.findIndex((e) => e.id === lastQuestion?.id);
};
let elementIdx = currentQustionIdx || 0.5;
const lastprevQuestionIdx = getLastQuestionIndex();
if (lastprevQuestionIdx > 0) elementIdx = Math.min(middleIdx, lastprevQuestionIdx - 1);
if (possibleNextQuestions.includes("end")) elementIdx = middleIdx;
return elementIdx;
};

View File

@@ -25,7 +25,7 @@ export const ZSurveyWelcomeCard = z.object({
html: z.string().optional(),
fileUrl: z.string().optional(),
buttonLabel: z.string().optional(),
timeToFinish: z.boolean().default(false),
timeToFinish: z.boolean().default(true),
});
export const ZSurveyHiddenFields = z.object({

View File

@@ -15,6 +15,8 @@ export const ColorPicker = ({ color, onChange }: { color: string; onChange: (v:
className="ml-2 mr-2 h-10 w-32 flex-1 border-0 bg-transparent text-slate-500 outline-none focus:border-none"
color={color}
onChange={onChange}
id="color"
aria-label="Primary color"
/>
</div>
<PopoverPicker color={color} onChange={onChange} />

139
pnpm-lock.yaml generated
View File

@@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@@ -330,8 +326,8 @@ importers:
specifier: ^0.0.9
version: 0.0.9(react@18.2.0)
'@sentry/nextjs':
specifier: ^7.76.0
version: 7.76.0(encoding@0.1.13)(next@13.5.6)(react@18.2.0)(webpack@5.89.0)
specifier: ^7.77.0
version: 7.77.0(encoding@0.1.13)(next@13.5.6)(react@18.2.0)(webpack@5.89.0)
'@t3-oss/env-nextjs':
specifier: ^0.7.1
version: 0.7.1(zod@3.22.4)
@@ -526,7 +522,7 @@ importers:
version: 9.0.0(eslint@8.52.0)
eslint-config-turbo:
specifier: latest
version: 1.8.8(eslint@8.52.0)
version: 1.10.16(eslint@8.52.0)
eslint-plugin-react:
specifier: 7.33.2
version: 7.33.2(eslint@8.52.0)
@@ -7202,24 +7198,24 @@ packages:
selderee: 0.11.0
dev: false
/@sentry-internal/tracing@7.76.0:
resolution: {integrity: sha512-QQVIv+LS2sbGf/e5P2dRisHzXpy02dAcLqENLPG4sZ9otRaFNjdFYEqnlJ4qko+ORpJGQEQp/BX7Q/qzZQHlAg==}
/@sentry-internal/tracing@7.77.0:
resolution: {integrity: sha512-8HRF1rdqWwtINqGEdx8Iqs9UOP/n8E0vXUu3Nmbqj4p5sQPA7vvCfq+4Y4rTqZFc7sNdFpDsRION5iQEh8zfZw==}
engines: {node: '>=8'}
dependencies:
'@sentry/core': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/core': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
dev: false
/@sentry/browser@7.76.0:
resolution: {integrity: sha512-83xA+cWrBhhkNuMllW5ucFsEO2NlUh2iBYtmg07lp3fyVW+6+b1yMKRnc4RFArJ+Wcq6UO+qk2ZEvrSAts1wEw==}
/@sentry/browser@7.77.0:
resolution: {integrity: sha512-nJ2KDZD90H8jcPx9BysQLiQW+w7k7kISCWeRjrEMJzjtge32dmHA8G4stlUTRIQugy5F+73cOayWShceFP7QJQ==}
engines: {node: '>=8'}
dependencies:
'@sentry-internal/tracing': 7.76.0
'@sentry/core': 7.76.0
'@sentry/replay': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry-internal/tracing': 7.77.0
'@sentry/core': 7.77.0
'@sentry/replay': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
dev: false
/@sentry/cli@1.75.2(encoding@0.1.13):
@@ -7239,26 +7235,26 @@ packages:
- supports-color
dev: false
/@sentry/core@7.76.0:
resolution: {integrity: sha512-M+ptkCTeCNf6fn7p2MmEb1Wd9/JXUWxIT/0QEc+t11DNR4FYy1ZP2O9Zb3Zp2XacO7ORrlL3Yc+VIfl5JTgjfw==}
/@sentry/core@7.77.0:
resolution: {integrity: sha512-Tj8oTYFZ/ZD+xW8IGIsU6gcFXD/gfE+FUxUaeSosd9KHwBQNOLhZSsYo/tTVf/rnQI/dQnsd4onPZLiL+27aTg==}
engines: {node: '>=8'}
dependencies:
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
dev: false
/@sentry/integrations@7.76.0:
resolution: {integrity: sha512-4ea0PNZrGN9wKuE/8bBCRrxxw4Cq5T710y8rhdKHAlSUpbLqr/atRF53h8qH3Fi+ec0m38PB+MivKem9zUwlwA==}
/@sentry/integrations@7.77.0:
resolution: {integrity: sha512-P055qXgBHeZNKnnVEs5eZYLdy6P49Zr77A1aWJuNih/EenzMy922GOeGy2mF6XYrn1YJSjEwsNMNsQkcvMTK8Q==}
engines: {node: '>=8'}
dependencies:
'@sentry/core': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/core': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
localforage: 1.10.0
dev: false
/@sentry/nextjs@7.76.0(encoding@0.1.13)(next@13.5.6)(react@18.2.0)(webpack@5.89.0):
resolution: {integrity: sha512-3/iTnBJ7qOrhoEUQw85CmZ+S2wTZapRui5yfWO6/We11T8q6tvrUPIYmnE0BY/2BIelz4dfPwXRHXRJlgEarhg==}
/@sentry/nextjs@7.77.0(encoding@0.1.13)(next@13.5.6)(react@18.2.0)(webpack@5.89.0):
resolution: {integrity: sha512-8tYPBt5luFjrng1sAMJqNjM9sq80q0jbt6yariADU9hEr7Zk8YqFaOI2/Q6yn9dZ6XyytIRtLEo54kk2AO94xw==}
engines: {node: '>=8'}
peerDependencies:
next: ^10.0.8 || ^11.0 || ^12.0 || ^13.0 || ^14.0
@@ -7269,13 +7265,13 @@ packages:
optional: true
dependencies:
'@rollup/plugin-commonjs': 24.0.0(rollup@2.78.0)
'@sentry/core': 7.76.0
'@sentry/integrations': 7.76.0
'@sentry/node': 7.76.0
'@sentry/react': 7.76.0(react@18.2.0)
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/vercel-edge': 7.76.0
'@sentry/core': 7.77.0
'@sentry/integrations': 7.77.0
'@sentry/node': 7.77.0
'@sentry/react': 7.77.0(react@18.2.0)
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
'@sentry/vercel-edge': 7.77.0
'@sentry/webpack-plugin': 1.20.0(encoding@0.1.13)
chalk: 3.0.0
next: 13.5.6(react-dom@18.2.0)(react@18.2.0)
@@ -7289,61 +7285,61 @@ packages:
- supports-color
dev: false
/@sentry/node@7.76.0:
resolution: {integrity: sha512-C+YZ5S5W9oTphdWTBgV+3nDdcV1ldnupIHylHzf2Co+xNtJ76V06N5NjdJ/l9+qvQjMn0DdSp7Uu7KCEeNBT/g==}
/@sentry/node@7.77.0:
resolution: {integrity: sha512-Ob5tgaJOj0OYMwnocc6G/CDLWC7hXfVvKX/ofkF98+BbN/tQa5poL+OwgFn9BA8ud8xKzyGPxGU6LdZ8Oh3z/g==}
engines: {node: '>=8'}
dependencies:
'@sentry-internal/tracing': 7.76.0
'@sentry/core': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry-internal/tracing': 7.77.0
'@sentry/core': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
https-proxy-agent: 5.0.1
transitivePeerDependencies:
- supports-color
dev: false
/@sentry/react@7.76.0(react@18.2.0):
resolution: {integrity: sha512-FtwB1TjCaHLbyAnEEu3gBdcnh/hhpC+j0dII5bOqp4YvmkGkXfgQcjZskZFX7GydMcRAjWX35s0VRjuBBZu/fA==}
/@sentry/react@7.77.0(react@18.2.0):
resolution: {integrity: sha512-Q+htKzib5em0MdaQZMmPomaswaU3xhcVqmLi2CxqQypSjbYgBPPd+DuhrXKoWYLDDkkbY2uyfe4Lp3yLRWeXYw==}
engines: {node: '>=8'}
peerDependencies:
react: 15.x || 16.x || 17.x || 18.x
dependencies:
'@sentry/browser': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/browser': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
hoist-non-react-statics: 3.3.2
react: 18.2.0
dev: false
/@sentry/replay@7.76.0:
resolution: {integrity: sha512-OACT7MfMHC/YGKnKST8SF1d6znr3Yu8fpUpfVVh2t9TNeh3+cQJVTOliHDqLy+k9Ljd5FtitgSn4IHtseCHDLQ==}
/@sentry/replay@7.77.0:
resolution: {integrity: sha512-M9Ik2J5ekl+C1Och3wzLRZVaRGK33BlnBwfwf3qKjgLDwfKW+1YkwDfTHbc2b74RowkJbOVNcp4m8ptlehlSaQ==}
engines: {node: '>=12'}
dependencies:
'@sentry-internal/tracing': 7.76.0
'@sentry/core': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry-internal/tracing': 7.77.0
'@sentry/core': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
dev: false
/@sentry/types@7.76.0:
resolution: {integrity: sha512-vj6z+EAbVrKAXmJPxSv/clpwS9QjPqzkraMFk2hIdE/kii8s8kwnkBwTSpIrNc8GnzV3qYC4r3qD+BXDxAGPaw==}
/@sentry/types@7.77.0:
resolution: {integrity: sha512-nfb00XRJVi0QpDHg+JkqrmEBHsqBnxJu191Ded+Cs1OJ5oPXEW6F59LVcBScGvMqe+WEk1a73eH8XezwfgrTsA==}
engines: {node: '>=8'}
dev: false
/@sentry/utils@7.76.0:
resolution: {integrity: sha512-40jFD+yfQaKpFYINghdhovzec4IEpB7aAuyH/GtE7E0gLpcqnC72r55krEIVILfqIR2Mlr5OKUzyeoCyWAU/yw==}
/@sentry/utils@7.77.0:
resolution: {integrity: sha512-NmM2kDOqVchrey3N5WSzdQoCsyDkQkiRxExPaNI2oKQ/jMWHs9yt0tSy7otPBcXs0AP59ihl75Bvm1tDRcsp5g==}
engines: {node: '>=8'}
dependencies:
'@sentry/types': 7.76.0
'@sentry/types': 7.77.0
dev: false
/@sentry/vercel-edge@7.76.0:
resolution: {integrity: sha512-CU/besmv2SWNfVh4v7yVs1VknxU4aG7+kIW001wTYnaNXF8IjV8Bgyn0lDRxFuBXRcrTn8KJO/rUN7aJEmeg4Q==}
/@sentry/vercel-edge@7.77.0:
resolution: {integrity: sha512-ffddPCgxVeAccPYuH5sooZeHBqDuJ9OIhIRYKoDi4TvmwAzWo58zzZWhRpkHqHgIQdQvhLVZ5F+FSQVWnYSOkw==}
engines: {node: '>=8'}
dependencies:
'@sentry/core': 7.76.0
'@sentry/types': 7.76.0
'@sentry/utils': 7.76.0
'@sentry/core': 7.77.0
'@sentry/types': 7.77.0
'@sentry/utils': 7.77.0
dev: false
/@sentry/webpack-plugin@1.20.0(encoding@0.1.13):
@@ -12840,13 +12836,13 @@ packages:
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
dev: true
/eslint-config-turbo@1.8.8(eslint@8.52.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
/eslint-config-turbo@1.10.16(eslint@8.52.0):
resolution: {integrity: sha512-O3NQI72bQHV7FvSC6lWj66EGx8drJJjuT1kuInn6nbMLOHdMBhSUX/8uhTAlHRQdlxZk2j9HtgFCIzSc93w42g==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.52.0
eslint-plugin-turbo: 1.8.8(eslint@8.52.0)
eslint-plugin-turbo: 1.10.16(eslint@8.52.0)
dev: true
/eslint-import-resolver-node@0.3.9:
@@ -13048,11 +13044,12 @@ packages:
- typescript
dev: true
/eslint-plugin-turbo@1.8.8(eslint@8.52.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
/eslint-plugin-turbo@1.10.16(eslint@8.52.0):
resolution: {integrity: sha512-ZjrR88MTN64PNGufSEcM0tf+V1xFYVbeiMeuIqr0aiABGomxFLo4DBkQ7WI4WzkZtWQSIA2sP+yxqSboEfL9MQ==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
dotenv: 16.0.3
eslint: 8.52.0
dev: true
@@ -23729,3 +23726,7 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@@ -112,7 +112,7 @@
"TELEMETRY_DISABLED",
"VERCEL_URL",
"WEBAPP_URL",
"AIR_TABLE_CLIENT_ID",
"AIRTABLE_CLIENT_ID",
"AWS_ACCESS_KEY",
"AWS_SECRET_KEY",
"S3_ACCESS_KEY",