@@ -83,6 +110,38 @@ export function CloudLoginForm() {
{t("Continue")}
+
+
+
+ {findEmailSent ? (
+
+ {t("We've sent you an email with your associated workspaces.")}
+
+ ) : (
+
+ )}
diff --git a/apps/client/src/ee/pages/verify-email.tsx b/apps/client/src/ee/pages/verify-email.tsx
new file mode 100644
index 00000000..623c3202
--- /dev/null
+++ b/apps/client/src/ee/pages/verify-email.tsx
@@ -0,0 +1,107 @@
+import { useEffect, useState } from "react";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import { Container, Title, Text, Button, Box } from "@mantine/core";
+import classes from "../../features/auth/components/auth.module.css";
+import {
+ verifyEmail,
+ resendVerificationEmail,
+} from "@/ee/cloud/service/cloud-service.ts";
+import { notifications } from "@mantine/notifications";
+import APP_ROUTE from "@/lib/app-route.ts";
+import { useTranslation } from "react-i18next";
+
+export default function VerifyEmail() {
+ const { t } = useTranslation();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const token = searchParams.get("token");
+ const rawEmail = searchParams.get("email");
+ const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
+ const sig = searchParams.get("sig");
+ const [isResending, setIsResending] = useState(false);
+ const [resent, setResent] = useState(false);
+
+ useEffect(() => {
+ if (token) {
+ handleVerify(token);
+ }
+ }, [token]);
+
+ async function handleVerify(verifyToken: string) {
+ try {
+ await verifyEmail({ token: verifyToken });
+ navigate(APP_ROUTE.HOME);
+ } catch (err) {
+ notifications.show({
+ message: t("Verification failed. The link may have expired."),
+ color: "red",
+ });
+ navigate(APP_ROUTE.AUTH.LOGIN);
+ }
+ }
+
+ async function handleResend() {
+ if (!email || !sig) return;
+ setIsResending(true);
+
+ try {
+ await resendVerificationEmail({ email, sig });
+ setResent(true);
+ } catch {
+ notifications.show({
+ message: t("Failed to resend verification email. Please try again."),
+ color: "red",
+ });
+ }
+
+ setIsResending(false);
+ }
+
+ if (token) {
+ return (
+
+
+
+ {t("Verifying your email")}
+
+
+ {t("Please wait...")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t("Check your email")}
+
+
+ {email
+ ? t("We sent a verification link to {{email}}.", { email })
+ : t("We sent a verification link to your email.")}
+
+
+ {t("Click the link to verify your email and access your workspace.")}
+
+ {email && sig && !resent && (
+
+ )}
+ {resent && (
+
+ {t("Verification email sent. Please check your inbox.")}
+
+ )}
+
+
+ );
+}
diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx
index 261412a9..6eaf3d12 100644
--- a/apps/client/src/features/auth/components/setup-workspace-form.tsx
+++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(),
- name: z.string().min(1).max(50),
+ name: z.string().min(1, { message: "Name is required" }).max(50),
email: z
- .email()
- .min(1, { message: "email is required" }),
- password: z.string().min(8),
+ .email({ message: "Invalid email address" })
+ .min(1, { message: "Email is required" }),
+ password: z.string().min(8, { message: "Password must be at least 8 characters" }),
});
type FormValues = z.infer
;
diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts
index 6e1b4e34..411e04b4 100644
--- a/apps/client/src/features/auth/hooks/use-auth.ts
+++ b/apps/client/src/features/auth/hooks/use-auth.ts
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts";
-import { exchangeTokenRedirectUrl } from "@/ee/utils.ts";
+import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() {
const { t } = useTranslation();
@@ -52,9 +52,18 @@ export default function useAuth() {
}
} catch (err) {
setIsLoading(false);
- console.log(err);
+
+ const message = err.response?.data?.message;
+ if (isCloud() && message?.includes("verify your email")) {
+ const sig = err.response?.data?.emailSignature;
+ navigate(
+ `${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
+ );
+ return;
+ }
+
notifications.show({
- message: err.response?.data.message,
+ message,
color: "red",
});
}
@@ -92,6 +101,17 @@ export default function useAuth() {
try {
if (isCloud()) {
const res = await createWorkspace(data);
+
+ if (res?.requiresEmailVerification) {
+ const hostname = res?.workspace?.hostname;
+ if (hostname) {
+ window.location.href =
+ getHostnameUrl(hostname) +
+ `/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
+ }
+ return;
+ }
+
const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) {
diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts
index 20e437f3..20552d56 100644
--- a/apps/client/src/features/auth/services/auth-service.ts
+++ b/apps/client/src/features/auth/services/auth-service.ts
@@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise {
export async function getCollabToken(): Promise {
const req = await api.post("/auth/collab-token");
return req.data;
-}
\ No newline at end of file
+}
+
diff --git a/apps/client/src/features/comment/components/comment-actions.tsx b/apps/client/src/features/comment/components/comment-actions.tsx
index 882c6f74..c0792bc8 100644
--- a/apps/client/src/features/comment/components/comment-actions.tsx
+++ b/apps/client/src/features/comment/components/comment-actions.tsx
@@ -24,7 +24,12 @@ function CommentActions({
)}
-
-