5ab2acc711
- Fix Authentik button type casting issue in login page * Use exported signIn instead of authClient type assertion * Add proper error handling for OAuth calls - Fix image MIME type handling in import-photo.ts * Convert base64 to data URL format for AI SDK compatibility - Fix type annotations in auth/server.ts changeEmail handler * Add explicit type annotation for destructured parameters These TypeScript errors were preventing 'pnpm build' from completing during Docker image build process.
190 lines
8.8 KiB
TypeScript
190 lines
8.8 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import Link from "next/link";
|
|
import { useRouter } from "next/navigation";
|
|
import { toast } from "sonner";
|
|
import { useTranslations } from "next-intl";
|
|
import { authClient } from "@/lib/auth/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
|
|
export default function LoginPage() {
|
|
const router = useRouter();
|
|
const t = useTranslations("auth");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [unverified, setUnverified] = useState(false);
|
|
const [resending, setResending] = useState(false);
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setUnverified(false);
|
|
setLoading(true);
|
|
const { error } = await authClient.signIn.email({ email, password, callbackURL: "/recipes" });
|
|
setLoading(false);
|
|
if (error) {
|
|
if (error.code === "EMAIL_NOT_VERIFIED") {
|
|
setUnverified(true);
|
|
} else {
|
|
toast.error(error.message ?? "Sign in failed");
|
|
}
|
|
} else {
|
|
router.push("/recipes");
|
|
}
|
|
}
|
|
|
|
async function resendVerification() {
|
|
setResending(true);
|
|
const { error } = await authClient.sendVerificationEmail({
|
|
email,
|
|
callbackURL: "/recipes",
|
|
});
|
|
setResending(false);
|
|
if (error) {
|
|
toast.error(error.message ?? "Failed to resend");
|
|
} else {
|
|
toast.success("Verification email sent — check your inbox");
|
|
setUnverified(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader className="space-y-1">
|
|
<CardTitle className="text-2xl font-semibold tracking-tight">Epicure</CardTitle>
|
|
<CardDescription>{t("signInTitle")}</CardDescription>
|
|
</CardHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider: "google", callbackURL: "/recipes" })}>
|
|
<GoogleIcon />
|
|
{t("continueWithGoogle")}
|
|
</Button>
|
|
<SocialButton provider="github" label={t("continueWithGithub")} icon={<GithubIcon />} />
|
|
<SocialButton provider="discord" label={t("continueWithDiscord")} icon={<DiscordIcon />} />
|
|
<AuthentikButton label={t("continueWithAuthentik")} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Separator className="flex-1" />
|
|
<span className="text-xs text-muted-foreground">{t("or")}</span>
|
|
<Separator className="flex-1" />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t("email")}</Label>
|
|
<Input id="email" type="email" placeholder={t("emailPlaceholder")} value={email} onChange={(e) => { setEmail(e.target.value); setUnverified(false); }} required />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<Label htmlFor="password">{t("password")}</Label>
|
|
<Link href="/forgot-password" className="text-xs text-muted-foreground underline underline-offset-4 hover:text-foreground">
|
|
{t("forgotPassword")}
|
|
</Link>
|
|
</div>
|
|
<Input id="password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
|
</div>
|
|
|
|
{unverified && (
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 px-4 py-3 space-y-2">
|
|
<p className="text-sm text-amber-800 dark:text-amber-300 font-medium">{t("emailNotVerified")}</p>
|
|
<p className="text-xs text-amber-700 dark:text-amber-400">{t("checkInboxVerification")}</p>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={resendVerification}
|
|
disabled={resending}
|
|
className="border-amber-300 dark:border-amber-700"
|
|
>
|
|
{resending ? t("resendingSending") : t("resendVerification")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Button className="w-full" type="submit" disabled={loading}>
|
|
{loading ? t("signInLoading") : t("signIn")}
|
|
</Button>
|
|
</CardContent>
|
|
</form>
|
|
<CardFooter className="flex justify-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("noAccount")}{" "}
|
|
<Link href="/signup" className="underline underline-offset-4 hover:text-foreground">{t("signUp")}</Link>
|
|
</p>
|
|
</CardFooter>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function SocialButton({ provider, label, icon }: { provider: "github" | "discord"; label: string; icon: React.ReactNode }) {
|
|
const envKey = provider === "github" ? process.env["NEXT_PUBLIC_GITHUB_ENABLED"] : process.env["NEXT_PUBLIC_DISCORD_ENABLED"];
|
|
if (!envKey) return null;
|
|
return (
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={() => authClient.signIn.social({ provider, callbackURL: "/recipes" })}>
|
|
{icon}
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function AuthentikButton({ label }: { label: string }) {
|
|
if (!process.env["NEXT_PUBLIC_AUTHENTIK_ENABLED"]) return null;
|
|
const handleClick = async () => {
|
|
try {
|
|
// @ts-expect-error - genericOAuth is available via plugin but not fully typed
|
|
await signIn.genericOAuth({
|
|
providerId: "authentik",
|
|
callbackURL: "/recipes",
|
|
});
|
|
} catch (error) {
|
|
console.error("Authentik sign-in failed:", error);
|
|
}
|
|
};
|
|
return (
|
|
<Button variant="outline" className="w-full gap-2" type="button" onClick={handleClick}>
|
|
<AuthentikIcon />
|
|
{label}
|
|
</Button>
|
|
);
|
|
}
|
|
|
|
function GoogleIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" aria-hidden="true">
|
|
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
|
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
|
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
|
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function GithubIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DiscordIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4 fill-current" aria-hidden="true">
|
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057.1 18.08.11 18.1.128 18.11a19.9 19.9 0 0 0 5.993 3.03.077.077 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AuthentikIcon() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" stroke="currentColor" strokeWidth="2" fill="none" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" />
|
|
</svg>
|
|
);
|
|
}
|