feat: add social features, leaderboard, auth enhancements, and admin fixes
continuous-integration/drone/push Build is passing
continuous-integration/drone/push Build is passing
- Implement Social Features: Shared Tasks, Global Access, Privacy Settings (Leaderboard/Searchable). - Add Leaderboard Page and API. - Enhance Auth: Support Email/Username login, explicit duplicate registration errors. - Fix: Admin login password hash regression. - Refactor: Move to wouter for routing, add Admin Dashboard and User Management. - Add Setup Wizard. - Update UI with Sidebar and Gamification elements.
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { Trophy, Target, TrendingUp, Plus, CheckCircle2, Circle, Flame } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Goal, Reward, User } from '@shared/schema';
|
||||
import { getLevelFromXP, getRankKey } from '@/lib/gamification';
|
||||
import { RewardCard } from '@/components/gamification/RewardCard';
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function AchievementsPage({ user }: { user: User }) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
const [isGoalModalOpen, setIsGoalModalOpen] = useState(false);
|
||||
|
||||
// Mock User Data (Replace with context later)
|
||||
// const user = { xp: 350, streak: 5, bestStreak: 12 };
|
||||
const level = getLevelFromXP(user.xp);
|
||||
const rankKey = getRankKey(level);
|
||||
|
||||
// Fetch Analytics Data
|
||||
const { data: weeklyData } = useQuery({
|
||||
queryKey: ['/api/analytics/weekly'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/analytics/weekly');
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
const { data: yearlyData } = useQuery({
|
||||
queryKey: ['/api/analytics/yearly'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/analytics/yearly');
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
const { data: monthlyData } = useQuery({
|
||||
queryKey: ['/api/analytics/monthly'],
|
||||
queryFn: async () => {
|
||||
const res = await fetch('/api/analytics/monthly');
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
// Fetch Goals
|
||||
const { data: goals = [] } = useQuery<Goal[]>({
|
||||
queryKey: ['/api/goals'],
|
||||
});
|
||||
|
||||
// Create Goal Mutation
|
||||
const createGoalMutation = useMutation({
|
||||
mutationFn: async (newGoal: any) => {
|
||||
const res = await fetch('/api/goals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(newGoal)
|
||||
});
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/goals'] });
|
||||
setIsGoalModalOpen(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Rewards
|
||||
const { data: rewards = [] } = useQuery<Reward[]>({
|
||||
queryKey: ['/api/rewards', user.id],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/rewards?userId=${user.id}`);
|
||||
return res.json();
|
||||
}
|
||||
});
|
||||
|
||||
const buyRewardMutation = useMutation({
|
||||
mutationFn: async (rewardId: string) => {
|
||||
const res = await fetch('/api/rewards/buy', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ rewardId, userId: user.id })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to buy");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
|
||||
// Invalidate user query if we had one for XP, assuming manual update for now or refetch
|
||||
// user.xp -= data.cost... (but user object is static const in this file currently)
|
||||
toast({
|
||||
title: t('rewards.processing'), // Should contain success message really
|
||||
description: "Purchase successful!",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: t('common.error'),
|
||||
description: t('rewards.insufficientFunds') === error.message ? t('rewards.insufficientFunds') : error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const handleBuyReward = (rewardId: string) => {
|
||||
buyRewardMutation.mutate(rewardId);
|
||||
};
|
||||
|
||||
const handleCreateGoal = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
createGoalMutation.mutate({
|
||||
title: formData.get('title'),
|
||||
target: parseInt(formData.get('target') as string),
|
||||
type: formData.get('type'),
|
||||
userId: user.id
|
||||
});
|
||||
};
|
||||
|
||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||
if (active && payload && payload.length) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-background/95 p-3 shadow-xl backdrop-blur-sm">
|
||||
<div className="mb-1 text-xs font-medium text-muted-foreground">
|
||||
{t(`analytics.${label}`) === `analytics.${label}` ?
|
||||
(label && !isNaN(label) ? `${t('analytics.cw')} ${label}` : label)
|
||||
: t(`analytics.${label}`)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2 w-2 rounded-full bg-primary" />
|
||||
<span className="text-sm font-bold">
|
||||
{payload[0].value} XP
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create Reward Mutation
|
||||
const createRewardMutation = useMutation({
|
||||
mutationFn: async (newReward: { title: string, description: string, cost: number }) => {
|
||||
const res = await fetch('/api/rewards', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...newReward,
|
||||
icon: "gift", // Default icon for now
|
||||
type: "virtual"
|
||||
})
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create reward");
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['/api/rewards'] });
|
||||
toast({ title: "Reward created!" });
|
||||
},
|
||||
onError: () => {
|
||||
toast({ title: "Failed to create reward", variant: "destructive" });
|
||||
}
|
||||
});
|
||||
|
||||
const handleCreateReward = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target as HTMLFormElement);
|
||||
createRewardMutation.mutate({
|
||||
title: formData.get('title') as string,
|
||||
description: formData.get('description') as string,
|
||||
cost: parseInt(formData.get('cost') as string)
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-20 md:pb-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-1">{t('achievements.title')}</h1>
|
||||
<p className="text-muted-foreground">{t('achievements.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">{t('achievements.title')}</TabsTrigger>
|
||||
<TabsTrigger value="rewards">{t('rewards.shopTitle')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Level Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('gamification.level', { level })}</CardTitle>
|
||||
<Trophy className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-foreground">{t(`ranks.${rankKey}`)}</div>
|
||||
<p className="text-xs text-muted-foreground flex items-center mt-1">
|
||||
<span className="text-green-500 flex items-center mr-1">
|
||||
<TrendingUp className="h-3 w-3 mr-1" /> +15%
|
||||
</span>
|
||||
{t('achievements.fromLastWeek')}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Streak Card */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{t('achievements.currentStreak')}</CardTitle>
|
||||
<Flame className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{t('achievements.days', { count: user.currentStreak })}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{t('achievements.bestStreak', { count: user.currentStreak })}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* More stats placeholder */}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-7">
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('achievements.xpActivity')}</CardTitle>
|
||||
<CardDescription>{t('achievements.xpDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pl-2">
|
||||
<Tabs defaultValue="weekly" className="space-y-4">
|
||||
<div className="flex items-center justify-end px-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="weekly">{t('achievements.weekly')}</TabsTrigger>
|
||||
<TabsTrigger value="monthly">{t('achievements.monthly')}</TabsTrigger>
|
||||
<TabsTrigger value="yearly">{t('achievements.yearly')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="monthly" className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={monthlyData}>
|
||||
<XAxis
|
||||
dataKey="labelKey"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => {
|
||||
const translationKey = `analytics.${val}`;
|
||||
const translated = t(translationKey);
|
||||
return translated !== translationKey ? translated : `${t('analytics.cw')} ${val}`;
|
||||
}}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
||||
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
|
||||
{monthlyData?.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TabsContent>
|
||||
<TabsContent value="weekly" className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={weeklyData}>
|
||||
<XAxis
|
||||
dataKey="labelKey"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => t(`analytics.${val}`)}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
||||
<Bar dataKey="xp" radius={[4, 4, 0, 0]}>
|
||||
{weeklyData?.map((entry: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.xp > 300 ? 'hsl(var(--primary))' : 'hsl(var(--muted-foreground))'} opacity={0.8} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TabsContent>
|
||||
<TabsContent value="yearly" className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={yearlyData}>
|
||||
<XAxis
|
||||
dataKey="labelKey"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(val) => t(`analytics.${val}`)}
|
||||
/>
|
||||
<YAxis stroke="#888888" fontSize={12} tickLine={false} axisLine={false} />
|
||||
<Tooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(255,255,255,0.05)' }} />
|
||||
<Bar dataKey="xp" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} opacity={0.8} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="col-span-3">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>{t('achievements.goals')}</CardTitle>
|
||||
<CardDescription>{t('achievements.goalsDescription')}</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isGoalModalOpen} onOpenChange={setIsGoalModalOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{t('achievements.addGoal')}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('achievements.createGoal')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateGoal} className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('achievements.goalTitle')}</label>
|
||||
<Input name="title" placeholder={t('achievements.goalTitlePlaceholder')} required />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('achievements.targetValue')}</label>
|
||||
<Input name="target" type="number" placeholder="50" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">{t('achievements.type')}</label>
|
||||
<Select name="type" defaultValue="weekly_tasks">
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="weekly_tasks">{t('achievements.types.weekly_tasks')}</SelectItem>
|
||||
<SelectItem value="total_xp">{t('achievements.types.total_xp')}</SelectItem>
|
||||
<SelectItem value="streak">{t('achievements.types.streak')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">{t('achievements.createGoal')}</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{goals.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<Target className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">{t('achievements.noGoals')}</p>
|
||||
</div>
|
||||
) : (
|
||||
goals.map((goal) => (
|
||||
<div key={goal.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{goal.completed ? (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className={`text-sm font-medium ${goal.completed ? 'line-through text-muted-foreground' : ''}`}>
|
||||
{goal.title}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{goal.current} / {goal.target}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={(goal.current / goal.target) * 100} className="h-2" />
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="rewards">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="col-span-full mb-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border-primary/20">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Trophy className="h-5 w-5 text-primary" />
|
||||
<CardTitle>{t('rewards.shopTitle')}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Spend your {user.xp} XP on exclusive rewards!</CardDescription>
|
||||
</div>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Reward
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Custom Reward</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleCreateReward} className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Title</label>
|
||||
<Input name="title" required />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input name="description" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Cost (XP)</label>
|
||||
<Input name="cost" type="number" required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full">Create Reward</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{rewards.map(reward => (
|
||||
<div key={reward.id} className="h-full">
|
||||
<RewardCard
|
||||
reward={reward}
|
||||
userXp={user.xp}
|
||||
onBuy={handleBuyReward}
|
||||
isBuying={buyRewardMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { User } from "@shared/schema";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Card, CardHeader, CardTitle, CardContent, CardDescription } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Plus, Shield, ShieldAlert, User as UserIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
export default function AdminUserManagement() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newUser, setNewUser] = useState({ username: '', email: '', password: '', role: 'user' });
|
||||
|
||||
// Queries
|
||||
const { data: users = [], isLoading } = useQuery<User[]>({
|
||||
queryKey: ["/api/admin/users"],
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
|
||||
queryKey: ["/api/admin/settings"],
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const toggleActiveMutation = useMutation({
|
||||
mutationFn: (userId: string) => apiRequest("POST", `/api/admin/users/${userId}/toggle-active`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
|
||||
toast({ title: "User status updated" });
|
||||
},
|
||||
onError: (e: Error) => toast({ title: "Failed to update", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
const toggleRegistrationMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => apiRequest("POST", "/api/admin/settings", { registration_enabled: enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/settings"] });
|
||||
toast({ title: "Settings updated" });
|
||||
},
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: (data: typeof newUser) => apiRequest("POST", "/api/admin/users", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/admin/users"] });
|
||||
setIsCreateOpen(false);
|
||||
setNewUser({ username: '', email: '', password: '', role: 'user' });
|
||||
toast({ title: "User created" });
|
||||
},
|
||||
onError: (e: Error) => toast({ title: "Failed to create", description: e.message, variant: "destructive" }),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6 container mx-auto p-4 max-w-5xl">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Management</h1>
|
||||
</div>
|
||||
|
||||
{/* Global Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>System Settings</CardTitle>
|
||||
<CardDescription>Control global access and registration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Public Registration</p>
|
||||
<p className="text-sm text-muted-foreground">Allow new users to sign up</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings?.registration_enabled}
|
||||
onCheckedChange={(checked) => toggleRegistrationMutation.mutate(checked)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* User Table */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Registered Users</CardTitle>
|
||||
<CardDescription>Manage user accounts and roles</CardDescription>
|
||||
</div>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button><Plus className="mr-2 h-4 w-4" /> Create User</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New User</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Username</Label>
|
||||
<Input value={newUser.username} onChange={e => setNewUser({ ...newUser, username: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Email</Label>
|
||||
<Input value={newUser.email} onChange={e => setNewUser({ ...newUser, email: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Password</Label>
|
||||
<Input type="password" value={newUser.password} onChange={e => setNewUser({ ...newUser, password: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Role</Label>
|
||||
<select
|
||||
className="w-full p-2 border rounded-md bg-background"
|
||||
value={newUser.role}
|
||||
onChange={e => setNewUser({ ...newUser, role: e.target.value })}
|
||||
>
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => createUserMutation.mutate(newUser)}
|
||||
disabled={createUserMutation.isPending}
|
||||
className="w-full"
|
||||
>
|
||||
{createUserMutation.isPending ? 'Creating...' : 'Create User'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>XP / Level</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center h-24">Loading users...</TableCell>
|
||||
</TableRow>
|
||||
) : users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{user.username}</span>
|
||||
<span className="text-xs text-muted-foreground">{user.email}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.role === 'admin' ? (
|
||||
<Badge variant="default" className="bg-primary/20 text-primary hover:bg-primary/30">
|
||||
<Shield className="w-3 h-3 mr-1" /> Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">
|
||||
<UserIcon className="w-3 h-3 mr-1" /> User
|
||||
</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.isActive ? (
|
||||
<Badge variant="secondary" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Inactive</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{user.xp} XP (Lvl {user.level})
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant={user.isActive ? "destructive" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => toggleActiveMutation.mutate(user.id)}
|
||||
disabled={user.role === 'admin' && user.username === 'admin'} // Protect super admin heuristic
|
||||
>
|
||||
{user.isActive ? 'Deactivate' : 'Activate'}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useLocation } from "wouter";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { insertUserSchema, InsertUser, loginSchema, registerSchema, LoginUser } from "@shared/schema";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BrainCircuit } from "lucide-react";
|
||||
|
||||
export default function AuthPage() {
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: settings } = useQuery<{ registration_enabled: boolean }>({
|
||||
queryKey: ["/api/settings/public"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/settings/public");
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to fetch settings");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const loginMutation = useMutation({
|
||||
mutationFn: async (data: LoginUser) => {
|
||||
const res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error("Invalid username or password");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Welcome back!" });
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Login failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const registerMutation = useMutation({
|
||||
mutationFn: async (data: InsertUser) => {
|
||||
const res = await fetch("/api/register", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(text || "Registration failed");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Account created!" });
|
||||
},
|
||||
// Error handling is done in the form submission handler to set field errors
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen grid lg:grid-cols-2">
|
||||
<div className="hidden lg:flex flex-col justify-center items-center bg-zinc-900 p-12 text-white">
|
||||
<div className="max-w-md space-y-4 text-center">
|
||||
<div className="bg-white/10 p-4 rounded-2xl inline-block mb-4 backdrop-blur-sm">
|
||||
<BrainCircuit className="w-16 h-16 text-primary-foreground" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">TaskFlow</h1>
|
||||
<p className="text-lg text-zinc-400">
|
||||
Master your productivity with AI-driven task management, gamified
|
||||
achievements, and intelligent focus modes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center p-4 bg-background">
|
||||
<Card className="w-full max-w-md shadow-xl border-border/50">
|
||||
<CardHeader className="text-center space-y-2">
|
||||
<div className="lg:hidden mx-auto bg-primary/10 p-3 rounded-xl w-fit mb-2">
|
||||
<BrainCircuit className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">Welcome Back</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your account
|
||||
{settings?.registration_enabled && " or create a new one"} to get started
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Tabs defaultValue="login" className="space-y-6">
|
||||
<TabsList className={`grid w-full ${settings?.registration_enabled ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
||||
<TabsTrigger value="login">Login</TabsTrigger>
|
||||
{settings?.registration_enabled && (
|
||||
<TabsTrigger value="register">Register</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="login">
|
||||
<AuthForm
|
||||
mode="login"
|
||||
onSubmit={(data) => loginMutation.mutate(data)}
|
||||
isLoading={loginMutation.isPending}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{settings?.registration_enabled && (
|
||||
<TabsContent value="register">
|
||||
<AuthForm
|
||||
mode="register"
|
||||
onSubmit={(data) => {
|
||||
registerMutation.mutate(data as InsertUser, {
|
||||
onError: (error) => {
|
||||
// We can't access form here directly easily without refactoring,
|
||||
// but we can pass a callback or handle it in AuthForm if we passed mutation there.
|
||||
// However, simpler is to catch it here if we want global toast.
|
||||
// The Requirements say "indicate failure".
|
||||
// To set FIELD errors, we must be inside the form submit context or have access to form methods.
|
||||
// Let's refactor AuthForm to handle the mutation itself or return the error?
|
||||
// Actually, simpler: pass the mutation TO AuthForm so it can handle onError.
|
||||
}
|
||||
})
|
||||
}}
|
||||
isLoading={registerMutation.isPending}
|
||||
registerMutation={registerMutation} // Pass mutation to handle errors inside
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthForm({
|
||||
mode,
|
||||
onSubmit,
|
||||
isLoading,
|
||||
registerMutation,
|
||||
}: {
|
||||
mode: "login" | "register";
|
||||
onSubmit: (data: InsertUser | LoginUser) => void;
|
||||
isLoading: boolean;
|
||||
registerMutation?: any; // Type accurately if possible, but 'any' for quick fix avoids generic complexities
|
||||
}) {
|
||||
const { toast } = useToast();
|
||||
const form = useForm<InsertUser>({
|
||||
resolver: zodResolver(mode === "login" ? loginSchema : registerSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (data: InsertUser | LoginUser) => {
|
||||
if (mode === 'register' && registerMutation) {
|
||||
registerMutation.mutate(data, {
|
||||
onError: (error: Error) => {
|
||||
const msg = error.message.toLowerCase();
|
||||
if (msg.includes("username")) {
|
||||
form.setError("username", { type: "manual", message: "Username already exists" });
|
||||
} else if (msg.includes("email")) {
|
||||
form.setError("email", { type: "manual", message: "Email already exists" });
|
||||
} else {
|
||||
toast({
|
||||
title: "Registration failed",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
onSubmit(data);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{mode === 'login' ? 'Username or Email' : 'Username'}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={mode === 'login' ? "Enter username or email" : "Choose a username"} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{mode === "register" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="Enter your email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading
|
||||
? mode === "login"
|
||||
? "Logging in..."
|
||||
: "Creating account..."
|
||||
: mode === "login"
|
||||
? "Sign In"
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Loader2, Trophy, Medal } from "lucide-react";
|
||||
|
||||
interface LeaderboardUser {
|
||||
id: string;
|
||||
username: string;
|
||||
xp: number;
|
||||
level: number;
|
||||
}
|
||||
|
||||
export default function LeaderboardPage() {
|
||||
const { data: leaderboard, isLoading } = useQuery<LeaderboardUser[]>({
|
||||
queryKey: ["/api/leaderboard"],
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-[50vh] items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-4xl mx-auto p-4 md:p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-yellow-500/20 p-3 rounded-xl">
|
||||
<Trophy className="h-8 w-8 text-yellow-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Leaderboard</h1>
|
||||
<p className="text-muted-foreground">Top performers in the community</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Global Rankings</CardTitle>
|
||||
<CardDescription>Users ranked by total XP (opt-in only)</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{leaderboard?.map((user, index) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className={`flex items-center justify-between p-4 rounded-lg border ${index === 0 ? 'bg-yellow-500/10 border-yellow-500/50' :
|
||||
index === 1 ? 'bg-slate-400/10 border-slate-400/50' :
|
||||
index === 2 ? 'bg-amber-700/10 border-amber-700/50' :
|
||||
'bg-card hover:bg-accent/50 transition-colors'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-full font-bold ${index < 3 ? 'text-foreground' : 'text-muted-foreground'
|
||||
}`}>
|
||||
{index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : `#${index + 1}`}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-lg">{user.username}</span>
|
||||
<span className="text-xs text-muted-foreground">Level {user.level}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono font-bold text-lg text-primary">{user.xp}</span>
|
||||
<span className="text-xs text-muted-foreground uppercase tracking-wider">XP</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{leaderboard?.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No users on the leaderboard yet. Be the first to join in Settings!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { insertUserSchema } from "@shared/schema";
|
||||
import { useLocation } from "wouter";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
|
||||
export default function SetupWizard() {
|
||||
const [, setLocation] = useLocation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(insertUserSchema),
|
||||
defaultValues: {
|
||||
username: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const setupMutation = useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
const res = await apiRequest("POST", "/api/setup", data);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: (user) => {
|
||||
queryClient.setQueryData(["/api/user"], user);
|
||||
setLocation("/");
|
||||
toast({
|
||||
title: "Setup Complete",
|
||||
description: "Super Administrator created successfully.",
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast({
|
||||
title: "Setup Failed",
|
||||
description: error.message,
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col items-center justify-center p-4 bg-gradient-to-br from-background to-muted">
|
||||
<Card className="w-full max-w-md border-2 border-primary/20 shadow-xl">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto bg-primary/10 p-3 rounded-full w-fit mb-4">
|
||||
<ShieldCheck className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl">First-Time Setup</CardTitle>
|
||||
<CardDescription>
|
||||
Create your Super Administrator account to get started.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit((data) => setupMutation.mutate(data))} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Admin Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="admin" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Admin Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="admin@example.com" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={setupMutation.isPending}>
|
||||
{setupMutation.isPending ? "Creating Admin..." : "Create Administrator"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,10 +6,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers } from 'lucide-react';
|
||||
import { Globe, Tag, Plus, Edit, Trash2, Layers, User as UserIcon, ShieldCheck } from 'lucide-react'; // Added ShieldCheck
|
||||
import { Label } from '@shared/schema';
|
||||
import { User } from '@shared/schema';
|
||||
import { queryClient, apiRequest } from '@/lib/queryClient';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
interface SettingsProps {
|
||||
onNavigateToTemplates: () => void;
|
||||
@@ -18,16 +20,37 @@ interface SettingsProps {
|
||||
export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
const [, setLocation] = useLocation();
|
||||
|
||||
// Fetch user
|
||||
const { data: user } = useQuery<User>({
|
||||
queryKey: ['/api/user']
|
||||
});
|
||||
|
||||
const [isLabelDialogOpen, setIsLabelDialogOpen] = useState(false);
|
||||
const [editingLabel, setEditingLabel] = useState<Label | null>(null);
|
||||
const [labelName, setLabelName] = useState('');
|
||||
const [labelColor, setLabelColor] = useState('#3B82F6');
|
||||
|
||||
const changeLanguage = (lng: string) => {
|
||||
i18n.changeLanguage(lng);
|
||||
localStorage.setItem('taskflow-language', lng);
|
||||
console.log('Language changed to:', lng);
|
||||
const handleLanguageChange = (value: string) => {
|
||||
i18n.changeLanguage(value);
|
||||
localStorage.setItem('taskflow-language', value);
|
||||
console.log('Language changed to:', value);
|
||||
};
|
||||
|
||||
const privacyMutation = useMutation({
|
||||
mutationFn: async (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
|
||||
const res = await apiRequest("PATCH", "/api/user/privacy", updates);
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/user"] });
|
||||
toast({ title: "Privacy settings updated" });
|
||||
},
|
||||
});
|
||||
|
||||
const handlePrivacyUpdate = (updates: { showOnLeaderboard?: boolean; isSearchable?: boolean }) => {
|
||||
privacyMutation.mutate(updates);
|
||||
};
|
||||
|
||||
// Fetch labels
|
||||
@@ -115,6 +138,49 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Account Settings */}
|
||||
<Card data-testid="card-account-settings">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
Account
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your account settings
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">Username</p>
|
||||
<p className="text-sm text-muted-foreground">{user?.username || 'Loading...'}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">User ID</p>
|
||||
<p className="text-sm text-muted-foreground font-mono">{user?.id || '...'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Admin Settings */}
|
||||
{user?.role === 'admin' && (
|
||||
<Card className="border-primary/50 bg-primary/5">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-primary" />
|
||||
Administration
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
System-wide settings and user management
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button onClick={() => setLocation("/admin/users")} className="w-full sm:w-auto">
|
||||
Manage Users & Registration
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Language Settings */}
|
||||
<Card data-testid="card-language-settings">
|
||||
<CardHeader>
|
||||
@@ -197,8 +263,8 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsLabelDialogOpen(false);
|
||||
setEditingLabel(null);
|
||||
@@ -210,7 +276,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
>
|
||||
{t('settings.labels.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
<Button
|
||||
onClick={handleSaveLabel}
|
||||
disabled={!labelName.trim() || createLabelMutation.isPending || updateLabelMutation.isPending}
|
||||
className="flex-1"
|
||||
@@ -232,15 +298,15 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
) : (
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{labels.map((label) => (
|
||||
<Card
|
||||
key={label.id}
|
||||
className="p-4 hover-elevate active-elevate-2"
|
||||
<Card
|
||||
key={label.id}
|
||||
className="p-4 hover-elevate active-elevate-2"
|
||||
style={{ borderLeft: `4px solid ${label.color}` }}
|
||||
data-testid={`label-${label.id}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
<div
|
||||
className="w-4 h-4 rounded"
|
||||
style={{ backgroundColor: label.color }}
|
||||
/>
|
||||
@@ -305,7 +371,7 @@ export default function Settings({ onNavigateToTemplates }: SettingsProps) {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button
|
||||
<Button
|
||||
onClick={onNavigateToTemplates}
|
||||
data-testid="button-manage-templates"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user