Add IsFree subscription toggle to bypass all tier limitations
- Add IsFree boolean field to UserSubscription model - When IsFree is true, user sees limitations_enabled=false regardless of global setting - CheckLimit() bypasses all limit checks for IsFree users - Add admin endpoint GET /api/admin/subscriptions/user/:user_id - Add IsFree toggle to admin user detail page under Subscription card - Add database migration 004_subscription_is_free - Add integration tests for IsFree functionality - Add task kanban categorization documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,12 +7,13 @@ import Link from 'next/link';
|
||||
import { ArrowLeft, Edit, Trash2, Bell, Mail } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { usersApi, notificationsApi, emailsApi } from '@/lib/api';
|
||||
import { usersApi, notificationsApi, emailsApi, subscriptionsApi } from '@/lib/api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@@ -51,6 +52,26 @@ export function UserDetailClient() {
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const { data: subscription } = useQuery({
|
||||
queryKey: ['subscription', 'user', userId],
|
||||
queryFn: () => subscriptionsApi.getByUser(userId),
|
||||
enabled: !!userId,
|
||||
});
|
||||
|
||||
const updateSubscriptionMutation = useMutation({
|
||||
mutationFn: (data: { is_free: boolean }) => {
|
||||
if (!subscription) throw new Error('No subscription');
|
||||
return subscriptionsApi.update(subscription.id, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success('Subscription updated');
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', 'user', userId] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update subscription');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => usersApi.delete(userId),
|
||||
onSuccess: () => {
|
||||
@@ -258,6 +279,71 @@ export function UserDetailClient() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Subscription */}
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Subscription</CardTitle>
|
||||
<CardDescription>User subscription and access settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Tier
|
||||
</div>
|
||||
<Badge variant={subscription?.tier === 'pro' ? 'default' : 'secondary'}>
|
||||
{subscription?.tier || 'free'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Platform
|
||||
</div>
|
||||
<div>{subscription?.platform || '-'}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Auto Renew
|
||||
</div>
|
||||
<Badge variant={subscription?.auto_renew ? 'default' : 'outline'}>
|
||||
{subscription?.auto_renew ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Expires
|
||||
</div>
|
||||
<div>
|
||||
{subscription?.expires_at
|
||||
? new Date(subscription.expires_at).toLocaleDateString()
|
||||
: '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="is-free" className="text-base">
|
||||
Free Access (No Limitations)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
When enabled, this user bypasses all tier limitations regardless of subscription status or global settings.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="is-free"
|
||||
checked={subscription?.is_free ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSubscriptionMutation.mutate({ is_free: checked });
|
||||
}}
|
||||
disabled={updateSubscriptionMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Residences */}
|
||||
{user.residences && user.residences.length > 0 && (
|
||||
<Card className="md:col-span-2">
|
||||
|
||||
@@ -335,6 +335,11 @@ export const subscriptionsApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getByUser: async (userId: number): Promise<Subscription> => {
|
||||
const response = await api.get<Subscription>(`/subscriptions/user/${userId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
update: async (id: number, data: UpdateSubscriptionRequest): Promise<Subscription> => {
|
||||
const response = await api.put<Subscription>(`/subscriptions/${id}`, data);
|
||||
return response.data;
|
||||
|
||||
@@ -472,6 +472,7 @@ export interface Subscription {
|
||||
tier: 'free' | 'premium' | 'pro';
|
||||
platform: string;
|
||||
auto_renew: boolean;
|
||||
is_free: boolean;
|
||||
subscribed_at?: string;
|
||||
expires_at?: string;
|
||||
cancelled_at?: string;
|
||||
@@ -491,6 +492,7 @@ export interface SubscriptionListParams extends ListParams {
|
||||
export interface UpdateSubscriptionRequest {
|
||||
tier?: string;
|
||||
auto_renew?: boolean;
|
||||
is_free?: boolean;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
|
||||
Reference in New Issue
Block a user