mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-05 16:19:55 -06:00
feat: avatar upload (#1546)
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
@@ -311,18 +311,17 @@ export default function Navigation({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<div tabIndex={0} className="flex cursor-pointer flex-row items-center space-x-5">
|
||||
<ProfileAvatar userId={session.user.id} />
|
||||
{/* {session.user.image ? (
|
||||
{session.user.imageUrl ? (
|
||||
<Image
|
||||
src={session.user.image}
|
||||
width="100"
|
||||
height="100"
|
||||
className="ph-no-capture h-9 w-9 rounded-full"
|
||||
src={session.user.imageUrl}
|
||||
width="40"
|
||||
height="40"
|
||||
className="ph-no-capture h-10 w-10 rounded-full"
|
||||
alt="Profile picture"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session.user.id} />
|
||||
)} */}
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="ph-no-capture ph-no-capture -mb-0.5 text-sm font-bold text-slate-700">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { createOrUpdateIntegration, deleteIntegration } from "@formbricks/lib/integration/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use server";
|
||||
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getSpreadSheets } from "@formbricks/lib/googleSheet/service";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
|
||||
@@ -67,3 +67,17 @@ export async function disableTwoFactorAuthAction(params: TDisableTwoFactorAuthPa
|
||||
|
||||
return await disableTwoFactorAuth(session.user.id, params);
|
||||
}
|
||||
|
||||
export async function updateAvatarAction(avatarUrl: string) {
|
||||
const session = await getServerSession(authOptions);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Not authenticated");
|
||||
}
|
||||
|
||||
if (!session.user.id) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return await updateProfile(session.user.id, { imageUrl: avatarUrl });
|
||||
}
|
||||
|
||||
@@ -3,25 +3,91 @@
|
||||
import { Button } from "@formbricks/ui/Button";
|
||||
import { ProfileAvatar } from "@formbricks/ui/Avatars";
|
||||
import { Session } from "next-auth";
|
||||
import { useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import Image from "next/image";
|
||||
import { updateAvatarAction } from "@/app/(app)/environments/[environmentId]/settings/profile/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { handleFileUpload } from "../lib";
|
||||
|
||||
export function EditAvatar({ session, environmentId }: { session: Session | null; environmentId: string }) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleUpload = async (file: File, environmentId: string) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const { url, error } = await handleFileUpload(file, environmentId);
|
||||
|
||||
if (error) {
|
||||
toast.error(error);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await updateAvatarAction(url);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
toast.error("Avatar update failed. Please try again.");
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
export function EditAvatar({ session }: { session: Session | null }) {
|
||||
return (
|
||||
<div>
|
||||
{/* {session?.user?.image ? (
|
||||
<Image
|
||||
src={AvatarPlaceholder}
|
||||
width="100"
|
||||
height="100"
|
||||
className="h-24 w-24 rounded-full"
|
||||
alt="Avatar placeholder"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session!.user.id} />
|
||||
)} */}
|
||||
<ProfileAvatar userId={session!.user.id} />
|
||||
<div className="relative h-10 w-10 overflow-hidden rounded-full">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black bg-opacity-30">
|
||||
<svg className="h-7 w-7 animate-spin text-slate-200" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="mt-4" variant="darkCTA" disabled={true}>
|
||||
{session?.user?.imageUrl ? (
|
||||
<Image
|
||||
src={session.user.imageUrl}
|
||||
width="40"
|
||||
height="40"
|
||||
style={{
|
||||
objectFit: "cover",
|
||||
}}
|
||||
className="h-10 w-10 rounded-full"
|
||||
alt="Avatar placeholder"
|
||||
/>
|
||||
) : (
|
||||
<ProfileAvatar userId={session!.user.id} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="mt-4"
|
||||
variant="darkCTA"
|
||||
onClick={() => {
|
||||
inputRef.current?.click();
|
||||
}}>
|
||||
Upload Image
|
||||
<input
|
||||
type="file"
|
||||
id="hiddenFileInput"
|
||||
ref={inputRef}
|
||||
className="hidden"
|
||||
accept="image/*"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
await handleUpload(file, environmentId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
export const handleFileUpload = async (
|
||||
file: File,
|
||||
environmentId: string
|
||||
): Promise<{
|
||||
error?: string;
|
||||
url: string;
|
||||
}> => {
|
||||
if (!file) return { error: "No file provided", url: "" };
|
||||
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return { error: "Please upload an image file.", url: "" };
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
return {
|
||||
error: "File size must be less than 10 MB.",
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
const payload = {
|
||||
fileName: file.name,
|
||||
fileType: file.type,
|
||||
environmentId,
|
||||
};
|
||||
|
||||
const response = await fetch("/api/v1/management/storage", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// throw new Error(`Upload failed with status: ${response.status}`);
|
||||
return {
|
||||
error: "Upload failed. Please try again.",
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
|
||||
const { data } = json;
|
||||
const { signedUrl, fileUrl, signingData, presignedFields } = data;
|
||||
|
||||
let requestHeaders: Record<string, string> = {};
|
||||
|
||||
if (signingData) {
|
||||
const { signature, timestamp, uuid } = signingData;
|
||||
|
||||
requestHeaders = {
|
||||
fileType: file.type,
|
||||
fileName: file.name,
|
||||
environmentId: environmentId ?? "",
|
||||
signature,
|
||||
timestamp,
|
||||
uuid,
|
||||
};
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
|
||||
if (presignedFields) {
|
||||
Object.keys(presignedFields).forEach((key) => {
|
||||
formData.append(key, presignedFields[key]);
|
||||
});
|
||||
}
|
||||
|
||||
// Add the actual file to be uploaded
|
||||
formData.append("file", file);
|
||||
|
||||
const uploadResponse = await fetch(signedUrl, {
|
||||
method: "POST",
|
||||
...(signingData ? { headers: requestHeaders } : {}),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
return {
|
||||
error: "Upload failed. Please try again.",
|
||||
url: "",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url: fileUrl,
|
||||
};
|
||||
};
|
||||
@@ -11,7 +11,8 @@ import { EditAvatar } from "./components/EditAvatar";
|
||||
import AccountSecurity from "@/app/(app)/environments/[environmentId]/settings/profile/components/AccountSecurity";
|
||||
import { getProfile } from "@formbricks/lib/profile/service";
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
|
||||
const { environmentId } = params;
|
||||
const session = await getServerSession(authOptions);
|
||||
const profile = session ? await getProfile(session.user.id) : null;
|
||||
|
||||
@@ -24,7 +25,7 @@ export default async function ProfileSettingsPage() {
|
||||
<EditName profile={profile} />
|
||||
</SettingsCard>
|
||||
<SettingsCard title="Avatar" description="Assist your team in identifying you on Formbricks.">
|
||||
<EditAvatar session={session} />
|
||||
<EditAvatar session={session} environmentId={environmentId} />
|
||||
</SettingsCard>
|
||||
{profile.identityProvider === "email" && (
|
||||
<SettingsCard title="Security" description="Manage your password and other security settings.">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TProductUpdateInput } from "@formbricks/types/product";
|
||||
import { TProfileUpdateInput } from "@formbricks/types/profile";
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
export async function updateProfileAction(updatedProfile: Partial<TProfileUpdateInput>) {
|
||||
export async function updateProfileAction(updatedProfile: TProfileUpdateInput) {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new AuthorizationError("Not authorized");
|
||||
|
||||
|
||||
@@ -1,416 +0,0 @@
|
||||
import { verifyPassword } from "@/app/lib/auth";
|
||||
import { env } from "@/env.mjs";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY } from "@formbricks/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@formbricks/lib/crypto";
|
||||
import { verifyToken } from "@formbricks/lib/jwt";
|
||||
import { getProfileByEmail } from "@formbricks/lib/profile/service";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import GitHubProvider from "next-auth/providers/github";
|
||||
import GoogleProvider from "next-auth/providers/google";
|
||||
import AzureAD from "next-auth/providers/azure-ad";
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
CredentialsProvider({
|
||||
id: "credentials",
|
||||
// The name to display on the sign in form (e.g. "Sign in with...")
|
||||
name: "Credentials",
|
||||
// The credentials is used to generate a suitable form on the sign in page.
|
||||
// You can specify whatever fields you are expecting to be submitted.
|
||||
// e.g. domain, username, password, 2FA token, etc.
|
||||
// You can pass any HTML attribute to the <input> tag through the object.
|
||||
credentials: {
|
||||
email: {
|
||||
label: "Email Address",
|
||||
type: "email",
|
||||
placeholder: "Your email address",
|
||||
},
|
||||
password: {
|
||||
label: "Password",
|
||||
type: "password",
|
||||
placeholder: "Your password",
|
||||
},
|
||||
totpCode: { label: "Two-factor Code", type: "input", placeholder: "Code from authenticator app" },
|
||||
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
let user;
|
||||
try {
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials?.email,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw Error("Internal server error. Please try again later");
|
||||
}
|
||||
|
||||
if (!user || !credentials) {
|
||||
throw new Error("No user matches the provided credentials");
|
||||
}
|
||||
if (!user.password) {
|
||||
throw new Error("No user matches the provided credentials");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error("No user matches the provided credentials");
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled && credentials.backupCode) {
|
||||
if (!ENCRYPTION_KEY) {
|
||||
console.error("Missing encryption key; cannot proceed with backup code login.");
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!user.backupCodes) throw new Error("No backup codes found");
|
||||
|
||||
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, ENCRYPTION_KEY));
|
||||
|
||||
// check if user-supplied code matches one
|
||||
const index = backupCodes.indexOf(credentials.backupCode.replaceAll("-", ""));
|
||||
if (index === -1) throw new Error("Invalid backup code");
|
||||
|
||||
// delete verified backup code and re-encrypt remaining
|
||||
backupCodes[index] = null;
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), ENCRYPTION_KEY),
|
||||
},
|
||||
});
|
||||
} else if (user.twoFactorEnabled) {
|
||||
if (!credentials.totpCode) {
|
||||
throw new Error("second factor required");
|
||||
}
|
||||
|
||||
if (!user.twoFactorSecret) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
|
||||
if (secret.length !== 32) {
|
||||
throw new Error("Internal Server Error");
|
||||
}
|
||||
|
||||
const isValidToken = (await import("@formbricks/lib/totp")).totpAuthenticatorCheck(
|
||||
credentials.totpCode,
|
||||
secret
|
||||
);
|
||||
if (!isValidToken) {
|
||||
throw new Error("Invalid second factor code");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstname: user.firstname,
|
||||
lastname: user.firstname,
|
||||
emailVerified: user.emailVerified,
|
||||
};
|
||||
},
|
||||
}),
|
||||
CredentialsProvider({
|
||||
id: "token",
|
||||
// The name to display on the sign in form (e.g. "Sign in with...")
|
||||
name: "Token",
|
||||
// The credentials is used to generate a suitable form on the sign in page.
|
||||
// You can specify whatever fields you are expecting to be submitted.
|
||||
// e.g. domain, username, password, 2FA token, etc.
|
||||
// You can pass any HTML attribute to the <input> tag through the object.
|
||||
credentials: {
|
||||
token: {
|
||||
label: "Verification Token",
|
||||
type: "string",
|
||||
},
|
||||
},
|
||||
async authorize(credentials, _req) {
|
||||
let user;
|
||||
try {
|
||||
if (!credentials?.token) {
|
||||
throw new Error("Token not found");
|
||||
}
|
||||
const { id } = await verifyToken(credentials?.token);
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: id,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
throw new Error("Either a user does not match the provided token or the token is invalid");
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
throw new Error("Either a user does not match the provided token or the token is invalid");
|
||||
}
|
||||
|
||||
if (user.emailVerified) {
|
||||
throw new Error("Email already verified");
|
||||
}
|
||||
|
||||
user = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: { emailVerified: new Date().toISOString() },
|
||||
});
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstname: user.firstname,
|
||||
lastname: user.firstname,
|
||||
emailVerified: user.emailVerified,
|
||||
};
|
||||
},
|
||||
}),
|
||||
GitHubProvider({
|
||||
clientId: env.GITHUB_ID || "",
|
||||
clientSecret: env.GITHUB_SECRET || "",
|
||||
}),
|
||||
GoogleProvider({
|
||||
clientId: env.GOOGLE_CLIENT_ID || "",
|
||||
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
}),
|
||||
AzureAD({
|
||||
clientId: env.AZUREAD_CLIENT_ID || "",
|
||||
clientSecret: env.AZUREAD_CLIENT_SECRET || "",
|
||||
tenantId: env.AZUREAD_TENANT_ID || "",
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async jwt({ token }) {
|
||||
const existingUser = await getProfileByEmail(token?.email!);
|
||||
|
||||
if (!existingUser) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const additionalAttributs = {
|
||||
id: existingUser.id,
|
||||
createdAt: existingUser.createdAt,
|
||||
onboardingCompleted: existingUser.onboardingCompleted,
|
||||
name: existingUser.name,
|
||||
};
|
||||
|
||||
return {
|
||||
...token,
|
||||
...additionalAttributs,
|
||||
};
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// @ts-ignore
|
||||
session.user.id = token?.id;
|
||||
// @ts-ignore
|
||||
session.user.createdAt = token?.createdAt ? new Date(token?.createdAt).toISOString() : undefined;
|
||||
// @ts-ignore
|
||||
session.user.onboardingCompleted = token?.onboardingCompleted;
|
||||
// @ts-ignore
|
||||
session.user.name = token.name || "";
|
||||
|
||||
return session;
|
||||
},
|
||||
async signIn({ user, account }: any) {
|
||||
if (account.provider === "credentials" || account.provider === "token") {
|
||||
if (!user.emailVerified && !EMAIL_VERIFICATION_DISABLED) {
|
||||
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!user.email || !user.name || account.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
const provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
// check if accounts for this provider / account Id already exists
|
||||
const existingUserWithAccount = await prisma.user.findFirst({
|
||||
include: {
|
||||
accounts: {
|
||||
where: {
|
||||
provider: account.provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: account.providerAccountId,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingUserWithAccount) {
|
||||
// User with this provider found
|
||||
// check if email still the same
|
||||
if (existingUserWithAccount.email === user.email) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// user seemed to change his email within the provider
|
||||
// check if user with this email already exist
|
||||
// if not found just update user with new email address
|
||||
// if found throw an error (TODO find better solution)
|
||||
const otherUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (!otherUserWithEmail) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUserWithAccount.id },
|
||||
data: { email: user.email },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
// There is no existing account for this identity provider / account id
|
||||
// check if user account with this email already exists
|
||||
// if user already exists throw error and request password login
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
}
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
emailVerified: new Date(Date.now()),
|
||||
onboardingCompleted: false,
|
||||
identityProvider: provider,
|
||||
identityProviderAccountId: user.id as string,
|
||||
accounts: {
|
||||
create: [{ ...account }],
|
||||
},
|
||||
memberships: {
|
||||
create: [
|
||||
{
|
||||
accepted: true,
|
||||
role: "owner",
|
||||
team: {
|
||||
create: {
|
||||
name: `${user.name}'s Team`,
|
||||
products: {
|
||||
create: [
|
||||
{
|
||||
name: "My Product",
|
||||
environments: {
|
||||
create: [
|
||||
{
|
||||
type: "production",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "development",
|
||||
eventClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "New Session",
|
||||
description: "Gets fired when a new session is created",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "Exit Intent (Desktop)",
|
||||
description: "A user on Desktop leaves the website with the cursor.",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "50% Scroll",
|
||||
description: "A user scrolled 50% of the current page",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
attributeClasses: {
|
||||
create: [
|
||||
{
|
||||
name: "userId",
|
||||
description: "The internal ID of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email of the person",
|
||||
type: "automatic",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
memberships: true,
|
||||
},
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
pages: {
|
||||
signIn: "/auth/login",
|
||||
signOut: "/auth/logout",
|
||||
error: "/auth/login", // Error code passed in query string as ?error=
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
import NextAuth from "next-auth";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
|
||||
const handler = NextAuth(authOptions);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
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 { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import crypto from "crypto";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { getTables } from "@formbricks/lib/airtable/service";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import { getIntegrationByType } from "@formbricks/lib/integration/service";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
import getSignedUrlForPublicFile from "./lib/getSignedUrl";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
|
||||
import { authOptions } from "@formbricks/lib/authOptions";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "imageUrl" TEXT;
|
||||
@@ -509,6 +509,7 @@ model User {
|
||||
name String?
|
||||
email String @unique
|
||||
emailVerified DateTime? @map(name: "email_verified")
|
||||
imageUrl String?
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
backupCodes String?
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { env } from "@/env.mjs";
|
||||
import { env } from "../../apps/web/env.mjs";
|
||||
import { verifyPassword } from "@/app/lib/auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
|
||||
import { verifyToken } from "./jwt";
|
||||
import { getProfileByEmail } from "./profile/service";
|
||||
import { getProfileByEmail, updateProfile } from "./profile/service";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { NextAuthOptions } from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
@@ -62,9 +62,8 @@ export const authOptions: NextAuthOptions = {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstname: user.firstname,
|
||||
lastname: user.firstname,
|
||||
emailVerified: user.emailVerified,
|
||||
imageUrl: user.imageUrl,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -107,20 +106,9 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Email already verified");
|
||||
}
|
||||
|
||||
user = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: { emailVerified: new Date().toISOString() },
|
||||
});
|
||||
user = await updateProfile(user.id, { emailVerified: new Date() });
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstname: user.firstname,
|
||||
lastname: user.firstname,
|
||||
emailVerified: user.emailVerified,
|
||||
};
|
||||
return user;
|
||||
},
|
||||
}),
|
||||
GitHubProvider({
|
||||
@@ -146,27 +134,16 @@ export const authOptions: NextAuthOptions = {
|
||||
return token;
|
||||
}
|
||||
|
||||
const additionalAttributs = {
|
||||
id: existingUser.id,
|
||||
createdAt: existingUser.createdAt,
|
||||
onboardingCompleted: existingUser.onboardingCompleted,
|
||||
name: existingUser.name,
|
||||
};
|
||||
|
||||
return {
|
||||
...token,
|
||||
...additionalAttributs,
|
||||
profile: existingUser || null,
|
||||
};
|
||||
},
|
||||
async session({ session, token }) {
|
||||
// @ts-ignore
|
||||
session.user.id = token?.id;
|
||||
// @ts-ignore
|
||||
session.user.createdAt = token?.createdAt ? new Date(token?.createdAt).toISOString() : undefined;
|
||||
// @ts-ignore
|
||||
session.user.onboardingCompleted = token?.onboardingCompleted;
|
||||
// @ts-ignore
|
||||
session.user.name = token.name || "";
|
||||
session.user = token.profile;
|
||||
|
||||
return session;
|
||||
},
|
||||
@@ -210,15 +187,10 @@ export const authOptions: NextAuthOptions = {
|
||||
// check if user with this email already exist
|
||||
// if not found just update user with new email address
|
||||
// if found throw an error (TODO find better solution)
|
||||
const otherUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
const otherUserWithEmail = await getProfileByEmail(user.email);
|
||||
|
||||
if (!otherUserWithEmail) {
|
||||
await prisma.user.update({
|
||||
where: { id: existingUserWithAccount.id },
|
||||
data: { email: user.email },
|
||||
});
|
||||
await updateProfile(existingUserWithAccount.id, { email: user.email });
|
||||
return true;
|
||||
}
|
||||
return "/auth/login?error=Looks%20like%20you%20updated%20your%20email%20somewhere%20else.%0AA%20user%20with%20this%20new%20email%20exists%20already.";
|
||||
@@ -227,9 +199,7 @@ export const authOptions: NextAuthOptions = {
|
||||
// There is no existing account for this identity provider / account id
|
||||
// check if user account with this email already exists
|
||||
// if user already exists throw error and request password login
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
const existingUserWithEmail = await getProfileByEmail(user.email);
|
||||
|
||||
if (existingUserWithEmail) {
|
||||
return "/auth/login?error=A%20user%20with%20this%20email%20exists%20already.";
|
||||
|
||||
@@ -23,6 +23,8 @@ const responseSelection = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
imageUrl: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
onboardingCompleted: true,
|
||||
@@ -102,10 +104,7 @@ const getAdminMemberships = (memberships: TMembership[]): TMembership[] =>
|
||||
memberships.filter((membership) => membership.role === "admin");
|
||||
|
||||
// function to update a user's profile
|
||||
export const updateProfile = async (
|
||||
personId: string,
|
||||
data: Partial<TProfileUpdateInput>
|
||||
): Promise<TProfile> => {
|
||||
export const updateProfile = async (personId: string, data: TProfileUpdateInput): Promise<TProfile> => {
|
||||
validateInputs([personId, ZId], [data, ZProfileUpdateInput.partial()]);
|
||||
|
||||
try {
|
||||
|
||||
@@ -17,6 +17,8 @@ export const ZProfile = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string(),
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
identityProvider: z.enum(["email", "google", "github", "azuread"]),
|
||||
createdAt: z.date(),
|
||||
@@ -30,9 +32,11 @@ export type TProfile = z.infer<typeof ZProfile>;
|
||||
export const ZProfileUpdateInput = z.object({
|
||||
name: z.string().nullish(),
|
||||
email: z.string().optional(),
|
||||
emailVerified: z.date().nullish(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
role: ZRole.optional(),
|
||||
objective: ZProfileObjective.nullish(),
|
||||
imageUrl: z.string().url().nullish(),
|
||||
});
|
||||
|
||||
export type TProfileUpdateInput = z.infer<typeof ZProfileUpdateInput>;
|
||||
@@ -40,6 +44,7 @@ export type TProfileUpdateInput = z.infer<typeof ZProfileUpdateInput>;
|
||||
export const ZProfileCreateInput = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
emailVerified: z.date().optional(),
|
||||
onboardingCompleted: z.boolean().optional(),
|
||||
role: ZRole.optional(),
|
||||
objective: ZProfileObjective.nullish(),
|
||||
|
||||
@@ -89,7 +89,6 @@ const uploadFile = async (
|
||||
url: fileUrl,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log({ error });
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user