feat: Automatic team assignment + skip onboarding (#1347)

Co-authored-by: jonas.hoebenreich <jonas.hoebenreich@flixbus.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Jonas Höbenreich
2023-12-11 16:27:49 +01:00
committed by GitHub
parent 3103760611
commit 81234c4bde
19 changed files with 174 additions and 67 deletions

View File

@@ -99,7 +99,6 @@ AZUREAD_AUTH_ENABLED=0
AZUREAD_CLIENT_ID=
AZUREAD_CLIENT_SECRET=
AZUREAD_TENANT_ID=
AZURE_DIRECT_REDIRECT=0
# Cron Secret
CRON_SECRET=
@@ -127,4 +126,13 @@ AIRTABLE_CLIENT_ID=
# Enterprise License Key
ENTERPRISE_LICENSE_KEY=
# Automatically assign new users to a specific team and role within that team
# Insert an existing team id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_TEAM_ID=
# DEFAULT_TEAM_ROLE=admin
# set to 1 to skip onboarding for new users
# ONBOARDING_DISABLED=1
*/

View File

@@ -242,8 +242,10 @@ These variables can be provided at the runtime i.e. in your docker-compose file.
| TELEMETRY_DISABLED | Disables telemetry if set to `1`. | optional | |
| INSTANCE_ID | Instance ID for Formbricks Cloud to be sent to Telemetry. | optional | |
| INTERNAL_SECRET | Internal Secret (Currently we overwrite the value with a random value). | optional | |
| IS_FORMBRICKS_CLOUD | Uses Formbricks Cloud if set to `1` | optional | |
| DEFAULT_BRAND_COLOR | Default brand color for your app (Can be overwritten from the UI as well). | optional | `#64748b` |
| DEFAULT_TEAM_ID | Automatically assign new users to a specific team when joining | optional | |
| DEFAULT_TEAM_ROLE | Role of the user in the default team. | optional | `admin` |
| ONBOARDING_DISABLED | Disables onboarding for new users if set to `1` | optional | |
## Build-time Variables

View File

@@ -1,7 +1,11 @@
import TeamActions from "@/app/(app)/environments/[environmentId]/settings/members/components/EditMemberships/TeamActions";
import { getIsEnterpriseEdition } from "@formbricks/ee/lib/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getMembershipsByUserId, getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { getMembershipByUserIdTeamId, getMembershipsByUserId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { SettingsId } from "@formbricks/ui/SettingsId";
import { Skeleton } from "@formbricks/ui/Skeleton";
import { getServerSession } from "next-auth";
import { Suspense } from "react";
@@ -10,9 +14,6 @@ import SettingsTitle from "../components/SettingsTitle";
import DeleteTeam from "./components/DeleteTeam";
import { EditMemberships } from "./components/EditMemberships";
import EditTeamName from "./components/EditTeamName";
import { INVITE_DISABLED } from "@formbricks/lib/constants";
import { getIsEnterpriseEdition } from "@formbricks/ee/lib/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
const MembersLoading = () => (
<div className="rounded-lg border border-slate-200">
@@ -103,6 +104,7 @@ export default async function MembersSettingsPage({ params }: { params: { enviro
isUserOwner={currentUserRole === "owner"}
/>
</SettingsCard>
<SettingsId title="Team" id={team.id}></SettingsId>
</div>
);
}

View File

@@ -1,18 +1,17 @@
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import EditProductName from "./components/EditProductName";
import EditWaitingTime from "./components/EditWaitingTime";
import DeleteProduct from "./components/DeleteProduct";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { authOptions } from "@formbricks/lib/authOptions";
import { getServerSession } from "next-auth";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { getEnvironment } from "@formbricks/lib/environment/service";
import { getMembershipByUserIdTeamId } from "@formbricks/lib/membership/service";
import { getAccessFlags } from "@formbricks/lib/membership/utils";
import { getProductByEnvironmentId } from "@formbricks/lib/product/service";
import { getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { ErrorComponent } from "@formbricks/ui/ErrorComponent";
import { SettingsId } from "@formbricks/ui/SettingsId";
import { getServerSession } from "next-auth";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import DeleteProduct from "./components/DeleteProduct";
import EditProductName from "./components/EditProductName";
import EditWaitingTime from "./components/EditWaitingTime";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const [, product, session, team] = await Promise.all([
@@ -60,6 +59,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
description="Delete product with all surveys, responses, people, actions and attributes. This cannot be undone.">
<DeleteProduct environmentId={params.environmentId} product={product} />
</SettingsCard>
<SettingsId title="Product" id={product.id}></SettingsId>
</div>
);
}

View File

@@ -1,15 +1,16 @@
export const revalidate = REVALIDATION_INTERVAL;
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getServerSession } from "next-auth";
import AccountSecurity from "@/app/(app)/environments/[environmentId]/settings/profile/components/AccountSecurity";
import { authOptions } from "@formbricks/lib/authOptions";
import { REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import { getProfile } from "@formbricks/lib/profile/service";
import { SettingsId } from "@formbricks/ui/SettingsId";
import { getServerSession } from "next-auth";
import SettingsCard from "../components/SettingsCard";
import SettingsTitle from "../components/SettingsTitle";
import { DeleteAccount } from "./components/DeleteAccount";
import { EditName } from "./components/EditName";
import { EditAvatar } from "./components/EditAvatar";
import AccountSecurity from "@/app/(app)/environments/[environmentId]/settings/profile/components/AccountSecurity";
import { getProfile } from "@formbricks/lib/profile/service";
import { EditName } from "./components/EditName";
export default async function ProfileSettingsPage({ params }: { params: { environmentId: string } }) {
const { environmentId } = params;
@@ -38,6 +39,7 @@ export default async function ProfileSettingsPage({ params }: { params: { enviro
description="Delete your account with all of your personal information and data.">
<DeleteAccount session={session} />
</SettingsCard>
<SettingsId title="Profile" id={profile.id}></SettingsId>
</div>
)}
</>

View File

@@ -1,12 +1,13 @@
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/app/lib/email";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED, INVITE_DISABLED, SIGNUP_ENABLED } from "@formbricks/lib/constants";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { env } from "@formbricks/lib/env.mjs";
import { deleteInvite } from "@formbricks/lib/invite/service";
import { verifyInviteToken } from "@formbricks/lib/jwt";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createProfile } from "@formbricks/lib/profile/service";
import { createTeam } from "@formbricks/lib/team/service";
import { createTeam, getTeam } from "@formbricks/lib/team/service";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
@@ -21,6 +22,10 @@ export async function POST(request: Request) {
try {
let invite;
// create the user
user = await createProfile(user);
// User is invited to team
if (inviteToken) {
let inviteTokenData = await verifyInviteToken(inviteToken);
inviteId = inviteTokenData?.inviteId;
@@ -36,35 +41,47 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Invalid invite ID" }, { status: 400 });
}
// create a user and assign him to the team
const profile = await createProfile(user);
await createMembership(invite.teamId, profile.id, {
// assign user to existing team
await createMembership(invite.teamId, user.id, {
accepted: true,
role: invite.role,
});
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(profile);
await sendVerificationEmail(user);
}
await sendInviteAcceptedEmail(invite.creator.name, user.name, invite.creator.email);
await deleteInvite(inviteId);
return NextResponse.json(profile);
} else {
const team = await createTeam({
name: `${user.name}'s Team`,
});
await createProduct(team.id, { name: "My Product" });
const profile = await createProfile(user);
await createMembership(team.id, profile.id, { role: "owner", accepted: true });
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(profile);
}
return NextResponse.json(profile);
return NextResponse.json(user);
}
// User signs up without invite
// Default team assignment is enabled
if (env.DEFAULT_TEAM_ID && env.DEFAULT_TEAM_ID.length > 0) {
// check if team exists
let team = await getTeam(env.DEFAULT_TEAM_ID);
let isNewTeam = false;
if (!team) {
// create team with id from env
team = await createTeam({ id: env.DEFAULT_TEAM_ID, name: user.name + "'s Team" });
isNewTeam = true;
}
const role = isNewTeam ? "owner" : env.DEFAULT_TEAM_ROLE || "admin";
await createMembership(team.id, user.id, { role, accepted: true });
}
// Without default team assignment
else {
const team = await createTeam({ name: user.name + "'s Team" });
await createMembership(team.id, user.id, { role: "owner", accepted: true });
await createProduct(team.id, { name: "My Product" });
}
// send verification email amd return user
if (!EMAIL_VERIFICATION_DISABLED) {
await sendVerificationEmail(user);
}
return NextResponse.json(user);
} catch (e) {
if (e.code === "P2002") {
return NextResponse.json(

View File

@@ -1,4 +1,4 @@
import { hashPassword } from "../auth";
import { hashPassword } from "@formbricks/lib/auth/util";
export const createUser = async (
name: string,

View File

@@ -4,6 +4,7 @@ import { getFirstEnvironmentByUserId } from "@formbricks/lib/environment/service
import type { Session } from "next-auth";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { ONBOARDING_DISABLED } from "@formbricks/lib/constants";
export default async function Home() {
const session: Session | null = await getServerSession(authOptions);
@@ -12,7 +13,7 @@ export default async function Home() {
redirect("/auth/login");
}
if (session?.user && !session?.user?.onboardingCompleted) {
if (!ONBOARDING_DISABLED && session?.user && !session?.user?.onboardingCompleted) {
return redirect(`/onboarding`);
}

View File

@@ -61,8 +61,10 @@ x-google-client-secret: &google_client_secret
x-sentry-ignore-api-resolution-error: &sentry_ignore_api_resolution_error # Disable Sentry warning
x-next-public-sentry-dsn: &next_public_sentry_dsn # Enable Sentry Error Tracking
x-cron-secret: &cron_secret YOUR_CRON_SECRET # Set this to a random string to secure your cron endpoints
services:

View File

@@ -23,6 +23,10 @@ x-environment: &environment
# PostgreSQL password
POSTGRES_PASSWORD: postgres
# Enterprise License Key
# Required to access Enterprise-only features
# ENTERPRISE_LICENSE_KEY:
# Email Configuration
# MAIL_FROM:
# SMTP_HOST:
@@ -65,6 +69,15 @@ x-environment: &environment
# GOOGLE_CLIENT_ID:
# GOOGLE_CLIENT_SECRET:
# Uncomment the below to automatically assign new users to a specific team and role within that team
# Insert an existing team id or generate a valid CUID for a new one at https://www.getuniqueid.com/cuid (e.g. cjld2cjxh0000qzrmn831i7rn)
# (Role Management is an Enterprise feature)
# DEFAULT_TEAM_ID:
# DEFAULT_TEAM_ROLE: admin
# Uncomment and set to 1 to skip onboarding for new users
# ONBOARDING_DISABLED: 1
services:
postgres:
restart: always

View File

@@ -1,19 +1,19 @@
import { env } from "./env.mjs";
import { verifyPassword } from "@/app/lib/auth";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
import { verifyToken } from "./jwt";
import { createProfile, getProfileByEmail, updateProfile } from "./profile/service";
import type { IdentityProvider } from "@prisma/client";
import type { NextAuthOptions } from "next-auth";
import AzureAD from "next-auth/providers/azure-ad";
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";
import { createTeam } from "./team/service";
import { createProduct } from "./product/service";
import { createAccount } from "./account/service";
import { verifyPassword } from "./auth/util";
import { EMAIL_VERIFICATION_DISABLED } from "./constants";
import { env } from "./env.mjs";
import { verifyToken } from "./jwt";
import { createMembership } from "./membership/service";
import { createProduct } from "./product/service";
import { createProfile, getProfileByEmail, updateProfile } from "./profile/service";
import { createTeam, getTeam } from "./team/service";
export const authOptions: NextAuthOptions = {
providers: [
@@ -219,14 +219,35 @@ export const authOptions: NextAuthOptions = {
identityProvider: provider,
identityProviderAccountId: account.providerAccountId,
});
const team = await createTeam({ name: userProfile.name + "'s Team" });
await createAccount({
...account,
userId: userProfile.id,
});
await createProduct(team.id, { name: "My Product" });
await createMembership(team.id, userProfile.id, { role: "owner", accepted: true });
return true;
// Default team assignment if env variable is set
if (env.DEFAULT_TEAM_ID && env.DEFAULT_TEAM_ID.length > 0) {
// check if team exists
let team = await getTeam(env.DEFAULT_TEAM_ID);
let isNewTeam = false;
if (!team) {
// create team with id from env
team = await createTeam({ id: env.DEFAULT_TEAM_ID, name: userProfile.name + "'s Team" });
isNewTeam = true;
}
const role = isNewTeam ? "owner" : env.DEFAULT_TEAM_ROLE || "admin";
await createMembership(team.id, userProfile.id, { role, accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
return true;
}
// Without default team assignment
else {
const team = await createTeam({ name: userProfile.name + "'s Team" });
await createMembership(team.id, userProfile.id, { role: "owner", accepted: true });
await createAccount({
...account,
userId: userProfile.id,
});
await createProduct(team.id, { name: "My Product" });
return true;
}
}
return true;

View File

@@ -57,6 +57,10 @@ export const ITEMS_PER_PAGE = 50;
export const RESPONSES_PER_PAGE = 10;
export const TEXT_RESPONSES_PER_PAGE = 5;
export const DEFAULT_TEAM_ID = env.DEFAULT_TEAM_ID;
export const DEFAULT_TEAM_ROLE = env.DEFAULT_TEAM_ROLE || "";
export const ONBOARDING_DISABLED = env.ONBOARDING_DISABLED;
// Storage constants
export const UPLOADS_DIR = "./uploads";
export const MAX_SIZES = {

View File

@@ -1,7 +1,5 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
/* import { config } from 'dotenv';
config({ path: '../../.env' }); */
export const env = createEnv({
/*
@@ -66,6 +64,9 @@ export const env = createEnv({
AZUREAD_CLIENT_SECRET: z.string().optional(),
AZUREAD_TENANT_ID: z.string().optional(),
AZUREAD_CLIENT_ID: z.string().optional(),
DEFAULT_TEAM_ID: z.string().optional(),
DEFAULT_TEAM_ROLE: z.enum(["owner", "admin", "editor", "developer", "viewer"]).optional(),
ONBOARDING_DISABLED: z.string().optional(),
ENTERPRISE_LICENSE_KEY: z.string().optional(),
},
@@ -141,7 +142,10 @@ 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,
AIRTABLE_CLIENT_ID: process.env.AIRTABLE_CLIENT_ID,
AIR_TABLE_CLIENT_ID: process.env.AIR_TABLE_CLIENT_ID,
DEFAULT_TEAM_ID: process.env.DEFAULT_TEAM_ID,
DEFAULT_TEAM_ROLE: process.env.DEFAULT_TEAM_ROLE,
ONBOARDING_DISABLED: process.env.ONBOARDING_DISABLED,
ENTERPRISE_LICENSE_KEY: process.env.ENTERPRISE_LICENSE_KEY,
},
});

View File

@@ -114,6 +114,7 @@ export const createMembership = async (
data: Partial<TMembership>
): Promise<TMembership> => {
validateInputs([teamId, ZString], [userId, ZString], [data, ZMembership.partial()]);
console.log("createMembership", teamId, userId, data);
try {
const membership = await prisma.membership.create({

View File

@@ -4,15 +4,22 @@ import { prisma } from "@formbricks/database";
import { ZOptionalNumber, ZString } from "@formbricks/types/common";
import { ZId } from "@formbricks/types/environment";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TTeam, TTeamBilling, TTeamUpdateInput, ZTeam, ZTeamUpdateInput } from "@formbricks/types/teams";
import {
TTeam,
TTeamBilling,
TTeamCreateInput,
TTeamUpdateInput,
ZTeam,
ZTeamCreateInput,
} from "@formbricks/types/teams";
import { Prisma } from "@prisma/client";
import { unstable_cache } from "next/cache";
import { ITEMS_PER_PAGE, SERVICES_REVALIDATION_INTERVAL } from "../constants";
import { environmentCache } from "../environment/cache";
import { getProducts } from "../product/service";
import { formatDateFields } from "../utils/datetime";
import { validateInputs } from "../utils/validate";
import { teamCache } from "./cache";
import { formatDateFields } from "../utils/datetime";
export const select = {
id: true,
@@ -135,9 +142,9 @@ export const getTeam = async (teamId: string): Promise<TTeam | null> => {
return team ? formatDateFields(team, ZTeam) : null;
};
export const createTeam = async (teamInput: TTeamUpdateInput): Promise<TTeam> => {
export const createTeam = async (teamInput: TTeamCreateInput): Promise<TTeam> => {
try {
validateInputs([teamInput, ZTeamUpdateInput]);
validateInputs([teamInput, ZTeamCreateInput]);
const team = await prisma.team.create({
data: teamInput,

View File

@@ -26,6 +26,14 @@ export const ZTeam = z.object({
billing: ZTeamBilling,
});
export const ZTeamCreateInput = z.object({
id: z.string().cuid2().optional(),
name: z.string(),
billing: ZTeamBilling.optional(),
});
export type TTeamCreateInput = z.infer<typeof ZTeamCreateInput>;
export const ZTeamUpdateInput = z.object({
name: z.string(),
billing: ZTeamBilling.optional(),

View File

@@ -0,0 +1,12 @@
interface SettingsIdProps {
title: string;
id: string;
}
export function SettingsId({ title, id }: SettingsIdProps) {
return (
<p className="pb-3 text-xs text-slate-400">
{title} ID: {id}
</p>
);
}

View File

@@ -54,6 +54,9 @@
"AZUREAD_CLIENT_ID",
"AZUREAD_CLIENT_SECRET",
"AZUREAD_TENANT_ID",
"DEFAULT_TEAM_ID",
"DEFAULT_TEAM_ROLE",
"ONBOARDING_DISABLED",
"CRON_SECRET",
"DEBUG",
"EMAIL_VERIFICATION_DISABLED",