mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-14 08:56:39 +00:00
move all components to components dir
This commit is contained in:
@@ -1,126 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { ValidateOidcUrlCallbackResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type ValidateOidcTokenParams = {
|
||||
orgId: string;
|
||||
idpId: string;
|
||||
code: string | undefined;
|
||||
expectedState: string | undefined;
|
||||
stateCookie: string | undefined;
|
||||
idp: { name: string };
|
||||
};
|
||||
|
||||
export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
useEffect(() => {
|
||||
async function validate() {
|
||||
setLoading(true);
|
||||
|
||||
console.log(t('idpOidcTokenValidating'), {
|
||||
code: props.code,
|
||||
expectedState: props.expectedState,
|
||||
stateCookie: props.stateCookie
|
||||
});
|
||||
|
||||
if (isLicenseViolation()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||
>(`/auth/idp/${props.idpId}/oidc/validate-callback`, {
|
||||
code: props.code,
|
||||
state: props.expectedState,
|
||||
storedState: props.stateCookie
|
||||
});
|
||||
|
||||
console.log(t('idpOidcTokenResponse'), res.data);
|
||||
|
||||
const redirectUrl = res.data.data.redirectUrl;
|
||||
|
||||
if (!redirectUrl) {
|
||||
router.push("/");
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
if (redirectUrl.startsWith("http")) {
|
||||
window.location.href = res.data.data.redirectUrl; // this is validated by the parent using this component
|
||||
} else {
|
||||
router.push(res.data.data.redirectUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(formatAxiosError(e, t('idpErrorOidcTokenValidating')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
validate();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('idpConnectingTo', {name: props.idp.name})}</CardTitle>
|
||||
<CardDescription>{t('idpConnectingToDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t('idpConnectingToProcess')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t('idpConnectingToFinished')}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>
|
||||
{t('idpErrorConnectingTo', {name: props.idp.name})}
|
||||
</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { cookies } from "next/headers";
|
||||
import ValidateOidcToken from "./ValidateOidcToken";
|
||||
import ValidateOidcToken from "@app/components/ValidateOidcToken";
|
||||
import { cache } from "react";
|
||||
import { priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type DashboardLoginFormProps = {
|
||||
redirect?: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function DashboardLoginForm({
|
||||
redirect,
|
||||
idps
|
||||
}: DashboardLoginFormProps) {
|
||||
const router = useRouter();
|
||||
const { env } = useEnvContext();
|
||||
const t = useTranslations();
|
||||
|
||||
function getSubtitle() {
|
||||
return t("loginStart");
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="shadow-md w-full max-w-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<LoginForm
|
||||
redirect={redirect}
|
||||
idps={idps}
|
||||
onLogin={() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import DashboardLoginForm from "./DashboardLoginForm";
|
||||
import DashboardLoginForm from "@app/components/DashboardLoginForm";
|
||||
import { Mail } from "lucide-react";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
ResetPasswordResponse
|
||||
} from "@server/routers/auth";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
@@ -210,7 +210,7 @@ export default function ResetPasswordForm({
|
||||
} catch (verificationError) {
|
||||
console.error("Failed to send verification code:", verificationError);
|
||||
}
|
||||
|
||||
|
||||
if (redirect) {
|
||||
router.push(`/auth/verify-email?redirect=${redirect}`);
|
||||
} else {
|
||||
@@ -254,8 +254,8 @@ export default function ResetPasswordForm({
|
||||
{quickstart ? t('completeAccountSetup') : t('passwordReset')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{quickstart
|
||||
? t('completeAccountSetupDescription')
|
||||
{quickstart
|
||||
? t('completeAccountSetupDescription')
|
||||
: t('passwordResetDescription')
|
||||
}
|
||||
</CardDescription>
|
||||
@@ -282,8 +282,8 @@ export default function ResetPasswordForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupSent')
|
||||
{quickstart
|
||||
? t('accountSetupSent')
|
||||
: t('passwordResetSent')
|
||||
}
|
||||
</FormDescription>
|
||||
@@ -325,8 +325,8 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('accountSetupCode')
|
||||
{quickstart
|
||||
? t('accountSetupCode')
|
||||
: t('passwordResetCode')
|
||||
}
|
||||
</FormLabel>
|
||||
@@ -338,8 +338,8 @@ export default function ResetPasswordForm({
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
<FormDescription>
|
||||
{quickstart
|
||||
? t('accountSetupCodeDescription')
|
||||
{quickstart
|
||||
? t('accountSetupCodeDescription')
|
||||
: t('passwordResetCodeDescription')
|
||||
}
|
||||
</FormDescription>
|
||||
@@ -354,8 +354,8 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreate')
|
||||
{quickstart
|
||||
? t('passwordCreate')
|
||||
: t('passwordNew')
|
||||
}
|
||||
</FormLabel>
|
||||
@@ -375,8 +375,8 @@ export default function ResetPasswordForm({
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{quickstart
|
||||
? t('passwordCreateConfirm')
|
||||
{quickstart
|
||||
? t('passwordCreateConfirm')
|
||||
: t('passwordNewConfirm')
|
||||
}
|
||||
</FormLabel>
|
||||
@@ -490,8 +490,8 @@ export default function ResetPasswordForm({
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{quickstart
|
||||
? t('accountSetupSubmit')
|
||||
{quickstart
|
||||
? t('accountSetupSubmit')
|
||||
: t('passwordResetSubmit')
|
||||
}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import { cache } from "react";
|
||||
import ResetPasswordForm from "./ResetPasswordForm";
|
||||
import ResetPasswordForm from "@app/components/ResetPasswordForm";
|
||||
import Link from "next/link";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@app/components/ui/card";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { AuthWithAccessTokenResponse } from "@server/routers/resource";
|
||||
import { AxiosResponse } from "axios";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AccessTokenProps = {
|
||||
token: string;
|
||||
resourceId?: number;
|
||||
};
|
||||
|
||||
export default function AccessToken({
|
||||
token,
|
||||
resourceId
|
||||
}: AccessTokenProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isValid, setIsValid] = useState(false);
|
||||
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const t = useTranslations();
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let accessTokenId = "";
|
||||
let accessToken = "";
|
||||
|
||||
const parts = token.split(".");
|
||||
|
||||
if (parts.length === 2) {
|
||||
accessTokenId = parts[0];
|
||||
accessToken = parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
accessToken = parts[0];
|
||||
} else {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
async function checkSHA256() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function check() {
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<AuthWithAccessTokenResponse>
|
||||
>(`/auth/resource/${resourceId}/access-token`, {
|
||||
accessToken,
|
||||
accessTokenId
|
||||
});
|
||||
|
||||
if (res.data.data.session) {
|
||||
setIsValid(true);
|
||||
window.location.href = appendRequestToken(
|
||||
res.data.data.redirectUrl!,
|
||||
res.data.data.session
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(t('accessTokenError'), e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!accessTokenId) {
|
||||
// no access token id so check the sha256
|
||||
checkSHA256();
|
||||
} else {
|
||||
check();
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
function renderTitle() {
|
||||
if (isValid) {
|
||||
return t('accessGranted');
|
||||
} else {
|
||||
return t('accessUrlInvalid');
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent() {
|
||||
if (isValid) {
|
||||
return (
|
||||
<div>
|
||||
{t('accessGrantedDescription')}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div>
|
||||
{t('accessUrlInvalidDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return loading ? (
|
||||
<div></div>
|
||||
) : (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{renderTitle()}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>{renderContent()}</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { GenerateOidcUrlResponse } from "@server/routers/idp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
CardDescription
|
||||
} from "@app/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
type AutoLoginHandlerProps = {
|
||||
resourceId: number;
|
||||
skipToIdpId: number;
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export default function AutoLoginHandler({
|
||||
resourceId,
|
||||
skipToIdpId,
|
||||
redirectUrl
|
||||
}: AutoLoginHandlerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const api = createApiClient({ env });
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function initiateAutoLogin() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await api.post<
|
||||
AxiosResponse<GenerateOidcUrlResponse>
|
||||
>(`/auth/idp/${skipToIdpId}/oidc/generate-url`, {
|
||||
redirectUrl
|
||||
});
|
||||
|
||||
if (res.data.data.redirectUrl) {
|
||||
// Redirect to the IDP for authentication
|
||||
window.location.href = res.data.data.redirectUrl;
|
||||
} else {
|
||||
setError(t("autoLoginErrorNoRedirectUrl"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to generate OIDC URL:", e);
|
||||
setError(formatAxiosError(e, t("autoLoginErrorGeneratingUrl")));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initiateAutoLogin();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("autoLoginTitle")}</CardTitle>
|
||||
<CardDescription>{t("autoLoginDescription")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center space-y-4">
|
||||
{loading && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span>{t("autoLoginProcessing")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && (
|
||||
<div className="flex items-center space-x-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
<span>{t("autoLoginRedirecting")}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<Alert variant="destructive" className="w-full">
|
||||
<AlertCircle className="h-5 w-5" />
|
||||
<AlertDescription className="flex flex-col space-y-2">
|
||||
<span>{t("autoLoginError")}</span>
|
||||
<span className="text-xs">{error}</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export default function ResourceAccessDenied() {
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('accessDenied')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('accessDeniedDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,662 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as z from "zod";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { LockIcon, Binary, Key, User, Send, AtSign } from "lucide-react";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@app/components/ui/input-otp";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import LoginForm, { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import {
|
||||
AuthWithPasswordResponse,
|
||||
AuthWithWhitelistResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const pinSchema = z.object({
|
||||
pin: z
|
||||
.string()
|
||||
.length(6, { message: "PIN must be exactly 6 digits" })
|
||||
.regex(/^\d+$/, { message: "PIN must only contain numbers" })
|
||||
});
|
||||
|
||||
const passwordSchema = z.object({
|
||||
password: z.string().min(1, {
|
||||
message: "Password must be at least 1 character long"
|
||||
})
|
||||
});
|
||||
|
||||
const requestOtpSchema = z.object({
|
||||
email: z.string().email()
|
||||
});
|
||||
|
||||
const submitOtpSchema = z.object({
|
||||
email: z.string().email(),
|
||||
otp: z.string().min(1, {
|
||||
message: "OTP must be at least 1 character long"
|
||||
})
|
||||
});
|
||||
|
||||
type ResourceAuthPortalProps = {
|
||||
methods: {
|
||||
password: boolean;
|
||||
pincode: boolean;
|
||||
sso: boolean;
|
||||
whitelist: boolean;
|
||||
};
|
||||
resource: {
|
||||
name: string;
|
||||
id: number;
|
||||
};
|
||||
redirect: string;
|
||||
idps?: LoginFormIDP[];
|
||||
};
|
||||
|
||||
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const getNumMethods = () => {
|
||||
let colLength = 0;
|
||||
if (props.methods.pincode) colLength++;
|
||||
if (props.methods.password) colLength++;
|
||||
if (props.methods.sso) colLength++;
|
||||
if (props.methods.whitelist) colLength++;
|
||||
return colLength;
|
||||
};
|
||||
|
||||
const [numMethods, setNumMethods] = useState(getNumMethods());
|
||||
|
||||
const [passwordError, setPasswordError] = useState<string | null>(null);
|
||||
const [pincodeError, setPincodeError] = useState<string | null>(null);
|
||||
const [whitelistError, setWhitelistError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState<boolean>(false);
|
||||
const [loadingLogin, setLoadingLogin] = useState(false);
|
||||
|
||||
const [otpState, setOtpState] = useState<"idle" | "otp_sent">("idle");
|
||||
|
||||
const { env } = useEnvContext();
|
||||
|
||||
const api = createApiClient({ env });
|
||||
|
||||
const { supporterStatus } = useSupporterStatusContext();
|
||||
|
||||
function getDefaultSelectedMethod() {
|
||||
if (props.methods.sso) {
|
||||
return "sso";
|
||||
}
|
||||
|
||||
if (props.methods.password) {
|
||||
return "password";
|
||||
}
|
||||
|
||||
if (props.methods.pincode) {
|
||||
return "pin";
|
||||
}
|
||||
|
||||
if (props.methods.whitelist) {
|
||||
return "whitelist";
|
||||
}
|
||||
}
|
||||
|
||||
const [activeTab, setActiveTab] = useState(getDefaultSelectedMethod());
|
||||
|
||||
const pinForm = useForm<z.infer<typeof pinSchema>>({
|
||||
resolver: zodResolver(pinSchema),
|
||||
defaultValues: {
|
||||
pin: ""
|
||||
}
|
||||
});
|
||||
|
||||
const passwordForm = useForm<z.infer<typeof passwordSchema>>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
password: ""
|
||||
}
|
||||
});
|
||||
|
||||
const requestOtpForm = useForm<z.infer<typeof requestOtpSchema>>({
|
||||
resolver: zodResolver(requestOtpSchema),
|
||||
defaultValues: {
|
||||
email: ""
|
||||
}
|
||||
});
|
||||
|
||||
const submitOtpForm = useForm<z.infer<typeof submitOtpSchema>>({
|
||||
resolver: zodResolver(submitOtpSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
otp: ""
|
||||
}
|
||||
});
|
||||
|
||||
function appendRequestToken(url: string, token: string) {
|
||||
const fullUrl = new URL(url);
|
||||
fullUrl.searchParams.append(
|
||||
env.server.resourceSessionRequestParam,
|
||||
token
|
||||
);
|
||||
return fullUrl.toString();
|
||||
}
|
||||
|
||||
const onWhitelistSubmit = (values: any) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithWhitelistResponse>>(
|
||||
`/auth/resource/${props.resource.id}/whitelist`,
|
||||
{ email: values.email, otp: values.otp }
|
||||
)
|
||||
.then((res) => {
|
||||
setWhitelistError(null);
|
||||
|
||||
if (res.data.data.otpSent) {
|
||||
setOtpState("otp_sent");
|
||||
submitOtpForm.setValue("email", values.email);
|
||||
toast({
|
||||
title: t("otpEmailSent"),
|
||||
description: t("otpEmailSentDescription")
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setWhitelistError(
|
||||
formatAxiosError(e, t("otpEmailErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/pincode`,
|
||||
{ pincode: values.pin }
|
||||
)
|
||||
.then((res) => {
|
||||
setPincodeError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPincodeError(
|
||||
formatAxiosError(e, t("pincodeErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.then(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
const onPasswordSubmit = (values: z.infer<typeof passwordSchema>) => {
|
||||
setLoadingLogin(true);
|
||||
|
||||
api.post<AxiosResponse<AuthWithPasswordResponse>>(
|
||||
`/auth/resource/${props.resource.id}/password`,
|
||||
{
|
||||
password: values.password
|
||||
}
|
||||
)
|
||||
.then((res) => {
|
||||
setPasswordError(null);
|
||||
const session = res.data.data.session;
|
||||
if (session) {
|
||||
window.location.href = appendRequestToken(
|
||||
props.redirect,
|
||||
session
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setPasswordError(
|
||||
formatAxiosError(e, t("passwordErrorAuthenticate"))
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingLogin(false));
|
||||
};
|
||||
|
||||
async function handleSSOAuth() {
|
||||
let isAllowed = false;
|
||||
try {
|
||||
await api.get(`/resource/${props.resource.id}`);
|
||||
isAllowed = true;
|
||||
} catch (e) {
|
||||
setAccessDenied(true);
|
||||
}
|
||||
|
||||
if (isAllowed) {
|
||||
// window.location.href = props.redirect;
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function getTitle() {
|
||||
return t("authenticationRequired");
|
||||
}
|
||||
|
||||
function getSubtitle(resourceName: string) {
|
||||
return numMethods > 1
|
||||
? t("authenticationMethodChoose", { name: props.resource.name })
|
||||
: t("authenticationRequest", { name: props.resource.name });
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!accessDenied ? (
|
||||
<div>
|
||||
<div className="text-center mb-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("poweredBy")}{" "}
|
||||
<Link
|
||||
href="https://github.com/fosrl/pangolin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
Pangolin
|
||||
</Link>
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{getTitle()}</CardTitle>
|
||||
<CardDescription>
|
||||
{getSubtitle(props.resource.name)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
orientation="horizontal"
|
||||
>
|
||||
{numMethods > 1 && (
|
||||
<TabsList
|
||||
className={`grid w-full ${
|
||||
numMethods === 1
|
||||
? "grid-cols-1"
|
||||
: numMethods === 2
|
||||
? "grid-cols-2"
|
||||
: numMethods === 3
|
||||
? "grid-cols-3"
|
||||
: "grid-cols-4"
|
||||
}`}
|
||||
>
|
||||
{props.methods.pincode && (
|
||||
<TabsTrigger value="pin">
|
||||
<Binary className="w-4 h-4 mr-1" />{" "}
|
||||
PIN
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.password && (
|
||||
<TabsTrigger value="password">
|
||||
<Key className="w-4 h-4 mr-1" />{" "}
|
||||
{t("password")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsTrigger value="sso">
|
||||
<User className="w-4 h-4 mr-1" />{" "}
|
||||
{t("user")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsTrigger value="whitelist">
|
||||
<AtSign className="w-4 h-4 mr-1" />{" "}
|
||||
{t("email")}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
)}
|
||||
{props.methods.pincode && (
|
||||
<TabsContent
|
||||
value="pin"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<Form {...pinForm}>
|
||||
<form
|
||||
onSubmit={pinForm.handleSubmit(
|
||||
onPinSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={pinForm.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"pincodeInput"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={
|
||||
6
|
||||
}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={
|
||||
0
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
1
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
2
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
3
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
4
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={
|
||||
5
|
||||
}
|
||||
obscured
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{pincodeError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{pincodeError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("pincodeSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.password && (
|
||||
<TabsContent
|
||||
value="password"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<Form {...passwordForm}>
|
||||
<form
|
||||
onSubmit={passwordForm.handleSubmit(
|
||||
onPasswordSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
passwordForm.control
|
||||
}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("password")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{passwordError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{passwordError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("passwordSubmit")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.sso && (
|
||||
<TabsContent
|
||||
value="sso"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
<LoginForm
|
||||
idps={props.idps}
|
||||
redirect={props.redirect}
|
||||
onLogin={async () =>
|
||||
await handleSSOAuth()
|
||||
}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
{props.methods.whitelist && (
|
||||
<TabsContent
|
||||
value="whitelist"
|
||||
className={`${numMethods <= 1 ? "mt-0" : ""}`}
|
||||
>
|
||||
{otpState === "idle" && (
|
||||
<Form {...requestOtpForm}>
|
||||
<form
|
||||
onSubmit={requestOtpForm.handleSubmit(
|
||||
onWhitelistSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
requestOtpForm.control
|
||||
}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("email")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"otpEmailDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{whitelistError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{whitelistError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<Send className="w-4 h-4 mr-2" />
|
||||
{t("otpEmailSend")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
{otpState === "otp_sent" && (
|
||||
<Form {...submitOtpForm}>
|
||||
<form
|
||||
onSubmit={submitOtpForm.handleSubmit(
|
||||
onWhitelistSubmit
|
||||
)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={
|
||||
submitOtpForm.control
|
||||
}
|
||||
name="otp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"otpEmail"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{whitelistError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{whitelistError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
loading={loadingLogin}
|
||||
disabled={loadingLogin}
|
||||
>
|
||||
<LockIcon className="w-4 h-4 mr-2" />
|
||||
{t("otpEmailSubmit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
variant={"outline"}
|
||||
onClick={() => {
|
||||
setOtpState("idle");
|
||||
submitOtpForm.reset();
|
||||
}}
|
||||
>
|
||||
{t("backToEmail")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{supporterStatus?.visible && (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm text-muted-foreground opacity-50">
|
||||
{t("noSupportKey")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@app/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
export default async function ResourceNotFound() {
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center text-2xl font-bold">
|
||||
{t('resourceNotFound')}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{t('resourceNotFoundDescription')}
|
||||
<div className="text-center mt-4">
|
||||
<Button>
|
||||
<Link href="/">{t('goHome')}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,20 +2,20 @@ import {
|
||||
GetResourceAuthInfoResponse,
|
||||
GetExchangeTokenResponse
|
||||
} from "@server/routers/resource";
|
||||
import ResourceAuthPortal from "./ResourceAuthPortal";
|
||||
import ResourceAuthPortal from "@app/components/ResourceAuthPortal";
|
||||
import { internal, priv } from "@app/lib/api";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { cache } from "react";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { redirect } from "next/navigation";
|
||||
import ResourceNotFound from "./ResourceNotFound";
|
||||
import ResourceAccessDenied from "./ResourceAccessDenied";
|
||||
import AccessToken from "./AccessToken";
|
||||
import ResourceNotFound from "@app/components/ResourceNotFound";
|
||||
import ResourceAccessDenied from "@app/components/ResourceAccessDenied";
|
||||
import AccessToken from "@app/components/AccessToken";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
import { LoginFormIDP } from "@app/components/LoginForm";
|
||||
import { ListIdpsResponse } from "@server/routers/idp";
|
||||
import AutoLoginHandler from "./AutoLoginHandler";
|
||||
import AutoLoginHandler from "@app/components/AutoLoginHandler";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
|
||||
@@ -1,454 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { SignUpResponse } from "@server/routers/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import Image from "next/image";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
import BrandingLogo from "@app/components/BrandingLogo";
|
||||
import { build } from "@server/build";
|
||||
import { Check, X } from "lucide-react";
|
||||
import { cn } from "@app/lib/cn";
|
||||
|
||||
// Password strength calculation
|
||||
const calculatePasswordStrength = (password: string) => {
|
||||
const requirements = {
|
||||
length: password.length >= 8,
|
||||
uppercase: /[A-Z]/.test(password),
|
||||
lowercase: /[a-z]/.test(password),
|
||||
number: /[0-9]/.test(password),
|
||||
special: /[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]/.test(password)
|
||||
};
|
||||
|
||||
const score = Object.values(requirements).filter(Boolean).length;
|
||||
let strength: "weak" | "medium" | "strong" = "weak";
|
||||
let color = "bg-red-500";
|
||||
let percentage = 0;
|
||||
|
||||
if (score >= 5) {
|
||||
strength = "strong";
|
||||
color = "bg-green-500";
|
||||
percentage = 100;
|
||||
} else if (score >= 3) {
|
||||
strength = "medium";
|
||||
color = "bg-yellow-500";
|
||||
percentage = 60;
|
||||
} else if (score >= 1) {
|
||||
strength = "weak";
|
||||
color = "bg-red-500";
|
||||
percentage = 30;
|
||||
}
|
||||
|
||||
return { requirements, strength, color, percentage, score };
|
||||
};
|
||||
|
||||
type SignupFormProps = {
|
||||
redirect?: string;
|
||||
inviteId?: string;
|
||||
inviteToken?: string;
|
||||
emailParam?: string;
|
||||
};
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
email: z.string().email({ message: "Invalid email address" }),
|
||||
password: passwordSchema,
|
||||
confirmPassword: passwordSchema,
|
||||
agreeToTerms: z.boolean().refine(
|
||||
(val) => {
|
||||
if (build === "saas") {
|
||||
val === true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
"You must agree to the terms of service and privacy policy"
|
||||
}
|
||||
)
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
path: ["confirmPassword"],
|
||||
message: "Passwords do not match"
|
||||
});
|
||||
|
||||
export default function SignupForm({
|
||||
redirect,
|
||||
inviteId,
|
||||
inviteToken,
|
||||
emailParam
|
||||
}: SignupFormProps) {
|
||||
const router = useRouter();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const t = useTranslations();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [termsAgreedAt, setTermsAgreedAt] = useState<string | null>(null);
|
||||
const [passwordValue, setPasswordValue] = useState("");
|
||||
const [confirmPasswordValue, setConfirmPasswordValue] = useState("");
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: emailParam || "",
|
||||
password: "",
|
||||
confirmPassword: "",
|
||||
agreeToTerms: false
|
||||
},
|
||||
mode: "onChange" // Enable real-time validation
|
||||
});
|
||||
|
||||
const passwordStrength = calculatePasswordStrength(passwordValue);
|
||||
const doPasswordsMatch = passwordValue.length > 0 && confirmPasswordValue.length > 0 && passwordValue === confirmPasswordValue;
|
||||
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
const { email, password } = values;
|
||||
|
||||
setLoading(true);
|
||||
const res = await api
|
||||
.put<AxiosResponse<SignUpResponse>>("/auth/signup", {
|
||||
email,
|
||||
password,
|
||||
inviteId,
|
||||
inviteToken,
|
||||
termsAcceptedTimestamp: termsAgreedAt
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
setError(formatAxiosError(e, t("signupError")));
|
||||
});
|
||||
|
||||
if (res && res.status === 200) {
|
||||
setError(null);
|
||||
|
||||
if (res.data?.data?.emailVerificationRequired) {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(`/auth/verify-email?redirect=${safe}`);
|
||||
} else {
|
||||
router.push("/auth/verify-email");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getSubtitle() {
|
||||
return t("authCreateAccount");
|
||||
}
|
||||
|
||||
const handleTermsChange = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const isoNow = new Date().toISOString();
|
||||
console.log("Terms agreed at:", isoNow);
|
||||
setTermsAgreedAt(isoNow);
|
||||
form.setValue("agreeToTerms", true);
|
||||
} else {
|
||||
form.setValue("agreeToTerms", false);
|
||||
setTermsAgreedAt(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-md shadow-md">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex flex-row items-center justify-center">
|
||||
<BrandingLogo height={58} width={175} />
|
||||
</div>
|
||||
<div className="text-center space-y-1 pt-3">
|
||||
<p className="text-muted-foreground">{getSubtitle()}</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("email")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
disabled={!!emailParam}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t("password")}</FormLabel>
|
||||
{passwordStrength.strength === "strong" && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
passwordStrength.strength === "strong" && "border-green-500 focus-visible:ring-green-500",
|
||||
passwordStrength.strength === "medium" && "border-yellow-500 focus-visible:ring-yellow-500",
|
||||
passwordStrength.strength === "weak" && passwordValue.length > 0 && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
{passwordValue.length > 0 && (
|
||||
<div className="space-y-3 mt-2">
|
||||
{/* Password Strength Meter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm font-medium text-foreground">{t("passwordStrength")}</span>
|
||||
<span className={cn(
|
||||
"text-sm font-semibold",
|
||||
passwordStrength.strength === "strong" && "text-green-600 dark:text-green-400",
|
||||
passwordStrength.strength === "medium" && "text-yellow-600 dark:text-yellow-400",
|
||||
passwordStrength.strength === "weak" && "text-red-600 dark:text-red-400"
|
||||
)}>
|
||||
{t(`passwordStrength${passwordStrength.strength.charAt(0).toUpperCase() + passwordStrength.strength.slice(1)}`)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={passwordStrength.percentage}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements Checklist */}
|
||||
<div className="bg-muted rounded-lg p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-foreground mb-2">{t("passwordRequirements")}</div>
|
||||
<div className="grid grid-cols-1 gap-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.length ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.length ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLengthText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.uppercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.uppercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementUppercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.lowercase ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.lowercase ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementLowercaseText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.number ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.number ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementNumberText")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{passwordStrength.requirements.special ? (
|
||||
<Check className="h-3.5 w-3.5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<X className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
"text-sm",
|
||||
passwordStrength.requirements.special ? "text-green-600 dark:text-green-400" : "text-muted-foreground"
|
||||
)}>
|
||||
{t("passwordRequirementSpecialText")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Only show FormMessage when not showing our custom requirements */}
|
||||
{passwordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel>{t('confirmPassword')}</FormLabel>
|
||||
{doPasswordsMatch && (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="password"
|
||||
{...field}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setConfirmPasswordValue(e.target.value);
|
||||
}}
|
||||
className={cn(
|
||||
doPasswordsMatch && "border-green-500 focus-visible:ring-green-500",
|
||||
confirmPasswordValue.length > 0 && !doPasswordsMatch && "border-red-500 focus-visible:ring-red-500"
|
||||
)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
{confirmPasswordValue.length > 0 && !doPasswordsMatch && (
|
||||
<p className="text-sm text-red-600 mt-1">
|
||||
{t("passwordsDoNotMatch")}
|
||||
</p>
|
||||
)}
|
||||
{/* Only show FormMessage when field is empty */}
|
||||
{confirmPasswordValue.length === 0 && <FormMessage />}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build === "saas" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="agreeToTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
field.onChange(checked);
|
||||
handleTermsChange(
|
||||
checked as boolean
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-none">
|
||||
<FormLabel className="text-sm font-normal">
|
||||
{t("signUpTerms.IAgreeToThe")}
|
||||
<a
|
||||
href="https://digpangolin.com/terms-of-service.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.termsOfService"
|
||||
)}
|
||||
</a>
|
||||
{t("signUpTerms.and")}
|
||||
<a
|
||||
href="https://digpangolin.com/privacy-policy.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t(
|
||||
"signUpTerms.privacyPolicy"
|
||||
)}
|
||||
</a>
|
||||
</FormLabel>
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full">
|
||||
{t("createAccount")}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import SignupForm from "@app/app/auth/signup/SignupForm";
|
||||
import SignupForm from "@app/components/SignupForm";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
@@ -11,7 +11,7 @@ import { getTranslations } from "next-intl/server";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page(props: {
|
||||
searchParams: Promise<{
|
||||
searchParams: Promise<{
|
||||
redirect: string | undefined;
|
||||
email: string | undefined;
|
||||
}>;
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot
|
||||
} from "@/components/ui/input-otp";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { VerifyEmailResponse } from "@server/routers/auth";
|
||||
import { ArrowRight, IdCard, Loader2 } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "../../../components/ui/alert";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatAxiosError } from "@app/lib/api";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type VerifyEmailFormProps = {
|
||||
email: string;
|
||||
redirect?: string;
|
||||
};
|
||||
|
||||
export default function VerifyEmailForm({
|
||||
email,
|
||||
redirect
|
||||
}: VerifyEmailFormProps) {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [isResending, setIsResending] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const api = createApiClient(useEnvContext());
|
||||
|
||||
function logout() {
|
||||
api.post("/auth/logout")
|
||||
.catch((e) => {
|
||||
console.error(t("logoutError"), e);
|
||||
toast({
|
||||
title: t("logoutError"),
|
||||
description: formatAxiosError(e, t("logoutError"))
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
router.push("/auth/login");
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
const FormSchema = z.object({
|
||||
email: z.string().email({ message: t("emailInvalid") }),
|
||||
pin: z.string().min(8, {
|
||||
message: t("verificationCodeLengthRequirements")
|
||||
})
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof FormSchema>>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
defaultValues: {
|
||||
email: email,
|
||||
pin: ""
|
||||
}
|
||||
});
|
||||
|
||||
async function onSubmit(data: z.infer<typeof FormSchema>) {
|
||||
setIsSubmitting(true);
|
||||
|
||||
const res = await api
|
||||
.post<AxiosResponse<VerifyEmailResponse>>("/auth/verify-email", {
|
||||
code: data.pin
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(formatAxiosError(e, t("errorOccurred")));
|
||||
console.error(t("emailErrorVerify"), e);
|
||||
setIsSubmitting(false);
|
||||
});
|
||||
|
||||
if (res && res.data?.data?.valid) {
|
||||
setError(null);
|
||||
setSuccessMessage(t("emailVerified"));
|
||||
setTimeout(() => {
|
||||
if (redirect) {
|
||||
const safe = cleanRedirect(redirect);
|
||||
router.push(safe);
|
||||
} else {
|
||||
router.push("/");
|
||||
}
|
||||
setIsSubmitting(false);
|
||||
}, 1500);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResendCode() {
|
||||
setIsResending(true);
|
||||
|
||||
const res = await api.post("/auth/verify-email/request").catch((e) => {
|
||||
setError(formatAxiosError(e, t("errorOccurred")));
|
||||
console.error(t("verificationCodeErrorResend"), e);
|
||||
});
|
||||
|
||||
if (res) {
|
||||
setError(null);
|
||||
toast({
|
||||
variant: "default",
|
||||
title: t("verificationCodeResend"),
|
||||
description: t("verificationCodeResendDescription")
|
||||
});
|
||||
}
|
||||
|
||||
setIsResending(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle>{t("emailVerify")}</CardTitle>
|
||||
<CardDescription>
|
||||
{t("emailVerifyDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-center text-muted-foreground mb-4">
|
||||
{email}
|
||||
</p>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
id="verify-email-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pin"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<div className="flex justify-center">
|
||||
<InputOTP
|
||||
maxLength={8}
|
||||
{...field}
|
||||
>
|
||||
<InputOTPGroup className="flex">
|
||||
<InputOTPSlot
|
||||
index={0}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={1}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={2}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={3}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={4}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={5}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={6}
|
||||
/>
|
||||
<InputOTPSlot
|
||||
index={7}
|
||||
/>
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
onClick={handleResendCode}
|
||||
disabled={isResending}
|
||||
>
|
||||
{isResending
|
||||
? t("emailVerifyResendProgress")
|
||||
: t("emailVerifyResend")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{successMessage && (
|
||||
<Alert variant="success">
|
||||
<AlertDescription>
|
||||
{successMessage}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting}
|
||||
form="verify-email-form"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
{t("submit")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant={"secondary"}
|
||||
className="w-full"
|
||||
onClick={logout}
|
||||
>
|
||||
Log in with another account
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import VerifyEmailForm from "@app/app/auth/verify-email/VerifyEmailForm";
|
||||
import VerifyEmailForm from "@app/components/VerifyEmailForm";
|
||||
import { verifySession } from "@app/lib/auth/verifySession";
|
||||
import { cleanRedirect } from "@app/lib/cleanRedirect";
|
||||
import { pullEnv } from "@app/lib/pullEnv";
|
||||
|
||||
Reference in New Issue
Block a user