chore: Remove pages/settings (#18064)

* Remove unused org mkigration pages and delete pages/settings

* Remove more unused code causing ts errors

* Remove SettingsLayout
This commit is contained in:
Hariom Balhara
2024-12-09 15:19:12 +01:00
committed by GitHub
parent 15e93af2ac
commit ae1b844bea
14 changed files with 2 additions and 3545 deletions
File diff suppressed because it is too large Load Diff
@@ -1,76 +0,0 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveTeamToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveTeamToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveTeamToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
log.debug(
"Moving team to org:",
safeStringify({
body: rawBody,
})
);
const translate = await getTranslation("en", "common");
const moveTeamToOrgSchema = getFormSchema(translate);
const parsedBody = moveTeamToOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req, res });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("moveTeamToOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId, moveMembers, teamSlugInOrganization } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await moveTeamToOrg({
targetOrg: {
id: targetOrgId,
teamSlug: teamSlugInOrganization,
},
teamId,
moveMembers,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("moveTeamToOrg failed:", safeStringify(error.message));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("moveTeamToOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({
message: `Added team ${teamId} to Org: ${targetOrgId} ${
moveMembers ? " along with the members" : " without the members"
}`,
});
}
@@ -1,75 +0,0 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/moveUserToOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["moveUserToOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const migrateBodySchema = getFormSchema(translate);
log.debug(
"Starting migration:",
safeStringify({
body: rawBody,
})
);
const parsedBody = migrateBodySchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
res.status(403).json({ message: "No session found" });
return;
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (parsedBody.success) {
const { userId, userName, shouldMoveTeams, targetOrgId, targetOrgUsername, targetOrgRole } =
parsedBody.data;
const isAllowed = isAdmin;
if (isAllowed) {
try {
await moveUserToOrg({
targetOrg: {
id: targetOrgId,
username: targetOrgUsername,
membership: {
role: targetOrgRole,
},
},
user: {
id: userId,
userName,
},
shouldMoveTeams,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(400).json({ message: errorMessage });
}
} else {
return res.status(403).json({ message: "Not Authorized" });
}
return res.status(200).json({ message: "Migrated" });
}
log.error("Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
@@ -1,63 +0,0 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeTeamFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeTeamFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeTeamFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const rawBody = req.body;
const translate = await getTranslation("en", "common");
const removeTeamFromOrgSchema = getFormSchema(translate);
log.debug(
"Removing team from org:",
safeStringify({
body: rawBody,
})
);
const parsedBody = removeTeamFromOrgSchema.safeParse(rawBody);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!parsedBody.success) {
log.error("RemoveTeamFromOrg failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
const { teamId, targetOrgId } = parsedBody.data;
const isAllowed = isAdmin;
if (!isAllowed) {
return res.status(403).json({ message: "Not Authorized" });
}
try {
await removeTeamFromOrg({
targetOrgId,
teamId,
});
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("RemoveTeamFromOrg failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: `Removed team ${teamId} from ${targetOrgId}` });
}
@@ -1,59 +0,0 @@
import { getFormSchema } from "@pages/settings/admin/orgMigrations/removeUserFromOrg";
import type { NextApiRequest, NextApiResponse } from "next/types";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { getTranslation } from "@calcom/lib/server";
import { UserPermissionRole } from "@calcom/prisma/enums";
import { removeUserFromOrg } from "../../../lib/orgMigration";
const log = logger.getSubLogger({ prefix: ["removeUserFromOrg"] });
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const body = req.body;
log.debug(
"Starting reverse migration:",
safeStringify({
body,
})
);
const translate = await getTranslation("en", "common");
const migrateRevertBodySchema = getFormSchema(translate);
const parsedBody = migrateRevertBodySchema.safeParse(body);
const session = await getServerSession({ req });
if (!session) {
return res.status(403).json({ message: "No session found" });
}
const isAdmin = session.user.role === UserPermissionRole.ADMIN;
if (!isAdmin) {
return res.status(403).json({ message: "Only admin can take this action" });
}
if (parsedBody.success) {
const { userId, targetOrgId } = parsedBody.data;
try {
await removeUserFromOrg({ targetOrgId, userId });
} catch (error) {
if (error instanceof HttpError) {
if (error.statusCode > 300) {
log.error("Reverse migration failed:", safeStringify(error));
}
return res.status(error.statusCode).json({ message: error.message });
}
log.error("Reverse migration failed:", safeStringify(error));
const errorMessage = error instanceof Error ? error.message : "Something went wrong";
return res.status(500).json({ message: errorMessage });
}
return res.status(200).json({ message: "Reverted" });
}
log.error("Reverse Migration failed:", safeStringify(parsedBody.error));
return res.status(400).json({ message: JSON.stringify(parsedBody.error) });
}
@@ -1,33 +0,0 @@
import { getLayout as getSettingsLayout } from "@calcom/features/settings/layouts/SettingsLayout";
import { HorizontalTabs } from "@calcom/ui";
export default function OrgMigrationLayout({ children }: { children: React.ReactElement }) {
return getSettingsLayout(
<div>
<HorizontalTabs
tabs={[
{
name: "Move Team to Org",
href: "/settings/admin/orgMigrations/moveTeamToOrg",
},
{
name: "Move User to Org",
href: "/settings/admin/orgMigrations/moveUserToOrg",
},
{
name: "Revert: Move Team to Org",
href: "/settings/admin/orgMigrations/removeTeamFromOrg",
},
{
name: "Revert: Move User to Org",
href: "/settings/admin/orgMigrations/removeUserFromOrg",
},
]}
/>
{children}
</div>
);
}
export const getLayout = (page: React.ReactElement) => {
return <OrgMigrationLayout>{page}</OrgMigrationLayout>;
};
@@ -1,153 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { TFunction } from "next-i18next";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/admin/orgMigrations/moveTeamToOrg/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
export { getServerSideProps };
export const getFormSchema = (t: TFunction) => {
return z.object({
teamId: z.number().or(getStringAsNumberRequiredSchema(t)),
targetOrgId: z.number().or(getStringAsNumberRequiredSchema(t)),
moveMembers: z.boolean(),
teamSlugInOrganization: z.string(),
});
};
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta title="Organization Migration: Move a team" description="Migrates a team to an organization" />
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export default function MoveTeamToOrg() {
const [state, setState] = useState(State.IDLE);
const moveUsersOptions = [
{
label: "No",
value: "false",
},
{
label: "Yes",
value: "true",
},
];
const { t } = useLocale();
const formSchema = getFormSchema(t);
const formMethods = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const { register, watch } = formMethods;
const moveMembers = watch("moveMembers");
return (
<Wrapper>
<Form
className="space-y-6"
noValidate={true}
form={formMethods}
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/moveTeamToOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
{...register("teamId")}
label="Team ID"
required
placeholder="Enter teamId to move to org"
/>
<TextField
{...register("teamSlugInOrganization")}
label="New Slug"
required
placeholder="Team slug in the Organization"
/>
<TextField
{...register("targetOrgId")}
label="Target Organization ID"
required
placeholder="Enter Target organization ID"
/>
<div>
<Controller
name="moveMembers"
render={({ field: { value, onChange } }) => (
<SelectField
containerClassName="mb-0"
label="Move users"
onChange={(option) => {
onChange(option?.value === "true");
}}
value={moveUsersOptions.find((opt) => opt.value === value)}
options={moveUsersOptions}
/>
)}
/>
{moveMembers === true ? (
<div className="mt-2">Members of the team will also be moved to the organization</div>
) : moveMembers === false ? (
<div className="mt-2">Members of the team will not be moved to the organization</div>
) : null}
</div>
</div>
<Button type="submit" loading={state === State.LOADING}>
Move Team to Org
</Button>
</Form>
</Wrapper>
);
}
MoveTeamToOrg.PageWrapper = PageWrapper;
MoveTeamToOrg.getLayout = getLayout;
@@ -1,188 +0,0 @@
"use client";
/**
* It could be an admin feature to move a user to an organization but because it's a temporary thing before mono-user orgs are implemented, it's not right to spend time on it.
* Plus, we need to do it only for cal.com and not provide as a feature to our self hosters.
*/
import { zodResolver } from "@hookform/resolvers/zod";
import type { TFunction } from "next-i18next";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { MembershipRole } from "@calcom/prisma/client";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, SelectField, TextField, showToast } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/admin/orgMigrations/moveUserToOrg/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
export { getServerSideProps };
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta
title="Organization Migration: Move a user"
description="Migrates a user to an organization along with the user's teams. But the teams' users are not migrated"
/>
{children}
</div>
);
}
export const getFormSchema = (t: TFunction) =>
z.object({
userId: z.union([z.string().pipe(z.coerce.number()), z.number()]).optional(),
userName: z.string().optional(),
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
targetOrgUsername: z.string().min(1, t("error_required_field")),
shouldMoveTeams: z.boolean(),
targetOrgRole: z.union([
z.literal(MembershipRole.ADMIN),
z.literal(MembershipRole.MEMBER),
z.literal(MembershipRole.OWNER),
]),
});
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export default function MoveUserToOrg() {
const [state, setState] = useState(State.IDLE);
const roles = Object.values(MembershipRole).map((role) => ({
label: role,
value: role,
}));
const moveTeamsOptions = [
{
label: "Yes",
value: "true",
},
{
label: "No",
value: "false",
},
];
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const shouldMoveTeams = form.watch("shouldMoveTeams");
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/moveUserToOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
type="text"
{...register("userName")}
label="User Name"
required
defaultValue=""
placeholder="Enter username to move to Org"
/>
<Controller
name="targetOrgRole"
render={({ field: { value, onChange } }) => (
<SelectField
label="Role"
options={roles}
onChange={(option) => {
if (!option) return;
onChange(option.value);
}}
value={roles.find((role) => role.value === value)}
required
placeholder="Enter userId"
/>
)}
/>
<TextField
label="Username in Target Org"
type="text"
required
{...register("targetOrgUsername")}
placeholder="Enter New username for the Org"
/>
<TextField
label="Target Organization ID"
type="number"
required
{...register("targetOrgId")}
placeholder="Enter Target organization ID"
/>
<Controller
name="shouldMoveTeams"
render={({ field: { value, onChange } }) => (
<SelectField
label="Move Teams"
className="mb-0"
onChange={(option) => {
if (!option) return;
onChange(option.value === "true");
}}
value={moveTeamsOptions.find((opt) => opt.value === value)}
required
options={moveTeamsOptions}
/>
)}
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
{shouldMoveTeams
? "Move User to Org along with its teams(except the teams' users)"
: "Move User to Org"}
</Button>
</Form>
</Wrapper>
);
}
MoveUserToOrg.PageWrapper = PageWrapper;
MoveUserToOrg.getLayout = getLayout;
@@ -1,117 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { TFunction } from "next-i18next";
import { useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, Form, Meta, TextField, showToast } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/admin/orgMigrations/removeTeamFromOrg/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
export { getServerSideProps };
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta
title="Organization Migration: Revert a team"
description="Reverts a migration of a team to an organization"
/>
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export const getFormSchema = (t: TFunction) =>
z.object({
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
teamId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
});
export default function RemoveTeamFromOrg() {
const [state, setState] = useState(State.IDLE);
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/removeTeamFromOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
label="Team ID"
{...register("teamId")}
required
placeholder="Enter teamId to remove from org"
/>
<TextField
className="mb-0"
{...register("targetOrgId")}
label="Target Organization ID"
type="number"
required
placeholder="Enter Target organization ID"
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
Remove Team from Org
</Button>
</Form>
</Wrapper>
);
}
RemoveTeamFromOrg.PageWrapper = PageWrapper;
RemoveTeamFromOrg.getLayout = getLayout;
@@ -1,115 +0,0 @@
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import type { TFunction } from "next-i18next";
import { useState } from "react";
import { useForm } from "react-hook-form";
import z from "zod";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { getStringAsNumberRequiredSchema } from "@calcom/prisma/zod-utils";
import { Button, TextField, Meta, showToast, Form } from "@calcom/ui";
import { getServerSideProps } from "@lib/settings/admin/orgMigrations/removeUserFromOrg/getServerSideProps";
import PageWrapper from "@components/PageWrapper";
import { getLayout } from "./_OrgMigrationLayout";
export { getServerSideProps };
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<div>
<Meta
title="Organization Migration: Revert a user"
description="Reverts a migration of a user to an organization"
/>
{children}
</div>
);
}
const enum State {
IDLE,
LOADING,
SUCCESS,
ERROR,
}
export const getFormSchema = (t: TFunction) =>
z.object({
userId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
targetOrgId: z.union([getStringAsNumberRequiredSchema(t), z.number()]),
});
export default function RemoveUserFromOrg() {
const [state, setState] = useState(State.IDLE);
const { t } = useLocale();
const formSchema = getFormSchema(t);
const form = useForm({
mode: "onSubmit",
resolver: zodResolver(formSchema),
});
const register = form.register;
return (
<Wrapper>
{/* Due to some reason auth from website doesn't work if /api endpoint is used. Spent a lot of time and in the end went with submitting data to the same page, because you can't do POST request to a page in Next.js, doing a GET request */}
<Form
form={form}
className="space-y-6"
handleSubmit={async (values) => {
setState(State.LOADING);
const res = await fetch(`/api/orgMigration/removeUserFromOrg`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(values),
});
let response = null;
try {
response = await res.json();
} catch (e) {
if (e instanceof Error) {
showToast(e.message, "error", 10000);
} else {
showToast(t("something_went_wrong"), "error", 10000);
}
setState(State.ERROR);
return;
}
if (res.status === 200) {
setState(State.SUCCESS);
showToast(response.message, "success", 10000);
} else {
setState(State.ERROR);
showToast(response.message, "error", 10000);
}
}}>
<div className="space-y-6">
<TextField
label="User ID"
{...register("userId")}
required
placeholder="Enter userId to remove from org"
/>
<TextField
className="mb-0"
label="Target Organization ID"
type="number"
required
{...register("targetOrgId")}
placeholder="Enter Target organization ID"
/>
</div>
<Button type="submit" loading={state === State.LOADING}>
Remove User from Org along with its teams
</Button>
</Form>
</Wrapper>
);
}
RemoveUserFromOrg.PageWrapper = PageWrapper;
RemoveUserFromOrg.getLayout = getLayout;
+1 -2
View File
@@ -4,9 +4,8 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { MembershipRole } from "@calcom/prisma/enums";
import { moveUserToOrg } from "@lib/orgMigration";
import { test } from "./lib/fixtures";
import { moveUserToOrg } from "./lib/orgMigration";
import { IS_STRIPE_ENABLED, submitAndWaitForResponse } from "./lib/testUtils";
test.describe.configure({ mode: "parallel" });
@@ -179,122 +179,6 @@ export async function removeUserFromOrg({ targetOrgId, userId }: { targetOrgId:
log.debug(`orgId:${targetOrgId} attached to userId:${userId}`);
}
/**
* Make sure that the migration is idempotent
*/
export async function moveTeamToOrg({
targetOrg,
teamId,
moveMembers,
}: {
targetOrg: { id: number; teamSlug: string };
teamId: number;
moveMembers?: boolean;
}) {
const possibleOrg = await getTeamOrThrowError(targetOrg.id);
const { oldTeamSlug, updatedTeam } = await dbMoveTeamToOrg({ teamId, targetOrg });
const teamMetadata = teamMetadataSchema.parse(possibleOrg?.metadata);
if (!possibleOrg.isOrganization) {
throw new Error(`${targetOrg.id} is not an Org`);
}
const targetOrganization = possibleOrg;
const orgMetadata = teamMetadata;
await addTeamRedirect({
oldTeamSlug,
teamSlug: updatedTeam.slug,
orgSlug: targetOrganization.slug || orgMetadata?.requestedSlug || null,
});
await setOrgSlugIfNotSet({ slug: targetOrganization.slug }, orgMetadata, targetOrg.id);
if (moveMembers) {
for (const membership of updatedTeam.members) {
await moveUserToOrg({
user: {
id: membership.userId,
},
targetOrg: {
id: targetOrg.id,
membership: {
role: membership.role,
accepted: membership.accepted,
},
},
shouldMoveTeams: false,
});
}
}
log.debug(`Successfully moved team ${teamId} to org ${targetOrg.id}`);
}
/**
* Make sure that the migration is idempotent
*/
export async function removeTeamFromOrg({ targetOrgId, teamId }: { targetOrgId: number; teamId: number }) {
const removedTeam = await dbRemoveTeamFromOrg({ teamId });
await removeTeamRedirect(removedTeam.slug);
for (const membership of removedTeam.members) {
await removeUserFromOrg({
userId: membership.userId,
targetOrgId,
});
}
log.debug(`Successfully removed team ${teamId} from org ${targetOrgId}`);
}
async function dbMoveTeamToOrg({
teamId,
targetOrg,
}: {
teamId: number;
targetOrg: {
id: number;
teamSlug: string;
};
}) {
const team = await prisma.team.findUnique({
where: {
id: teamId,
},
include: {
members: true,
},
});
if (!team) {
throw new HttpError({
statusCode: 400,
message: `Team with id: ${teamId} not found`,
});
}
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
const oldTeamSlug = teamMetadata?.migratedToOrgFrom?.teamSlug || team.slug;
const updatedTeam = await prisma.team.update({
where: {
id: teamId,
},
data: {
slug: targetOrg.teamSlug,
parentId: targetOrg.id,
metadata: {
...teamMetadata,
migratedToOrgFrom: {
teamSlug: team.slug,
lastMigrationTime: new Date().toISOString(),
},
},
},
include: {
members: true,
},
});
return { oldTeamSlug, updatedTeam };
}
async function getUniqueUserThatDoesntBelongToOrg(
userName: string | undefined,
userId: number | undefined,
@@ -491,53 +375,6 @@ async function addRedirect({
}
}
async function addTeamRedirect({
oldTeamSlug,
teamSlug,
orgSlug,
}: {
oldTeamSlug: string | null;
teamSlug: string | null;
orgSlug: string | null;
}) {
if (!oldTeamSlug) {
throw new HttpError({
statusCode: 400,
message: "No oldSlug for team. Not adding the redirect",
});
}
if (!teamSlug) {
throw new HttpError({
statusCode: 400,
message: "No slug for team. Not adding the redirect",
});
}
if (!orgSlug) {
log.warn(`No slug for org. Not adding the redirect`);
return;
}
const orgUrlPrefix = getOrgFullOrigin(orgSlug);
await prisma.tempOrgRedirect.upsert({
where: {
from_type_fromOrgId: {
type: RedirectType.Team,
from: oldTeamSlug,
fromOrgId: 0,
},
},
create: {
type: RedirectType.Team,
from: oldTeamSlug,
fromOrgId: 0,
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
update: {
toUrl: `${orgUrlPrefix}/${teamSlug}`,
},
});
}
async function updateMembership({
targetOrgId,
userToMoveToOrg,
@@ -700,24 +537,6 @@ async function setOrgSlug({ targetOrgId, targetSlug }: { targetOrgId: number; ta
});
}
async function removeTeamRedirect(teamSlug: string | null) {
if (!teamSlug) {
throw new HttpError({
statusCode: 400,
message: "No slug for team. Not removing the redirect",
});
return;
}
await prisma.tempOrgRedirect.deleteMany({
where: {
type: RedirectType.Team,
from: teamSlug,
fromOrgId: 0,
},
});
}
async function removeUserAlongWithItsTeamsRedirects({
nonOrgUserName,
teamsToBeRemovedFromOrg,
@@ -5,9 +5,8 @@ import prisma from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/client";
import { SchedulingType } from "@calcom/prisma/enums";
import { moveUserToOrg } from "@lib/orgMigration";
import { test } from "../lib/fixtures";
import { moveUserToOrg } from "../lib/orgMigration";
import { bookTeamEvent, doOnOrgDomain, expectPageToBeNotFound, getInviteLink } from "../lib/testUtils";
import { expectInvitationEmailToBeReceived } from "./expects";
@@ -1,797 +0,0 @@
"use client";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@radix-ui/react-collapsible";
import { useSession } from "next-auth/react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import type { ComponentProps } from "react";
import React, { Suspense, useEffect, useState, useMemo } from "react";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { HOSTED_CAL_FEATURES, IS_CALCOM, WEBAPP_URL } from "@calcom/lib/constants";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { IdentityProvider, MembershipRole, UserPermissionRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { VerticalTabItemProps } from "@calcom/ui";
import { Badge, Button, ErrorBoundary, Icon, Skeleton, useMeta, VerticalTabItem } from "@calcom/ui";
const tabs: VerticalTabItemProps[] = [
{
name: "my_account",
href: "/settings/my-account",
icon: "user",
children: [
{ name: "profile", href: "/settings/my-account/profile" },
{ name: "general", href: "/settings/my-account/general" },
{ name: "calendars", href: "/settings/my-account/calendars" },
{ name: "conferencing", href: "/settings/my-account/conferencing" },
{ name: "appearance", href: "/settings/my-account/appearance" },
{ name: "out_of_office", href: "/settings/my-account/out-of-office" },
// TODO
// { name: "referrals", href: "/settings/my-account/referrals" },
],
},
{
name: "security",
href: "/settings/security",
icon: "key",
children: [
{ name: "password", href: "/settings/security/password" },
{ name: "impersonation", href: "/settings/security/impersonation" },
{ name: "2fa_auth", href: "/settings/security/two-factor-auth" },
],
},
{
name: "billing",
href: "/settings/billing",
icon: "credit-card",
children: [{ name: "manage_billing", href: "/settings/billing" }],
},
{
name: "developer",
href: "/settings/developer",
icon: "terminal",
children: [
//
{ name: "webhooks", href: "/settings/developer/webhooks" },
{ name: "api_keys", href: "/settings/developer/api-keys" },
{ name: "admin_api", href: "/settings/organizations/admin-api" },
// TODO: Add profile level for embeds
// { name: "embeds", href: "/v2/settings/developer/embeds" },
],
},
{
name: "organization",
href: "/settings/organizations",
children: [
{
name: "profile",
href: "/settings/organizations/profile",
},
{
name: "general",
href: "/settings/organizations/general",
},
{
name: "privacy",
href: "/settings/organizations/privacy",
},
{
name: "billing",
href: "/settings/organizations/billing",
},
{ name: "OAuth Clients", href: "/settings/organizations/platform/oauth-clients" },
{
name: "SSO",
href: "/settings/organizations/sso",
},
{
name: "directory_sync",
href: "/settings/organizations/dsync",
},
{
name: "admin_api",
href: "https://cal.com/docs/enterprise-features/api/api-reference/bookings#admin-access",
},
// {
// name: "domain_wide_delegation",
// href: "/settings/organizations/domain-wide-delegation",
// },
],
},
{
name: "teams",
href: "/teams",
icon: "users",
children: [],
},
{
name: "other_teams",
href: "/settings/organizations/teams/other",
icon: "users",
children: [],
},
{
name: "admin",
href: "/settings/admin",
icon: "lock",
children: [
//
{ name: "features", href: "/settings/admin/flags" },
{ name: "license", href: "/auth/setup?step=1" },
{ name: "impersonation", href: "/settings/admin/impersonation" },
{ name: "apps", href: "/settings/admin/apps/calendar" },
{ name: "users", href: "/settings/admin/users" },
{ name: "organizations", href: "/settings/admin/organizations" },
{ name: "lockedSMS", href: "/settings/admin/lockedSMS" },
{ name: "oAuth", href: "/settings/admin/oAuth" },
{ name: "Workspace Platforms", href: "/settings/admin/workspace-platforms" },
],
},
];
tabs.find((tab) => {
if (tab.name === "security" && !HOSTED_CAL_FEATURES) {
tab.children?.push({ name: "sso_configuration", href: "/settings/security/sso" });
// TODO: Enable dsync for self hosters
// tab.children?.push({ name: "directory_sync", href: "/settings/security/dsync" });
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_your_org", href: "/settings/organizations/new" });
}
if (tab.name === "admin" && IS_CALCOM) {
tab.children?.push({ name: "create_license_key", href: "/settings/license-key/new" });
}
});
// The following keys are assigned to admin only
const adminRequiredKeys = ["admin"];
const organizationRequiredKeys = ["organization"];
const organizationAdminKeys = ["privacy", "billing", "OAuth Clients", "SSO", "directory_sync"];
const useTabs = () => {
const session = useSession();
const { data: user } = trpc.viewer.me.useQuery({ includePasswordAdded: true });
const orgBranding = useOrgBranding();
const isAdmin = session.data?.user.role === UserPermissionRole.ADMIN;
const isOrgAdminOrOwner =
orgBranding?.role === MembershipRole.ADMIN || orgBranding?.role === MembershipRole.OWNER;
// const flags = useFlags();
// console.log("flags", flags);
const processTabsMemod = useMemo(() => {
const processedTabs = tabs.map((tab) => {
if (tab.href === "/settings/my-account") {
return {
...tab,
name: user?.name || "my_account",
icon: undefined,
avatar: getUserAvatarUrl(user),
};
} else if (tab.href === "/settings/organizations") {
const newArray = (tab?.children ?? []).filter(
(child) => isOrgAdminOrOwner || !organizationAdminKeys.includes(child.name)
);
// TODO: figure out feature flag as it doesnt cause a re-render of the component when loaded.
// You have to refresh the page to see the changes.
if (true) {
newArray.splice(4, 0, {
name: "attributes",
href: "/settings/organizations/attributes",
});
}
return {
...tab,
children: newArray,
name: orgBranding?.name || "organization",
avatar: getPlaceholderAvatar(orgBranding?.logoUrl, orgBranding?.name),
};
} else if (
tab.href === "/settings/security" &&
user?.identityProvider === IdentityProvider.GOOGLE &&
!user?.twoFactorEnabled &&
!user?.passwordAdded
) {
const filtered = tab?.children?.filter(
(childTab) => childTab.href !== "/settings/security/two-factor-auth"
);
return { ...tab, children: filtered };
} else if (tab.href === "/settings/developer") {
const filtered = tab?.children?.filter(
(childTab) => isOrgAdminOrOwner || childTab.name !== "admin_api"
);
return { ...tab, children: filtered };
}
return tab;
});
// check if name is in adminRequiredKeys
return processedTabs.filter((tab) => {
if (organizationRequiredKeys.includes(tab.name)) return !!orgBranding;
if (tab.name === "other_teams" && !isOrgAdminOrOwner) return false;
if (isAdmin) return true;
return !adminRequiredKeys.includes(tab.name);
});
}, [isAdmin, orgBranding, isOrgAdminOrOwner, user]);
return processTabsMemod;
};
const BackButtonInSidebar = ({ name }: { name: string }) => {
return (
<Link
href="/"
className="hover:bg-subtle todesktop:mt-10 [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-emphasis group my-6 flex h-6 max-h-6 w-full flex-row items-center rounded-md px-3 py-2 text-sm font-medium leading-4 transition"
data-testid={`vertical-tab-${name}`}>
<Icon
name="arrow-left"
className="h-4 w-4 stroke-[2px] ltr:mr-[10px] rtl:ml-[10px] rtl:rotate-180 md:mt-0"
/>
<Skeleton title={name} as="p" className="min-h-4 max-w-36 truncate" loadingClassName="ms-3">
{name}
</Skeleton>
</Link>
);
};
interface SettingsSidebarContainerProps {
className?: string;
navigationIsOpenedOnMobile?: boolean;
bannersHeight?: number;
}
const TeamListCollapsible = () => {
const { data: teams } = trpc.viewer.teams.list.useQuery();
const { t } = useLocale();
const [teamMenuState, setTeamMenuState] =
useState<{ teamId: number | undefined; teamMenuOpen: boolean }[]>();
const searchParams = useCompatSearchParams();
useEffect(() => {
if (teams) {
const teamStates = teams?.map((team) => ({
teamId: team.id,
teamMenuOpen: String(team.id) === searchParams?.get("id"),
}));
setTeamMenuState(teamStates);
setTimeout(() => {
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
)[1];
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded
tabMembers?.scrollIntoView({ behavior: "smooth" });
}, 100);
}
}, [searchParams?.get("id"), teams]);
return (
<>
{teams &&
teamMenuState &&
teams.map((team, index: number) => {
if (!teamMenuState[index]) {
return null;
}
if (teamMenuState.some((teamState) => teamState.teamId === team.id))
return (
<Collapsible
className="cursor-pointer"
key={team.id}
open={teamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none transition"
onClick={() =>
setTeamMenuState([
...teamMenuState,
(teamMenuState[index] = {
...teamMenuState[index],
teamMenuOpen: !teamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{teamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
) : (
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{!team.parentId && (
<img
src={getPlaceholderAvatar(team.logoUrl, team.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={team.name || "Team logo"}
/>
)}
<p className="w-1/2 truncate leading-normal">{team.name}</p>
{!team.accepted && (
<Badge className="ms-3" variant="orange">
Inv.
</Badge>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
{team.accepted && (
<VerticalTabItem
name={t("profile")}
href={`/settings/teams/${team.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
)}
<VerticalTabItem
name={t("members")}
href={`/settings/teams/${team.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("event_types_page_title")}
href={`/event-types?teamId=${team.id}`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{(team.role === MembershipRole.OWNER ||
team.role === MembershipRole.ADMIN ||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore this exists wtf?
(team.isOrgAdmin && team.isOrgAdmin)) && (
<>
{/* TODO */}
{/* <VerticalTabItem
name={t("general")}
href={`${WEBAPP_URL}/settings/my-account/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
<VerticalTabItem
name={t("appearance")}
href={`/settings/teams/${team.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
{/* Hide if there is a parent ID */}
{!team.parentId ? (
<>
<VerticalTabItem
name={t("billing")}
href={`/settings/teams/${team.id}/billing`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
</>
) : null}
<VerticalTabItem
name={t("booking_limits")}
href={`/settings/teams/${team.id}/bookingLimits`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
</>
)}
</CollapsibleContent>
</Collapsible>
);
})}
</>
);
};
const SettingsSidebarContainer = ({
className = "",
navigationIsOpenedOnMobile,
bannersHeight,
}: SettingsSidebarContainerProps) => {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const tabsWithPermissions = useTabs();
const [otherTeamMenuState, setOtherTeamMenuState] = useState<
{
teamId: number | undefined;
teamMenuOpen: boolean;
}[]
>();
const session = useSession();
const { data: currentOrg } = trpc.viewer.organizations.listCurrent.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
const { data: otherTeams } = trpc.viewer.organizations.listOtherTeams.useQuery(undefined, {
enabled: !!session.data?.user?.org,
});
// Same as above but for otherTeams
useEffect(() => {
if (otherTeams) {
const otherTeamStates = otherTeams?.map((team) => ({
teamId: team.id,
teamMenuOpen: String(team.id) === searchParams?.get("id"),
}));
setOtherTeamMenuState(otherTeamStates);
setTimeout(() => {
// @TODO: test if this works for 2 dataset testids
const tabMembers = Array.from(document.getElementsByTagName("a")).filter(
(bottom) => bottom.dataset.testid === "vertical-tab-Members"
)[1];
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- Settings layout isn't embedded
tabMembers?.scrollIntoView({ behavior: "smooth" });
}, 100);
}
}, [searchParams?.get("id"), otherTeams]);
const isOrgAdminOrOwner =
currentOrg && currentOrg?.user?.role && ["OWNER", "ADMIN"].includes(currentOrg?.user?.role);
return (
<nav
style={{ maxHeight: `calc(100vh - ${bannersHeight}px)`, top: `${bannersHeight}px` }}
className={classNames(
"no-scrollbar bg-muted fixed bottom-0 left-0 top-0 z-20 flex max-h-screen w-56 flex-col space-y-1 overflow-x-hidden overflow-y-scroll px-2 pb-3 transition-transform max-lg:z-10 lg:sticky lg:flex",
className,
navigationIsOpenedOnMobile
? "translate-x-0 opacity-100"
: "-translate-x-full opacity-0 lg:translate-x-0 lg:opacity-100"
)}
aria-label="Tabs">
<>
<BackButtonInSidebar name={t("back")} />
{tabsWithPermissions.map((tab) => {
return (
<React.Fragment key={tab.href}>
{!["teams", "other_teams"].includes(tab.name) && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "!mb-3" : ""}`}>
<div className="[&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default group flex h-7 w-full flex-row items-center rounded-md px-2 text-sm font-medium leading-none">
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
{!tab.icon && tab?.avatar && (
<img
className="h-4 w-4 rounded-full ltr:mr-3 rtl:ml-3"
src={tab?.avatar}
alt="Organization Logo"
/>
)}
<Skeleton
title={tab.name}
as="p"
className="text-subtle truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(tab.name)}
</Skeleton>
</div>
</div>
<div className="my-3 space-y-px">
{tab.children?.map((child, index) => (
<VerticalTabItem
key={child.href}
name={t(child.name)}
isExternalLink={child.isExternalLink}
href={child.href || "/"}
textClassNames="text-emphasis font-medium text-sm"
className={`me-5 h-7 !px-2 ${
tab.children && index === tab.children?.length - 1 && "!mb-3"
}`}
disableChevron
/>
))}
</div>
</React.Fragment>
)}
{tab.name === "teams" && (
<React.Fragment key={tab.href}>
<div data-testid="tab-teams" className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none transition">
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
<Skeleton
title={tab.name}
as="p"
className="text-subtle truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t(isOrgAdminOrOwner ? "my_teams" : tab.name)}
</Skeleton>
</div>
</Link>
<TeamListCollapsible />
{(!currentOrg || (currentOrg && currentOrg?.user?.role !== "MEMBER")) && (
<VerticalTabItem
name={t("add_a_team")}
href={`${WEBAPP_URL}/settings/teams/new`}
textClassNames="px-3 items-center mt-2 text-emphasis font-medium text-sm"
icon="plus"
disableChevron
/>
)}
</div>
</React.Fragment>
)}
{tab.name === "other_teams" && (
<React.Fragment key={tab.href}>
<div className={`${!tab.children?.length ? "mb-3" : ""}`}>
<Link href={tab.href}>
<div className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis group-hover:text-default text-default group flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-sm font-medium leading-none transition">
{tab && tab.icon && (
<Icon
name={tab.icon}
className="text-subtle h-[16px] w-[16px] stroke-[2px] ltr:mr-3 rtl:ml-3 md:mt-0"
/>
)}
<Skeleton
title={t("org_admin_other_teams")}
as="p"
className="text-subtle truncate text-sm font-medium leading-5"
loadingClassName="ms-3">
{t("org_admin_other_teams")}
</Skeleton>
</div>
</Link>
{otherTeams &&
otherTeamMenuState &&
otherTeams.map((otherTeam, index: number) => {
if (!otherTeamMenuState[index]) {
return null;
}
if (otherTeamMenuState.some((teamState) => teamState.teamId === otherTeam.id))
return (
<Collapsible
className="cursor-pointer"
key={otherTeam.id}
open={otherTeamMenuState[index].teamMenuOpen}
onOpenChange={() =>
setOtherTeamMenuState([
...otherTeamMenuState,
(otherTeamMenuState[index] = {
...otherTeamMenuState[index],
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
}),
])
}>
<CollapsibleTrigger asChild>
<div
className="hover:bg-subtle [&[aria-current='page']]:bg-emphasis [&[aria-current='page']]:text-emphasis text-default flex h-9 w-full flex-row items-center rounded-md px-2 py-[10px] text-left text-sm font-medium leading-none transition"
onClick={() =>
setOtherTeamMenuState([
...otherTeamMenuState,
(otherTeamMenuState[index] = {
...otherTeamMenuState[index],
teamMenuOpen: !otherTeamMenuState[index].teamMenuOpen,
}),
])
}>
<div className="me-3">
{otherTeamMenuState[index].teamMenuOpen ? (
<Icon name="chevron-down" className="h-4 w-4" />
) : (
<Icon name="chevron-right" className="h-4 w-4" />
)}
</div>
{!otherTeam.parentId && (
<img
src={getPlaceholderAvatar(otherTeam.logoUrl, otherTeam.name)}
className="h-[16px] w-[16px] self-start rounded-full stroke-[2px] ltr:mr-2 rtl:ml-2 md:mt-0"
alt={otherTeam.name || "Team logo"}
/>
)}
<p className="w-1/2 truncate leading-normal">{otherTeam.name}</p>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="space-y-0.5">
<VerticalTabItem
name={t("profile")}
href={`/settings/organizations/teams/other/${otherTeam.id}/profile`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<VerticalTabItem
name={t("members")}
href={`/settings/organizations/teams/other/${otherTeam.id}/members`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/>
<>
{/* TODO: enable appearance edit */}
{/* <VerticalTabItem
name={t("appearance")}
href={`/settings/organizations/teams/other/${otherTeam.id}/appearance`}
textClassNames="px-3 text-emphasis font-medium text-sm"
disableChevron
/> */}
</>
</CollapsibleContent>
</Collapsible>
);
})}
</div>
</React.Fragment>
)}
</React.Fragment>
);
})}
</>
</nav>
);
};
const MobileSettingsContainer = (props: { onSideContainerOpen?: () => void }) => {
const { t } = useLocale();
const router = useRouter();
return (
<>
<nav className="bg-muted border-muted sticky top-0 z-20 flex w-full items-center justify-between border-b px-2 py-2 sm:relative lg:hidden">
<div className="flex items-center space-x-3">
<Button StartIcon="menu" color="minimal" variant="icon" onClick={props.onSideContainerOpen}>
<span className="sr-only">{t("show_navigation")}</span>
</Button>
<button
className="hover:bg-emphasis flex items-center space-x-2 rounded-md px-3 py-1 rtl:space-x-reverse"
onClick={() => router.back()}>
<Icon name="arrow-left" className="text-default h-4 w-4" />
<p className="text-emphasis font-semibold">{t("settings")}</p>
</button>
</div>
</nav>
</>
);
};
export type SettingsLayoutProps = {
children: React.ReactNode;
hideHeader?: boolean;
containerClassName?: string;
} & ComponentProps<typeof Shell>;
export default function SettingsLayout({ children, hideHeader, ...rest }: SettingsLayoutProps) {
const pathname = usePathname();
const state = useState(false);
const { t } = useLocale();
const [sideContainerOpen, setSideContainerOpen] = state;
useEffect(() => {
const closeSideContainer = () => {
if (window.innerWidth >= 1024) {
setSideContainerOpen(false);
}
};
window.addEventListener("resize", closeSideContainer);
return () => {
window.removeEventListener("resize", closeSideContainer);
};
}, []);
useEffect(() => {
if (sideContainerOpen) {
setSideContainerOpen(!sideContainerOpen);
}
}, [pathname]);
return (
<Shell
withoutSeo={true}
flexChildrenContainer
hideHeadingOnMobile
{...rest}
SidebarContainer={
<SidebarContainerElement
sideContainerOpen={sideContainerOpen}
setSideContainerOpen={setSideContainerOpen}
/>
}
drawerState={state}
MobileNavigationContainer={null}
TopNavContainer={
<MobileSettingsContainer onSideContainerOpen={() => setSideContainerOpen(!sideContainerOpen)} />
}>
<div className="flex flex-1 [&>*]:flex-1">
<div
className={classNames("mx-auto max-w-full justify-center lg:max-w-3xl", rest.containerClassName)}>
{!hideHeader && <ShellHeader />}
<ErrorBoundary>
<Suspense fallback={<Icon name="loader" />}>{children}</Suspense>
</ErrorBoundary>
</div>
</div>
</Shell>
);
}
const SidebarContainerElement = ({
sideContainerOpen,
bannersHeight,
setSideContainerOpen,
}: SidebarContainerElementProps) => {
const { t } = useLocale();
return (
<>
{/* Mobile backdrop */}
{sideContainerOpen && (
<button
onClick={() => setSideContainerOpen(false)}
className="fixed left-0 top-0 z-10 h-full w-full bg-black/50">
<span className="sr-only">{t("hide_navigation")}</span>
</button>
)}
<SettingsSidebarContainer
navigationIsOpenedOnMobile={sideContainerOpen}
bannersHeight={bannersHeight}
/>
</>
);
};
type SidebarContainerElementProps = {
sideContainerOpen: boolean;
bannersHeight?: number;
setSideContainerOpen: React.Dispatch<React.SetStateAction<boolean>>;
};
export const getLayout = (page: React.ReactElement) => <SettingsLayout>{page}</SettingsLayout>;
export function ShellHeader() {
const { meta } = useMeta();
const { t, isLocaleReady } = useLocale();
return (
<>
<header
className={classNames(
"border-subtle mx-auto block justify-between sm:flex",
meta.borderInShellHeader && "rounded-t-lg border px-4 py-6 sm:px-6",
meta.borderInShellHeader === undefined && "mb-8 border-b pb-8"
)}>
<div className="flex w-full items-center">
{meta.backButton && (
<a href="javascript:history.back()">
<Icon name="arrow-left" className="mr-7" />
</a>
)}
<div>
{meta.title && isLocaleReady ? (
<h1 className="font-cal text-emphasis mb-1 text-xl font-semibold leading-5 tracking-wide">
{t(meta.title)}
</h1>
) : (
<div className="bg-emphasis mb-1 h-5 w-24 animate-pulse rounded-lg" />
)}
{meta.description && isLocaleReady ? (
<p className="text-default text-sm ltr:mr-4 rtl:ml-4">{t(meta.description)}</p>
) : (
<div className="bg-emphasis h-5 w-32 animate-pulse rounded-lg" />
)}
</div>
<div className="ms-auto flex-shrink-0">{meta.CTA}</div>
</div>
</header>
</>
);
}