refactor: reorganize settings page with tabbed interface (#57)
* refactor: reorganize settings page with tabbed interface * feat: revamp API key management with new list view and confirmation modals * refactor: update tab styling * refactor: tweak UI/UX for managing API keys * refactor: only show update button when change has been made * refactor: only show update button when content of display name has been updated * feat: store tab state in params * refactor: remove focus state from tabs * refactor: tweak styling on mobile select * refactor: simplify settings pages * feat: open upgrade modal if upgrade=pro is in params * feat: add scroll to api key list on mobile * chore: add translations --------- Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
116
apps/web/src/views/settings/AccountSettings.tsx
Normal file
116
apps/web/src/views/settings/AccountSettings.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import { LanguageSelector } from "~/components/LanguageSelector";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import Avatar from "./components/Avatar";
|
||||
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
|
||||
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Settings | Account" />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Profile picture`}
|
||||
</h2>
|
||||
<Avatar userId={data?.id} userImage={data?.image} />
|
||||
|
||||
<div className="mb-4">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Display name`}
|
||||
</h2>
|
||||
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Language`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Change your language preferences.`}
|
||||
</p>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete account`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Once you delete your account, there is no going back. This action cannot be undone.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_ACCOUNT")}
|
||||
>
|
||||
{t`Delete account`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Change Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You are about to change your password.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Account-specific modals */}
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
|
||||
>
|
||||
<DeleteAccountConfirmation />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/views/settings/ApiSettings.tsx
Normal file
66
apps/web/src/views/settings/ApiSettings.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import ApiKeyList from "./components/ApiKeyList";
|
||||
import NewApiKeyModal from "./components/NewApiKeyModal";
|
||||
import { RevokeApiKeyConfirmation } from "./components/RevokeApiKeyConfirmation";
|
||||
|
||||
export default function ApiSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Settings | API" />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`API keys`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`View and manage your API keys.`}
|
||||
</p>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<Button variant="primary" onClick={() => openModal("NEW_API_KEY")}>
|
||||
{t`Create new key`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ApiKeyList />
|
||||
</div>
|
||||
|
||||
{/* API-specific modals */}
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_API_KEY"}
|
||||
>
|
||||
<NewApiKeyModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "REVOKE_API_KEY"}
|
||||
>
|
||||
<RevokeApiKeyConfirmation />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/views/settings/BillingSettings.tsx
Normal file
68
apps/web/src/views/settings/BillingSettings.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function BillingSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_billing_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating billing session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Settings | Billing" />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Billing`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`View and manage your billing and subscription.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||
onClick={handleOpenBillingPortal}
|
||||
>
|
||||
{t`Billing portal`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/views/settings/IntegrationsSettings.tsx
Normal file
130
apps/web/src/views/settings/IntegrationsSettings.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function IntegrationsSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
isLoading: integrationsLoading,
|
||||
} = api.integration.providers.useQuery();
|
||||
|
||||
const { data: trelloUrl, refetch: refetchTrelloUrl } =
|
||||
api.integration.getAuthorizationUrl.useQuery(
|
||||
{ provider: "trello" },
|
||||
{
|
||||
enabled:
|
||||
!integrationsLoading &&
|
||||
!integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
),
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchIntegrations();
|
||||
refetchTrelloUrl();
|
||||
showPopup({
|
||||
header: t`Trello disconnected`,
|
||||
message: t`Your Trello account has been disconnected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error disconnecting Trello`,
|
||||
message: t`An error occurred while disconnecting your Trello account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Settings | Integrations" />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Trello`}
|
||||
</h2>
|
||||
{!integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
) && trelloUrl ? (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Connect your Trello account to import boards.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
trelloUrl.url,
|
||||
"trello_auth",
|
||||
"height=800,width=600",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Connect Trello`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
) && (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Your Trello account is connected.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => disconnectTrello({ provider: "trello" })}
|
||||
>
|
||||
{t`Disconnect Trello`}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
145
apps/web/src/views/settings/WorkspaceSettings.tsx
Normal file
145
apps/web/src/views/settings/WorkspaceSettings.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
// Open upgrade modal if upgrade=pro is in URL params
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.upgrade === "pro" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasOpenedUpgradeModal
|
||||
) {
|
||||
openModal("UPGRADE_TO_PRO");
|
||||
setHasOpenedUpgradeModal(true);
|
||||
}
|
||||
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={`Settings | ${workspace.name ?? "Workspace"}`} />
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace name`}
|
||||
</h2>
|
||||
<UpdateWorkspaceNameForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceName={workspace.name}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace URL`}
|
||||
</h2>
|
||||
<UpdateWorkspaceUrlForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
/>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace description`}
|
||||
</h2>
|
||||
<UpdateWorkspaceDescriptionForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") && (
|
||||
<div className="my-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete workspace`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Once you delete your workspace, there is no going back. This action cannot be undone.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||
disabled={workspace.role !== "admin"}
|
||||
>
|
||||
{t`Delete workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace-specific modals */}
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/views/settings/components/ApiKeyList.tsx
Normal file
197
apps/web/src/views/settings/components/ApiKeyList.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { HiEllipsisHorizontal } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export default function ApiKeyList() {
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["apiKeys"],
|
||||
queryFn: () => authClient.apiKey.list(),
|
||||
});
|
||||
|
||||
const TableRow = ({
|
||||
keyId,
|
||||
keyName,
|
||||
keyStart,
|
||||
createdAt,
|
||||
lastRequest,
|
||||
isLastRow,
|
||||
showSkeleton,
|
||||
}: {
|
||||
keyId?: string;
|
||||
keyName?: string | null | undefined;
|
||||
keyStart?: string | null | undefined;
|
||||
createdAt?: Date | null;
|
||||
lastRequest?: Date | null;
|
||||
isLastRow?: boolean | undefined;
|
||||
showSkeleton?: boolean | undefined;
|
||||
}) => {
|
||||
const formatDate = (date?: Date | string | null) => {
|
||||
if (!date) return "Never";
|
||||
const dateObj = date instanceof Date ? date : new Date(date);
|
||||
return dateObj.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td className={twMerge("w-[30%]", isLastRow ? "rounded-bl-lg" : "")}>
|
||||
<div className="flex items-center p-4">
|
||||
<div className="ml-2 min-w-0 flex-1">
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<p
|
||||
className={twMerge(
|
||||
"mr-2 text-sm font-medium text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"md mb-2 h-3 w-[125px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{keyName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-[20%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{formatDate(createdAt)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[20%] px-3 py-4">
|
||||
<p
|
||||
className={twMerge(
|
||||
"text-sm text-light-900 dark:text-dark-900",
|
||||
showSkeleton &&
|
||||
"h-3 w-[80px] animate-pulse rounded-sm bg-light-200 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{formatDate(lastRequest)}
|
||||
</p>
|
||||
</td>
|
||||
<td className="w-[25%] px-3 py-4">
|
||||
<div>
|
||||
<span
|
||||
className={twMerge(
|
||||
"inline-flex items-center rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[11px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20",
|
||||
showSkeleton &&
|
||||
"h-5 w-[50px] animate-pulse bg-light-200 ring-0 dark:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{keyStart}...
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-[5%] min-w-[50px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center px-3">
|
||||
<div className={twMerge("relative")}>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: "Revoke",
|
||||
action: () =>
|
||||
openModal("REVOKE_API_KEY", keyId, keyName ?? ""),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal
|
||||
size={25}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="overflow-x-auto">
|
||||
<div className="inline-block min-w-full py-2 align-middle">
|
||||
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-[600px] divide-y divide-light-600 dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[30%] rounded-tl-lg py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-light-900 dark:text-dark-900 sm:pl-6"
|
||||
>
|
||||
Name
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
Created
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[20%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
Last Used
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[25%] px-3 py-3.5 text-left text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
Key
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
className="w-[5%] rounded-tr-lg px-3 py-3.5 text-center text-sm font-semibold text-light-900 dark:text-dark-900"
|
||||
>
|
||||
{/* Actions column */}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.data?.map((apiKey, index) => (
|
||||
<TableRow
|
||||
key={apiKey.id}
|
||||
keyId={apiKey.id}
|
||||
keyName={apiKey.name}
|
||||
keyStart={apiKey.start}
|
||||
createdAt={apiKey.createdAt}
|
||||
lastRequest={apiKey.lastRequest}
|
||||
isLastRow={index === data.data.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<>
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton />
|
||||
<TableRow showSkeleton isLastRow />
|
||||
</>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
|
||||
const CreateAPIKeyForm = ({
|
||||
apiKey,
|
||||
refetchUser,
|
||||
}: {
|
||||
apiKey:
|
||||
| {
|
||||
id: number;
|
||||
prefix: string | null;
|
||||
key: string;
|
||||
}
|
||||
| null
|
||||
| undefined;
|
||||
refetchUser: () => void;
|
||||
}) => {
|
||||
const handleCreateAPIKey = async () => {
|
||||
await authClient.apiKey.create({
|
||||
name: "Kan API Key",
|
||||
prefix: "kan_",
|
||||
});
|
||||
|
||||
refetchUser();
|
||||
};
|
||||
|
||||
const handleRevokeAPIKey = async () => {
|
||||
if (!apiKey) return;
|
||||
await authClient.apiKey.delete({
|
||||
keyId: apiKey.id.toString(),
|
||||
});
|
||||
|
||||
refetchUser();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{apiKey ? (
|
||||
<div className="flex gap-2">
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input value={apiKey.key} readOnly type="password" />
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="danger" onClick={handleRevokeAPIKey}>
|
||||
{t`Revoke`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button onClick={handleCreateAPIKey}>{t`Create new key`}</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateAPIKeyForm;
|
||||
69
apps/web/src/views/settings/components/NewApiKeyForm.tsx
Normal file
69
apps/web/src/views/settings/components/NewApiKeyForm.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
const newApiKeySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
});
|
||||
|
||||
export default function NewApiKeyForm() {
|
||||
const { openModal } = useModal();
|
||||
const form = useForm({
|
||||
resolver: zodResolver(newApiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const createApiKeyMutation = useMutation({
|
||||
mutationFn: ({ name }: { name: string }) =>
|
||||
authClient.apiKey.create({ name, prefix: "kan_" }),
|
||||
onSuccess: ({ data: apiKey }) => {
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["apiKeys"],
|
||||
});
|
||||
openModal("API_KEY_CREATED", apiKey?.key, apiKey?.name ?? "");
|
||||
},
|
||||
onError: () => {
|
||||
form.setError("name", {
|
||||
type: "manual",
|
||||
message: "Failed to create API key",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: z.infer<typeof newApiKeySchema>) => {
|
||||
createApiKeyMutation.mutate({ name: data.name });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
<form
|
||||
onSubmit={form.handleSubmit(handleSubmit)}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
New API key
|
||||
</h2>
|
||||
<Input
|
||||
{...form.register("name")}
|
||||
placeholder="Name"
|
||||
className="w-full"
|
||||
errorMessage={form.formState.errors.name?.message}
|
||||
/>
|
||||
<Button type="submit" isLoading={createApiKeyMutation.isPending}>
|
||||
Create
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
apps/web/src/views/settings/components/NewApiKeyModal.tsx
Normal file
181
apps/web/src/views/settings/components/NewApiKeyModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiInformationCircle,
|
||||
HiMiniCheck,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useClipboard } from "~/hooks/useClipboard";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
const newApiKeySchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`API key name is required` })
|
||||
.max(30, { message: t`API key name cannot exceed 30 characters` }),
|
||||
});
|
||||
|
||||
export default function NewApiKeyModal() {
|
||||
const { closeModal } = useModal();
|
||||
const { copied, copy } = useClipboard({ timeout: 2000 });
|
||||
const [createdApiKey, setCreatedApiKey] = useState<{
|
||||
key: string;
|
||||
name: string;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<z.infer<typeof newApiKeySchema>>({
|
||||
resolver: zodResolver(newApiKeySchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
const qc = useQueryClient();
|
||||
|
||||
const createApiKeyMutation = useMutation({
|
||||
mutationFn: ({ name }: { name: string }) =>
|
||||
authClient.apiKey.create({ name, prefix: "kan_" }),
|
||||
onSuccess: ({ data: apiKey }) => {
|
||||
void qc.invalidateQueries({
|
||||
queryKey: ["apiKeys"],
|
||||
});
|
||||
if (apiKey && apiKey.key && apiKey.name) {
|
||||
setCreatedApiKey({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
// Handle error if needed
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: z.infer<typeof newApiKeySchema>) => {
|
||||
createApiKeyMutation.mutate({ name: data.name });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Reset state and form when modal opens
|
||||
setCreatedApiKey(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createdApiKey) {
|
||||
const nameElement = document.querySelector<HTMLElement>("#name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}
|
||||
}, [createdApiKey]);
|
||||
|
||||
if (createdApiKey) {
|
||||
return (
|
||||
<div>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`API key created`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={createdApiKey.key}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={() => copy(createdApiKey.key)}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`This API key will only be shown once. Please save it in a secure location.`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button onClick={() => closeModal()}>{t`Close`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4 text-neutral-900 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`New API key`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`API key name`}
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button type="submit" isLoading={createApiKeyMutation.isPending}>
|
||||
{t`Create API key`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export function RevokeApiKeyConfirmation() {
|
||||
const { closeModal, entityId, entityLabel } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
|
||||
|
||||
const deleteApiKeyMutation = useMutation({
|
||||
mutationFn: () => authClient.apiKey.delete({ keyId: entityId }),
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "API key revoked",
|
||||
message: `Your API key: ${entityLabel} has been revoked.`,
|
||||
icon: "success",
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["apiKeys"],
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: "Error revoking API key",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleRevokeApiKey = () => {
|
||||
deleteApiKeyMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{`Are you sure you want to revoke this API key: ${entityLabel}?`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
|
||||
Keep in mind that this action is irreversible.
|
||||
</p>
|
||||
<p className="text-sm text-light-900 dark:text-dark-900">
|
||||
This will result in the permanent revocation of this API key.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative flex items-start">
|
||||
<div className="flex h-6 items-center">
|
||||
<input
|
||||
id="acknowledgment"
|
||||
name="acknowledgment"
|
||||
type="checkbox"
|
||||
aria-describedby="acknowledgment-description"
|
||||
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
|
||||
checked={isAcknowledgmentChecked}
|
||||
onChange={() =>
|
||||
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-3 text-sm leading-6">
|
||||
<p
|
||||
id="comments-description"
|
||||
className="text-light-900 dark:text-dark-1000"
|
||||
>
|
||||
I acknowledge that this API key will be permanently revoked and want
|
||||
to proceed.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleRevokeApiKey}
|
||||
disabled={!isAcknowledgmentChecked}
|
||||
isLoading={deleteApiKeyMutation.isPending}
|
||||
>
|
||||
Revoke API key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -69,16 +69,18 @@ const UpdateDisplayNameForm = ({ displayName }: { displayName: string }) => {
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input {...register("name")} errorMessage={errors.name?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={!isDirty || updateDisplayName.isPending}
|
||||
isLoading={updateDisplayName.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={updateDisplayName.isPending}
|
||||
isLoading={updateDisplayName.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -80,16 +80,18 @@ const UpdateWorkspaceDescriptionForm = ({
|
||||
errorMessage={errors.description?.message}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={!isDirty || updateWorkspaceDescription.isPending}
|
||||
isLoading={updateWorkspaceDescription.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={updateWorkspaceDescription.isPending}
|
||||
isLoading={updateWorkspaceDescription.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -72,16 +72,18 @@ const UpdateWorkspaceNameForm = ({
|
||||
<div className="mb-4 flex w-full max-w-[325px] items-center gap-2">
|
||||
<Input {...register("name")} errorMessage={errors.name?.message} />
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={!isDirty || updateWorkspaceName.isPending}
|
||||
isLoading={updateWorkspaceName.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={updateWorkspaceName.isPending}
|
||||
isLoading={updateWorkspaceName.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -138,22 +138,23 @@ const UpdateWorkspaceUrlForm = ({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={
|
||||
!isDirty ||
|
||||
updateWorkspaceSlug.isPending ||
|
||||
checkWorkspaceSlugAvailability.isPending ||
|
||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
||||
isTyping
|
||||
}
|
||||
isLoading={updateWorkspaceSlug.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
{isDirty && (
|
||||
<div>
|
||||
<Button
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
variant="primary"
|
||||
disabled={
|
||||
updateWorkspaceSlug.isPending ||
|
||||
checkWorkspaceSlugAvailability.isPending ||
|
||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
||||
isTyping
|
||||
}
|
||||
isLoading={updateWorkspaceSlug.isPending}
|
||||
>
|
||||
{t`Update`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import { LanguageSelector } from "~/components/LanguageSelector";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import Avatar from "./components/Avatar";
|
||||
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
|
||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
isLoading: integrationsLoading,
|
||||
} = api.integration.providers.useQuery();
|
||||
|
||||
const { data: trelloUrl, refetch: refetchTrelloUrl } =
|
||||
api.integration.getAuthorizationUrl.useQuery(
|
||||
{ provider: "trello" },
|
||||
{
|
||||
enabled:
|
||||
!integrationsLoading &&
|
||||
!integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
),
|
||||
refetchOnWindowFocus: true,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handleFocus = () => {
|
||||
refetchIntegrations();
|
||||
};
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [refetchIntegrations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.edit === "workspace_url" &&
|
||||
workspaceUrlSectionRef.current &&
|
||||
scrollContainerRef.current
|
||||
) {
|
||||
const element = workspaceUrlSectionRef.current;
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
container.scrollTop = element.offsetTop - 40;
|
||||
|
||||
const input = element.querySelector('input[type="text"]');
|
||||
if (input instanceof HTMLInputElement) {
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
}, [router.query.edit]);
|
||||
|
||||
// Open upgrade modal if upgrade=pro is in URL params
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.upgrade === "pro" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasOpenedUpgradeModal
|
||||
) {
|
||||
openModal("UPGRADE_TO_PRO");
|
||||
setHasOpenedUpgradeModal(true);
|
||||
}
|
||||
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
refetchUser();
|
||||
refetchIntegrations();
|
||||
refetchTrelloUrl();
|
||||
showPopup({
|
||||
header: t`Trello disconnected`,
|
||||
message: t`Your Trello account has been disconnected.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error disconnecting Trello`,
|
||||
message: t`An error occurred while disconnecting your Trello account.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const refetchUser = () => utils.user.getUser.refetch();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_billing_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating billing session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="h-full max-h-[calc(100vdh-3rem)] overflow-y-auto md:max-h-[calc(100vdh-4rem)]"
|
||||
>
|
||||
<PageHead title={t`Settings | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto max-w-[1100px] px-5 py-6 md:px-28 md:py-12">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Settings`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Profile picture`}
|
||||
</h2>
|
||||
<Avatar userId={data?.id} userImage={data?.image} />
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Display name`}
|
||||
</h2>
|
||||
<UpdateDisplayNameForm displayName={data?.name ?? ""} />
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace name`}
|
||||
</h2>
|
||||
<UpdateWorkspaceNameForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceName={workspace.name}
|
||||
/>
|
||||
|
||||
<div ref={workspaceUrlSectionRef}>
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace URL`}
|
||||
</h2>
|
||||
<UpdateWorkspaceUrlForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceUrl={workspace.slug ?? ""}
|
||||
workspacePlan={workspace.plan ?? "free"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Workspace description`}
|
||||
</h2>
|
||||
<UpdateWorkspaceDescriptionForm
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") && (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Language`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Change the language of the app.`}
|
||||
</p>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Billing`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`View and manage your billing and subscription.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||
onClick={handleOpenBillingPortal}
|
||||
>
|
||||
{t`Billing portal`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
Trello
|
||||
</h2>
|
||||
{!integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
) && trelloUrl ? (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Connect your Trello account to import boards.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
iconRight={<HiMiniArrowTopRightOnSquare />}
|
||||
onClick={() =>
|
||||
window.open(
|
||||
trelloUrl.url,
|
||||
"trello_auth",
|
||||
"height=800,width=600",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Connect Trello`}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
integrations?.some(
|
||||
(integration) => integration.provider === "trello",
|
||||
) && (
|
||||
<>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Your Trello account is connected.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => disconnectTrello({ provider: "trello" })}
|
||||
>
|
||||
{t`Disconnect Trello`}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`API keys`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`View and manage your API keys.`}
|
||||
</p>
|
||||
<CreateAPIKeyForm
|
||||
apiKey={data?.apiKey}
|
||||
refetchUser={refetchUser}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete workspace`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Once you delete your workspace, there is no going back. This action cannot be undone.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_WORKSPACE")}
|
||||
disabled={workspace.role !== "admin"}
|
||||
>
|
||||
{t`Delete workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Change Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You are about to change your password.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete account`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`Once you delete your account, there is no going back. This action cannot be undone.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_ACCOUNT")}
|
||||
>
|
||||
{t`Delete account`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
|
||||
>
|
||||
<DeleteAccountConfirmation />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user