✨ feat: add Google and Apple OAuth via better-auth socialProviders
This commit is contained in:
@@ -1,74 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signIn, useSession } from "@/lib/auth-client";
|
||||
// Server Component — reads env vars and passes provider list to the client UI
|
||||
import { getSignInProviders } from "@/lib/get-sign-in-providers";
|
||||
import { SignInForm } from "./sign-in-form";
|
||||
|
||||
export default function SignInPage() {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const handleSignIn = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await signIn.oauth2({
|
||||
providerId: "authentik",
|
||||
callbackURL: "/",
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to sign in. Please try again.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-strong p-8 max-w-sm w-full text-center"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-6">
|
||||
<CalendarDays className="h-6 w-6 text-primary" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">Local iCal</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-6">
|
||||
Sign in to unlock AI-powered event creation
|
||||
</p>
|
||||
|
||||
<Button onClick={handleSignIn} className="w-full" disabled={isLoading}>
|
||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
{isLoading ? "Signing in..." : "Continue with Authentik"}
|
||||
</Button>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Continue without signing in
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
const providers = getSignInProviders(
|
||||
process.env as Record<string, string | undefined>,
|
||||
);
|
||||
return <SignInForm providers={providers} />;
|
||||
}
|
||||
|
||||
207
src/app/auth/signin/sign-in-form.tsx
Normal file
207
src/app/auth/signin/sign-in-form.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { motion } from "framer-motion";
|
||||
import { CalendarDays, Loader2 } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { signIn, useSession } from "@/lib/auth-client";
|
||||
import type { SignInProvider } from "@/lib/get-sign-in-providers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider icon components — inline SVGs keep the bundle lean and avoid
|
||||
// external CDN requests. Official brand colours per brand guidelines.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
>
|
||||
<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 AppleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthentikIcon({ className }: { className?: string }) {
|
||||
// Generic key/lock icon for Authentik (no official SVG logo required)
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const PROVIDER_ICONS: Record<
|
||||
string,
|
||||
React.ComponentType<{ className?: string }>
|
||||
> = {
|
||||
google: GoogleIcon,
|
||||
apple: AppleIcon,
|
||||
authentik: AuthentikIcon,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface SignInFormProps {
|
||||
providers: SignInProvider[];
|
||||
}
|
||||
|
||||
export function SignInForm({ providers }: SignInFormProps) {
|
||||
const { data: session, isPending } = useSession();
|
||||
const router = useRouter();
|
||||
const [loadingId, setLoadingId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [session, router]);
|
||||
|
||||
const handleSignIn = async (provider: SignInProvider) => {
|
||||
setLoadingId(provider.id);
|
||||
try {
|
||||
if (provider.signInMethod === "social") {
|
||||
await signIn.social({
|
||||
provider: provider.id as
|
||||
| "google"
|
||||
| "apple"
|
||||
| "github"
|
||||
| "discord"
|
||||
| "spotify"
|
||||
| "twitch"
|
||||
| "facebook"
|
||||
| "microsoft"
|
||||
| "github",
|
||||
callbackURL: "/",
|
||||
});
|
||||
} else {
|
||||
await signIn.oauth2({
|
||||
providerId: provider.id,
|
||||
callbackURL: "/",
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
toast.error(
|
||||
`Failed to sign in with ${provider.label}. Please try again.`,
|
||||
);
|
||||
} finally {
|
||||
setLoadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (isPending || session?.user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isAnyLoading = loadingId !== null;
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="glass-strong p-8 max-w-sm w-full"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<CalendarDays className="h-6 w-6 text-primary" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold tracking-tight">Local iCal</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground text-center mb-7">
|
||||
Sign in to unlock AI-powered event creation
|
||||
</p>
|
||||
|
||||
{/* Provider buttons */}
|
||||
{providers.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => {
|
||||
const Icon = PROVIDER_ICONS[provider.id];
|
||||
const isLoading = loadingId === provider.id;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={provider.id}
|
||||
variant="outline"
|
||||
className="w-full h-11 gap-3 justify-center font-medium cursor-pointer transition-colors"
|
||||
onClick={() => handleSignIn(provider)}
|
||||
disabled={isAnyLoading}
|
||||
aria-label={`Sign in with ${provider.label}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin shrink-0" />
|
||||
) : Icon ? (
|
||||
<Icon className="h-5 w-5 shrink-0" />
|
||||
) : null}
|
||||
<span>
|
||||
{isLoading
|
||||
? "Signing in…"
|
||||
: `Continue with ${provider.label}`}
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground text-center py-2">
|
||||
No sign-in providers configured.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Divider + guest link */}
|
||||
<div className="mt-6 pt-5 border-t border-border/50 text-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Continue without signing in
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/auth.ts
62
src/auth.ts
@@ -3,43 +3,57 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { genericOAuth } from "better-auth/plugins";
|
||||
import { db } from "@/db/index";
|
||||
import * as schema from "@/db/schema";
|
||||
import { buildSocialProviders } from "@/lib/build-social-providers";
|
||||
|
||||
// Validate required environment variables
|
||||
// ---------------------------------------------------------------------------
|
||||
// Required vars — the app cannot start without these
|
||||
// ---------------------------------------------------------------------------
|
||||
if (!process.env.BETTER_AUTH_SECRET) {
|
||||
throw new Error("BETTER_AUTH_SECRET is required");
|
||||
}
|
||||
if (!process.env.BETTER_AUTH_URL) {
|
||||
throw new Error("BETTER_AUTH_URL is required");
|
||||
}
|
||||
if (!process.env.AUTH_AUTHENTIK_CLIENT_ID) {
|
||||
throw new Error("AUTH_AUTHENTIK_CLIENT_ID is required");
|
||||
}
|
||||
if (!process.env.AUTH_AUTHENTIK_CLIENT_SECRET) {
|
||||
throw new Error("AUTH_AUTHENTIK_CLIENT_SECRET is required");
|
||||
}
|
||||
if (!process.env.AUTH_AUTHENTIK_ISSUER) {
|
||||
throw new Error("AUTH_AUTHENTIK_ISSUER is required");
|
||||
}
|
||||
|
||||
export const auth = betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
trustedOrigins: [process.env.BETTER_AUTH_URL],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
plugins: [
|
||||
genericOAuth({
|
||||
config: [
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentik is optional: only configured when all three vars are present.
|
||||
// Google and Apple are also optional via buildSocialProviders().
|
||||
// ---------------------------------------------------------------------------
|
||||
const authentikConfig =
|
||||
process.env.AUTH_AUTHENTIK_CLIENT_ID &&
|
||||
process.env.AUTH_AUTHENTIK_CLIENT_SECRET &&
|
||||
process.env.AUTH_AUTHENTIK_ISSUER
|
||||
? [
|
||||
{
|
||||
providerId: "authentik",
|
||||
providerId: "authentik" as const,
|
||||
clientId: process.env.AUTH_AUTHENTIK_CLIENT_ID,
|
||||
clientSecret: process.env.AUTH_AUTHENTIK_CLIENT_SECRET,
|
||||
discoveryUrl: `${process.env.AUTH_AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
|
||||
const socialProviders = buildSocialProviders(
|
||||
process.env as Record<string, string | undefined>,
|
||||
);
|
||||
|
||||
export const auth = betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET,
|
||||
baseURL: process.env.BETTER_AUTH_URL,
|
||||
trustedOrigins: [
|
||||
process.env.BETTER_AUTH_URL,
|
||||
// Required for Sign in with Apple's form_post redirect
|
||||
...(socialProviders.apple ? ["https://appleid.apple.com"] : []),
|
||||
],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema,
|
||||
}),
|
||||
socialProviders,
|
||||
plugins: [
|
||||
...(authentikConfig.length > 0
|
||||
? [genericOAuth({ config: authentikConfig })]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
|
||||
51
src/lib/build-social-providers.ts
Normal file
51
src/lib/build-social-providers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Builds the `socialProviders` config object for betterAuth().
|
||||
*
|
||||
* Only includes a provider when ALL of its required env vars are present
|
||||
* (non-empty strings). This lets the app start without Google/Apple
|
||||
* credentials and enables providers incrementally via env vars.
|
||||
*/
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export interface SocialProviderConfig {
|
||||
google?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
};
|
||||
apple?: {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
appBundleIdentifier?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function buildSocialProviders(env: Env): SocialProviderConfig {
|
||||
const providers: SocialProviderConfig = {};
|
||||
|
||||
// Google — needs clientId + clientSecret
|
||||
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
providers.google = {
|
||||
clientId: env.AUTH_GOOGLE_CLIENT_ID,
|
||||
clientSecret: env.AUTH_GOOGLE_CLIENT_SECRET,
|
||||
};
|
||||
}
|
||||
|
||||
// Apple — needs clientId, clientSecret (pre-generated JWT), teamId, keyId,
|
||||
// and privateKey. If the caller has already generated the JWT client secret
|
||||
// and stored it in AUTH_APPLE_CLIENT_SECRET, all five vars must be present.
|
||||
if (
|
||||
env.AUTH_APPLE_CLIENT_ID &&
|
||||
env.AUTH_APPLE_CLIENT_SECRET &&
|
||||
env.AUTH_APPLE_TEAM_ID &&
|
||||
env.AUTH_APPLE_KEY_ID &&
|
||||
env.AUTH_APPLE_PRIVATE_KEY
|
||||
) {
|
||||
providers.apple = {
|
||||
clientId: env.AUTH_APPLE_CLIENT_ID,
|
||||
clientSecret: env.AUTH_APPLE_CLIENT_SECRET,
|
||||
};
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
61
src/lib/get-sign-in-providers.ts
Normal file
61
src/lib/get-sign-in-providers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Returns the ordered list of available sign-in providers based on which
|
||||
* environment variables are configured.
|
||||
*
|
||||
* Used by the sign-in page to render provider buttons.
|
||||
*
|
||||
* signInMethod:
|
||||
* - "social" → call signIn.social({ provider: id, callbackURL })
|
||||
* - "oauth2" → call signIn.oauth2({ providerId: id, callbackURL })
|
||||
*/
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
export interface SignInProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
signInMethod: "social" | "oauth2";
|
||||
}
|
||||
|
||||
export function getSignInProviders(env: Env): SignInProvider[] {
|
||||
const providers: SignInProvider[] = [];
|
||||
|
||||
// Authentik (genericOAuth)
|
||||
if (
|
||||
env.AUTH_AUTHENTIK_CLIENT_ID &&
|
||||
env.AUTH_AUTHENTIK_CLIENT_SECRET &&
|
||||
env.AUTH_AUTHENTIK_ISSUER
|
||||
) {
|
||||
providers.push({
|
||||
id: "authentik",
|
||||
label: "Authentik",
|
||||
signInMethod: "oauth2",
|
||||
});
|
||||
}
|
||||
|
||||
// Google (socialProviders)
|
||||
if (env.AUTH_GOOGLE_CLIENT_ID && env.AUTH_GOOGLE_CLIENT_SECRET) {
|
||||
providers.push({
|
||||
id: "google",
|
||||
label: "Google",
|
||||
signInMethod: "social",
|
||||
});
|
||||
}
|
||||
|
||||
// Apple (socialProviders) — all five vars required
|
||||
if (
|
||||
env.AUTH_APPLE_CLIENT_ID &&
|
||||
env.AUTH_APPLE_CLIENT_SECRET &&
|
||||
env.AUTH_APPLE_TEAM_ID &&
|
||||
env.AUTH_APPLE_KEY_ID &&
|
||||
env.AUTH_APPLE_PRIVATE_KEY
|
||||
) {
|
||||
providers.push({
|
||||
id: "apple",
|
||||
label: "Apple",
|
||||
signInMethod: "social",
|
||||
});
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
Reference in New Issue
Block a user