This commit is contained in:
jjbrunton committed 2026-01-01 13:29:25 +00:00
1 parent 75509b0e45
commit ef7bc8c90d
16 files changed
+886 -32

No files matched your search

+9
View File
@@ -16,6 +16,7 @@ import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
import { FeedbackPage } from '@/pages/FeedbackPage';
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
import { AiSettingsPage } from '@/pages/AiSettingsPage';
import { AppSettingsPage } from '@/pages/AppSettingsPage';
function App() {
return (
@@ -137,6 +138,14 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/app-settings"
element={
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
<AppSettingsPage />
</ProtectedRoute>
}
/>
</Route>
{/* Catch all - redirect to dashboard */}
+20 -3
View File
@@ -1,23 +1,40 @@
import { useState } from 'react';
import { Outlet } from 'react-router-dom';
import { Menu } from 'lucide-react';
import { Sidebar } from './Sidebar';
import { Breadcrumbs } from './Breadcrumbs';
import { Toaster } from 'sonner';
import { Button } from '@/components/ui/button';
export function AppLayout() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Subtle gradient overlay */}
<div className="fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-transparent pointer-events-none" />
<Sidebar />
<Sidebar
mobileOpen={mobileMenuOpen}
onMobileClose={() => setMobileMenuOpen(false)}
/>
<div className="flex flex-1 flex-col overflow-hidden relative">
{/* Header */}
<header className="flex h-16 items-center border-b border-white/5 px-8 bg-card/50 backdrop-blur-sm">
<header className="flex h-14 xl:h-16 items-center gap-3 border-b border-white/5 px-4 xl:px-8 bg-card/50 backdrop-blur-sm">
{/* Mobile menu button - visible below xl breakpoint */}
<Button
variant="ghost"
size="icon"
className="xl:hidden h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setMobileMenuOpen(true)}
>
<Menu className="h-5 w-5" />
</Button>
<Breadcrumbs />
</header>
{/* Main content */}
<main className="flex-1 overflow-auto p-8">
<main className="flex-1 overflow-auto p-4 xl:p-8">
<div className="max-w-7xl mx-auto">
<Outlet />
</div>
+124 -5
View File
@@ -13,11 +13,13 @@ import {
MessageSquareText,
Target,
Bot,
Smartphone,
X,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import logoImage from '@/assets/logo.png';
interface NavItem {
@@ -70,6 +72,12 @@ const systemNav: NavItem[] = [
icon: <Bot className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
},
{
label: 'App Settings',
href: '/app-settings',
icon: <Smartphone className="h-5 w-5" />,
requiredPermission: PERMISSION_KEYS.MANAGE_APP_CONFIG,
},
{
label: 'Redemption Codes',
href: '/redemption-codes',
@@ -84,11 +92,23 @@ const systemNav: NavItem[] = [
},
];
export function Sidebar() {
interface SidebarProps {
mobileOpen?: boolean;
onMobileClose?: () => void;
}
export function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const location = useLocation();
const { user, permissions, logout, hasPermission } = useAuth();
const [collapsed, setCollapsed] = useState(false);
// Close mobile sidebar on route change
useEffect(() => {
if (mobileOpen && onMobileClose) {
onMobileClose();
}
}, [location.pathname]);
const NavLink = ({ item }: { item: NavItem }) => {
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
@@ -102,7 +122,7 @@ export function Sidebar() {
isActive
? "bg-gradient-rose text-white shadow-lg glow-rose-sm"
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
collapsed && "justify-center px-2"
collapsed && "justify-center px-2 lg:justify-center lg:px-2"
)}
>
<span className={cn(
@@ -134,11 +154,13 @@ export function Sidebar() {
);
};
return (
const sidebarContent = (
<div
className={cn(
"flex h-screen flex-col border-r border-white/5 bg-gradient-to-b from-card to-background transition-all duration-300",
collapsed ? "w-20" : "w-72"
// Desktop only: hidden on mobile/tablet, show on desktop
"hidden xl:flex",
collapsed ? "xl:w-20" : "xl:w-72"
)}
>
{/* Logo */}
@@ -236,4 +258,101 @@ export function Sidebar() {
</div>
</div>
);
// Mobile sidebar (overlay)
const mobileSidebar = (
<>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity xl:hidden",
mobileOpen ? "opacity-100" : "opacity-0 pointer-events-none"
)}
onClick={onMobileClose}
/>
{/* Slide-in sidebar */}
<div
className={cn(
"fixed inset-y-0 left-0 z-50 w-72 transform transition-transform duration-300 ease-in-out xl:hidden",
mobileOpen ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex h-full flex-col border-r border-white/5 bg-gradient-to-b from-card to-background">
{/* Mobile header with close button */}
<div className="flex h-20 items-center justify-between px-4 border-b border-white/5">
<Link to="/" className="flex items-center gap-3 group">
<div className="relative">
<img
src={logoImage}
alt="Sauci"
className="h-10 w-10 object-contain"
/>
</div>
<div className="flex flex-col">
<span className="text-xl font-bold tracking-tight text-gradient-sunset">
Sauci
</span>
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
Admin Portal
</span>
</div>
</Link>
<Button
variant="ghost"
size="icon"
onClick={onMobileClose}
className="h-10 w-10 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/5"
>
<X className="h-5 w-5" />
</Button>
</div>
{/* Navigation */}
<div className="flex-1 space-y-6 overflow-auto px-3 py-4">
<NavSection title="Content" items={contentNav} />
<NavSection title="Users" items={usersNav} />
<NavSection title="System" items={systemNav} />
</div>
<Separator className="bg-white/5" />
{/* User info */}
<div className="p-4">
<div className="flex items-center gap-3 rounded-xl p-2 transition-colors hover:bg-white/5">
<Avatar className="h-9 w-9 ring-2 ring-primary/20">
<AvatarFallback className="bg-gradient-rose text-white font-semibold">
{user?.email?.charAt(0).toUpperCase() || 'A'}
</AvatarFallback>
</Avatar>
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm font-medium text-foreground">
{user?.email}
</p>
<p className="text-xs text-muted-foreground capitalize">
{permissions?.role?.replace('_', ' ')}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="mt-2 w-full justify-start text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors"
onClick={logout}
>
<LogOut className="h-4 w-4" />
<span className="ml-2">Sign Out</span>
</Button>
</div>
</div>
</div>
</>
);
return (
<>
{sidebarContent}
{mobileSidebar}
</>
);
}
@@ -30,7 +30,7 @@ export function EmojiPicker({ value, onChange }: EmojiPickerProps) {
};
}, [open]);
const handleEmojiSelect = (emoji: any) => {
const handleEmojiSelect = (emoji: { native: string; id: string; name: string }) => {
onChange(emoji.native);
setOpen(false);
};
+2
View File
@@ -22,6 +22,7 @@ export const PERMISSION_KEYS = {
MANAGE_CODES: 'manage_codes',
MANAGE_ADMINS: 'manage_admins',
MANAGE_AI_CONFIG: 'manage_ai_config',
MANAGE_APP_CONFIG: 'manage_app_config',
VIEW_AUDIT_LOGS: 'view_audit_logs',
} as const;
@@ -40,6 +41,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
[PERMISSION_KEYS.MANAGE_APP_CONFIG]: { label: 'Can manage app settings', description: 'Configure mobile app behavior settings', group: 'system' },
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
};
+111
View File
@@ -0,0 +1,111 @@
import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config';
export interface AppConfig {
id: string;
answer_gap_threshold: number;
updated_at: string | null;
updated_by: string | null;
}
interface UseAppConfigReturn {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
updateConfig: (updates: Partial<AppConfig>) => Promise<{ error: string | null }>;
}
// Cache for the config to avoid refetching on every use
let cachedConfig: AppConfig | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
export function useAppConfig(): UseAppConfigReturn {
const [config, setConfig] = useState<AppConfig | null>(cachedConfig);
const [loading, setLoading] = useState(!cachedConfig);
const [error, setError] = useState<string | null>(null);
const fetchConfig = useCallback(async () => {
// Use cache if valid
if (cachedConfig && Date.now() - cacheTimestamp < CACHE_TTL) {
setConfig(cachedConfig);
setLoading(false);
return;
}
setLoading(true);
setError(null);
try {
const { data, error: fetchError } = await supabase
.from('app_config')
.select('*')
.limit(1)
.maybeSingle();
if (fetchError) {
setError(fetchError.message);
setConfig(null);
} else if (data) {
cachedConfig = data as AppConfig;
cacheTimestamp = Date.now();
setConfig(data as AppConfig);
} else {
// No config row exists yet
setConfig(null);
setError('App config not initialized. Please contact support.');
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch app config');
} finally {
setLoading(false);
}
}, []);
const updateConfig = useCallback(async (updates: Partial<AppConfig>): Promise<{ error: string | null }> => {
if (!config?.id) {
return { error: 'No config to update' };
}
try {
const { error: updateError } = await supabase
.from('app_config')
.update(updates)
.eq('id', config.id);
if (updateError) {
return { error: updateError.message };
}
// Invalidate cache and refetch
cachedConfig = null;
cacheTimestamp = 0;
await fetchConfig();
return { error: null };
} catch (err) {
return { error: err instanceof Error ? err.message : 'Failed to update app config' };
}
}, [config?.id, fetchConfig]);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
return {
config,
loading,
error,
refetch: fetchConfig,
updateConfig,
};
}
/**
* Clear the config cache (useful when logging out)
*/
export function clearAppConfigCache(): void {
cachedConfig = null;
cacheTimestamp = 0;
}
+181
View File
@@ -0,0 +1,181 @@
import { useState, useEffect } from 'react';
import { useAppConfig, AppConfig } from '@/hooks/useAppConfig';
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Save, AlertTriangle, RefreshCw, Users } from 'lucide-react';
import { toast } from 'sonner';
import { format } from 'date-fns';
export function AppSettingsPage() {
const { hasPermission, isSuperAdmin } = useAuth();
const { config, loading, error, updateConfig, refetch } = useAppConfig();
// Local form state
const [formData, setFormData] = useState<Partial<AppConfig>>({});
const [isSaving, setIsSaving] = useState(false);
const [hasChanges, setHasChanges] = useState(false);
// Permission check - only super admins or those with MANAGE_APP_CONFIG can access
const canManage = isSuperAdmin || hasPermission(PERMISSION_KEYS.MANAGE_APP_CONFIG);
// Initialize form data when config loads
useEffect(() => {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
});
setHasChanges(false);
}
}, [config]);
const handleChange = <K extends keyof AppConfig>(field: K, value: AppConfig[K]) => {
setFormData(prev => ({ ...prev, [field]: value }));
setHasChanges(true);
};
const handleSave = async () => {
setIsSaving(true);
try {
const { error: saveError } = await updateConfig(formData);
if (saveError) {
toast.error(saveError);
} else {
toast.success('App settings saved successfully');
setHasChanges(false);
}
} catch {
toast.error('Failed to save settings');
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
if (config) {
setFormData({
answer_gap_threshold: config.answer_gap_threshold ?? 10,
});
setHasChanges(false);
}
};
if (!canManage) {
return (
<div className="flex items-center justify-center h-[50vh]">
<Alert variant="destructive" className="max-w-md">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Access Denied</AlertTitle>
<AlertDescription>
You don't have permission to manage app settings. Contact a super admin for access.
</AlertDescription>
</Alert>
</div>
);
}
if (loading) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
<p className="text-muted-foreground">Configure mobile app behavior settings</p>
</div>
<div className="grid gap-6">
<Skeleton className="h-48 w-full" />
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
<p className="text-muted-foreground">Configure mobile app behavior settings</p>
</div>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Error loading settings</AlertTitle>
<AlertDescription className="flex items-center gap-4">
{error}
<Button variant="outline" size="sm" onClick={refetch}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">App Settings</h1>
<p className="text-muted-foreground">
Configure mobile app behavior settings
</p>
</div>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="ghost" onClick={handleReset}>
Cancel
</Button>
)}
<Button onClick={handleSave} disabled={!hasChanges || isSaving}>
<Save className="h-4 w-4 mr-2" />
{isSaving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{config?.updated_at && (
<p className="text-sm text-muted-foreground">
Last updated: {format(new Date(config.updated_at), 'MMM d, yyyy HH:mm')}
</p>
)}
{/* Answer Spam Prevention */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Users className="h-5 w-5" />
Answer Spam Prevention
</CardTitle>
<CardDescription>
Prevent one partner from getting too far ahead in answering questions
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="threshold">Answer Gap Threshold</Label>
<Input
id="threshold"
type="number"
min="0"
max="100"
value={formData.answer_gap_threshold ?? 10}
onChange={(e) => handleChange('answer_gap_threshold', parseInt(e.target.value) || 0)}
className="max-w-[200px]"
/>
<p className="text-xs text-muted-foreground">
Maximum number of questions a user can answer ahead of their partner.
When exceeded, they'll see a "Waiting for your partner" message.
</p>
<p className="text-xs text-muted-foreground">
Set to <strong>0</strong> to disable this feature.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
+5 -5
View File
@@ -165,13 +165,13 @@ export function DashboardPage() {
];
return (
<div className="space-y-8">
<div className="space-y-6">
{/* Header */}
<div className="relative">
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl hidden xl:block" />
<div className="relative">
<div className="flex items-center gap-3 mb-2">
<h1 className="text-4xl font-bold tracking-tight">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<h1 className="text-2xl md:text-3xl xl:text-4xl font-bold tracking-tight">
Dashboard
</h1>
<RealtimeStatusIndicator
@@ -179,7 +179,7 @@ export function DashboardPage() {
showLabel
/>
</div>
<p className="text-muted-foreground text-lg">
<p className="text-muted-foreground text-sm md:text-base">
Welcome back. Here's what's happening with Sauci.
</p>
</div>
@@ -224,12 +224,12 @@ export function CategoriesPage() {
}
return (
<div className="space-y-6">
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
<p className="text-muted-foreground">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Categories</h1>
<p className="text-muted-foreground text-sm">
Organize your question packs into categories
</p>
</div>
@@ -238,7 +238,8 @@ export function CategoriesPage() {
<DialogTrigger asChild>
<Button onClick={form.openCreate}>
<Plus className="mr-2 h-4 w-4" />
Add Category
<span className="hidden sm:inline">Add Category</span>
<span className="sm:hidden">Add</span>
</Button>
</DialogTrigger>
<Button variant="outline" onClick={() => setAiIdeasOpen(true)}>
+2 -2
View File
@@ -67,7 +67,7 @@ export function PacksPage() {
description: '',
icon: '💕',
is_premium: false,
is_public: true,
is_public: false,
is_explicit: false,
},
(pack) => ({
@@ -267,7 +267,7 @@ export function PacksPage() {
description: result.description,
icon: result.icon || '💕',
is_premium: false,
is_public: true,
is_public: false,
is_explicit: false,
});
setAiDialogOpen(false);
+10 -10
View File
@@ -163,19 +163,19 @@ export function UsersPage() {
}
return (
<div className="space-y-6">
<div className="space-y-4 md:space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">Users</h1>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">Users</h1>
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<p className="text-muted-foreground">
<p className="text-muted-foreground text-sm">
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
</p>
</div>
<div className="relative w-64">
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search users..."
@@ -187,7 +187,7 @@ export function UsersPage() {
</div>
{/* Users Table */}
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
@@ -211,17 +211,17 @@ export function UsersPage() {
filteredProfiles.map((profile) => (
<TableRow key={profile.id}>
<TableCell>
<div className="flex items-center gap-3">
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
<Avatar className="h-9 w-9">
<AvatarImage src={profile.avatar_url || undefined} />
<AvatarFallback>
{profile.name?.charAt(0).toUpperCase() || 'U'}
</AvatarFallback>
</Avatar>
<span className="font-medium">
<span className="font-medium hover:text-primary transition-colors">
{profile.name || 'Unnamed User'}
</span>
</div>
</Link>
</TableCell>
<TableCell className="text-muted-foreground">
{profile.email || '—'}
+34
View File
@@ -9,3 +9,37 @@ interface ImportMetaEnv {
interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Type declarations for emoji-mart packages
declare module '@emoji-mart/data' {
const data: Record<string, unknown>;
export default data;
}
declare module '@emoji-mart/react' {
import { ComponentType } from 'react';
interface PickerProps {
data: Record<string, unknown>;
onEmojiSelect: (emoji: { native: string; id: string; name: string }) => void;
theme?: 'light' | 'dark' | 'auto';
previewPosition?: 'top' | 'bottom' | 'none';
skinTonePosition?: 'preview' | 'search' | 'none';
searchPosition?: 'sticky' | 'static' | 'none';
navPosition?: 'top' | 'bottom' | 'none';
perLine?: number;
maxFrequentRows?: number;
emojiSize?: number;
emojiButtonSize?: number;
emojiButtonRadius?: string;
emojiButtonColors?: string[];
icons?: 'auto' | 'outline' | 'solid';
set?: 'native' | 'apple' | 'facebook' | 'google' | 'twitter';
locale?: string;
autoFocus?: boolean;
dynamicWidth?: boolean;
}
const Picker: ComponentType<PickerProps>;
export default Picker;
}