move all components to components dir

This commit is contained in:
miloschwartz
2025-09-04 11:18:42 -07:00
parent 4292d3262e
commit df85f13aea
90 changed files with 2166 additions and 86 deletions

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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>

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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>
);
}

View File

@@ -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;
}>;

View File

@@ -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>
);
}

View File

@@ -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";