mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 00:06:36 -05:00
feat: add oidc support with authentik
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user