Compare commits
97 Commits
feat/log-d
...
fix/file-u
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb10b45e95 | ||
|
|
2893e95597 | ||
|
|
89654c20b0 | ||
|
|
4442e67832 | ||
|
|
d7e8b5ae6b | ||
|
|
03e1e19464 | ||
|
|
a3353acf44 | ||
|
|
a4a807ed1d | ||
|
|
6b8ada8d90 | ||
|
|
22899cd14c | ||
|
|
833c69a73e | ||
|
|
d9cf363302 | ||
|
|
9212b1ca2e | ||
|
|
ede2475796 | ||
|
|
e32ad1cd32 | ||
|
|
bac22021ad | ||
|
|
8d8cfa3bae | ||
|
|
971348a397 | ||
|
|
d3920cd246 | ||
|
|
5af375825b | ||
|
|
861416a18e | ||
|
|
a7e78ad580 | ||
|
|
f288047a6a | ||
|
|
edb88525c0 | ||
|
|
d69051611c | ||
|
|
cf566e04c6 | ||
|
|
6ea622e7f7 | ||
|
|
0e7b7a072c | ||
|
|
f801d2a7eb | ||
|
|
d9157b6218 | ||
|
|
711ff7f610 | ||
|
|
742e8a1fab | ||
|
|
69ed5fd8f2 | ||
|
|
e21830138d | ||
|
|
f38533590d | ||
|
|
7d52e4eb88 | ||
|
|
692123433b | ||
|
|
8436644966 | ||
|
|
0c2b670baf | ||
|
|
584fe13244 | ||
|
|
cc6804c21d | ||
|
|
8512946102 | ||
|
|
6bebb14855 | ||
|
|
0adb68d547 | ||
|
|
e26c122d76 | ||
|
|
95321c1c0d | ||
|
|
f459c489f9 | ||
|
|
460008d340 | ||
|
|
4371fe2d4d | ||
|
|
60d8e1bd83 | ||
|
|
4459fbb4b2 | ||
|
|
507c553778 | ||
|
|
3d9e859b31 | ||
|
|
54dcd08d9a | ||
|
|
468aeef8b6 | ||
|
|
71205b67dc | ||
|
|
607672718c | ||
|
|
dd262ee50f | ||
|
|
82c8eef0c2 | ||
|
|
ea116268ff | ||
|
|
74384d841e | ||
|
|
c0962e9f70 | ||
|
|
3e6aec2d6b | ||
|
|
4658b2961c | ||
|
|
bc8b5463b3 | ||
|
|
9eee59a877 | ||
|
|
e32e400976 | ||
|
|
b4b07f3120 | ||
|
|
e39faa1172 | ||
|
|
b46979f539 | ||
|
|
f4237fffe6 | ||
|
|
1d9bb96367 | ||
|
|
bcb449e394 | ||
|
|
ae4bc77407 | ||
|
|
6056167063 | ||
|
|
adf91c7dfd | ||
|
|
bba9a94e45 | ||
|
|
e36eb64c59 | ||
|
|
201fe05493 | ||
|
|
25490b2223 | ||
|
|
b3108942db | ||
|
|
2ba759966e | ||
|
|
7045cd8e25 | ||
|
|
603cc46b7d | ||
|
|
e9d338384a | ||
|
|
8a2a9ecef5 | ||
|
|
3a44957662 | ||
|
|
20e6c99e32 | ||
|
|
c0aae5449f | ||
|
|
596f346181 | ||
|
|
7b59e2ac07 | ||
|
|
2a9b1fd2b5 | ||
|
|
a2d4314e14 | ||
|
|
f94e4d6da2 | ||
|
|
97f4defc0e | ||
|
|
dd29dc89fd | ||
|
|
486cdf8313 |
@@ -23,8 +23,8 @@ SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
S3_REGION= # required for AWS S3 (e.g. us-east-1); ignored by most S3-compatible providers — defaults to us-east-1 if unset
|
||||
S3_ENDPOINT= # set for S3-compatible providers (MinIO, R2, Spaces, etc.); leave blank for AWS S3
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
|
||||
@@ -227,6 +227,12 @@ We welcome contributions! Please read our [contribution guidelines](CONTRIBUTING
|
||||
<img src="https://contrib.rocks/image?repo=kanbn/kan" />
|
||||
</a>
|
||||
|
||||
## Sponsors ❤️
|
||||
|
||||
[<img height="100" alt="image" src="https://github.com/user-attachments/assets/e331c71f-ac86-46a6-bceb-ce276de094b0" />](https://www.testmuai.com)
|
||||
|
||||
Proudly sponsored by [TestMu AI (formerly LambdaTest)](https://www.testmuai.com) - an AI-native testing cloud platform built for modern engineering teams. Covering everything from autonomous test creation and fast execution to testing AI agents like chatbots and voice assistants. If you're serious about testing, go check them out.
|
||||
|
||||
## License 📝
|
||||
|
||||
Kan is licensed under the [AGPLv3 license](LICENSE).
|
||||
|
||||
@@ -11,6 +11,7 @@ RUN apk update && \
|
||||
apk add --no-cache libc6-compat && \
|
||||
rm -rf /var/cache/apk/* /tmp/* || true && \
|
||||
corepack enable && \
|
||||
corepack prepare pnpm@9.14.2 --activate && \
|
||||
npm install turbo@2.3.1 --global && \
|
||||
pnpm config set store-dir ~/.pnpm-store
|
||||
|
||||
@@ -109,13 +110,15 @@ ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Copy the standalone Next.js server
|
||||
COPY --from=builder /app/apps/web/.next/standalone/ ./
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/standalone/ ./
|
||||
# Copy static assets and public files
|
||||
COPY --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/.next/static/ ./apps/web/.next/static/
|
||||
COPY --chown=1000:1000 --from=builder /app/apps/web/public/ ./apps/web/public/
|
||||
|
||||
# Copy bootstrap script for runtime env var injection
|
||||
COPY apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
COPY --chown=1000:1000 apps/web/bootstrap.cjs ./bootstrap.cjs
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["bootstrap.cjs"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,7 @@ const config = {
|
||||
],
|
||||
},
|
||||
|
||||
|
||||
/** Enables hot reloading for local packages without a build step */
|
||||
transpilePackages: [
|
||||
"@kan/api",
|
||||
@@ -65,6 +66,8 @@ const config = {
|
||||
},
|
||||
},
|
||||
},
|
||||
serverExternalPackages: ["pino"],
|
||||
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
swcPlugins: [["@lingui/swc-plugin", {}]],
|
||||
|
||||
@@ -46,6 +46,7 @@ interface FormValues {
|
||||
interface AuthProps {
|
||||
setIsMagicLinkSent: (value: boolean, recipient: string) => void;
|
||||
isSignUp?: boolean;
|
||||
callbackURL?: string;
|
||||
}
|
||||
|
||||
const EmailSchema = z.object({
|
||||
@@ -152,7 +153,11 @@ const availableSocialProviders = {
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
export function Auth({
|
||||
setIsMagicLinkSent,
|
||||
isSignUp,
|
||||
callbackURL: callbackURLProp,
|
||||
}: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
@@ -165,7 +170,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
const callbackURL = callbackURLProp ?? redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
|
||||
@@ -33,6 +33,7 @@ type SearchResult =
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
cardNumber: number | null;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
@@ -159,8 +160,17 @@ export default function CommandPallette({
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" &&
|
||||
result.cardNumber != null &&
|
||||
workspace.cardPrefix && (
|
||||
<span className="flex-shrink-0 text-xs text-light-600 dark:text-dark-600">
|
||||
{workspace.cardPrefix}-{result.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
@@ -11,8 +14,12 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { ChangePasswordFormConfirmation } from "~/views/settings/components/ChangePasswordConfirmation";
|
||||
import Button from "./Button";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -41,8 +48,11 @@ export default function Dashboard({
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { openModal, closeModal, modalContentType } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
const { data: user, isLoading: userLoading } = api.user.getUser.useQuery(
|
||||
@@ -97,10 +107,65 @@ export default function Dashboard({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
const partnerActivated = searchParams.get("partner_activated");
|
||||
const partnerError = searchParams.get("partner_error");
|
||||
|
||||
if (partnerActivated) {
|
||||
showPopup({
|
||||
header: t`License activated`,
|
||||
message: t`Your license has been activated successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_activated");
|
||||
router.replace(`?${params.toString()}`);
|
||||
} else if (partnerError) {
|
||||
const messages: Record<string, string> = {
|
||||
invalid_license: t`That license key could not be found. Please contact support.`,
|
||||
license_inactive: t`Your license is not active. Please check your account.`,
|
||||
missing_license: t`No license key was provided. Please try activating again.`,
|
||||
};
|
||||
showPopup({
|
||||
header: t`License activation failed`,
|
||||
message:
|
||||
messages[partnerError] ??
|
||||
t`Something went wrong during license activation.`,
|
||||
icon: "error",
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete("partner_error");
|
||||
router.replace(`?${params.toString()}`);
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
}, [searchParams, showPopup, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
|
||||
if (
|
||||
!userLoading &&
|
||||
user &&
|
||||
isCredentialsEnabled &&
|
||||
user.hasMagicLinkAccount &&
|
||||
!user.hasPassword &&
|
||||
!sessionStorage.getItem("set_password_prompted")
|
||||
) {
|
||||
sessionStorage.setItem("set_password_prompted", "1");
|
||||
openModal("SET_PASSWORD_PROMPT");
|
||||
}
|
||||
}, [user, userLoading, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
@@ -196,6 +261,30 @@ export default function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={modalContentType === "SET_PASSWORD_PROMPT"}
|
||||
>
|
||||
{user?.hasPassword ? (
|
||||
<div className="p-5">
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">{t`Password already set`}</h2>
|
||||
<p className="mb-6 text-sm text-light-900">
|
||||
{t`Your account already has a password. You can change it from your account settings.`}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={closeModal}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Close`}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ChangePasswordFormConfirmation hasPassword={false} />
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export interface RenderSuggestionsProps {
|
||||
export interface WorkspaceMember {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
id: string | null;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
@@ -474,7 +474,7 @@ export default function Editor({
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
}),
|
||||
Markdown,
|
||||
Markdown.configure({ transformPastedText: true }),
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
|
||||
@@ -88,7 +88,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
</button>
|
||||
)}
|
||||
{iconRight && type !== "password" && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 leading-[0]">
|
||||
{iconRight}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -39,7 +39,7 @@ export function LabelForm({
|
||||
labelPublicId: entityId,
|
||||
},
|
||||
{
|
||||
enabled: isEdit && !!entityId,
|
||||
enabled: !!isEdit && entityId.length >= 12,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -207,7 +207,7 @@ export function LabelForm({
|
||||
/>
|
||||
</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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isEdit && (
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiBolt,
|
||||
HiCheck,
|
||||
HiCheckBadge,
|
||||
HiInformationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { HiCheck, HiXMark } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -54,7 +47,6 @@ export function NewWorkspaceForm() {
|
||||
watch,
|
||||
trigger,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
@@ -67,16 +59,10 @@ export function NewWorkspaceForm() {
|
||||
|
||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const slug = watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
// Pro toggle state management
|
||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
||||
|
||||
// Validate slug only after debounce
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
@@ -101,7 +87,7 @@ export function NewWorkspaceForm() {
|
||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: async (values, variables) => {
|
||||
onSuccess: (values) => {
|
||||
if (values.publicId && values.name) {
|
||||
void utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
@@ -110,46 +96,11 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
cardPrefix: values.cardPrefix,
|
||||
role: "admin",
|
||||
weekStartDay: 1,
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/stripe/create_checkout_session",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: slug || undefined,
|
||||
workspacePublicId: values.publicId,
|
||||
cancelUrl: "/settings/workspace?upgrade=pro",
|
||||
successUrl: "/boards",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return; // Don't close modal if redirecting to checkout
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
showPopup({
|
||||
header: t`Error upgrading to Pro`,
|
||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
||||
icon: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
@@ -168,57 +119,8 @@ export function NewWorkspaceForm() {
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
||||
|
||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudEnv &&
|
||||
!checkWorkspaceSlugAvailability.isPending &&
|
||||
isValidSlug
|
||||
) {
|
||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
||||
setShouldShowBenefits(isAvailable);
|
||||
|
||||
// Automatically enable Pro toggle when slug is available
|
||||
if (isAvailable) {
|
||||
setIsProToggleEnabled(true);
|
||||
setLastAvailableSlug(slug);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isValidSlug,
|
||||
isWorkspaceSlugAvailable?.isAvailable,
|
||||
checkWorkspaceSlugAvailability.isPending,
|
||||
slug,
|
||||
isCloudEnv,
|
||||
]);
|
||||
|
||||
// Reset benefits when slug becomes invalid
|
||||
useEffect(() => {
|
||||
if (!isValidSlug) {
|
||||
setShouldShowBenefits(false);
|
||||
}
|
||||
}, [isValidSlug]);
|
||||
|
||||
// Handle Pro toggle changes
|
||||
const handleProToggleChange = () => {
|
||||
if (isProToggleEnabled) {
|
||||
// Turning off: clear the slug and disable Pro
|
||||
setValue("slug", "");
|
||||
setIsProToggleEnabled(false);
|
||||
} else {
|
||||
// Turning on: restore the last available slug if we have one
|
||||
if (lastAvailableSlug) {
|
||||
setValue("slug", lastAvailableSlug);
|
||||
}
|
||||
setIsProToggleEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showProBenefits = isProToggleEnabled;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Don't submit if slug is provided but not available
|
||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||
@@ -227,7 +129,7 @@ export function NewWorkspaceForm() {
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
slug: values.slug,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -290,11 +192,7 @@ export function NewWorkspaceForm() {
|
||||
? t`This workspace URL is reserved`
|
||||
: undefined)
|
||||
}
|
||||
prefix={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||
}
|
||||
prefix={`${env("NEXT_PUBLIC_BASE_URL")}/`}
|
||||
iconRight={
|
||||
slug && slug.length >= 3 && !errors.slug ? (
|
||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
||||
@@ -311,68 +209,9 @@ export function NewWorkspaceForm() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Custom URLs require upgrading to a Pro plan`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProBenefits && (
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
||||
!showProBenefits && "mt-12",
|
||||
)}
|
||||
>
|
||||
{/* Pro Toggle - only show in cloud environment */}
|
||||
{isCloudEnv && (
|
||||
<Toggle
|
||||
isChecked={isProToggleEnabled}
|
||||
onChange={handleProToggleChange}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
||||
@@ -28,7 +28,6 @@ import ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
@@ -53,7 +52,6 @@ export default function SideNavigation({
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
@@ -223,21 +221,19 @@ export default function SideNavigation({
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
aria-label={t`Start free trial`}
|
||||
title={t`Start free trial`}
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
{t`Start free trial`}
|
||||
</ButtonComponent>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,15 +7,17 @@ const Toggle = ({
|
||||
label,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
labelPosition = "before",
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
labelPosition?: "before" | "after";
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
{showLabel && (
|
||||
<div className="flex items-center">
|
||||
{showLabel && labelPosition === "before" && (
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
@@ -38,6 +40,11 @@ const Toggle = ({
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
{showLabel && labelPosition === "after" && (
|
||||
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
|
||||
{label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { Fragment, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
@@ -7,6 +9,7 @@ import { twMerge } from "tailwind-merge";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
|
||||
@@ -18,6 +21,9 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const { data: hasPartnerSlot } =
|
||||
api.workspace.hasAvailablePartnerSlot.useQuery();
|
||||
const router = useRouter();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const { tooltipContent: commandPaletteShortcutTooltipContent } =
|
||||
@@ -147,7 +153,19 @@ export default function WorkspaceMenu({
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
onClick={() => {
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
openModal("NEW_WORKSPACE");
|
||||
} else if (hasPartnerSlot) {
|
||||
router.push(
|
||||
`/onboarding/workspace?partner=1&returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
} else {
|
||||
router.push(
|
||||
`/onboarding/select-plan?returnUrl=${encodeURIComponent(window.location.pathname)}`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Locale as DateFnsLocale } from "date-fns";
|
||||
import { useLingui } from "@lingui/react";
|
||||
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
|
||||
import { de, enGB, es, fr, it, nl, pl, ptBR, ru } from "date-fns/locale";
|
||||
|
||||
import type { Locale } from "~/locales";
|
||||
import { useLinguiContext } from "~/providers/lingui";
|
||||
@@ -19,7 +19,7 @@ export function useLocalisation() {
|
||||
nl,
|
||||
ru,
|
||||
pl,
|
||||
"pt-BR": ptBR,
|
||||
ptbr: ptBR,
|
||||
};
|
||||
|
||||
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -1,12 +1,13 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
@@ -32,7 +33,9 @@ export default withRateLimit(
|
||||
try {
|
||||
allowedHost = new URL(s3Endpoint).hostname.toLowerCase();
|
||||
} catch {
|
||||
return res.status(500).json({ message: "Storage endpoint misconfigured" });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Storage endpoint misconfigured" });
|
||||
}
|
||||
|
||||
if (hostname !== allowedHost && !hostname.endsWith(`.${allowedHost}`)) {
|
||||
@@ -66,10 +69,7 @@ export default withRateLimit(
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to download attachment" });
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
},
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
20
apps/web/src/pages/api/partner/_utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type WorkspacePlan = "free" | "team" | "pro" | "enterprise";
|
||||
|
||||
export interface TierConfig {
|
||||
plan: WorkspacePlan;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
workspaceSlots: number;
|
||||
}
|
||||
|
||||
const TIER_MAP: Record<number, TierConfig> = {
|
||||
1: { plan: "team", seats: 5, unlimitedSeats: false, workspaceSlots: 1 },
|
||||
2: { plan: "pro", seats: 15, unlimitedSeats: false, workspaceSlots: 2 },
|
||||
3: { plan: "pro", seats: null, unlimitedSeats: true, workspaceSlots: 4 },
|
||||
};
|
||||
|
||||
export function tierConfig(tier: number): TierConfig {
|
||||
const config = TIER_MAP[tier];
|
||||
if (!config) throw new Error(`Unknown partner tier: ${tier}`);
|
||||
return config;
|
||||
}
|
||||
144
apps/web/src/pages/api/partner/callback.ts
Normal file
144
apps/web/src/pages/api/partner/callback.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
interface TokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
interface OAuthLicenseResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface LicenseDetailResponse {
|
||||
license_key: string;
|
||||
status: string;
|
||||
tier: number;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code: string): Promise<TokenResponse> {
|
||||
const tokenUrl = process.env.PARTNER_TOKEN_URL;
|
||||
const clientId = process.env.PARTNER_CLIENT_ID;
|
||||
const clientSecret = process.env.PARTNER_CLIENT_SECRET;
|
||||
const redirectUrl = process.env.PARTNER_REDIRECT_URL;
|
||||
if (!tokenUrl) throw new Error("PARTNER_TOKEN_URL not configured");
|
||||
if (!clientId) throw new Error("PARTNER_CLIENT_ID not configured");
|
||||
if (!clientSecret) throw new Error("PARTNER_CLIENT_SECRET not configured");
|
||||
if (!redirectUrl) throw new Error("PARTNER_REDIRECT_URL not configured");
|
||||
|
||||
const res = await fetch(tokenUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
||||
return res.json() as Promise<TokenResponse>;
|
||||
}
|
||||
|
||||
async function fetchOAuthLicense(
|
||||
accessToken: string,
|
||||
): Promise<OAuthLicenseResponse> {
|
||||
const oauthLicenseUrl = process.env.PARTNER_OAUTH_LICENSE_URL;
|
||||
if (!oauthLicenseUrl)
|
||||
throw new Error("PARTNER_OAUTH_LICENSE_URL not configured");
|
||||
|
||||
const res = await fetch(`${oauthLicenseUrl}?access_token=${accessToken}`);
|
||||
if (!res.ok) throw new Error(`OAuth license fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<OAuthLicenseResponse>;
|
||||
}
|
||||
|
||||
async function fetchLicenseDetail(
|
||||
licenseKey: string,
|
||||
): Promise<LicenseDetailResponse> {
|
||||
const licenseApiUrl = process.env.PARTNER_LICENSE_API_URL;
|
||||
const apiKey = process.env.PARTNER_API_KEY;
|
||||
if (!licenseApiUrl) throw new Error("PARTNER_LICENSE_API_URL not configured");
|
||||
if (!apiKey) throw new Error("PARTNER_API_KEY not configured");
|
||||
|
||||
const apiKeyHeader = process.env.PARTNER_API_KEY_HEADER;
|
||||
if (!apiKeyHeader) throw new Error("PARTNER_API_KEY_HEADER not configured");
|
||||
|
||||
const res = await fetch(`${licenseApiUrl}/${licenseKey}`, {
|
||||
headers: { [apiKeyHeader]: apiKey },
|
||||
});
|
||||
if (!res.ok) throw new Error(`License detail fetch failed: ${res.status}`);
|
||||
return res.json() as Promise<LicenseDetailResponse>;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { code } = req.query;
|
||||
|
||||
if (!code || typeof code !== "string") {
|
||||
return res.status(200).json({ message: "OK" });
|
||||
}
|
||||
|
||||
let license: LicenseDetailResponse;
|
||||
|
||||
try {
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
const oauthLicense = await fetchOAuthLicense(tokenData.access_token);
|
||||
license = await fetchLicenseDetail(oauthLicense.license_key);
|
||||
} catch (err) {
|
||||
log.error({ err }, "Partner OAuth flow failed");
|
||||
return res.redirect(`/partner/activate?error=oauth_failed`);
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
const cfg = tierConfig(license.tier);
|
||||
const status = license.status === "active" ? "active" : "inactive";
|
||||
|
||||
// Ensure subscription slots exist — webhook may have already created them
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license.license_key,
|
||||
);
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license.license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: license.tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/partner/activate?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license.license_key)}`,
|
||||
);
|
||||
}),
|
||||
);
|
||||
98
apps/web/src/pages/api/partner/link.ts
Normal file
98
apps/web/src/pages/api/partner/link.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 20, duration: 60 },
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { license_key } = req.query;
|
||||
|
||||
if (!license_key || typeof license_key !== "string") {
|
||||
return res.redirect("/boards?partner_error=missing_license");
|
||||
}
|
||||
|
||||
const { db, user } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
return res.redirect(
|
||||
`/login?next=${encodeURIComponent(`/api/partner/link?license_key=${license_key}`)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
if (!allSlots.length) {
|
||||
return res.redirect("/boards?partner_error=invalid_license");
|
||||
}
|
||||
|
||||
const activeSlots = allSlots.filter((s) =>
|
||||
["active", "trialing"].includes(s.status),
|
||||
);
|
||||
|
||||
if (!activeSlots.length) {
|
||||
return res.redirect("/boards?partner_error=license_inactive");
|
||||
}
|
||||
|
||||
const unlinkedSlot = activeSlots.find((s) => !s.referenceId);
|
||||
|
||||
if (!unlinkedSlot) {
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}
|
||||
|
||||
const linkedIds = new Set(
|
||||
activeSlots.filter((s) => s.referenceId).map((s) => s.referenceId!),
|
||||
);
|
||||
|
||||
const memberships = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
const availableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.find((w) => w && !w.deletedAt && !linkedIds.has(w.publicId));
|
||||
|
||||
if (!availableWorkspace) {
|
||||
return res.redirect(
|
||||
`/onboarding/workspace?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await subscriptionRepo.updateById(db, unlinkedSlot.id, {
|
||||
referenceId: availableWorkspace.publicId,
|
||||
});
|
||||
|
||||
await workspaceRepo.update(db, availableWorkspace.publicId, {
|
||||
plan: unlinkedSlot.plan as "free" | "team" | "pro" | "enterprise",
|
||||
});
|
||||
|
||||
const remainingUnlinked = activeSlots.filter(
|
||||
(s) => !s.referenceId && s.id !== unlinkedSlot.id,
|
||||
);
|
||||
|
||||
if (remainingUnlinked.length > 0) {
|
||||
const updatedLinkedIds = new Set([
|
||||
...linkedIds,
|
||||
availableWorkspace.publicId,
|
||||
]);
|
||||
const hasMoreAvailableWorkspace = memberships
|
||||
.map((m) => m.workspace)
|
||||
.some((w) => w && !w.deletedAt && !updatedLinkedIds.has(w.publicId));
|
||||
|
||||
if (hasMoreAvailableWorkspace) {
|
||||
return res.redirect(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(license_key)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return res.redirect("/boards?partner_activated=1");
|
||||
}),
|
||||
);
|
||||
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
418
apps/web/src/pages/api/partner/webhook.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { cancelWorkspaceAccess } from "@kan/api/utils/workspace";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { getActiveSubscriptions } from "@kan/shared/utils";
|
||||
|
||||
import type { TierConfig } from "./_utils";
|
||||
import { tierConfig } from "./_utils";
|
||||
|
||||
const log = createLogger("api");
|
||||
|
||||
async function createAndLinkSlots(
|
||||
db: dbClient,
|
||||
licenseKey: string,
|
||||
cfg: TierConfig,
|
||||
tier: number,
|
||||
alreadyLinkedReferenceIds: Set<string>,
|
||||
count: number,
|
||||
) {
|
||||
const linkedId = [...alreadyLinkedReferenceIds][0];
|
||||
let autoLinked = 0;
|
||||
|
||||
if (linkedId) {
|
||||
const linkedWorkspace = await workspaceRepo.getByPublicId(db, linkedId);
|
||||
|
||||
if (linkedWorkspace?.createdBy) {
|
||||
const owned = await workspaceRepo.getAllOwnedByUserId(
|
||||
db,
|
||||
linkedWorkspace.createdBy,
|
||||
);
|
||||
|
||||
const candidates = owned.filter(
|
||||
(w) => !alreadyLinkedReferenceIds.has(w.publicId),
|
||||
);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
const existingSubs =
|
||||
await subscriptionRepo.getAllActivePartnerSubsByWorkspaceIds(
|
||||
db,
|
||||
candidates.map((w) => w.publicId),
|
||||
);
|
||||
const alreadySubscribed = new Set(
|
||||
existingSubs
|
||||
.map((s) => s.referenceId)
|
||||
.filter((id): id is string => !!id),
|
||||
);
|
||||
|
||||
const available = candidates
|
||||
.filter((w) => !alreadySubscribed.has(w.publicId))
|
||||
.slice(0, count);
|
||||
|
||||
if (available.length > 0) {
|
||||
const newSlots = await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
available.length,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
available.map((workspace, i) => {
|
||||
const slot = newSlots[i];
|
||||
if (!slot) return;
|
||||
return Promise.all([
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
referenceId: workspace.publicId,
|
||||
}),
|
||||
workspaceRepo.update(db, workspace.publicId, {
|
||||
plan: cfg.plan,
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
);
|
||||
|
||||
autoLinked = available.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const remaining = count - autoLinked;
|
||||
if (remaining > 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
licenseKey,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of readable) {
|
||||
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function verifySignature(
|
||||
rawBody: string,
|
||||
signature: string,
|
||||
timestamp: string,
|
||||
): boolean {
|
||||
const secret = process.env.PARTNER_API_KEY;
|
||||
if (!secret) return false;
|
||||
|
||||
const ts = Number(timestamp);
|
||||
if (isNaN(ts) || Date.now() - ts > 300_000) return false;
|
||||
|
||||
const payload = `${timestamp}${rawBody}`;
|
||||
const expected = createHmac("sha256", secret).update(payload).digest("hex");
|
||||
|
||||
try {
|
||||
return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasReferenceId<T extends { referenceId: string | null | undefined }>(
|
||||
s: T,
|
||||
): s is T & { referenceId: string } {
|
||||
return !!s.referenceId;
|
||||
}
|
||||
|
||||
interface WebhookPayload {
|
||||
event:
|
||||
| "purchase"
|
||||
| "activate"
|
||||
| "deactivate"
|
||||
| "upgrade"
|
||||
| "downgrade"
|
||||
| "migrate";
|
||||
license_key: string;
|
||||
license_status: string;
|
||||
tier: number;
|
||||
prev_license_key?: string;
|
||||
}
|
||||
|
||||
export default withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const signatureHeader = process.env.PARTNER_SIGNATURE_HEADER;
|
||||
const timestampHeader = process.env.PARTNER_TIMESTAMP_HEADER;
|
||||
|
||||
if (!signatureHeader || !timestampHeader) {
|
||||
log.error("Webhook signature headers not configured");
|
||||
return res.status(500).json({ message: "Server misconfiguration" });
|
||||
}
|
||||
|
||||
const signature = req.headers[signatureHeader] as string | undefined;
|
||||
const timestamp = req.headers[timestampHeader] as string | undefined;
|
||||
|
||||
if (!signature || !timestamp) {
|
||||
return res.status(400).json({ message: "Missing signature headers" });
|
||||
}
|
||||
|
||||
const buf = await buffer(req as unknown as Readable);
|
||||
const rawBody = buf.toString("utf8");
|
||||
|
||||
if (!verifySignature(rawBody, signature, timestamp)) {
|
||||
return res.status(401).json({ message: "Invalid signature" });
|
||||
}
|
||||
|
||||
const payload = JSON.parse(rawBody) as WebhookPayload;
|
||||
const { event, license_key, license_status, tier, prev_license_key } =
|
||||
payload;
|
||||
|
||||
log.info({ headers: req.headers, payload }, "partner webhook received");
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
switch (event) {
|
||||
case "purchase":
|
||||
case "activate": {
|
||||
const cfg = tierConfig(tier);
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
const status = event === "activate" ? "active" : license_status;
|
||||
|
||||
if (existing.length === 0) {
|
||||
await subscriptionRepo.createPartnerLicenseSlots(
|
||||
db,
|
||||
license_key,
|
||||
{
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
},
|
||||
cfg.workspaceSlots,
|
||||
);
|
||||
} else {
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: cfg.plan,
|
||||
status,
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "deactivate": {
|
||||
const allSlots = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
license_key,
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
allSlots.filter(hasReferenceId).map(async (slot) => {
|
||||
const siblingSubs = await subscriptionRepo.getByReferenceId(
|
||||
db,
|
||||
slot.referenceId,
|
||||
);
|
||||
const hasOtherActiveSub = getActiveSubscriptions(siblingSubs).some(
|
||||
(s) => s.id !== slot.id,
|
||||
);
|
||||
if (!hasOtherActiveSub) {
|
||||
await cancelWorkspaceAccess(db, slot.referenceId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
await subscriptionRepo.updateAllByPartnerLicenseKey(db, license_key, {
|
||||
plan: "free",
|
||||
status: "canceled",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "upgrade":
|
||||
case "downgrade": {
|
||||
const lookupKey = prev_license_key ?? license_key;
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
lookupKey,
|
||||
);
|
||||
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
// Prefer keeping linked slots; among linked, keep in insertion order (LIFO removal)
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
plan: cfg.plan,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
partnerTier: tier,
|
||||
status: "active",
|
||||
...(prev_license_key ? { partnerLicenseKey: license_key } : {}),
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "migrate": {
|
||||
if (!prev_license_key) break;
|
||||
|
||||
const existing = await subscriptionRepo.getAllByPartnerLicenseKey(
|
||||
db,
|
||||
prev_license_key,
|
||||
);
|
||||
if (existing.length === 0) break;
|
||||
|
||||
const cfg = tierConfig(tier);
|
||||
const newCount = cfg.workspaceSlots;
|
||||
|
||||
const preferKeep = [
|
||||
...existing.filter((s) => s.referenceId),
|
||||
...existing.filter((s) => !s.referenceId),
|
||||
];
|
||||
|
||||
const slotsToKeep = preferKeep.slice(0, newCount);
|
||||
const slotsToRemove = preferKeep.slice(newCount);
|
||||
|
||||
await Promise.all([
|
||||
...slotsToKeep.map((slot) =>
|
||||
subscriptionRepo.updateById(db, slot.id, {
|
||||
partnerLicenseKey: license_key,
|
||||
plan: cfg.plan,
|
||||
status: "active",
|
||||
partnerTier: tier,
|
||||
seats: cfg.seats,
|
||||
unlimitedSeats: cfg.unlimitedSeats,
|
||||
}),
|
||||
),
|
||||
...slotsToKeep
|
||||
.filter(hasReferenceId)
|
||||
.map((s) =>
|
||||
workspaceRepo.update(db, s.referenceId, { plan: cfg.plan }),
|
||||
),
|
||||
]);
|
||||
|
||||
if (newCount > existing.length) {
|
||||
const linkedIds = new Set(
|
||||
slotsToKeep.filter(hasReferenceId).map((s) => s.referenceId),
|
||||
);
|
||||
await createAndLinkSlots(
|
||||
db,
|
||||
license_key,
|
||||
cfg,
|
||||
tier,
|
||||
linkedIds,
|
||||
newCount - existing.length,
|
||||
);
|
||||
}
|
||||
|
||||
if (slotsToRemove.length > 0) {
|
||||
await Promise.all([
|
||||
...slotsToRemove
|
||||
.filter(hasReferenceId)
|
||||
.map((s) => cancelWorkspaceAccess(db, s.referenceId)),
|
||||
...slotsToRemove.map((s) =>
|
||||
subscriptionRepo.updateById(db, s.id, {
|
||||
plan: "free",
|
||||
status: "inactive",
|
||||
unlimitedSeats: false,
|
||||
seats: null,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
log.warn({ event }, "Unhandled partner webhook event");
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true, event });
|
||||
},
|
||||
);
|
||||
|
||||
export const config = {
|
||||
api: {
|
||||
bodyParser: false,
|
||||
},
|
||||
};
|
||||
@@ -2,34 +2,34 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
try {
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
},
|
||||
if (!user?.stripeCustomerId) {
|
||||
return res.status(404).json({ error: "No billing account found" });
|
||||
}
|
||||
|
||||
const session = await stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
return_url: `${env("NEXT_PUBLIC_BASE_URL")}/settings`,
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
return res.status(500).json({ error: "Error creating portal session" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,10 +3,13 @@ import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
const workspaceSlugSchema = z
|
||||
.string()
|
||||
@@ -17,21 +20,24 @@ const workspaceSlugSchema = z
|
||||
interface CheckoutSessionRequest {
|
||||
successUrl: string;
|
||||
cancelUrl: string;
|
||||
slug: string;
|
||||
workspacePublicId: string;
|
||||
stripeCustomerId: string;
|
||||
plan: "team" | "pro";
|
||||
billing?: string;
|
||||
workspacePublicId?: string;
|
||||
slug?: string;
|
||||
workspaceName?: string;
|
||||
workspaceDescription?: string;
|
||||
workspaceSlug?: string;
|
||||
}
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const { user, db } = await createNextApiContext(req);
|
||||
|
||||
if (!user) {
|
||||
@@ -39,81 +45,107 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
const {
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
plan,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
slug,
|
||||
workspaceName,
|
||||
workspaceDescription,
|
||||
workspaceSlug,
|
||||
} = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
||||
if (!successUrl || !cancelUrl || !plan) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
if (!workspacePublicId && !workspaceName) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Must provide workspacePublicId or workspaceName" });
|
||||
}
|
||||
|
||||
const resolvedSlug = slug ?? workspaceSlug;
|
||||
|
||||
if (resolvedSlug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(resolvedSlug);
|
||||
if (!slugResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
return res.status(400).json({ error: "Invalid workspace slug" });
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
let resolvedWorkspacePublicId = workspacePublicId;
|
||||
let subscriptionId: number | undefined;
|
||||
|
||||
const isMemberOfWorkspace = workspace.some(
|
||||
({ workspace }) => workspace.publicId === body.workspacePublicId,
|
||||
);
|
||||
if (workspacePublicId) {
|
||||
// Existing workspace upgrade
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
workspacePublicId,
|
||||
);
|
||||
|
||||
if (!isMemberOfWorkspace) {
|
||||
return new Response(JSON.stringify({ error: "Unauthorized" }), {
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
if (!workspace) {
|
||||
return res.status(404).json({ error: "Workspace not found" });
|
||||
}
|
||||
|
||||
try {
|
||||
await assertPermission(db, user.id, workspace.id, "workspace:manage");
|
||||
} catch {
|
||||
return res.status(403).json({ error: "Unauthorized" });
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: plan,
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
} else {
|
||||
resolvedWorkspacePublicId = generateUID();
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: "pro",
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
const subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
const isTeam = body.plan === "team";
|
||||
const annualPriceId = isTeam
|
||||
? (process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
|
||||
: (process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID ??
|
||||
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
|
||||
const monthlyPriceId = isTeam
|
||||
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
|
||||
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
|
||||
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
payment_method_collection: "always",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
},
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
line_items: [{ price: priceId, quantity: 1 }],
|
||||
subscription_data: { trial_period_days: 14 },
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
client_reference_id: resolvedWorkspacePublicId,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
...(slug && { workspaceSlug: slug }),
|
||||
workspacePublicId,
|
||||
workspacePublicId: resolvedWorkspacePublicId!,
|
||||
userId: user.id,
|
||||
subscriptionId,
|
||||
userEmail: user.email ?? "",
|
||||
...(resolvedSlug && { workspaceSlug: resolvedSlug }),
|
||||
...(workspaceName && { workspaceName }),
|
||||
...(workspaceDescription && { workspaceDescription }),
|
||||
...(subscriptionId && { subscriptionId: String(subscriptionId) }),
|
||||
plan: plan,
|
||||
isNewWorkspace: workspaceName ? "true" : "false",
|
||||
},
|
||||
});
|
||||
|
||||
return res.status(200).json({ url: session.url });
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return res.status(500).json({ error: "Error creating checkout session" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { randomUUID } from "crypto";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type { Readable } from "node:stream";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createLogger } from "@kan/logger";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
const log = createLogger("stripe-webhook");
|
||||
const log = createLogger("api");
|
||||
|
||||
async function buffer(readable: Readable) {
|
||||
const chunks = [];
|
||||
@@ -21,6 +23,9 @@ export default async function handler(
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
const start = Date.now();
|
||||
const requestId = randomUUID();
|
||||
const procedure = req.url?.split("?")[0] ?? "/api/stripe/webhook";
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
@@ -44,29 +49,88 @@ export default async function handler(
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
log.info({ eventType: event.type, eventId: event.id }, "Stripe webhook received");
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed": {
|
||||
const checkoutSession = event.data.object;
|
||||
const meta = checkoutSession.metadata;
|
||||
|
||||
const metaData = checkoutSession.metadata;
|
||||
if (!meta?.workspacePublicId) break;
|
||||
|
||||
if (metaData?.workspacePublicId) {
|
||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
||||
plan: "pro",
|
||||
const plan = meta.plan === "team" ? "team" : ("pro" as const);
|
||||
|
||||
if (
|
||||
meta.isNewWorkspace === "true" &&
|
||||
meta.workspaceName &&
|
||||
meta.userId &&
|
||||
meta.userEmail
|
||||
) {
|
||||
const existing = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
meta.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!existing) {
|
||||
const slug = meta.workspaceSlug ?? meta.workspacePublicId;
|
||||
|
||||
await workspaceRepo.create(db, {
|
||||
publicId: meta.workspacePublicId,
|
||||
name: meta.workspaceName,
|
||||
slug,
|
||||
plan,
|
||||
createdBy: meta.userId,
|
||||
createdByEmail: meta.userEmail,
|
||||
...(meta.workspaceDescription && {
|
||||
description: meta.workspaceDescription,
|
||||
}),
|
||||
});
|
||||
|
||||
await subscriptionRepo.create(db, {
|
||||
plan,
|
||||
referenceId: meta.workspacePublicId,
|
||||
userId: meta.userId,
|
||||
stripeCustomerId: checkoutSession.customer as string,
|
||||
status: "active",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await workspaceRepo.update(db, meta.workspacePublicId, {
|
||||
plan,
|
||||
...(plan === "pro" &&
|
||||
meta.workspaceSlug && { slug: meta.workspaceSlug }),
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default:
|
||||
log.warn({ eventType: event.type }, "Unhandled Stripe event type");
|
||||
}
|
||||
|
||||
log.info(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 200,
|
||||
input: { eventType: event.type, eventId: event.id },
|
||||
},
|
||||
"API OK",
|
||||
);
|
||||
|
||||
return res.status(200).json({ received: true });
|
||||
} catch (err) {
|
||||
log.error({ err }, "Stripe webhook handler failed");
|
||||
log.error(
|
||||
{
|
||||
requestId,
|
||||
procedure,
|
||||
transport: "rest",
|
||||
duration: Date.now() - start,
|
||||
status: 400,
|
||||
err,
|
||||
},
|
||||
"API error",
|
||||
);
|
||||
return res.status(400).json({ message: "Webhook handler failed" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +1,58 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { addYears } from "date-fns";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
import { addYears } from "date-fns";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import { integrations } from "@kan/db/schema";
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { user } = await createNextApiContext(req);
|
||||
const { user } = await createNextApiContext(req);
|
||||
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
if (!user)
|
||||
return res.status(401).json({ message: "User not authenticated" });
|
||||
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
const apiKey = process.env.TRELLO_APP_API_KEY;
|
||||
|
||||
if (!apiKey)
|
||||
return res.status(500).json({ message: "Trello API key not set in Environment Variables" });
|
||||
if (!apiKey)
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Trello API key not set in Environment Variables" });
|
||||
|
||||
const token = req.body.token;
|
||||
const token = req.body.token;
|
||||
|
||||
if (!token)
|
||||
return res.status(400).json({ message: "No token found" });
|
||||
if (!token) return res.status(400).json({ message: "No token found" });
|
||||
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
try {
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
await db.insert(integrations).values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
}).onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
await db
|
||||
.insert(integrations)
|
||||
.values({
|
||||
provider: "trello",
|
||||
userId: user.id,
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
set: {
|
||||
accessToken: token,
|
||||
expiresAt: addYears(new Date(), 1),
|
||||
},
|
||||
target: [integrations.userId, integrations.provider],
|
||||
});
|
||||
|
||||
return res.status(200).json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
console.error("Trello authentication error:", err);
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
},
|
||||
);
|
||||
return res
|
||||
.status(200)
|
||||
.json({ message: "Trello authentication successful" });
|
||||
} catch (err) {
|
||||
return res.status(400).json({ message: "Trello authentication failed" });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Novu } from "@novu/api";
|
||||
import { jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
|
||||
import { env } from "~/env";
|
||||
|
||||
const requestSchema = z.object({
|
||||
token: z.string().min(1),
|
||||
});
|
||||
@@ -22,84 +24,85 @@ const textEncoder = new TextEncoder();
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
withApiLogging(
|
||||
async (req: NextApiRequest, res: NextApiResponse<ResponseData>) => {
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: "Unsubscribe endpoint is not available.",
|
||||
code: "UNAVAILABLE",
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
if (req.method !== "POST") {
|
||||
res.setHeader("Allow", "POST");
|
||||
return res.status(405).json({
|
||||
success: false,
|
||||
error: "Method not allowed.",
|
||||
code: "METHOD_NOT_ALLOWED",
|
||||
});
|
||||
}
|
||||
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
const parsedBody = requestSchema.safeParse(req.body);
|
||||
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
if (!parsedBody.success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: "Invalid request payload.",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: "Unsubscribe service is not configured.",
|
||||
code: "NOT_CONFIGURED",
|
||||
});
|
||||
}
|
||||
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
let payload: z.infer<typeof tokenPayloadSchema>;
|
||||
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const verified = await jwtVerify(
|
||||
parsedBody.data.token,
|
||||
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
|
||||
{
|
||||
// We intentionally do not use exp/iat claims –
|
||||
// tokens are long-lived and validated only by signature + payload.
|
||||
clockTolerance: "0s",
|
||||
},
|
||||
);
|
||||
payload = tokenPayloadSchema.parse(verified.payload);
|
||||
} catch {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: "Your unsubscribe link is invalid or has expired.",
|
||||
code: "INVALID_TOKEN",
|
||||
});
|
||||
}
|
||||
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
|
||||
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Novu preferences", error);
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
try {
|
||||
await novu.subscribers.preferences.update(
|
||||
{
|
||||
channels: {
|
||||
email: false,
|
||||
},
|
||||
},
|
||||
payload.subscriberId,
|
||||
);
|
||||
} catch (error) {
|
||||
return res.status(502).json({
|
||||
success: false,
|
||||
error:
|
||||
"We could not update your email preferences right now. Please try again later.",
|
||||
code: "NOVU_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
return res.status(200).json({ success: true });
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { Upload } from "@aws-sdk/lib-storage";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { assertPermission } from "@kan/api/utils/permissions";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
@@ -22,7 +23,7 @@ export const config = {
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -36,7 +37,9 @@ export default withRateLimit(
|
||||
|
||||
const bucket = env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME;
|
||||
if (!bucket) {
|
||||
return res.status(500).json({ error: "Attachments bucket not configured" });
|
||||
return res
|
||||
.status(500)
|
||||
.json({ error: "Attachments bucket not configured" });
|
||||
}
|
||||
|
||||
const cardPublicId = req.query.cardPublicId;
|
||||
@@ -55,15 +58,24 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
const rawFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
@@ -129,8 +141,7 @@ export default withRateLimit(
|
||||
|
||||
return res.status(200).json({ attachment });
|
||||
} catch (error) {
|
||||
console.error("Attachment upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -2,13 +2,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
|
||||
import { env } from "~/env";
|
||||
import { withApiLogging } from "@kan/api/utils/apiLogging";
|
||||
import { withRateLimit } from "@kan/api/utils/rateLimit";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import { createS3Client } from "@kan/shared/utils";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(process.env.S3_AVATAR_UPLOAD_LIMIT || '2097152', 10); // Default 2MB
|
||||
import { env } from "~/env";
|
||||
|
||||
const MAX_SIZE_BYTES = parseInt(
|
||||
process.env.S3_AVATAR_UPLOAD_LIMIT || "2097152",
|
||||
10,
|
||||
); // Default 2MB
|
||||
const allowedContentTypes = ["image/jpeg", "image/png", "image/webp"];
|
||||
|
||||
export const config = {
|
||||
@@ -19,7 +23,7 @@ export const config = {
|
||||
|
||||
export default withRateLimit(
|
||||
{ points: 100, duration: 60 },
|
||||
async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
withApiLogging(async (req: NextApiRequest, res: NextApiResponse) => {
|
||||
if (req.method !== "POST") {
|
||||
return res.status(405).json({ error: "Method not allowed" });
|
||||
}
|
||||
@@ -51,15 +55,24 @@ export default withRateLimit(
|
||||
}
|
||||
|
||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid content length" });
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Missing or invalid content length" });
|
||||
}
|
||||
|
||||
if (contentLength > MAX_SIZE_BYTES) {
|
||||
return res.status(400).json({ error: "File too large" });
|
||||
}
|
||||
|
||||
const originalFilenameHeader =
|
||||
const rawFilenameHeader =
|
||||
(req.headers["x-original-filename"] as string | undefined) ?? "file";
|
||||
const originalFilenameHeader = (() => {
|
||||
try {
|
||||
return decodeURIComponent(rawFilenameHeader);
|
||||
} catch {
|
||||
return rawFilenameHeader;
|
||||
}
|
||||
})();
|
||||
|
||||
const sanitizedFilename = originalFilenameHeader
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "_")
|
||||
@@ -93,9 +106,7 @@ export default withRateLimit(
|
||||
user: updatedUser,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Avatar upload failed", error);
|
||||
return res.status(500).json({ error: "Internal server error" });
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
31
apps/web/src/pages/onboarding/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function SelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Select plan | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
28
apps/web/src/pages/onboarding/workspace.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import WorkspaceDetailsView from "~/views/onboarding/workspace-details";
|
||||
|
||||
export default function WorkspaceDetailsPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) router.push("/login");
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud")
|
||||
router.push("/boards");
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Create workspace | kan.bn" />
|
||||
<WorkspaceDetailsView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
apps/web/src/pages/partner/activate.tsx
Normal file
117
apps/web/src/pages/partner/activate.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { Auth } from "~/components/AuthForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
|
||||
export default function PartnerActivatePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const licenseKey = searchParams.get("license_key");
|
||||
const error = searchParams.get("error");
|
||||
|
||||
const partnerName = env("NEXT_PUBLIC_PARTNER_NAME");
|
||||
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKey) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKey);
|
||||
}
|
||||
}, [licenseKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && session?.user && licenseKey) {
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`,
|
||||
);
|
||||
} else if (!isPending && session?.user && !licenseKey) {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, licenseKey, router]);
|
||||
|
||||
if (isPending || (session?.user && licenseKey)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Activate | kan.bn`} />
|
||||
<main className="h-screen bg-light-100 pt-20 dark:bg-dark-50 sm:pt-0">
|
||||
<div className="justify-top flex h-full flex-col items-center px-4 sm:justify-center">
|
||||
<div className="z-10 flex w-full flex-col items-center">
|
||||
<Link href="/">
|
||||
<h1 className="mb-6 text-lg font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
<p className="mb-2 text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{isMagicLinkSent ? t`Check your inbox` : t`Activate your account`}
|
||||
</p>
|
||||
<p className="mb-10 text-sm text-light-800 dark:text-dark-800">
|
||||
{isMagicLinkSent ? (
|
||||
<Trans>We sent a link to {magicLinkRecipient}</Trans>
|
||||
) : partnerName ? (
|
||||
<Trans>
|
||||
Sign in or create an account to activate your {partnerName}{" "}
|
||||
license
|
||||
</Trans>
|
||||
) : (
|
||||
t`Sign in or create an account to activate your license`
|
||||
)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 w-full rounded-md bg-red-50 px-4 py-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400 sm:max-w-md">
|
||||
{t`Something went wrong during activation. Please try again.`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMagicLinkSent && (
|
||||
<div className="w-full rounded-lg border border-light-500 bg-light-300 px-4 py-10 dark:border-dark-400 dark:bg-dark-200 sm:max-w-md lg:px-10">
|
||||
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<Auth
|
||||
setIsMagicLinkSent={(val, recipient) => {
|
||||
setIsMagicLinkSent(val);
|
||||
setMagicLinkRecipient(recipient);
|
||||
}}
|
||||
callbackURL={
|
||||
licenseKey
|
||||
? `/api/partner/link?license_key=${encodeURIComponent(licenseKey)}`
|
||||
: "/boards"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-4 text-sm text-light-1000 dark:text-dark-1000">
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link
|
||||
href={
|
||||
licenseKey
|
||||
? `/login?next=${encodeURIComponent(`/api/partner/link?license_key=${licenseKey}`)}`
|
||||
: "/login"
|
||||
}
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<PatternedBackground />
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
31
apps/web/src/pages/upgrade/select-plan.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import SelectPlanView from "~/views/onboarding/select-plan";
|
||||
|
||||
export default function UpgradeSelectPlanPage() {
|
||||
const router = useRouter();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isPending && !session?.user) {
|
||||
router.push("/login");
|
||||
}
|
||||
if (!isPending && env("NEXT_PUBLIC_KAN_ENV") !== "cloud") {
|
||||
router.push("/boards");
|
||||
}
|
||||
}, [session, isPending, router]);
|
||||
|
||||
if (isPending || !session?.user) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title="Upgrade | kan.bn" />
|
||||
<SelectPlanView />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -17,9 +17,10 @@ interface Workspace {
|
||||
description: string | null | undefined;
|
||||
publicId: string;
|
||||
slug: string | undefined;
|
||||
plan: "free" | "pro" | "enterprise" | undefined;
|
||||
plan: "free" | "team" | "pro" | "enterprise" | undefined;
|
||||
role: "admin" | "member" | "guest";
|
||||
weekStartDay: 0 | 1 | 6;
|
||||
cardPrefix: string;
|
||||
}
|
||||
|
||||
const initialWorkspace: Workspace = {
|
||||
@@ -27,9 +28,10 @@ const initialWorkspace: Workspace = {
|
||||
description: null,
|
||||
publicId: "",
|
||||
slug: "",
|
||||
plan: "free",
|
||||
plan: "free" as const,
|
||||
role: "member",
|
||||
weekStartDay: 1,
|
||||
cardPrefix: "",
|
||||
};
|
||||
|
||||
const initialAvailableWorkspaces: Workspace[] = [];
|
||||
@@ -50,7 +52,18 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery();
|
||||
const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
|
||||
workspacePublicId,
|
||||
);
|
||||
const pollAttemptsRef = React.useRef(0);
|
||||
const MAX_POLL_ATTEMPTS = 5;
|
||||
|
||||
const { data, isLoading, isFetching } = api.workspace.all.useQuery(
|
||||
undefined,
|
||||
{
|
||||
refetchInterval: pendingWorkspaceId ? 2000 : false,
|
||||
},
|
||||
);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
@@ -66,7 +79,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.length) {
|
||||
if (!isLoading) setHasLoaded(true);
|
||||
if (!isLoading && !isFetching) setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -82,6 +95,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
weekStartDay: workspace.weekStartDay,
|
||||
cardPrefix: workspace.cardPrefix,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
@@ -89,44 +103,72 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
if (storedWorkspaceId !== null) {
|
||||
const newData = data;
|
||||
const selectedWorkspace = newData.find(
|
||||
const selectedWorkspace = data.find(
|
||||
({ workspace }) => workspace.publicId === storedWorkspaceId,
|
||||
);
|
||||
|
||||
if (!selectedWorkspace?.workspace) return;
|
||||
if (!selectedWorkspace?.workspace) {
|
||||
if (pendingWorkspaceId) {
|
||||
pollAttemptsRef.current += 1;
|
||||
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
|
||||
setPendingWorkspaceId(null);
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// Clear stale workspacePublicId from localStorage
|
||||
localStorage.removeItem("workspacePublicId");
|
||||
}
|
||||
} else {
|
||||
pollAttemptsRef.current = 0;
|
||||
setPendingWorkspaceId(null);
|
||||
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
setWorkspace({
|
||||
publicId: selectedWorkspace.workspace.publicId,
|
||||
name: selectedWorkspace.workspace.name,
|
||||
slug: selectedWorkspace.workspace.slug,
|
||||
plan: selectedWorkspace.workspace.plan,
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role as "admin" | "member" | "guest",
|
||||
weekStartDay: selectedWorkspace.workspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: selectedWorkspace.workspace.cardPrefix,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
|
||||
setHasLoaded(true);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole,
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
|
||||
if (!primaryWorkspace || !primaryWorkspaceRole) return;
|
||||
localStorage.setItem("workspacePublicId", primaryWorkspace.publicId);
|
||||
setWorkspace({
|
||||
publicId: primaryWorkspace.publicId,
|
||||
name: primaryWorkspace.name,
|
||||
slug: primaryWorkspace.slug,
|
||||
plan: primaryWorkspace.plan,
|
||||
description: primaryWorkspace.description,
|
||||
role: primaryWorkspaceRole as "admin" | "member" | "guest",
|
||||
weekStartDay: primaryWorkspace.weekStartDay as 0 | 1 | 6,
|
||||
cardPrefix: primaryWorkspace.cardPrefix,
|
||||
});
|
||||
setHasLoaded(true);
|
||||
}, [
|
||||
data,
|
||||
isLoading,
|
||||
isFetching,
|
||||
workspacePublicId,
|
||||
pendingWorkspaceId,
|
||||
router,
|
||||
]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
export const formatToArray = (
|
||||
value: string | string[] | undefined,
|
||||
): string[] => {
|
||||
@@ -50,5 +52,21 @@ export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
// Construct URL from S3 key
|
||||
const useVirtualHosted = env("NEXT_PUBLIC_USE_VIRTUAL_HOSTED_URLS") === "true";
|
||||
const storageDomain = env("NEXT_PUBLIC_STORAGE_DOMAIN");
|
||||
const storageUrl = env("NEXT_PUBLIC_STORAGE_URL");
|
||||
const bucket = env("NEXT_PUBLIC_AVATAR_BUCKET_NAME");
|
||||
|
||||
if (useVirtualHosted && storageDomain && bucket) {
|
||||
// Virtual-hosted style: https://{bucket}.{domain}/{key}
|
||||
return `https://${bucket}.${storageDomain}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
if (storageUrl && bucket) {
|
||||
// Path-style: {storageUrl}/{bucket}/{key}
|
||||
return `${storageUrl}/${bucket}/${imageOrKey}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
|
||||
@@ -22,8 +22,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
case "pt-BR":
|
||||
return (await import("~/locales/pt-BR/messages")).messages;
|
||||
case "ptbr":
|
||||
return (await import("~/locales/ptbr/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function LoginPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function SignUpPage() {
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
if (data?.user.id) router.push(redirect ?? "/boards");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
@@ -29,6 +30,7 @@ export default function BoardDropdown({
|
||||
isFavorite?: boolean;
|
||||
boardName?: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditBoard, canDeleteBoard, canCreateBoard, canArchiveBoard } =
|
||||
@@ -47,6 +49,7 @@ export default function BoardDropdown({
|
||||
: t`The board has been unarchived.`,
|
||||
icon: "success",
|
||||
});
|
||||
void router.push(`/boards`);
|
||||
} else if (variables.favorite !== undefined) {
|
||||
showPopup({
|
||||
header: variables.favorite
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
const Card = ({
|
||||
title,
|
||||
ticketNumber,
|
||||
labels,
|
||||
members,
|
||||
checklists,
|
||||
@@ -25,6 +26,7 @@ const Card = ({
|
||||
dueDate,
|
||||
}: {
|
||||
title: string;
|
||||
ticketNumber?: string | null;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
members: {
|
||||
publicId: string;
|
||||
@@ -67,6 +69,11 @@ const Card = ({
|
||||
|
||||
return (
|
||||
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
{ticketNumber && (
|
||||
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
|
||||
{ticketNumber}
|
||||
</span>
|
||||
)}
|
||||
<span className="break-words">{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import { DueDateSelector } from "~/views/card/components/DueDateSelector";
|
||||
|
||||
export function CardContextDueDateModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Set due date`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<DueDateSelector
|
||||
cardPublicId={cardPublicId}
|
||||
dueDate={card?.dueDate ?? null}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import {
|
||||
Listbox,
|
||||
ListboxButton,
|
||||
ListboxOption,
|
||||
ListboxOptions,
|
||||
Transition,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
import { HiChevronDown } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface CardContextDuplicateModalProps {
|
||||
boardPublicId?: string;
|
||||
isTemplate?: boolean;
|
||||
}
|
||||
|
||||
export function CardContextDuplicateModal({
|
||||
boardPublicId: boardPublicIdProp,
|
||||
isTemplate: isTemplateProp,
|
||||
}: CardContextDuplicateModalProps = {}) {
|
||||
const { entityId: cardPublicId, closeModal, getModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const modalState = getModalState("CARD_CONTEXT_DUPLICATE") as
|
||||
| { boardPublicId: string; isTemplate?: boolean }
|
||||
| undefined;
|
||||
const boardPublicId =
|
||||
boardPublicIdProp ?? modalState?.boardPublicId ?? "";
|
||||
const isTemplate = isTemplateProp ?? modalState?.isTemplate ?? false;
|
||||
|
||||
const [listPublicId, setListPublicId] = useState("");
|
||||
const [copyLabels, setCopyLabels] = useState(true);
|
||||
const [copyMembers, setCopyMembers] = useState(true);
|
||||
const [copyChecklists, setCopyChecklists] = useState(true);
|
||||
const [position, setPosition] = useState<string>("");
|
||||
const [title, setTitle] = useState("");
|
||||
|
||||
const { data: card, isLoading: isCardLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const boardType = isTemplate ? "template" : "regular";
|
||||
const { data: board } = api.board.byId.useQuery(
|
||||
{ boardPublicId, type: boardType },
|
||||
{ enabled: !!boardPublicId },
|
||||
);
|
||||
const lists = board?.lists ?? [];
|
||||
const listOptions = lists.map((l) => ({ publicId: l.publicId, name: l.name }));
|
||||
const currentListPublicId = card?.list?.publicId;
|
||||
const hasLabels = (card?.labels?.length ?? 0) > 0;
|
||||
const hasMembers = (card?.members?.length ?? 0) > 0;
|
||||
const hasChecklists = (card?.checklists?.length ?? 0) > 0;
|
||||
const hasAnyCopyOption = hasLabels || hasMembers || hasChecklists;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`The card has been duplicated.`,
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
message: t`Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!cardPublicId || !listPublicId) return;
|
||||
const indexNum =
|
||||
position === "" ? undefined : parseInt(position, 10);
|
||||
if (
|
||||
position !== "" &&
|
||||
(indexNum === undefined || isNaN(indexNum) || indexNum < 0)
|
||||
)
|
||||
return;
|
||||
duplicateCard.mutate({
|
||||
cardPublicId,
|
||||
listPublicId,
|
||||
copyLabels,
|
||||
copyMembers,
|
||||
copyChecklists,
|
||||
...(typeof indexNum === "number" && { index: indexNum }),
|
||||
title: title.trim() || undefined,
|
||||
});
|
||||
};
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Duplicate card`}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`List`}
|
||||
</label>
|
||||
<Listbox value={listPublicId} onChange={setListPublicId}>
|
||||
<div className="relative">
|
||||
<ListboxButton
|
||||
className={twMerge(
|
||||
"relative w-full cursor-pointer rounded-md border border-light-300 bg-white py-2 pl-3 pr-10 text-left text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-light-400 focus:ring-offset-0 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:focus:ring-dark-500",
|
||||
!listPublicId && "text-light-600 dark:text-dark-600",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{listPublicId
|
||||
? listOptions.find((o) => o.publicId === listPublicId)
|
||||
?.name
|
||||
: t`Select a list`}
|
||||
</span>
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center">
|
||||
<HiChevronDown
|
||||
className="h-4 w-4 text-light-600 dark:text-dark-600"
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
</ListboxButton>
|
||||
<Transition
|
||||
leave="transition ease-in duration-100"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<ListboxOptions className="absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-light-200 bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-400 dark:bg-dark-200">
|
||||
<div className="max-h-60 overflow-y-auto py-1 pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
{listOptions.map((option) => {
|
||||
const isCurrentList =
|
||||
option.publicId === currentListPublicId;
|
||||
return (
|
||||
<ListboxOption
|
||||
key={option.publicId}
|
||||
value={option.publicId}
|
||||
disabled={isCurrentList}
|
||||
className={({ focus }) =>
|
||||
twMerge(
|
||||
"relative select-none py-2 pl-3 pr-9 text-sm",
|
||||
isCurrentList
|
||||
? "cursor-default opacity-50"
|
||||
: "cursor-pointer",
|
||||
!isCurrentList && focus
|
||||
? "bg-light-200 text-light-1000 dark:bg-dark-400 dark:text-dark-1000"
|
||||
: "text-light-900 dark:text-dark-900",
|
||||
)
|
||||
}
|
||||
>
|
||||
{option.name}
|
||||
</ListboxOption>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ListboxOptions>
|
||||
</Transition>
|
||||
</div>
|
||||
</Listbox>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Title (optional)`}
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={card?.title ?? ""}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Position in list (0 = first, leave empty for end)`}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
value={position}
|
||||
onChange={(e) => setPosition(e.target.value)}
|
||||
placeholder={t`End of list`}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasAnyCopyOption && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{hasLabels && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyLabels}
|
||||
onChange={(e) => setCopyLabels(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy labels`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasMembers && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyMembers}
|
||||
onChange={(e) => setCopyMembers(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy members`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{hasChecklists && (
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-pointer items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={copyChecklists}
|
||||
onChange={(e) => setCopyChecklists(e.target.checked)}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
"cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Copy checklists`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={closeModal}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={
|
||||
!listPublicId ||
|
||||
duplicateCard.isPending ||
|
||||
isCardLoading ||
|
||||
lists.length === 0
|
||||
}
|
||||
>
|
||||
{duplicateCard.isPending ? t`Duplicating…` : t`Duplicate`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import LabelSelector from "~/views/card/components/LabelSelector";
|
||||
|
||||
export function CardContextLabelsModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const boardLabels = card?.list?.board?.labels ?? [];
|
||||
const selectedLabels = card?.labels ?? [];
|
||||
|
||||
const formattedLabels = boardLabels.map((label) => ({
|
||||
key: label.publicId,
|
||||
value: label.name,
|
||||
selected: selectedLabels.some((l) => l.publicId === label.publicId),
|
||||
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
||||
}));
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Labels`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<LabelSelector
|
||||
cardPublicId={cardPublicId}
|
||||
labels={formattedLabels}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import MemberSelector from "~/views/card/components/MemberSelector";
|
||||
|
||||
export function CardContextMembersModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const board = card?.list?.board;
|
||||
const workspaceMembers = board?.workspace?.members ?? [];
|
||||
const selectedMembers = card?.members ?? [];
|
||||
|
||||
const formattedMembers = workspaceMembers.map((member) => {
|
||||
const isSelected = selectedMembers.some(
|
||||
(m) => m.publicId === member.publicId,
|
||||
);
|
||||
return {
|
||||
key: member.publicId,
|
||||
value: formatMemberDisplayName(
|
||||
member.user?.name ?? null,
|
||||
member.user?.email ?? member.email,
|
||||
),
|
||||
imageUrl: member.user?.image ? getAvatarUrl(member.user.image) : undefined,
|
||||
selected: isSelected,
|
||||
leftIcon: (
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={member.user?.name ?? ""}
|
||||
imageUrl={
|
||||
member.user?.image ? getAvatarUrl(member.user.image) : undefined
|
||||
}
|
||||
email={member.user?.email ?? member.email}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Manage members`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300" />
|
||||
) : (
|
||||
<MemberSelector
|
||||
cardPublicId={cardPublicId}
|
||||
members={formattedMembers}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="rounded-md border border-light-300 bg-light-50 px-3 py-1.5 text-sm font-medium text-light-1000 hover:bg-light-200 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300"
|
||||
>
|
||||
{t`Done`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
130
apps/web/src/views/board/components/CardContextMenu.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
HiLink,
|
||||
HiOutlineCalendar,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTag,
|
||||
HiOutlineTrash,
|
||||
HiOutlineUserGroup,
|
||||
HiOutlineArrowRightCircle,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
export type CardContextMenuAction =
|
||||
| "members"
|
||||
| "move"
|
||||
| "labels"
|
||||
| "dueDate"
|
||||
| "copyLink"
|
||||
| "duplicate"
|
||||
| "delete";
|
||||
|
||||
interface CardContextMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
onAction: (action: CardContextMenuAction) => void;
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const MENU_ITEMS: {
|
||||
action: CardContextMenuAction;
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
requiresEdit: boolean;
|
||||
}[] = [
|
||||
{
|
||||
action: "members",
|
||||
label: t`Manage members`,
|
||||
icon: <HiOutlineUserGroup className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "move",
|
||||
label: t`Move to another list`,
|
||||
icon: <HiOutlineArrowRightCircle className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "labels",
|
||||
label: t`Add / edit label`,
|
||||
icon: <HiOutlineTag className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "dueDate",
|
||||
label: t`Set due date`,
|
||||
icon: <HiOutlineCalendar className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "copyLink",
|
||||
label: t`Copy link to card`,
|
||||
icon: <HiLink className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: false,
|
||||
},
|
||||
{
|
||||
action: "duplicate",
|
||||
label: t`Duplicate card`,
|
||||
icon: <HiOutlineDocumentDuplicate className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
{
|
||||
action: "delete",
|
||||
label: t`Delete card`,
|
||||
icon: <HiOutlineTrash className="h-4 w-4 shrink-0" />,
|
||||
requiresEdit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function CardContextMenu({
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
onAction,
|
||||
canEdit,
|
||||
}: CardContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
document.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const items = MENU_ITEMS.filter((item) => !item.requiresEdit || canEdit);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] min-w-[200px] rounded-md border border-light-200 bg-white py-1 shadow-lg dark:border-dark-400 dark:bg-dark-200"
|
||||
style={{ left: x, top: y }}
|
||||
>
|
||||
{items.map(({ action, label, icon }) => (
|
||||
<button
|
||||
key={action}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onAction(action);
|
||||
onClose();
|
||||
}}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
101
apps/web/src/views/board/components/CardContextMoveListModal.tsx
Normal file
101
apps/web/src/views/board/components/CardContextMoveListModal.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
export function CardContextMoveListModal() {
|
||||
const { entityId: cardPublicId, closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: card, isLoading } = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardPublicId ?? "" },
|
||||
{ enabled: !!cardPublicId && cardPublicId.length >= 12 },
|
||||
);
|
||||
|
||||
const updateCardList = api.card.update.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
if (!cardPublicId) return undefined;
|
||||
await utils.card.byId.cancel();
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old;
|
||||
const list = old.list.board?.lists?.find(
|
||||
(l) => l.publicId === vars.listPublicId,
|
||||
);
|
||||
if (!list) return old;
|
||||
return {
|
||||
...old,
|
||||
list: { ...old.list, publicId: list.publicId, name: list.name },
|
||||
};
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (cardPublicId && ctx?.previous) {
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
}
|
||||
showPopup({
|
||||
header: t`Unable to move card`,
|
||||
message: t`Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (cardPublicId) {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const lists = card?.list?.board?.lists ?? [];
|
||||
const currentListPublicId = card?.list?.publicId;
|
||||
|
||||
const handleSelectList = (listPublicId: string) => {
|
||||
if (listPublicId === currentListPublicId || !cardPublicId) return;
|
||||
updateCardList.mutate(
|
||||
{ cardPublicId, listPublicId, index: 0 },
|
||||
{ onSuccess: closeModal },
|
||||
);
|
||||
};
|
||||
|
||||
if (!cardPublicId) return null;
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h2 className="mb-4 text-lg font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{t`Move to list`}
|
||||
</h2>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-10 w-full animate-pulse rounded bg-light-200 dark:bg-dark-300"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-h-[60vh] overflow-y-auto pr-1 scrollbar scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
|
||||
<ul className="space-y-1">
|
||||
{lists.map((list) => (
|
||||
<li key={list.publicId}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectList(list.publicId)}
|
||||
disabled={list.publicId === currentListPublicId}
|
||||
className="w-full rounded-md px-3 py-2 text-left text-sm hover:bg-light-200 disabled:opacity-50 dark:hover:bg-dark-400"
|
||||
>
|
||||
{list.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,6 +146,10 @@ export function NewCardForm({
|
||||
listId: 2,
|
||||
description: "",
|
||||
dueDate: args.dueDate ?? null,
|
||||
cardNumber: null,
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
labels: oldBoard.labels.filter((label) =>
|
||||
args.labelPublicIds.includes(label.publicId),
|
||||
),
|
||||
@@ -158,6 +162,9 @@ export function NewCardForm({
|
||||
...member,
|
||||
deletedAt: null,
|
||||
})) ?? [],
|
||||
comments: [],
|
||||
checklists: [],
|
||||
attachments: [],
|
||||
_filteredLabels: labelPublicIds.map((id) => ({ publicId: id })),
|
||||
_filteredMembers: memberPublicIds.map((id) => ({ publicId: id })),
|
||||
index: position === "start" ? 0 : list.cards.length,
|
||||
@@ -515,7 +522,7 @@ export function NewCardForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div className="mt-5 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
|
||||
@@ -136,7 +136,7 @@ export function NewListForm({
|
||||
}}
|
||||
/>
|
||||
</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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Create another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useParams } from "next/navigation";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
import type { CardContextMenuAction } from "./components/CardContextMenu";
|
||||
import Button from "~/components/Button";
|
||||
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
@@ -26,16 +28,23 @@ import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppab
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { EditYouTubeModal } from "~/components/YouTubeEmbed/EditYouTubeModal";
|
||||
import { useDragToScroll } from "~/hooks/useDragToScroll";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useScrollRestore } from "~/hooks/useScrollRestore";
|
||||
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { formatToArray } from "~/utils/helpers";
|
||||
import { DeleteCardConfirmation } from "~/views/card/components/DeleteCardConfirmation";
|
||||
import BoardDropdown from "./components/BoardDropdown";
|
||||
import Card from "./components/Card";
|
||||
import { CardContextDueDateModal } from "./components/CardContextDueDateModal";
|
||||
import { CardContextDuplicateModal } from "./components/CardContextDuplicateModal";
|
||||
import { CardContextLabelsModal } from "./components/CardContextLabelsModal";
|
||||
import { CardContextMembersModal } from "./components/CardContextMembersModal";
|
||||
import { CardContextMenu } from "./components/CardContextMenu";
|
||||
import { CardContextMoveListModal } from "./components/CardContextMoveListModal";
|
||||
import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation";
|
||||
import { DeleteListConfirmation } from "./components/DeleteListConfirmation";
|
||||
import Filters from "./components/Filters";
|
||||
@@ -55,11 +64,18 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal, modalContentType, entityId, isOpen } = useModal();
|
||||
const { openModal, modalContentType, entityId, isOpen, setModalState } =
|
||||
useModal();
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
cardPublicId: string;
|
||||
} | null>(null);
|
||||
|
||||
const { ref: scrollRef, onMouseDown } = useDragToScroll({
|
||||
enabled: true,
|
||||
direction: "horizontal",
|
||||
@@ -134,7 +150,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
// Redirect to 404 if board doesn't exist
|
||||
useEffect(() => {
|
||||
if (router.isReady && boardId && !isQueryLoading) {
|
||||
if (error?.data?.code === "NOT_FOUND" || (!boardData && !isQueryLoading)) {
|
||||
if (
|
||||
error?.data?.code === "NOT_FOUND" ||
|
||||
(!boardData && !isQueryLoading)
|
||||
) {
|
||||
router.replace("/404");
|
||||
}
|
||||
}
|
||||
@@ -152,7 +171,12 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
const isLoading = isInitialLoading || isQueryLoading;
|
||||
|
||||
useScrollRestore(boardId, scrollRef, router, !isLoading && (boardData?.lists.length ?? 0) > 0);
|
||||
useScrollRestore(
|
||||
boardId,
|
||||
scrollRef,
|
||||
router,
|
||||
!isLoading && (boardData?.lists.length ?? 0) > 0,
|
||||
);
|
||||
|
||||
const updateListMutation = api.list.update.useMutation({
|
||||
onMutate: async (args) => {
|
||||
@@ -267,6 +291,56 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
setSelectedPublicListId(publicBoardId);
|
||||
};
|
||||
|
||||
const handleCardContextMenuAction = (action: CardContextMenuAction) => {
|
||||
const cardPublicId = contextMenu?.cardPublicId;
|
||||
if (!cardPublicId) return;
|
||||
setContextMenu(null);
|
||||
if (action === "copyLink") {
|
||||
const path = isTemplate
|
||||
? `/templates/${boardId}/cards/${cardPublicId}`
|
||||
: `/cards/${cardPublicId}`;
|
||||
const url = `${typeof window !== "undefined" ? window.location.origin : ""}${path}`;
|
||||
void navigator.clipboard.writeText(url).then(
|
||||
() => {
|
||||
showPopup({
|
||||
header: t`Link copied`,
|
||||
icon: "success",
|
||||
message: t`Card URL copied to clipboard`,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
showPopup({
|
||||
header: t`Unable to copy link`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (action === "duplicate") {
|
||||
setModalState("CARD_CONTEXT_DUPLICATE", {
|
||||
boardPublicId: boardId ?? "",
|
||||
isTemplate: !!isTemplate,
|
||||
});
|
||||
openModal("CARD_CONTEXT_DUPLICATE", cardPublicId);
|
||||
return;
|
||||
}
|
||||
if (action === "delete") {
|
||||
openModal("DELETE_CARD", cardPublicId);
|
||||
return;
|
||||
}
|
||||
const modalType =
|
||||
action === "members"
|
||||
? "CARD_CONTEXT_MEMBERS"
|
||||
: action === "move"
|
||||
? "CARD_CONTEXT_MOVE_LIST"
|
||||
: action === "labels"
|
||||
? "CARD_CONTEXT_LABELS"
|
||||
: "CARD_CONTEXT_DUE_DATE";
|
||||
openModal(modalType, cardPublicId);
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source: _source,
|
||||
destination,
|
||||
@@ -403,6 +477,49 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
<EditYouTubeModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MEMBERS"}
|
||||
>
|
||||
<CardContextMembersModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_MOVE_LIST"}
|
||||
>
|
||||
<CardContextMoveListModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_LABELS"}
|
||||
>
|
||||
<CardContextLabelsModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUE_DATE"}
|
||||
>
|
||||
<CardContextDueDateModal />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "CARD_CONTEXT_DUPLICATE"}
|
||||
>
|
||||
<CardContextDuplicateModal
|
||||
boardPublicId={boardId ?? ""}
|
||||
isTemplate={!!isTemplate}
|
||||
/>
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_CARD"}
|
||||
>
|
||||
<DeleteCardConfirmation
|
||||
cardPublicId={entityId}
|
||||
boardPublicId={boardId ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -605,24 +722,46 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)
|
||||
e.preventDefault();
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
if (
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
) ||
|
||||
env("NEXT_PUBLIC_KAN_ENV") ===
|
||||
"cloud"
|
||||
)
|
||||
return;
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
cardPublicId: card.publicId,
|
||||
});
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={
|
||||
isTemplate
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
)
|
||||
? "pointer-events-none"
|
||||
: ""
|
||||
}`}
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
>
|
||||
<Card
|
||||
title={card.title}
|
||||
ticketNumber={
|
||||
card.cardNumber != null
|
||||
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
checklists={card.checklists ?? []}
|
||||
@@ -653,6 +792,15 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{contextMenu && (
|
||||
<CardContextMenu
|
||||
x={contextMenu.x}
|
||||
y={contextMenu.y}
|
||||
onClose={() => setContextMenu(null)}
|
||||
onAction={handleCardContextMenuAction}
|
||||
canEdit={!!canEditCard}
|
||||
/>
|
||||
)}
|
||||
{renderModalContent()}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -184,7 +184,7 @@ const SelectSource = ({
|
||||
/>
|
||||
</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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -310,7 +310,7 @@ const ImportGithub: React.FC = () => {
|
||||
<form onSubmit={handleSubmitProjects(onSubmitProjects)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
@@ -459,7 +459,7 @@ const ImportTrello: React.FC = () => {
|
||||
<form onSubmit={handleSubmitBoards(onSubmitBoards)}>
|
||||
<div className="h-[105px] overflow-auto px-5">{renderContent()}</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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Select all`}
|
||||
isChecked={!!isSelectAllEnabled}
|
||||
|
||||
@@ -148,7 +148,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
showTemplates={showTemplates}
|
||||
customTemplates={formattedTemplates ?? []}
|
||||
/>
|
||||
<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 className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isTemplate && (
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
|
||||
@@ -525,7 +525,6 @@ const ActivityList = ({
|
||||
comment={activity.comment?.comment}
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
isViewOnly={!!isViewOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useRef, useState } from "react";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiCheckBadge } from "react-icons/hi2";
|
||||
@@ -7,7 +8,6 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { env } from "next-runtime-env";
|
||||
import { api } from "~/utils/api";
|
||||
import { invalidateCard } from "~/utils/cardInvalidation";
|
||||
|
||||
@@ -30,7 +30,7 @@ export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
"x-original-filename": file.name,
|
||||
"x-original-filename": encodeURIComponent(file.name),
|
||||
},
|
||||
body: file,
|
||||
},
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiHashtag,
|
||||
HiLink,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
@@ -12,24 +14,52 @@ import Dropdown from "~/components/Dropdown";
|
||||
import { usePermissions } from "~/hooks/usePermissions";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function CardDropdown({
|
||||
cardPublicId,
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
cardCreatedBy,
|
||||
ticketNumber,
|
||||
listPublicId,
|
||||
cardIndex,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
isTemplate?: boolean;
|
||||
boardPublicId?: string;
|
||||
cardCreatedBy?: string | null;
|
||||
ticketNumber?: string | null;
|
||||
listPublicId?: string;
|
||||
cardIndex?: number;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { canEditCard, canDeleteCard } = usePermissions();
|
||||
const { data: session } = authClient.useSession();
|
||||
const utils = api.useUtils();
|
||||
const isCreator = cardCreatedBy && session?.user.id === cardCreatedBy;
|
||||
|
||||
const duplicateCard = api.card.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
showPopup({
|
||||
header: t`Card duplicated`,
|
||||
icon: "success",
|
||||
message: t`Card duplicated successfully.`,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to duplicate card`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopyCardLink = async () => {
|
||||
const path =
|
||||
isTemplate && boardPublicId
|
||||
@@ -53,12 +83,40 @@ export default function CardDropdown({
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyTicketId = async () => {
|
||||
if (!ticketNumber) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(ticketNumber);
|
||||
showPopup({
|
||||
header: t`ID copied`,
|
||||
icon: "success",
|
||||
message: t`Ticket ID copied to clipboard`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showPopup({
|
||||
header: t`Unable to copy ID`,
|
||||
icon: "error",
|
||||
message: t`Please try again.`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
label: t`Copy card link`,
|
||||
action: handleCopyCardLink,
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
...(ticketNumber
|
||||
? [
|
||||
{
|
||||
label: t`Copy ticket ID`,
|
||||
action: handleCopyTicketId,
|
||||
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canEditCard
|
||||
? [
|
||||
{
|
||||
@@ -68,6 +126,24 @@ export default function CardDropdown({
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Duplicate card`,
|
||||
action: () => {
|
||||
if (!listPublicId || cardIndex === undefined) return;
|
||||
duplicateCard.mutate({
|
||||
cardPublicId,
|
||||
listPublicId,
|
||||
index: cardIndex + 1,
|
||||
copyLabels: true,
|
||||
copyMembers: true,
|
||||
copyChecklists: true,
|
||||
});
|
||||
},
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
disabled: duplicateCard.isPending || !listPublicId,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canDeleteCard || isCreator
|
||||
|
||||
@@ -78,6 +78,7 @@ export default function LabelSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ export default function ListSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ export default function MemberSelector({
|
||||
},
|
||||
onSettled: async () => {
|
||||
await invalidateCard(utils, cardPublicId);
|
||||
await utils.board.byId.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -185,7 +185,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
? router.query.cardId[0]
|
||||
: router.query.cardId;
|
||||
|
||||
const { data: card, isLoading, error } = api.card.byId.useQuery(
|
||||
const {
|
||||
data: card,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.card.byId.useQuery(
|
||||
{ cardPublicId: cardId ?? "" },
|
||||
{ enabled: !!cardId && cardId.length >= 12 },
|
||||
);
|
||||
@@ -340,6 +344,15 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
{card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix && (
|
||||
<>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
|
||||
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown
|
||||
@@ -347,6 +360,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isTemplate={isTemplate}
|
||||
boardPublicId={boardId}
|
||||
cardCreatedBy={card?.createdBy}
|
||||
ticketNumber={
|
||||
card.cardNumber != null &&
|
||||
card.list.board.workspace.cardPrefix
|
||||
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
|
||||
: null
|
||||
}
|
||||
listPublicId={card?.list.publicId}
|
||||
cardIndex={card?.index}
|
||||
/>
|
||||
<Link
|
||||
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}
|
||||
|
||||
@@ -36,6 +36,17 @@ export default function InvitePage() {
|
||||
return router.push(`/boards`);
|
||||
}
|
||||
|
||||
if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
setError(
|
||||
t`This workspace has reached its member limit. The workspace owner will need to upgrade their plan.`,
|
||||
);
|
||||
setIsProcessing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(
|
||||
error.message ||
|
||||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
||||
|
||||
@@ -13,7 +13,6 @@ import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -25,15 +24,15 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
userId,
|
||||
memberCount,
|
||||
seatLimit,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
userId: string | undefined;
|
||||
memberCount: number;
|
||||
seatLimit: number | null;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
@@ -98,6 +97,15 @@ export function InviteMemberForm({
|
||||
message: t`User is already a member of this workspace`,
|
||||
icon: "error",
|
||||
});
|
||||
} else if (
|
||||
error.data?.code === "FORBIDDEN" &&
|
||||
error.message === "SEAT_LIMIT_REACHED"
|
||||
) {
|
||||
showPopup({
|
||||
header: t`Seat limit reached`,
|
||||
message: t`You've reached your ${seatLimit ?? 0}-seat limit. Please upgrade your plan to add more members.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error inviting member`,
|
||||
@@ -153,6 +161,9 @@ export function InviteMemberForm({
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
const isPartnerTier = !!(
|
||||
teamSubscription?.partnerTier ?? proSubscription?.partnerTier
|
||||
);
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
@@ -175,13 +186,13 @@ export function InviteMemberForm({
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const isFreePlan =
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription;
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
)
|
||||
return handleUpgrade();
|
||||
if (isFreePlan) return;
|
||||
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
@@ -219,31 +230,6 @@ export function InviteMemberForm({
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
referenceId: workspace.publicId,
|
||||
metadata: { userId },
|
||||
seats: numberOfMembers,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const emailElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#email");
|
||||
@@ -270,11 +256,7 @@ export function InviteMemberForm({
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
disabled={isFreePlan}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -285,78 +267,92 @@ export function InviteMemberForm({
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
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={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
{!isFreePlan &&
|
||||
(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
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={copyToClipboard}
|
||||
>
|
||||
{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`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</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`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
(isPartnerTier && seatLimit !== null ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{unlimitedSeats
|
||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
<span className="text-light-900 dark:text-dark-900">
|
||||
{memberCount} / {seatLimit} {t`seats`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : !unlimitedSeats ? (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team or Pro plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null)}
|
||||
</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">
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end space-x-4 border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isFreePlan && (
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Start 14 day free trial`}
|
||||
{isFreePlan ? (
|
||||
<Button
|
||||
type="button"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
|
||||
@@ -11,7 +11,11 @@ import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
import {
|
||||
getSeatLimit,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -27,8 +31,8 @@ import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
import { EditMemberPermissionsModal } from "./components/EditMemberPermissionsModal";
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
@@ -76,6 +80,23 @@ export default function MembersPage() {
|
||||
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
const isProPlan =
|
||||
!!proSubscription ||
|
||||
workspace.plan === "pro" ||
|
||||
workspace.plan === "enterprise";
|
||||
const isTeamPlan = !!teamSubscription || workspace.plan === "team";
|
||||
const isPaidPlan = isProPlan || isTeamPlan;
|
||||
|
||||
const activeMembers = data?.members.length ?? 0;
|
||||
const seatLimit = getSeatLimit(subscriptions);
|
||||
const memberCount =
|
||||
data?.members.filter((m) => m.status === "active" || m.status === "invited")
|
||||
.length ?? 0;
|
||||
const totalSeats =
|
||||
teamSubscription?.seats ??
|
||||
proSubscription?.seats ??
|
||||
(isPaidPlan ? null : 1);
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
@@ -144,8 +165,8 @@ export default function MembersPage() {
|
||||
{memberName}
|
||||
</p>
|
||||
</div>
|
||||
{((workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true) ||
|
||||
{(workspace.role === "admin" ||
|
||||
data?.showEmailsToMembers === true ||
|
||||
showSkeleton) && (
|
||||
<p
|
||||
className={twMerge(
|
||||
@@ -180,8 +201,7 @@ export default function MembersPage() {
|
||||
<div className="relative inline-flex items-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400 ring-1 ring-inset ring-emerald-500/20 sm:text-[11px]">
|
||||
{memberRole &&
|
||||
memberRole.charAt(0).toUpperCase() +
|
||||
memberRole.slice(1)}
|
||||
memberRole.charAt(0).toUpperCase() + memberRole.slice(1)}
|
||||
{canEditMember && session?.user.id !== memberId && (
|
||||
<HiChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
@@ -264,38 +284,42 @@ export default function MembersPage() {
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !!data && (
|
||||
<>
|
||||
{!proSubscription && !teamSubscription && (
|
||||
{!isPaidPlan && (
|
||||
<Link
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
href={`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/members")}`}
|
||||
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
|
||||
>
|
||||
<HiBolt />
|
||||
<span className="ml-1 font-medium">
|
||||
{t`Launch offer: Get unlimited members with Pro`}
|
||||
</span>
|
||||
<span className="ml-1 font-medium">{t`Upgrade`}</span>
|
||||
</Link>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
teamSubscription || proSubscription
|
||||
isPaidPlan
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{proSubscription
|
||||
{isProPlan
|
||||
? t`Pro Plan`
|
||||
: teamSubscription
|
||||
: isTeamPlan
|
||||
? t`Team Plan`
|
||||
: t`Free Plan`}
|
||||
{proSubscription && unlimitedSeats && (
|
||||
<span className="ml-1 text-xs">∞</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{isPaidPlan && (unlimitedSeats || totalSeats !== null) && (
|
||||
<div className="flex items-center rounded-full border border-light-300 bg-light-50 px-3 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<span className="font-medium">
|
||||
{unlimitedSeats
|
||||
? t`Unlimited seats`
|
||||
: `${activeMembers}/${totalSeats} ${t`seats`}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
@@ -384,10 +408,10 @@ export default function MembersPage() {
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm
|
||||
userId={session?.user.id}
|
||||
numberOfMembers={data?.members.length ?? 1}
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
memberCount={memberCount}
|
||||
seatLimit={seatLimit}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
|
||||
320
apps/web/src/views/onboarding/select-plan/index.tsx
Normal file
320
apps/web/src/views/onboarding/select-plan/index.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { HiUser } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type PlanId = "solo" | "team" | "pro";
|
||||
type Billing = "monthly" | "annual";
|
||||
|
||||
// Each user orbits via a zero-size pivot div at center (144,144) that rotates.
|
||||
// The icon is offset from the pivot by `radius` pixels along the x-axis.
|
||||
// A counter-rotation on the icon keeps it upright.
|
||||
const ORBIT_USERS: Record<
|
||||
PlanId,
|
||||
{ id: string; angle: number; radius: number; duration: number }[]
|
||||
> = {
|
||||
solo: [],
|
||||
team: [
|
||||
{ id: "t1", angle: 45, radius: 104, duration: 50 },
|
||||
{ id: "t2", angle: 170, radius: 104, duration: 50 },
|
||||
{ id: "t3", angle: 290, radius: 104, duration: 50 },
|
||||
],
|
||||
pro: [
|
||||
{ id: "t1", angle: 45, radius: 104, duration: 50 },
|
||||
{ id: "t2", angle: 170, radius: 104, duration: 50 },
|
||||
{ id: "t3", angle: 290, radius: 104, duration: 50 },
|
||||
{ id: "p1", angle: 10, radius: 144, duration: 70 },
|
||||
{ id: "p2", angle: 120, radius: 144, duration: 70 },
|
||||
{ id: "p4", angle: 240, radius: 144, duration: 70 },
|
||||
{ id: "p3", angle: 230, radius: 64, duration: 30 },
|
||||
],
|
||||
};
|
||||
|
||||
export default function SelectPlanView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [selected, setSelected] = useState<PlanId>(
|
||||
(searchParams.get("plan") as PlanId | null) ?? "solo",
|
||||
);
|
||||
const [billing, setBilling] = useState<Billing>(
|
||||
(searchParams.get("billing") as Billing | null) ?? "annual",
|
||||
);
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const workspacePublicId = searchParams.get("workspacePublicId");
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
|
||||
const userImage = user?.image ?? session?.user.image ?? null;
|
||||
|
||||
const hasExistingWorkspace = !!workspaces?.length;
|
||||
|
||||
const FREQUENCIES: { value: Billing; label: string }[] = [
|
||||
{ value: "monthly", label: t`Monthly` },
|
||||
{ value: "annual", label: t`Annual` },
|
||||
];
|
||||
|
||||
const PLANS: {
|
||||
id: PlanId;
|
||||
name: string;
|
||||
monthly: string;
|
||||
annual: string;
|
||||
description: string;
|
||||
trial?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
id: "solo",
|
||||
name: t`Solo`,
|
||||
monthly: t`Free`,
|
||||
annual: t`Free`,
|
||||
description: t`Good for individuals starting out who just need the essentials.`,
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: t`Team`,
|
||||
monthly: "$10/user/mo",
|
||||
annual: "$8/user/mo",
|
||||
description: t`Best for small teams who want to collaborate and move faster together.`,
|
||||
trial: true,
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: t`Pro`,
|
||||
monthly: "$29/mo",
|
||||
annual: "$23/mo",
|
||||
description: t`Unlimited members and a custom workspace username for teams ready to scale.`,
|
||||
trial: true,
|
||||
},
|
||||
];
|
||||
|
||||
const buildSelectPlanUrl = (plan: PlanId, b: Billing) => {
|
||||
const base = `/onboarding/select-plan?plan=${plan}&billing=${b}&returnUrl=${encodeURIComponent(returnUrl)}`;
|
||||
return workspacePublicId
|
||||
? `${base}&workspacePublicId=${workspacePublicId}`
|
||||
: base;
|
||||
};
|
||||
|
||||
const handleSelectPlan = (plan: PlanId) => {
|
||||
setSelected(plan);
|
||||
router.replace(buildSelectPlanUrl(plan, billing));
|
||||
};
|
||||
|
||||
const handleSetBilling = (b: Billing) => {
|
||||
setBilling(b);
|
||||
router.replace(buildSelectPlanUrl(selected, b));
|
||||
};
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (workspacePublicId && selected !== "solo") {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
plan: selected,
|
||||
billing,
|
||||
workspacePublicId,
|
||||
successUrl: returnUrl,
|
||||
cancelUrl: returnUrl,
|
||||
}),
|
||||
});
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
if (url) window.location.href = url;
|
||||
return;
|
||||
}
|
||||
router.push(
|
||||
`/onboarding/workspace?plan=${selected}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
);
|
||||
};
|
||||
|
||||
const handleCancel = () => router.push(returnUrl);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
|
||||
<div className="flex flex-col md:h-[520px] md:flex-row">
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
|
||||
{t`Choose a plan`}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`Pick a plan to get started. All paid plans include a 14-day free trial.`}
|
||||
</p>
|
||||
|
||||
{/* Billing toggle */}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<RadioGroup
|
||||
value={billing}
|
||||
onChange={handleSetBilling}
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
>
|
||||
{FREQUENCIES.map((f) => (
|
||||
<Radio
|
||||
key={f.value}
|
||||
value={f.value}
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-0.5 text-xs transition-colors",
|
||||
billing === f.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-200 dark:text-dark-900 dark:hover:bg-dark-200",
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{PLANS.map((plan) => {
|
||||
const badge =
|
||||
billing === "annual" ? plan.annual : plan.monthly;
|
||||
return (
|
||||
<button
|
||||
key={plan.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectPlan(plan.id)}
|
||||
className={`relative w-full rounded-lg border px-4 py-3 text-left transition-colors ${
|
||||
selected === plan.id
|
||||
? "border-light-700 bg-light-300 dark:border-dark-600 dark:bg-dark-200"
|
||||
: "border-light-500 bg-light-200 hover:border-light-600 dark:border-dark-500 dark:bg-dark-100 dark:hover:border-dark-600"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{plan.name}
|
||||
</span>
|
||||
<span className="rounded-full bg-neutral-700 px-2 py-px text-[11px] font-medium text-neutral-200">
|
||||
{badge}
|
||||
</span>
|
||||
{plan.trial && billing === "annual" && (
|
||||
<span className="rounded-full bg-emerald-500/10 px-2 py-px text-[11px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400">
|
||||
-20%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-light-800 dark:text-dark-800">
|
||||
{plan.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
<div
|
||||
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
|
||||
selected === plan.id
|
||||
? "border-light-900 bg-light-900 dark:border-dark-900 dark:bg-dark-900"
|
||||
: "border-light-700 dark:border-dark-700"
|
||||
}`}
|
||||
>
|
||||
{selected === plan.id && (
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-light-100 dark:bg-dark-100" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-2 md:mt-8">
|
||||
{hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={handleCancel}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => void handleContinue()}>
|
||||
{workspacePublicId && selected !== "solo"
|
||||
? t`Upgrade`
|
||||
: t`Continue`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel */}
|
||||
<div className="hidden items-center justify-center bg-light-200 dark:bg-dark-200 md:flex md:w-[45%]">
|
||||
<div className="relative h-72 w-72">
|
||||
{/* Orbit rings */}
|
||||
<div className="absolute inset-0 m-auto h-72 w-72 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
<div className="absolute inset-0 m-auto h-52 w-52 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
<div className="absolute inset-0 m-auto h-32 w-32 rounded-full border border-light-400 dark:border-dark-400" />
|
||||
|
||||
{/* Centre person */}
|
||||
<div className="absolute inset-0 m-auto flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-light-300 dark:bg-dark-300">
|
||||
{userImage ? (
|
||||
<img
|
||||
src={userImage}
|
||||
alt={t`Your avatar`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<HiUser className="h-7 w-7 text-light-700 dark:text-dark-700" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Orbiting users — zero-size pivot at center rotates; icon offset by radius stays on the ring */}
|
||||
<AnimatePresence>
|
||||
{ORBIT_USERS[selected].map((u) => (
|
||||
<motion.div
|
||||
key={u.id}
|
||||
style={{ position: "absolute", left: 144, top: 144 }}
|
||||
initial={{ opacity: 0, rotate: u.angle }}
|
||||
animate={{ opacity: 1, rotate: u.angle + 360 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{
|
||||
opacity: { duration: 0.3 },
|
||||
rotate: {
|
||||
duration: u.duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
repeatType: "loop",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* Counter-rotate icon so it stays upright */}
|
||||
<motion.div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: u.radius - 12,
|
||||
top: -12,
|
||||
}}
|
||||
initial={{ rotate: -u.angle }}
|
||||
animate={{ rotate: -(u.angle + 360) }}
|
||||
transition={{
|
||||
duration: u.duration,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
repeatType: "loop",
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full bg-light-400 dark:bg-dark-400"
|
||||
>
|
||||
<HiUser className="h-3.5 w-3.5 text-light-800 dark:text-dark-800" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={() => authClient.signOut()}>
|
||||
{t`Sign out`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
391
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal file
391
apps/web/src/views/onboarding/workspace-details/index.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiArrowLeft,
|
||||
HiArrowPath,
|
||||
HiArrowRight,
|
||||
HiCheck,
|
||||
HiEllipsisVertical,
|
||||
HiInformationCircle,
|
||||
HiLockClosed,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { Tooltip } from "~/components/Tooltip";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60);
|
||||
}
|
||||
|
||||
export default function WorkspaceNameView() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const plan = searchParams.get("plan") ?? "solo";
|
||||
const billing = searchParams.get("billing") ?? "annual";
|
||||
const returnUrl = searchParams.get("returnUrl") ?? "/boards";
|
||||
const licenseKeyParam = searchParams.get("license_key");
|
||||
const isLicenseFlow =
|
||||
!!licenseKeyParam || searchParams.get("partner") === "1";
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
useEffect(() => {
|
||||
if (licenseKeyParam) {
|
||||
localStorage.setItem("partnerLicenseKey", licenseKeyParam);
|
||||
}
|
||||
}, [licenseKeyParam]);
|
||||
|
||||
const [isProToggle, setIsProToggle] = useState(plan === "pro");
|
||||
const effectivePlan = isProToggle ? "pro" : plan;
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugManuallyEdited, setSlugManuallyEdited] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
const handleNameChange = (value: string) => {
|
||||
setName(value);
|
||||
if (isProToggle && !slugManuallyEdited) {
|
||||
setSlug(slugify(value));
|
||||
}
|
||||
};
|
||||
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
const slugAvailability = api.workspace.checkSlugAvailability.useQuery(
|
||||
{ workspaceSlug: debouncedSlug },
|
||||
{ enabled: isProToggle && debouncedSlug.length >= 3 && !isTyping },
|
||||
);
|
||||
|
||||
const isSlugAvailable =
|
||||
slugAvailability.data?.isAvailable && !slugAvailability.data?.isReserved;
|
||||
const isSlugTaken =
|
||||
slugAvailability.data?.isAvailable === false &&
|
||||
!slugAvailability.data?.isReserved;
|
||||
const isSlugReserved = slugAvailability.data?.isReserved === true;
|
||||
|
||||
const slugError = isSlugTaken
|
||||
? t`This URL has already been taken`
|
||||
: isSlugReserved
|
||||
? t`This URL is reserved`
|
||||
: undefined;
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: user } = api.user.getUser.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
});
|
||||
const { data: workspaces } = api.workspace.all.useQuery();
|
||||
const hasExistingWorkspace = !!workspaces?.length;
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const previewSlug = isProToggle
|
||||
? slugify(slug) || slugify(name) || "your-workspace"
|
||||
: "your-workspace";
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: async (workspace) => {
|
||||
if (!workspace.publicId) return;
|
||||
localStorage.setItem("workspacePublicId", workspace.publicId);
|
||||
void utils.workspace.all.invalidate();
|
||||
void utils.workspace.hasAvailablePartnerSlot.invalidate();
|
||||
const storedLicenseKey = localStorage.getItem("partnerLicenseKey");
|
||||
if (storedLicenseKey) {
|
||||
localStorage.removeItem("partnerLicenseKey");
|
||||
router.push(
|
||||
`/api/partner/link?license_key=${encodeURIComponent(storedLicenseKey)}`,
|
||||
);
|
||||
} else {
|
||||
router.push("/boards");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to create workspace`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const [isRedirectingToCheckout, setIsRedirectingToCheckout] = useState(false);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!name.trim()) return;
|
||||
|
||||
if (effectivePlan === "solo" || isLicenseFlow) {
|
||||
createWorkspace.mutate({
|
||||
name: name.trim(),
|
||||
...(description.trim() && { description: description.trim() }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// team/pro: redirect to Stripe — workspace created on checkout_success
|
||||
setIsRedirectingToCheckout(true);
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
workspaceName: name.trim(),
|
||||
...(description.trim() && {
|
||||
workspaceDescription: description.trim(),
|
||||
}),
|
||||
...(effectivePlan === "pro" && slug ? { workspaceSlug: slug } : {}),
|
||||
cancelUrl: `${window.location.pathname}?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
successUrl: "/boards",
|
||||
billing,
|
||||
plan: effectivePlan,
|
||||
}),
|
||||
});
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
setIsRedirectingToCheckout(false);
|
||||
showPopup({
|
||||
header: t`Unable to start checkout`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.getElementById("workspace-name-input")?.focus();
|
||||
}, []);
|
||||
|
||||
const displayName = user?.name ?? session?.user.name ?? "";
|
||||
|
||||
const BOARDS = [t`Roadmap`, t`Engineering`, t`Marketing`];
|
||||
const [visibleCount, setVisibleCount] = useState(1);
|
||||
|
||||
useEffect(() => {
|
||||
const timers = BOARDS.slice(1).map((_, i) =>
|
||||
setTimeout(() => setVisibleCount(i + 2), (i + 1) * 2000),
|
||||
);
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-light-100 px-4 py-8 dark:bg-dark-50 md:px-6">
|
||||
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-light-400 bg-light-200 shadow-xl dark:border-dark-400 dark:bg-dark-100">
|
||||
<div className="flex flex-col md:h-[520px] md:flex-row">
|
||||
{/* Left panel */}
|
||||
<div className="flex flex-col p-6 md:w-[55%] md:p-8">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-light-1000 dark:text-dark-1000">
|
||||
{t`Set up your workspace`}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-light-800 dark:text-dark-800">
|
||||
{t`You can always change these later in settings.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
<Input
|
||||
id="workspace-name-input"
|
||||
placeholder={t`Workspace name`}
|
||||
value={name}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleContinue()}
|
||||
maxLength={64}
|
||||
/>
|
||||
|
||||
{!isLicenseFlow && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder={t`your-workspace`}
|
||||
value={isProToggle ? slug : t`your-workspace`}
|
||||
onChange={(e) => {
|
||||
setSlugManuallyEdited(true);
|
||||
setSlug(
|
||||
e.target.value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 60),
|
||||
);
|
||||
}}
|
||||
disabled={!isProToggle}
|
||||
prefix="kan.bn/"
|
||||
className={
|
||||
!isProToggle ? "cursor-not-allowed opacity-50" : ""
|
||||
}
|
||||
errorMessage={slugError}
|
||||
iconRight={
|
||||
!isProToggle ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<span className="text-xs">{t`Custom usernames require upgrading to a Pro plan`}</span>
|
||||
}
|
||||
placement="top"
|
||||
delay={0}
|
||||
>
|
||||
<HiInformationCircle className="h-4 w-4 leading-[0] text-dark-700 dark:text-dark-700" />
|
||||
</Tooltip>
|
||||
) : isProToggle && slug.length >= 3 ? (
|
||||
isTyping || slugAvailability.isPending ? (
|
||||
<LoadingSpinner />
|
||||
) : isSlugAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-white" />
|
||||
) : null
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLicenseFlow && plan !== "pro" && (
|
||||
<div className="pb-2">
|
||||
<Toggle
|
||||
isChecked={isProToggle}
|
||||
onChange={() => setIsProToggle((v) => !v)}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
labelPosition="after"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t`Workspace description`}
|
||||
maxLength={280}
|
||||
rows={3}
|
||||
className="block w-full resize-none rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6"
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] text-light-700 dark:text-dark-700">
|
||||
{description.length}/280
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="ml-auto flex gap-2">
|
||||
{!isLicenseFlow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.replace(
|
||||
`/onboarding/select-plan?plan=${effectivePlan}&billing=${billing}&returnUrl=${encodeURIComponent(returnUrl)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Back`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => void handleContinue()}
|
||||
disabled={
|
||||
!name.trim() ||
|
||||
createWorkspace.isPending ||
|
||||
isRedirectingToCheckout ||
|
||||
(isProToggle &&
|
||||
slug.length >= 3 &&
|
||||
(isTyping || slugAvailability.isPending || !!slugError))
|
||||
}
|
||||
isLoading={
|
||||
createWorkspace.isPending || isRedirectingToCheckout
|
||||
}
|
||||
>
|
||||
{t`Continue`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right panel — browser mockup */}
|
||||
<div className="hidden flex-col bg-light-300 dark:bg-dark-200 md:flex md:w-[45%]">
|
||||
{/* Browser chrome */}
|
||||
<div className="flex items-center gap-2 border-b border-light-400 px-3 py-2.5 dark:border-dark-400">
|
||||
<div className="flex items-center gap-1.5 text-light-700 dark:text-dark-700">
|
||||
<HiArrowLeft className="h-3.5 w-3.5" />
|
||||
<HiArrowRight className="h-3.5 w-3.5" />
|
||||
<HiArrowPath className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<div className="flex flex-1 items-center gap-1.5 rounded-md bg-light-200 px-2.5 py-1 dark:bg-dark-300">
|
||||
<HiLockClosed className="h-3 w-3 flex-shrink-0 text-light-700 dark:text-dark-700" />
|
||||
<span className="truncate text-xs text-light-900 dark:text-dark-900">
|
||||
kan.bn/
|
||||
<span className="text-light-1000 dark:text-dark-1000">
|
||||
{previewSlug}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<HiEllipsisVertical className="h-4 w-4 flex-shrink-0 text-light-700 dark:text-dark-700" />
|
||||
</div>
|
||||
|
||||
{/* Browser content */}
|
||||
<div className="flex flex-1 flex-col items-center overflow-hidden px-14 py-8">
|
||||
{/* Workspace name + description */}
|
||||
<p className="text-center text-xs font-bold text-light-1000 dark:text-dark-1000">
|
||||
{name || t`Your workspace`}
|
||||
</p>
|
||||
<p className="mt-0.5 line-clamp-2 break-all text-center text-[10px] text-light-800 dark:text-dark-800">
|
||||
{description || t`Your workspace description`}
|
||||
</p>
|
||||
|
||||
{/* Boards area */}
|
||||
<div className="mt-3 flex w-full flex-1 flex-col rounded-lg bg-light-200 p-2 dark:bg-dark-300">
|
||||
<div className="flex flex-1 flex-col gap-1.5">
|
||||
{BOARDS.map((board, i) => (
|
||||
<motion.div
|
||||
key={board}
|
||||
initial={{ opacity: 0, y: -12 }}
|
||||
animate={
|
||||
i < visibleCount
|
||||
? { opacity: 1, y: 0 }
|
||||
: { opacity: 0, y: -12 }
|
||||
}
|
||||
transition={{
|
||||
opacity: { duration: 0.2 },
|
||||
y: { duration: 0.25, ease: "easeOut" },
|
||||
}}
|
||||
className="flex w-full flex-1 items-center justify-center rounded border border-dashed border-light-400 bg-light-50 px-3 text-[10px] font-medium text-light-900 dark:border-dark-400 dark:bg-dark-100 dark:text-dark-900"
|
||||
>
|
||||
{board}
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-2 text-[10px] font-semibold text-light-900 dark:text-dark-900">
|
||||
kan.bn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!hasExistingWorkspace && (
|
||||
<Button variant="ghost" onClick={() => authClient.signOut()}>
|
||||
{t`Sign out`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,8 +95,28 @@ export function CardModal({
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="absolute right-[2rem] top-[2rem] flex items-center gap-1">
|
||||
<div className="flex w-full items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data?.cardNumber != null &&
|
||||
data.list.board.workspace.cardPrefix && (
|
||||
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
|
||||
{data.list.board.workspace.cardPrefix}-
|
||||
{data.cardNumber}
|
||||
</span>
|
||||
)}
|
||||
<h1 className="font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyCardLink}
|
||||
@@ -134,17 +154,6 @@ export function CardModal({
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -85,17 +85,19 @@ export default function AccountSettings() {
|
||||
{isCredentialsEnabled && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Change Password`}
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You are about to change your password.`}
|
||||
{data?.hasPassword
|
||||
? t`You are about to change your password.`
|
||||
: t`Set a password to enable password-based login.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{t`Change Password`}
|
||||
{data?.hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,7 +115,7 @@ export default function AccountSettings() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation />
|
||||
<ChangePasswordFormConfirmation hasPassword={data?.hasPassword ?? false} />
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -7,24 +9,49 @@ 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";
|
||||
|
||||
export default function BillingSettings() {
|
||||
const { modalContentType, isOpen } = useModal();
|
||||
const router = useRouter();
|
||||
const isCloud = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
const subscription = workspaceData?.subscriptions.find((s) =>
|
||||
["active", "trialing", "past_due"].includes(s.status),
|
||||
);
|
||||
|
||||
const planLabel = (() => {
|
||||
if (!subscription) return t`Free (1 member)`;
|
||||
if (subscription.unlimitedSeats) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${t`unlimited members`})`;
|
||||
}
|
||||
if (subscription.seats != null) {
|
||||
const name =
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1);
|
||||
return `${name} (${subscription.seats} ${subscription.seats === 1 ? t`member` : t`members`})`;
|
||||
}
|
||||
return (
|
||||
subscription.plan.charAt(0).toUpperCase() + subscription.plan.slice(1)
|
||||
);
|
||||
})();
|
||||
|
||||
const handleOpenBillingPortal = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_billing_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
if (url) window.location.href = url;
|
||||
} catch (error) {
|
||||
console.error("Error creating billing session:", error);
|
||||
}
|
||||
@@ -34,6 +61,28 @@ export default function BillingSettings() {
|
||||
<>
|
||||
<PageHead title={t`Settings | Billing`} />
|
||||
|
||||
<div className="border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Plan`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{planLabel}
|
||||
</p>
|
||||
{!subscription && isCloud && (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/upgrade/select-plan?plan=pro&workspacePublicId=${workspace.publicId}&returnUrl=${encodeURIComponent("/settings/billing")}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Choose plan`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Billing`}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
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";
|
||||
@@ -17,43 +12,21 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceEmailVisibilityForm from "./components/UpdateWorkspaceEmailVisibilityForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import UpdateWeekStartDayForm from "./components/UpdateWeekStartDayForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function WorkspaceSettings() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { canEditWorkspace } = usePermissions();
|
||||
const router = useRouter();
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: !!workspace.publicId && workspace.publicId.length >= 12 },
|
||||
);
|
||||
|
||||
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={t`Settings | Workspace`} />
|
||||
@@ -107,19 +80,6 @@ export default function WorkspaceSettings() {
|
||||
disabled={!canEditWorkspace}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
<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] font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete workspace`}
|
||||
@@ -146,16 +106,6 @@ export default function WorkspaceSettings() {
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
</Modal>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* Global modals */}
|
||||
<Modal
|
||||
modalSize="md"
|
||||
|
||||
@@ -110,18 +110,17 @@ export default function Avatar({
|
||||
const cropYpx = (crop.y / 100) * image.naturalHeight;
|
||||
const cropWpx = (crop.width / 100) * image.naturalWidth;
|
||||
const cropHpx = (crop.height / 100) * image.naturalHeight;
|
||||
canvas.width = Math.max(1, Math.floor(cropWpx));
|
||||
canvas.height = Math.max(1, Math.floor(cropHpx));
|
||||
|
||||
// Cap output at 512x512 — avatars display at 64x64, so higher res is wasteful
|
||||
// and causes "File too large" errors on HiDPI screens
|
||||
const maxSize = 512;
|
||||
const scale = Math.min(maxSize / cropWpx, maxSize / cropHpx, 1);
|
||||
canvas.width = Math.max(1, Math.floor(cropWpx * scale));
|
||||
canvas.height = Math.max(1, Math.floor(cropHpx * scale));
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("Canvas not supported");
|
||||
|
||||
// For better quality on HiDPI screens
|
||||
const pixelRatio = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.width * pixelRatio;
|
||||
canvas.height = canvas.height * pixelRatio;
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
|
||||
ctx.drawImage(
|
||||
image,
|
||||
cropXpx,
|
||||
@@ -130,8 +129,8 @@ export default function Avatar({
|
||||
cropHpx,
|
||||
0,
|
||||
0,
|
||||
canvas.width / pixelRatio,
|
||||
canvas.height / pixelRatio,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
|
||||
const mime = selectedFile?.type ?? "image/jpeg";
|
||||
@@ -139,6 +138,7 @@ export default function Avatar({
|
||||
canvas.toBlob(
|
||||
(b) => (b ? resolve(b) : reject(new Error("toBlob failed"))),
|
||||
mime,
|
||||
0.85,
|
||||
);
|
||||
});
|
||||
return blob;
|
||||
@@ -172,7 +172,7 @@ export default function Avatar({
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": blob.type,
|
||||
"x-original-filename": fileName,
|
||||
"x-original-filename": encodeURIComponent(fileName),
|
||||
},
|
||||
body: blob,
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -14,27 +13,43 @@ import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t`Current password is required`),
|
||||
const buildSchema = (hasPassword: boolean) => {
|
||||
const base = z.object({
|
||||
currentPassword: hasPassword
|
||||
? z.string().min(1, t`Current password is required`)
|
||||
: z.string().optional(),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, t`Password must be at least 8 characters`)
|
||||
.min(1, t`New password is required`),
|
||||
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
return base
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine(
|
||||
(data) => !hasPassword || data.currentPassword !== data.newPassword,
|
||||
{
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export function ChangePasswordFormConfirmation() {
|
||||
interface FormValues {
|
||||
currentPassword?: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
hasPassword: boolean;
|
||||
}
|
||||
|
||||
export function ChangePasswordFormConfirmation({ hasPassword }: Props) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
@@ -47,15 +62,24 @@ export function ChangePasswordFormConfirmation() {
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
resolver: zodResolver(buildSchema(hasPassword)),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const setPasswordMutation = api.user.setPassword.useMutation();
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
if (!hasPassword) {
|
||||
await setPasswordMutation.mutateAsync({
|
||||
newPassword: data.newPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await authClient.changePassword({
|
||||
newPassword: data.newPassword,
|
||||
currentPassword: data.currentPassword,
|
||||
currentPassword: data.currentPassword ?? "",
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
@@ -66,12 +90,18 @@ export function ChangePasswordFormConfirmation() {
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Password Changed`,
|
||||
message: t`Your password has been changed.`,
|
||||
header: hasPassword ? t`Password Changed` : t`Password Set`,
|
||||
message: hasPassword
|
||||
? t`Your password has been changed.`
|
||||
: t`Your password has been set.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
utils.invalidate();
|
||||
if (!hasPassword) {
|
||||
sessionStorage.removeItem("set_password_prompted");
|
||||
}
|
||||
|
||||
utils.user.getUser.invalidate();
|
||||
reset();
|
||||
router.push("/");
|
||||
},
|
||||
@@ -86,7 +116,9 @@ export function ChangePasswordFormConfirmation() {
|
||||
} else {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Error Changing Password`,
|
||||
header: hasPassword
|
||||
? t`Error Changing Password`
|
||||
: t`Error Setting Password`,
|
||||
message: t`An unexpected error occurred. Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
@@ -106,27 +138,33 @@ export function ChangePasswordFormConfirmation() {
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
|
||||
<h2 className="pb-4 text-base font-medium dark:text-white">
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm text-light-900">
|
||||
{t`Enter your current password and choose a new secure password.`}
|
||||
{hasPassword
|
||||
? t`Enter your current password and choose a new secure password.`
|
||||
: t`You signed in without a password. Set a password to enable password-based login.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{hasPassword && (
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Input
|
||||
@@ -175,7 +213,7 @@ export function ChangePasswordFormConfirmation() {
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Change Password`}
|
||||
{hasPassword ? t`Change Password` : t`Set Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -13,20 +13,29 @@ interface DeleteWebhookConfirmationProps {
|
||||
export function DeleteWebhookConfirmation({
|
||||
workspacePublicId,
|
||||
}: DeleteWebhookConfirmationProps) {
|
||||
const { closeModal, entityId: webhookPublicId, entityLabel: webhookName } = useModal();
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
entityLabel: webhookName,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteWebhookMutation = api.webhook.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook deleted successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook deleted`,
|
||||
message: t`Webhook deleted successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to delete webhook`,
|
||||
message: error.message || t`Failed to delete webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -35,7 +44,7 @@ export function DeleteWebhookConfirmation({
|
||||
if (!webhookPublicId) return;
|
||||
deleteWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -44,7 +44,12 @@ export function NewWebhookModal({
|
||||
workspacePublicId,
|
||||
isEdit = false,
|
||||
}: NewWebhookModalProps) {
|
||||
const { closeModal, entityId: webhookPublicId, getModalState, clearModalState } = useModal();
|
||||
const {
|
||||
closeModal,
|
||||
entityId: webhookPublicId,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const [isTestingWebhook, setIsTestingWebhook] = useState(false);
|
||||
|
||||
@@ -101,13 +106,18 @@ export function NewWebhookModal({
|
||||
const createWebhookMutation = api.webhook.create.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook created successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook created`,
|
||||
message: t`Webhook created successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to create webhook`,
|
||||
message: error.message || t`Failed to create webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -115,13 +125,18 @@ export function NewWebhookModal({
|
||||
const updateWebhookMutation = api.webhook.update.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.webhook.list.invalidate({ workspacePublicId });
|
||||
showPopup({ message: t`Webhook updated successfully`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Webhook updated`,
|
||||
message: t`Webhook updated successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
closeModal();
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to update webhook`,
|
||||
message: error.message || t`Failed to update webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -129,19 +144,25 @@ export function NewWebhookModal({
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
showPopup({
|
||||
header: t`Test sent`,
|
||||
message: t`Test webhook sent successfully!`,
|
||||
icon: "success",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
setIsTestingWebhook(false);
|
||||
},
|
||||
@@ -151,7 +172,7 @@ export function NewWebhookModal({
|
||||
if (isEdit && webhookPublicId) {
|
||||
updateWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
name: data.name,
|
||||
url: data.url,
|
||||
secret: data.secret || undefined,
|
||||
@@ -174,11 +195,12 @@ export function NewWebhookModal({
|
||||
setIsTestingWebhook(true);
|
||||
testWebhookMutation.mutate({
|
||||
workspacePublicId,
|
||||
webhookPublicId: webhookPublicId as string,
|
||||
webhookPublicId: webhookPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
const isPending = createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
const isPending =
|
||||
createWebhookMutation.isPending || updateWebhookMutation.isPending;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
@@ -231,7 +253,11 @@ export function NewWebhookModal({
|
||||
<Input
|
||||
id="secret"
|
||||
type="password"
|
||||
placeholder={isEdit ? t`Enter new secret to update` : t`HMAC secret for signature verification`}
|
||||
placeholder={
|
||||
isEdit
|
||||
? t`Enter new secret to update`
|
||||
: t`HMAC secret for signature verification`
|
||||
}
|
||||
{...register("secret")}
|
||||
errorMessage={errors.secret?.message}
|
||||
/>
|
||||
@@ -252,7 +278,7 @@ export function NewWebhookModal({
|
||||
{webhookEvents.map((event) => (
|
||||
<label
|
||||
key={event}
|
||||
className="flex items-center space-x-2 cursor-pointer"
|
||||
className="flex cursor-pointer items-center space-x-2"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -262,11 +288,11 @@ export function NewWebhookModal({
|
||||
field.onChange([...field.value, event]);
|
||||
} else {
|
||||
field.onChange(
|
||||
field.value.filter((v) => v !== event)
|
||||
field.value.filter((v) => v !== event),
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{event}
|
||||
@@ -277,17 +303,19 @@ export function NewWebhookModal({
|
||||
)}
|
||||
/>
|
||||
{errors.events && (
|
||||
<p className="mt-1 text-xs text-red-500">{errors.events.message}</p>
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
{errors.events.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEdit && (
|
||||
<div>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<label className="flex cursor-pointer items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
{...register("active")}
|
||||
className="h-4 w-4 rounded border-light-400 text-primary-600 focus:ring-primary-500 dark:border-dark-400"
|
||||
className="text-primary-600 focus:ring-primary-500 h-4 w-4 rounded border-light-400 dark:border-dark-400"
|
||||
/>
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
{t`Active`}
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function UpdateWeekStartDayForm({
|
||||
setValue(newValue);
|
||||
updateWorkspace.mutate({
|
||||
workspacePublicId,
|
||||
weekStartDay: newValue,
|
||||
weekStartDay: newValue as 0 | 1 | 6,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
workspaceUrl: string;
|
||||
workspacePlan: "free" | "pro" | "enterprise";
|
||||
workspacePlan: "free" | "team" | "pro" | "enterprise";
|
||||
disabled?: boolean;
|
||||
}) => {
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -24,6 +24,8 @@ export function UpgradeToProConfirmation({
|
||||
body: JSON.stringify({
|
||||
...(entityId && { slug: entityId }),
|
||||
workspacePublicId: workspacePublicId,
|
||||
plan: "pro",
|
||||
billing: "monthly",
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
|
||||
@@ -171,18 +171,20 @@ export default function WebhookList({ workspacePublicId }: WebhookListProps) {
|
||||
const testWebhookMutation = api.webhook.test.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
showPopup({ message: t`Test webhook sent successfully!`, type: "success" });
|
||||
showPopup({ header: t`Test sent`, message: t`Test webhook sent successfully!`, icon: "success" });
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Test failed`,
|
||||
message: result.error || t`Webhook test failed`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
showPopup({
|
||||
header: t`Unable to test webhook`,
|
||||
message: error.message || t`Failed to test webhook`,
|
||||
type: "error",
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -39,6 +39,8 @@ services:
|
||||
|
||||
# Logging (optional - debug, info, warn, error; defaults to debug in dev, info in prod)
|
||||
- LOG_LEVEL=${LOG_LEVEL}
|
||||
- AXIOM_TOKEN=${AXIOM_TOKEN}
|
||||
- AXIOM_DATASET=${AXIOM_DATASET}
|
||||
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
@@ -97,6 +99,19 @@ services:
|
||||
- NEXT_PUBLIC_UMAMI_ID=${NEXT_PUBLIC_UMAMI_ID}
|
||||
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
|
||||
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST}
|
||||
|
||||
# Partner config (optional)
|
||||
- NEXT_PUBLIC_PARTNER_NAME=${NEXT_PUBLIC_PARTNER_NAME}
|
||||
- PARTNER_CLIENT_ID=${PARTNER_CLIENT_ID}
|
||||
- PARTNER_CLIENT_SECRET=${PARTNER_CLIENT_SECRET}
|
||||
- PARTNER_REDIRECT_URL=${PARTNER_REDIRECT_URL}
|
||||
- PARTNER_TOKEN_URL=${PARTNER_TOKEN_URL}
|
||||
- PARTNER_OAUTH_LICENSE_URL=${PARTNER_OAUTH_LICENSE_URL}
|
||||
- PARTNER_LICENSE_API_URL=${PARTNER_LICENSE_API_URL}
|
||||
- PARTNER_API_KEY=${PARTNER_API_KEY}
|
||||
- PARTNER_API_KEY_HEADER=${PARTNER_API_KEY_HEADER}
|
||||
- PARTNER_SIGNATURE_HEADER=${PARTNER_SIGNATURE_HEADER}
|
||||
- PARTNER_TIMESTAMP_HEADER=${PARTNER_TIMESTAMP_HEADER}
|
||||
depends_on:
|
||||
migrator:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -61,7 +61,7 @@ services:
|
||||
- S3_ENDPOINT=${S3_ENDPOINT}
|
||||
- S3_FORCE_PATH_STYLE=${S3_FORCE_PATH_STYLE}
|
||||
- S3_AVATAR_UPLOAD_LIMIT=${S3_AVATAR_UPLOAD_LIMIT}
|
||||
- NEXT_PUBLIC_STORAGE_URL=${NEXT_PUBLIC_STORAGE_URL}
|
||||
- NEXT_PUBLIC_STORAGE_URL
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=${NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
@@ -5,32 +5,40 @@
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
},
|
||||
"./root": {
|
||||
"types": "./dist/root.d.ts",
|
||||
"types": "./src/root.ts",
|
||||
"default": "./src/root.ts"
|
||||
},
|
||||
"./trpc": {
|
||||
"types": "./dist/trpc.d.ts",
|
||||
"types": "./src/trpc.ts",
|
||||
"default": "./src/trpc.ts"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./dist/types.d.ts",
|
||||
"types": "./src/types/router.types.ts",
|
||||
"default": "./src/types/router.types.ts"
|
||||
},
|
||||
"./openapi": {
|
||||
"types": "./dist/openapi.d.ts",
|
||||
"types": "./src/openapi.ts",
|
||||
"default": "./src/openapi.ts"
|
||||
},
|
||||
"./utils/rateLimit": {
|
||||
"types": "./dist/utils/rateLimit.d.ts",
|
||||
"types": "./src/utils/rateLimit.ts",
|
||||
"default": "./src/utils/rateLimit.ts"
|
||||
},
|
||||
"./utils/apiLogging": {
|
||||
"types": "./src/utils/apiLogging.ts",
|
||||
"default": "./src/utils/apiLogging.ts"
|
||||
},
|
||||
"./utils/permissions": {
|
||||
"types": "./dist/utils/permissions.d.ts",
|
||||
"types": "./src/utils/permissions.ts",
|
||||
"default": "./src/utils/permissions.ts"
|
||||
},
|
||||
"./utils/workspace": {
|
||||
"types": "./src/utils/workspace.ts",
|
||||
"default": "./src/utils/workspace.ts"
|
||||
}
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { attachmentConfirmResponseSchema } from "../schemas";
|
||||
import { assertPermission } from "../utils/permissions";
|
||||
import { deleteObject, generateUploadUrl } from "@kan/shared/utils";
|
||||
|
||||
@@ -110,7 +111,7 @@ export const attachmentRouter = createTRPCRouter({
|
||||
size: z.number().positive(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardAttachmentRepo.create>>>())
|
||||
.output(attachmentConfirmResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
|
||||
@@ -16,6 +16,13 @@ import {
|
||||
} from "@kan/shared/utils";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
boardListItemSchema,
|
||||
boardDetailSchema,
|
||||
boardBySlugSchema,
|
||||
boardCreateResponseSchema,
|
||||
boardUpdateResponseSchema,
|
||||
} from "../schemas";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
|
||||
export const boardRouter = createTRPCRouter({
|
||||
@@ -37,9 +44,7 @@ export const boardRouter = createTRPCRouter({
|
||||
archived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
.output(z.array(boardListItemSchema))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -106,7 +111,7 @@ export const boardRouter = createTRPCRouter({
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
.output(boardDetailSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -227,7 +232,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(60)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
members: z.array(z.string().min(12)).optional(),
|
||||
labels: z.array(z.string().min(12)).optional(),
|
||||
@@ -246,7 +251,7 @@ export const boardRouter = createTRPCRouter({
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||
.output(boardBySlugSchema.nullable())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const workspace = await workspaceRepo.getBySlugWithBoards(
|
||||
ctx.db,
|
||||
@@ -299,7 +304,7 @@ export const boardRouter = createTRPCRouter({
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
.output(boardCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -469,7 +474,7 @@ export const boardRouter = createTRPCRouter({
|
||||
isArchived: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ success: z.boolean() }).or(z.custom<Awaited<ReturnType<typeof boardRepo.update>>>()))
|
||||
.output(boardUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -657,7 +662,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.max(60)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardPublicId: z.string().min(12),
|
||||
}),
|
||||
|
||||
@@ -4,11 +4,20 @@ import { z } from "zod";
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as cardCommentRepo from "@kan/db/repository/cardComment.repo";
|
||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
import * as labelRepo from "@kan/db/repository/label.repo";
|
||||
import * as listRepo from "@kan/db/repository/list.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import {
|
||||
cardCreateResponseSchema,
|
||||
cardUpdateResponseSchema,
|
||||
cardDetailSchema,
|
||||
commentResponseSchema,
|
||||
commentDeleteResponseSchema,
|
||||
activityItemSchema,
|
||||
} from "../schemas";
|
||||
import { mergeActivities } from "../utils/activities";
|
||||
import { sendMentionEmails } from "../utils/notifications";
|
||||
import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions";
|
||||
@@ -41,7 +50,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.create>>>())
|
||||
.output(cardCreateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -69,6 +78,7 @@ export const cardRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
createdBy: userId,
|
||||
listId: list.id,
|
||||
workspaceId: list.workspaceId,
|
||||
position: input.position,
|
||||
dueDate: input.dueDate ?? null,
|
||||
});
|
||||
@@ -177,10 +187,11 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.created",
|
||||
{
|
||||
id: String(newCard.id),
|
||||
publicId: newCard.publicId,
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
dueDate: input.dueDate ?? null,
|
||||
listId: String(newCard.listId),
|
||||
listId: list.publicId,
|
||||
},
|
||||
{
|
||||
boardId: list.boardPublicId,
|
||||
@@ -214,7 +225,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.create>>>())
|
||||
.output(commentResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -287,7 +298,7 @@ export const cardRouter = createTRPCRouter({
|
||||
comment: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.update>>>())
|
||||
.output(commentResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -375,7 +386,7 @@ export const cardRouter = createTRPCRouter({
|
||||
commentPublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardCommentRepo.softDelete>>>())
|
||||
.output(commentDeleteResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -434,7 +445,7 @@ export const cardRouter = createTRPCRouter({
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return deletedComment;
|
||||
return { publicId: input.commentPublicId };
|
||||
}),
|
||||
addOrRemoveLabel: protectedProcedure
|
||||
.meta({
|
||||
@@ -638,25 +649,7 @@ export const cardRouter = createTRPCRouter({
|
||||
},
|
||||
})
|
||||
.input(z.object({ cardPublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<
|
||||
Omit<
|
||||
NonNullable<
|
||||
Awaited<ReturnType<typeof cardRepo.getWithListAndMembersByPublicId>>
|
||||
>,
|
||||
"attachments"
|
||||
> & {
|
||||
attachments: {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
s3Key: string;
|
||||
originalFilename: string | null;
|
||||
size?: number | null;
|
||||
url: string | null;
|
||||
}[];
|
||||
}
|
||||
>(),
|
||||
)
|
||||
.output(cardDetailSchema)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
@@ -762,15 +755,7 @@ export const cardRouter = createTRPCRouter({
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
activities: z.array(
|
||||
z.custom<
|
||||
NonNullable<
|
||||
Awaited<
|
||||
ReturnType<typeof cardActivityRepo.getPaginatedActivities>
|
||||
>
|
||||
>["activities"][number]
|
||||
>(),
|
||||
),
|
||||
activities: z.array(activityItemSchema),
|
||||
hasMore: z.boolean(),
|
||||
nextCursor: z.string().datetime().nullable(),
|
||||
}),
|
||||
@@ -870,7 +855,7 @@ export const cardRouter = createTRPCRouter({
|
||||
dueDate: z.date().nullable().optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof cardRepo.update>>>())
|
||||
.output(cardUpdateResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
@@ -905,9 +890,18 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
|
||||
let newListId: number | undefined;
|
||||
let newList:
|
||||
| {
|
||||
id: number;
|
||||
publicId: string;
|
||||
name: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (input.listPublicId) {
|
||||
const newList = await listRepo.getByPublicId(
|
||||
newList = await listRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
@@ -952,7 +946,7 @@ export const cardRouter = createTRPCRouter({
|
||||
);
|
||||
}
|
||||
|
||||
if (input.index !== undefined) {
|
||||
if (input.index !== undefined || newListId !== undefined) {
|
||||
result = await cardRepo.reorder(ctx.db, {
|
||||
cardId: existingCard.id,
|
||||
newIndex: input.index,
|
||||
@@ -1054,8 +1048,19 @@ export const cardRouter = createTRPCRouter({
|
||||
) {
|
||||
webhookChanges.dueDate = { from: previousDueDate, to: input.dueDate };
|
||||
}
|
||||
if (newListId && existingCard.listId !== newListId) {
|
||||
webhookChanges.listId = { from: existingCard.listId, to: newListId };
|
||||
const movedToNewList = Boolean(newListId && existingCard.listId !== newListId);
|
||||
const currentWebhookListPublicId = movedToNewList
|
||||
? input.listPublicId!
|
||||
: existingCard.list.publicId;
|
||||
const currentWebhookListName = movedToNewList
|
||||
? newList?.name ?? card.listName
|
||||
: existingCard.list.name;
|
||||
|
||||
if (movedToNewList) {
|
||||
webhookChanges.listId = {
|
||||
from: existingCard.list.publicId,
|
||||
to: input.listPublicId!,
|
||||
};
|
||||
}
|
||||
|
||||
// Fire webhooks (non-blocking)
|
||||
@@ -1063,20 +1068,19 @@ export const cardRouter = createTRPCRouter({
|
||||
ctx.db,
|
||||
card.workspaceId,
|
||||
createCardWebhookPayload(
|
||||
newListId && existingCard.listId !== newListId
|
||||
? "card.moved"
|
||||
: "card.updated",
|
||||
movedToNewList ? "card.moved" : "card.updated",
|
||||
{
|
||||
id: String(result.id),
|
||||
publicId: result.publicId,
|
||||
title: result.title,
|
||||
description: result.description,
|
||||
dueDate: result.dueDate,
|
||||
listId: String(newListId ?? existingCard.listId),
|
||||
listId: currentWebhookListPublicId,
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
listName: currentWebhookListName,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1163,15 +1167,16 @@ export const cardRouter = createTRPCRouter({
|
||||
"card.deleted",
|
||||
{
|
||||
id: String(fullCard.id),
|
||||
publicId: fullCard.publicId,
|
||||
title: fullCard.title,
|
||||
description: fullCard.description,
|
||||
dueDate: fullCard.dueDate,
|
||||
listId: String(fullCard.listId),
|
||||
listId: fullCard.list.publicId,
|
||||
},
|
||||
{
|
||||
boardId: card.boardPublicId,
|
||||
boardName: card.boardName,
|
||||
listName: card.listName,
|
||||
listName: fullCard.list.name,
|
||||
user: ctx.user
|
||||
? { id: ctx.user.id, name: ctx.user.name }
|
||||
: undefined,
|
||||
@@ -1184,4 +1189,177 @@ export const cardRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
duplicate: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Duplicate a card",
|
||||
method: "POST",
|
||||
path: "/cards/{cardPublicId}/duplicate",
|
||||
description: "Duplicates a card to a target list with optional options",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().min(12),
|
||||
listPublicId: z.string().min(12),
|
||||
index: z.number().int().min(0).optional(),
|
||||
title: z.string().min(1).max(2000).optional(),
|
||||
copyLabels: z.boolean(),
|
||||
copyMembers: z.boolean(),
|
||||
copyChecklists: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const sourceCardMeta = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceCardMeta)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertPermission(
|
||||
ctx.db,
|
||||
userId,
|
||||
sourceCardMeta.workspaceId,
|
||||
"card:create",
|
||||
);
|
||||
|
||||
const targetList = await listRepo.getWorkspaceAndListIdByListPublicId(
|
||||
ctx.db,
|
||||
input.listPublicId,
|
||||
);
|
||||
|
||||
if (!targetList)
|
||||
throw new TRPCError({
|
||||
message: `List with public ID ${input.listPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
if (targetList.workspaceId !== sourceCardMeta.workspaceId)
|
||||
throw new TRPCError({
|
||||
message: `Target list must be in the same workspace`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
const sourceCard = await cardRepo.getWithListAndMembersByPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceCard)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const newCard = await cardRepo.create(ctx.db, {
|
||||
title: input.title ?? sourceCard.title,
|
||||
description: sourceCard.description ?? "",
|
||||
createdBy: userId,
|
||||
listId: targetList.id,
|
||||
workspaceId: targetList.workspaceId,
|
||||
position: "end",
|
||||
dueDate: sourceCard.dueDate ?? null,
|
||||
});
|
||||
|
||||
if (input.index !== undefined && input.index >= 0) {
|
||||
await cardRepo.reorder(ctx.db, {
|
||||
cardId: newCard.id,
|
||||
newIndex: input.index,
|
||||
newListId: targetList.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (input.copyLabels && sourceCard.labels?.length) {
|
||||
const labelPublicIds = sourceCard.labels.map((l) => l.publicId);
|
||||
const labels = await labelRepo.getAllByPublicIds(ctx.db, labelPublicIds);
|
||||
if (labels.length) {
|
||||
const labelsInsert = labels.map((label) => ({
|
||||
cardId: newCard.id,
|
||||
labelId: label.id,
|
||||
}));
|
||||
await cardRepo.bulkCreateCardLabelRelationships(ctx.db, labelsInsert);
|
||||
const cardActivitesInsert = labels.map((cardLabel) => ({
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: newCard.id,
|
||||
labelId: cardLabel.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.copyMembers && sourceCard.members?.length) {
|
||||
const memberPublicIds = sourceCard.members.map((m) => m.publicId);
|
||||
const members = await workspaceRepo.getAllMembersByPublicIds(
|
||||
ctx.db,
|
||||
memberPublicIds,
|
||||
);
|
||||
if (members.length) {
|
||||
const membersInsert = members.map((member) => ({
|
||||
cardId: newCard.id,
|
||||
workspaceMemberId: member.id,
|
||||
}));
|
||||
await cardRepo.bulkCreateCardWorkspaceMemberRelationships(
|
||||
ctx.db,
|
||||
membersInsert,
|
||||
);
|
||||
const cardActivitesInsert = members.map((member) => ({
|
||||
type: "card.updated.member.added" as const,
|
||||
cardId: newCard.id,
|
||||
workspaceMemberId: member.id,
|
||||
createdBy: userId,
|
||||
}));
|
||||
await cardActivityRepo.bulkCreate(ctx.db, cardActivitesInsert);
|
||||
}
|
||||
}
|
||||
|
||||
if (input.copyChecklists && sourceCard.checklists?.length) {
|
||||
for (const checklist of sourceCard.checklists) {
|
||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||
cardId: newCard.id,
|
||||
name: checklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
if (!newChecklist?.id) continue;
|
||||
if (checklist.items?.length) {
|
||||
for (const item of checklist.items) {
|
||||
await checklistRepo.createItem(ctx.db, {
|
||||
checklistId: newChecklist.id,
|
||||
title: item.title,
|
||||
createdBy: userId,
|
||||
completed: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.added",
|
||||
cardId: newCard.id,
|
||||
toTitle: newChecklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { publicId: newCard.publicId };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -340,6 +340,7 @@ export const importRouter = createTRPCRouter({
|
||||
description: card.description,
|
||||
createdBy: userId,
|
||||
listId: newListId,
|
||||
workspaceId: workspace.id,
|
||||
index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
@@ -888,6 +889,7 @@ export const importRouter = createTRPCRouter({
|
||||
description: data.description,
|
||||
createdBy: userId,
|
||||
listId: data.listId,
|
||||
workspaceId: workspace.id,
|
||||
index: index,
|
||||
importId: newImportId,
|
||||
}));
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user