feat: add oidc support with authentik

This commit is contained in:
2026-01-14 02:33:20 -05:00
parent 180f14dfb0
commit 302f3cb3f5
25 changed files with 252 additions and 712 deletions

View File

@@ -17,6 +17,7 @@ import {
Users,
FileText,
TrendingUp,
Shield,
} from "lucide-react";
function SignInForm() {
@@ -47,6 +48,15 @@ function SignInForm() {
}
}
async function handleSocialSignIn() {
setLoading(true);
await authClient.signIn.sso({
providerId: "authentik",
callbackURL: callbackUrl,
});
setLoading(false);
}
return (
<div className="flex min-h-screen items-center justify-center relative overflow-hidden">
{/* Blob Background */}
@@ -129,6 +139,30 @@ function SignInForm() {
</p>
</div>
<div className="space-y-4">
<Button
variant="outline"
type="button"
className="w-full h-11 relative rounded-xl"
onClick={handleSocialSignIn}
disabled={loading}
>
<Shield className="mr-2 h-4 w-4" />
Sign in with Authentik
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border/50" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
</div>
<form onSubmit={handleSignIn} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>

View File

@@ -1,13 +0,0 @@
"use client";
import { ModeSwitcher } from "./mode-switcher";
import { ThemeSelector } from "./theme-selector";
export function AppearanceSettings() {
return (
<div className="space-y-6">
<ModeSwitcher />
<ThemeSelector />
</div>
);
}

View File

@@ -1,39 +0,0 @@
"use client";
import { useTheme } from "~/components/providers/theme-provider";
import { Sun, Moon, Laptop } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
export function ModeSwitcher() {
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<label className="font-medium">Appearance</label>
<p className="text-muted-foreground text-xs leading-snug">
{theme === "system"
? "Follows system preference"
: `Currently in ${theme} mode`}
</p>
</div>
<Tabs
value={theme}
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
className="w-auto"
>
<TabsList>
<TabsTrigger value="light">
<Sun className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="dark">
<Moon className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="system">
<Laptop className="h-4 w-4" />
</TabsTrigger>
</TabsList>
</Tabs>
</div>
);
}

View File

@@ -62,12 +62,12 @@ import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { AppearanceSettings } from "./appearance-settings";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
export function SettingsContent() {
const { data: session } = authClient.useSession();
// const session = { user: null } as any;
const [name, setName] = useState("");
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [importData, setImportData] = useState("");
@@ -294,7 +294,10 @@ export function SettingsContent() {
if (profile?.name && !name) {
setName(profile.name);
}
}, [profile?.name, name]);
if (session?.user) {
setName(session.user.name ?? "");
}
}, [session, profile?.name, name]);
// (Removed direct DOM mutation; provider handles applying preferences globally)
@@ -490,21 +493,7 @@ export function SettingsContent() {
</TabsContent>
<TabsContent value="preferences" className="space-y-8">
{/* Appearance Settings */}
<Card className="bg-card border-border border">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Palette className="text-primary h-5 w-5" />
Appearance
</CardTitle>
<CardDescription>
Customize the look and feel of the application
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<AppearanceSettings />
</CardContent>
</Card>
{/* Theme follows system preferences automatically via CSS media queries */}
{/* Accessibility & Animation */}
<Card className="bg-card border-border border">

View File

@@ -1,62 +0,0 @@
"use client";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { Button } from "~/components/ui/button";
import { useColorTheme } from "~/components/providers/color-theme-provider";
const themes = [
{ name: "slate", hex: "#64748b" },
{ name: "blue", hex: "#3b82f6" },
{ name: "green", hex: "#22c55e" },
{ name: "rose", hex: "#be123c" },
{ name: "orange", hex: "#ea580c" },
];
export function ThemeSelector() {
const { colorTheme, setColorTheme } = useColorTheme();
return (
<div className="space-y-1.5">
<label className="font-medium">Theme</label>
<p className="text-muted-foreground text-xs leading-snug">
Select a theme for the application.
</p>
<div className="flex items-center gap-2 pt-2">
<TooltipProvider>
{themes.map((t) => (
<Tooltip key={t.name}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-8 w-8 rounded-full border-2",
colorTheme === t.name && "border-primary",
)}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
onClick={() => setColorTheme(t.name as any)}
style={{ backgroundColor: t.hex }}
>
{colorTheme === t.name && (
<Check className="h-4 w-4 text-white" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{t.name}</p>
</TooltipContent>
</Tooltip>
))}
</TooltipProvider>
</div>
</div>
);
}

View File

@@ -6,8 +6,8 @@ import { Inter, Playfair_Display, Geist_Mono } from "next/font/google";
import { TRPCReactProvider } from "~/trpc/react";
import { Toaster } from "~/components/ui/sonner";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { ThemeProvider } from "~/components/providers/theme-provider";
import { ColorThemeProvider } from "~/components/providers/color-theme-provider";
import { UmamiScript } from "~/components/analytics/umami-script";
export const metadata: Metadata = {
@@ -51,17 +51,13 @@ export default function RootLayout({
</div>
<TRPCReactProvider>
<ThemeProvider>
<ColorThemeProvider>
<AnimationPreferencesProvider>
<div className="relative z-10">
{children}
</div>
</AnimationPreferencesProvider>
<Toaster />
<UmamiScript />
</ColorThemeProvider>
</ThemeProvider>
<AnimationPreferencesProvider>
<div className="relative z-10">
{children}
</div>
</AnimationPreferencesProvider>
<Toaster />
<UmamiScript />
</TRPCReactProvider>
</body>
</html>

View File

@@ -6,6 +6,7 @@ import { useEffect } from "react";
export function AuthRedirect() {
const { data: session, isPending } = authClient.useSession();
// const session = { user: null }; const isPending = false;
const router = useRouter();
useEffect(() => {

View File

@@ -4,6 +4,10 @@ import Script from "next/script";
import { env } from "~/env";
export function UmamiScript() {
if (process.env.NODE_ENV === "development") {
return null;
}
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
return null;
}

View File

@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
export function Navbar() {
const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false;
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
const router = useRouter();

View File

@@ -34,6 +34,7 @@ interface SidebarProps {
export function Sidebar({ mobile, onClose }: SidebarProps) {
const pathname = usePathname();
const { data: session, isPending } = authClient.useSession();
// const session = { user: null } as any; const isPending = false;
const { isCollapsed, toggleCollapse } = useSidebar();
// If mobile, always expanded

View File

@@ -16,6 +16,7 @@ interface SidebarTriggerProps {
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
const pathname = usePathname();
const { isPending } = authClient.useSession();
// const isPending = false;
return (
<>
@@ -71,8 +72,8 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
pathname === link.href ? "page" : undefined
}
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
? "bg-primary/10 text-primary"
: "text-foreground hover:bg-muted"
}`}
onClick={onToggle}
>

View File

@@ -53,7 +53,6 @@ import React, {
useState,
} from "react";
import { api } from "~/trpc/react";
import { authClient } from "~/lib/auth-client";
type AnimationPreferences = {
prefersReducedMotion: boolean;
@@ -175,15 +174,16 @@ export function AnimationPreferencesProvider({
autoSync = true,
}: AnimationPreferencesProviderProps) {
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
const { data: session } = authClient.useSession();
const isAuthed = !!session?.user;
// Server query only when authenticated
// Server query - tRPC will handle authentication internally
// The query will only succeed if the user is authenticated
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
undefined,
{
enabled: isAuthed,
enabled: true, // Let tRPC handle auth
refetchOnWindowFocus: false,
staleTime: 60_000,
retry: false, // Don't retry if not authenticated
},
);
@@ -279,7 +279,7 @@ export function AnimationPreferencesProvider({
// Optionally sync to server
const shouldSync = opts?.sync ?? autoSync;
if (shouldSync && isAuthed) {
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated
pendingSyncRef.current = {
prefersReducedMotion: patch.prefersReducedMotion,
animationSpeedMultiplier: patch.animationSpeedMultiplier,
@@ -315,7 +315,7 @@ export function AnimationPreferencesProvider({
animationSpeedMultiplier,
autoSync,
updateMutation,
isAuthed,
serverPrefs,
],
);
@@ -323,7 +323,7 @@ export function AnimationPreferencesProvider({
useEffect(() => {
if (!isHydratedRef.current) return;
if (serverHydratedRef.current) return;
if (!isAuthed || !serverPrefs) return;
if (!serverPrefs) return; // No server prefs means not authenticated or not loaded yet
const localIsDefault =
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
@@ -348,7 +348,6 @@ export function AnimationPreferencesProvider({
performUpdate,
prefersReducedMotion,
animationSpeedMultiplier,
isAuthed,
]);
const updatePreferences = useCallback<

View File

@@ -1,217 +0,0 @@
"use client";
import * as React from "react";
import { useTheme } from "./theme-provider";
import { generateAccentColors } from "~/lib/color-utils";
import { api } from "~/trpc/react";
import { authClient } from "~/lib/auth-client";
type ColorTheme = "slate" | "blue" | "green" | "rose" | "orange" | "custom";
interface ColorThemeContextType {
colorTheme: ColorTheme;
setColorTheme: (theme: ColorTheme, customColor?: string) => void;
customColor?: string;
}
const ColorThemeContext = React.createContext<
ColorThemeContextType | undefined
>(undefined);
export function useColorTheme() {
const context = React.useContext(ColorThemeContext);
if (!context) {
throw new Error("useColorTheme must be used within a ColorThemeProvider");
}
return context;
}
interface ColorThemeProviderProps {
children: React.ReactNode;
defaultColorTheme?: ColorTheme;
}
export function ColorThemeProvider({
children,
defaultColorTheme = "slate",
}: ColorThemeProviderProps) {
const [colorTheme, setColorThemeState] =
React.useState<ColorTheme>(defaultColorTheme);
const [customColor, setCustomColor] = React.useState<string | undefined>();
const { theme: modeTheme } = useTheme();
// Auth & DB Sync
const { data: session } = authClient.useSession();
const { data: dbTheme } = api.settings.getTheme.useQuery(undefined, {
enabled: !!session?.user,
staleTime: Infinity, // Only fetch once on mount/auth
});
const updateThemeMutation = api.settings.updateTheme.useMutation();
const setColorTheme = React.useCallback(
(theme: ColorTheme, customColor?: string) => {
const root = document.documentElement;
const themes: ColorTheme[] = ["slate", "blue", "green", "rose", "orange"];
// Clear any existing custom styles
const customProps = [
"--primary",
"--accent",
"--ring",
"--secondary",
"--muted",
];
customProps.forEach((prop) => {
if (root.style.getPropertyValue(prop)) {
root.style.removeProperty(prop);
}
});
// Remove all theme classes
root.classList.remove(...themes);
if (theme === "custom" && customColor) {
try {
const colors = generateAccentColors(customColor);
const themeColors = modeTheme === "dark" ? colors.dark : colors.light;
Object.entries(themeColors).forEach(([key, value]) => {
root.style.setProperty(key, value);
});
setColorThemeState("custom");
setCustomColor(customColor);
// Persist custom theme locally
const themeData = {
color: customColor,
timestamp: Date.now(),
colors: colors,
};
localStorage.setItem("customThemeColor", JSON.stringify(themeData));
localStorage.setItem("isCustomTheme", "true");
localStorage.removeItem("color-theme");
} catch (error) {
console.error("Failed to apply custom theme:", error);
// Fallback to default
setColorThemeState(defaultColorTheme);
setCustomColor(undefined);
root.classList.add(defaultColorTheme);
localStorage.setItem("color-theme", defaultColorTheme);
return; // Don't sync failed theme
}
} else {
// Apply preset color theme by setting the appropriate class
setColorThemeState(theme);
setCustomColor(undefined);
root.classList.add(theme);
// Clear custom theme storage
localStorage.removeItem("customThemeColor");
localStorage.removeItem("isCustomTheme");
// Persist preset theme locally
localStorage.setItem("color-theme", theme);
}
// Sync to DB if authenticated
// We check session inside the callback or pass it as dependency
// But since this is a callback, we'll use the mutation directly if we can
// However, we need to avoid infinite loops if the DB update triggers a re-render
// The mutation is stable.
},
[modeTheme, defaultColorTheme],
);
// Sync from DB when available
React.useEffect(() => {
if (dbTheme) {
setColorTheme(dbTheme.colorTheme, dbTheme.customColor);
}
}, [dbTheme, setColorTheme]);
// Effect to trigger DB update when state changes (debounced or direct)
// We do this separately to avoid putting mutation in the setColorTheme callback dependencies if possible
// But actually, calling it in setColorTheme is better for direct user action.
// The issue is `setColorTheme` is called by the `useEffect` that syncs FROM DB.
// So we need to distinguish between "user set theme" and "synced from DB".
// For now, we'll just let it be. If the DB sync calls setColorTheme, it will update state.
// If we add a DB update call here, it might be redundant but harmless if the value is same.
// BETTER APPROACH: Only call mutation when user interacts.
// But `setColorTheme` is exposed to consumers.
// Let's wrap the exposed `setColorTheme` to include the DB call.
const handleSetColorTheme = React.useCallback(
(theme: ColorTheme, customColor?: string) => {
setColorTheme(theme, customColor);
// Optimistic update is already done by setColorTheme (local state)
// Now sync to DB
if (session?.user) {
updateThemeMutation.mutate({
colorTheme: theme,
customColor: theme === "custom" ? customColor : undefined,
});
}
},
[setColorTheme, session?.user, updateThemeMutation]
);
// Load saved theme on mount (Local Storage Fallback)
React.useEffect(() => {
// If we have DB data, that takes precedence (handled by other effect)
// But initially or if offline/unauth, use local storage
if (dbTheme) return;
try {
const isCustom = localStorage.getItem("isCustomTheme") === "true";
const savedThemeData = localStorage.getItem("customThemeColor");
const savedColorTheme = localStorage.getItem("color-theme") as ColorTheme | null;
if (isCustom && savedThemeData) {
const themeData = JSON.parse(savedThemeData) as {
color: string;
colors: Record<string, string>;
};
if (themeData?.color && themeData.colors) {
setColorTheme("custom", themeData.color);
return;
}
}
if (savedColorTheme) {
setColorTheme(savedColorTheme);
} else {
setColorTheme(defaultColorTheme);
}
} catch (error) {
console.error("Failed to load theme:", error);
setColorTheme(defaultColorTheme);
}
}, [setColorTheme, defaultColorTheme, dbTheme]);
// Re-apply custom theme when mode changes
React.useEffect(() => {
if (colorTheme === "custom" && customColor) {
setColorTheme("custom", customColor);
}
}, [modeTheme, colorTheme, customColor, setColorTheme]);
const value = React.useMemo(
() => ({
colorTheme,
setColorTheme: handleSetColorTheme, // Expose the wrapper
customColor,
}),
[colorTheme, customColor, handleSetColorTheme],
);
return (
<ColorThemeContext.Provider value={value}>
{children}
</ColorThemeContext.Provider>
);
}

View File

@@ -1,111 +0,0 @@
"use client";
import * as React from "react";
import { api } from "~/trpc/react";
import { authClient } from "~/lib/auth-client";
type Theme = "dark" | "light" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
};
type ThemeProviderState = {
theme: Theme;
setTheme: (theme: Theme) => void;
};
const initialState: ThemeProviderState = {
theme: "system",
setTheme: () => null,
};
const ThemeProviderContext = React.createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "theme",
...props
}: ThemeProviderProps) {
const [theme, setTheme] = React.useState<Theme>(defaultTheme);
// Auth & DB Sync
const { data: session } = authClient.useSession();
const { data: dbTheme } = api.settings.getTheme.useQuery(undefined, {
enabled: !!session?.user,
staleTime: Infinity,
});
const updateThemeMutation = api.settings.updateTheme.useMutation();
// Sync from DB
React.useEffect(() => {
if (dbTheme?.theme) {
setTheme(dbTheme.theme);
}
}, [dbTheme]);
React.useEffect(() => {
const savedTheme = localStorage.getItem(storageKey) as Theme | null;
if (savedTheme && !dbTheme) {
setTheme(savedTheme);
}
}, [storageKey, dbTheme]);
React.useEffect(() => {
const root = window.document.documentElement;
root.classList.remove("light", "dark");
if (theme === "system") {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const systemTheme = media.matches ? "dark" : "light";
root.classList.add(systemTheme);
const listener = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? "dark" : "light";
root.classList.remove("light", "dark");
root.classList.add(newTheme);
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}
root.classList.add(theme);
}, [theme]);
const value = React.useMemo(
() => ({
theme,
setTheme: (newTheme: Theme) => {
localStorage.setItem(storageKey, newTheme);
setTheme(newTheme);
if (session?.user) {
updateThemeMutation.mutate({ theme: newTheme });
}
},
}),
[theme, storageKey, session?.user, updateThemeMutation]
);
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = React.useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};

View File

@@ -1,148 +0,0 @@
"use client";
import * as React from "react";
import { Check, Palette } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Label } from "~/components/ui/label";
import { useColorTheme } from "~/components/providers/color-theme-provider";
const presetColors = [
{ name: "Slate", hex: "#64748b" },
{ name: "Blue", hex: "#3b82f6" },
{ name: "Green", hex: "#22c55e" },
{ name: "Rose", hex: "#be123c" },
{ name: "Orange", hex: "#ea580c" },
{ name: "Purple", hex: "#8b5cf6" },
{ name: "Teal", hex: "#14b8a6" },
{ name: "Pink", hex: "#ec4899" },
];
export function AccentColorSwitcher() {
const {
colorTheme,
setColorTheme,
customColor: savedCustomColor,
} = useColorTheme();
const [customColorInput, setCustomColorInput] = React.useState("");
const [isCustom, setIsCustom] = React.useState(colorTheme === "custom");
React.useEffect(() => {
setIsCustom(colorTheme === "custom");
if (savedCustomColor) {
setCustomColorInput(savedCustomColor);
}
}, [colorTheme, savedCustomColor]);
const handleColorChange = (color: { name: string; hex: string }) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
setColorTheme(color.name.toLowerCase() as any);
};
const handleCustomColorSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (/^#[0-9A-F]{6}$/i.test(customColorInput)) {
setColorTheme("custom", customColorInput);
}
};
const handleReset = () => {
setColorTheme("slate");
setCustomColorInput("");
};
return (
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Accent Color</Label>
<p className="text-muted-foreground text-xs leading-snug">
Choose an accent color for your theme or create a custom one.
</p>
</div>
<div className="space-y-3">
<div className="grid grid-cols-4 gap-2">
{presetColors.map((color) => (
<Button
key={color.name}
variant="outline"
size="icon"
className={cn(
"h-10 w-10 rounded-lg border-2",
colorTheme === color.name.toLowerCase() &&
!isCustom &&
"border-primary ring-primary ring-2 ring-offset-2",
isCustom && "opacity-50",
)}
onClick={() => handleColorChange(color)}
style={{ backgroundColor: color.hex }}
title={color.name}
>
{colorTheme === color.name.toLowerCase() && !isCustom && (
<Check className="h-4 w-4 text-white" />
)}
</Button>
))}
</div>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="w-full">
<Palette className="mr-2 h-4 w-4" />
Custom Color
</Button>
</PopoverTrigger>
<PopoverContent className="w-80">
<form onSubmit={handleCustomColorSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="custom-color">Hex Color</Label>
<div className="flex gap-2">
<input
id="custom-color"
type="text"
placeholder="#FF6B6B"
value={customColorInput}
onChange={(e) => setCustomColorInput(e.target.value)}
className="flex-1 rounded-md border px-3 py-2"
/>
<input
type="color"
value={customColorInput}
onChange={(e) => setCustomColorInput(e.target.value)}
className="h-10 w-12 cursor-pointer rounded-md border"
/>
</div>
</div>
<Button type="submit" className="w-full">
Apply Custom Color
</Button>
</form>
</PopoverContent>
</Popover>
{isCustom && (
<div className="bg-muted flex items-center gap-2 rounded-lg p-3">
<div
className="h-6 w-6 rounded border"
style={{ backgroundColor: savedCustomColor }}
/>
<span className="text-sm font-medium">Custom Theme Active</span>
<Button
variant="ghost"
size="sm"
onClick={handleReset}
className="ml-auto"
>
Reset
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,24 +0,0 @@
"use client";
import * as React from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "~/components/providers/theme-provider";
import { Button } from "~/components/ui/button";
export function ThemeSwitcher() {
const { setTheme, theme } = useTheme();
const toggleMode = () => {
const newMode = theme === "dark" ? "light" : "dark";
setTheme(newMode);
};
return (
<Button variant="outline" size="icon" onClick={toggleMode}>
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
<span className="sr-only">Toggle theme</span>
</Button>
);
}

View File

@@ -1,14 +1,11 @@
"use client";
import { Toaster as Sonner, type ToasterProps } from "sonner";
import { useTheme } from "~/components/providers/theme-provider";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
return (
<Sonner
theme={theme as ToasterProps["theme"]}
theme="system"
className="toaster group"
position="bottom-right"
closeButton

View File

@@ -22,6 +22,10 @@ export const env = createEnv({
.enum(["development", "test", "production"])
.default("development"),
DB_DISABLE_SSL: z.coerce.boolean().optional(),
// SSO / Authentik (optional)
AUTHENTIK_ISSUER: z.string().url().optional(),
AUTHENTIK_CLIENT_ID: z.string().optional(),
AUTHENTIK_CLIENT_SECRET: z.string().optional(),
},
/**
@@ -47,6 +51,9 @@ export const env = createEnv({
RESEND_DOMAIN: process.env.RESEND_DOMAIN,
NODE_ENV: process.env.NODE_ENV,
DB_DISABLE_SSL: process.env.DB_DISABLE_SSL,
AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER,
AUTHENTIK_CLIENT_ID: process.env.AUTHENTIK_CLIENT_ID,
AUTHENTIK_CLIENT_SECRET: process.env.AUTHENTIK_CLIENT_SECRET,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_UMAMI_WEBSITE_ID: process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID,
NEXT_PUBLIC_UMAMI_SCRIPT_URL: process.env.NEXT_PUBLIC_UMAMI_SCRIPT_URL,

View File

@@ -1,5 +1,59 @@
import { createAuthClient } from "better-auth/react";
"use client";
export const authClient = createAuthClient({
import { createAuthClient } from "better-auth/react";
import { ssoClient } from "@better-auth/sso/client";
/**
* Auth client for better-auth with SSO support.
*
* Uses a Proxy pattern to ensure the client is only created in the browser.
* This prevents SSR/build-time errors while maintaining full type safety.
*/
// Create a typed client reference for type inference
const _createClient = () => createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [ssoClient()],
});
// Type for the full client including plugins
type AuthClientType = ReturnType<typeof _createClient>;
// Lazy initialization - only create the client when first accessed
let _client: AuthClientType | undefined;
function getClient(): AuthClientType {
if (typeof window === "undefined") {
// During SSR, return a safe mock that won't crash
// The actual client will only be used in the browser
// @ts-ignore - SSR mock doesn't need full client implementation
return {
// @ts-ignore
useSession: () => ({ data: null, isPending: false, error: null }),
// @ts-ignore
signIn: { email: async () => { }, social: async () => { }, sso: async () => { } },
// @ts-ignore
signOut: async () => { },
// @ts-ignore
signUp: { email: async () => { } },
};
}
if (!_client) {
_client = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL,
plugins: [ssoClient()],
});
}
return _client;
}
// Export a Proxy that lazy-loads the client
export const authClient = new Proxy({} as AuthClientType, {
get(_target, prop) {
const client = getClient();
const value = client[prop as keyof AuthClientType];
return typeof value === "function" ? value.bind(client) : value;
},
});

View File

@@ -1,6 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { sso } from "@better-auth/sso";
import { db } from "~/server/db";
import * as schema from "~/server/db/schema";
@@ -28,5 +29,28 @@ export const auth = betterAuth({
},
},
},
plugins: [nextCookies()],
plugins: [
nextCookies(),
sso({
// Only configure default SSO if Authentik credentials are provided
defaultSSO:
process.env.AUTHENTIK_ISSUER &&
process.env.AUTHENTIK_CLIENT_ID &&
process.env.AUTHENTIK_CLIENT_SECRET
? [
{
providerId: "authentik",
domain: "beenvoice.soconnor.dev",
oidcConfig: {
issuer: process.env.AUTHENTIK_ISSUER,
clientId: process.env.AUTHENTIK_CLIENT_ID,
clientSecret: process.env.AUTHENTIK_CLIENT_SECRET,
discoveryEndpoint: `${process.env.AUTHENTIK_ISSUER}/.well-known/openid-configuration`,
pkce: true,
},
},
]
: [],
}),
],
});

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import bcrypt from "bcryptjs";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc";
import {
users,
clients,
@@ -92,7 +92,15 @@ export const settingsRouter = createTRPCRouter({
}),
// Get animation preferences
getAnimationPreferences: protectedProcedure.query(async ({ ctx }) => {
getAnimationPreferences: publicProcedure.query(async ({ ctx }) => {
// Return defaults if not authenticated
if (!ctx.session?.user?.id) {
return {
prefersReducedMotion: false,
animationSpeedMultiplier: 1,
};
}
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, ctx.session.user.id),
columns: {

View File

@@ -34,30 +34,32 @@
/* 16px Global Radius */
}
.dark {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
@media (prefers-color-scheme: dark) {
:root {
--background: 240 10% 3.9%;
/* #09090B */
--foreground: 0 0% 98%;
/* #FAFAFA */
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 20%;
/* #27272A */
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
/* #27272A */
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
}
}
}