feat: required Terms + Data Processing consent on register, consent timestamps in admin
- Register form: two required checkboxes (Terms of Service / Privacy Policy + UK GDPR data processing) - Zod schema uses z.literal(true) — form won't submit until both are checked - Backend: validates accept_terms + accept_data_processing flags (400 if missing) - User.save() writes created_at, consent_terms_at, consent_data_processing_at to MongoDB - Admin UsersTab: Registered column, email verified badge, consent timestamps in edit dialog - Fix: EU-hosted → UK hosted badge in register form Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fe59c42f0a
commit
f66d726e07
4 changed files with 126 additions and 5 deletions
|
|
@ -4,7 +4,8 @@ from app.db import get_db
|
|||
|
||||
class User:
|
||||
def __init__(self, username, email, password_hash=None, role="user", auth_type="local",
|
||||
email_verified=False, email_verify_token=None, email_verify_expires=None):
|
||||
email_verified=False, email_verify_token=None, email_verify_expires=None,
|
||||
consent_terms_at=None, consent_data_processing_at=None):
|
||||
self.username = username
|
||||
self.email = email
|
||||
self.password_hash = password_hash
|
||||
|
|
@ -13,6 +14,8 @@ class User:
|
|||
self.email_verified = email_verified
|
||||
self.email_verify_token = email_verify_token
|
||||
self.email_verify_expires = email_verify_expires
|
||||
self.consent_terms_at = consent_terms_at
|
||||
self.consent_data_processing_at = consent_data_processing_at
|
||||
|
||||
@staticmethod
|
||||
def hash_password(password):
|
||||
|
|
@ -114,7 +117,9 @@ class User:
|
|||
return result["credits_balance"] if result else 0
|
||||
|
||||
async def save(self):
|
||||
from datetime import timezone
|
||||
db = await get_db()
|
||||
now = datetime.now(timezone.utc)
|
||||
user_data = {
|
||||
"username": self.username,
|
||||
"email": self.email,
|
||||
|
|
@ -123,11 +128,16 @@ class User:
|
|||
"auth_type": self.auth_type,
|
||||
"credits_balance": 0,
|
||||
"email_verified": self.email_verified,
|
||||
"created_at": now,
|
||||
}
|
||||
if self.email_verify_token:
|
||||
user_data["email_verify_token"] = self.email_verify_token
|
||||
if self.email_verify_expires:
|
||||
user_data["email_verify_expires"] = self.email_verify_expires
|
||||
if self.consent_terms_at:
|
||||
user_data["consent_terms_at"] = self.consent_terms_at
|
||||
if self.consent_data_processing_at:
|
||||
user_data["consent_data_processing_at"] = self.consent_data_processing_at
|
||||
result = await db.users.insert_one(user_data)
|
||||
return result.inserted_id
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quart import Blueprint, request, jsonify
|
||||
from app.auth.quart_jwt import create_access_token, jwt_required, get_jwt_identity
|
||||
|
|
@ -23,9 +23,15 @@ async def register():
|
|||
if not data or not data.get('username') or not data.get('email') or not data.get('password'):
|
||||
return jsonify({"message": "Missing required fields"}), 400
|
||||
|
||||
if not data.get('accept_terms'):
|
||||
return jsonify({"message": "You must accept the Terms of Service"}), 400
|
||||
if not data.get('accept_data_processing'):
|
||||
return jsonify({"message": "You must consent to data processing"}), 400
|
||||
|
||||
username = data['username'].strip()
|
||||
email = data['email'].strip().lower()
|
||||
password = data['password']
|
||||
now_utc = datetime.now(timezone.utc)
|
||||
|
||||
if len(username) < 3:
|
||||
return jsonify({"message": "Username must be at least 3 characters"}), 400
|
||||
|
|
@ -51,6 +57,8 @@ async def register():
|
|||
email_verified=False,
|
||||
email_verify_token=verify_token,
|
||||
email_verify_expires=verify_expires,
|
||||
consent_terms_at=now_utc,
|
||||
consent_data_processing_at=now_utc,
|
||||
)
|
||||
user_id = await new_user.save()
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ interface User {
|
|||
quota?: { monthly_usd?: number };
|
||||
cost_mtd?: number;
|
||||
credits_balance?: number;
|
||||
created_at?: string;
|
||||
consent_terms_at?: string;
|
||||
consent_data_processing_at?: string;
|
||||
email_verified?: boolean;
|
||||
}
|
||||
|
||||
export default function UsersTab() {
|
||||
|
|
@ -165,6 +169,7 @@ export default function UsersTab() {
|
|||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Registered</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Credits</TableHead>
|
||||
|
|
@ -176,7 +181,7 @@ export default function UsersTab() {
|
|||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
|
||||
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
|
||||
No users found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
|
@ -186,6 +191,12 @@ export default function UsersTab() {
|
|||
<TableCell>
|
||||
<div className="font-medium">{u.username}</div>
|
||||
<div className="text-xs text-slate-500">{u.email}</div>
|
||||
{u.email_verified === false && (
|
||||
<span className="text-[10px] text-amber-500">unverified</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-500 whitespace-nowrap">
|
||||
{u.created_at ? new Date(u.created_at).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }) : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={u.role === 'admin' ? 'default' : 'secondary'}>
|
||||
|
|
@ -245,6 +256,34 @@ export default function UsersTab() {
|
|||
<DialogTitle>Edit User — {editUser?.username}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* Registration info */}
|
||||
<div className="rounded-lg bg-slate-50 dark:bg-slate-900 border px-3 py-2 text-xs space-y-1 text-slate-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Registered</span>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{editUser?.created_at ? new Date(editUser.created_at).toLocaleString('en-GB') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Email verified</span>
|
||||
<span className={editUser?.email_verified ? 'text-green-600 font-medium' : 'text-amber-500 font-medium'}>
|
||||
{editUser?.email_verified ? 'Yes' : 'No'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Terms accepted</span>
|
||||
<span className={editUser?.consent_terms_at ? 'text-green-600 font-medium' : 'text-slate-400'}>
|
||||
{editUser?.consent_terms_at ? new Date(editUser.consent_terms_at).toLocaleString('en-GB') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Data processing consent</span>
|
||||
<span className={editUser?.consent_data_processing_at ? 'text-green-600 font-medium' : 'text-slate-400'}>
|
||||
{editUser?.consent_data_processing_at ? new Date(editUser.consent_data_processing_at).toLocaleString('en-GB') : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<Label>Role</Label>
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { useForm } from 'react-hook-form';
|
|||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Loader2, Eye, EyeOff, Mail, CheckCircle2, Zap, DollarSign, Clock, Users } from 'lucide-react';
|
||||
|
|
@ -19,6 +20,8 @@ const registerSchema = z.object({
|
|||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(6, 'At least 6 characters'),
|
||||
confirmPassword: z.string(),
|
||||
acceptTerms: z.literal(true, { errorMap: () => ({ message: 'You must accept the Terms of Service' }) }),
|
||||
acceptDataProcessing: z.literal(true, { errorMap: () => ({ message: 'You must consent to data processing' }) }),
|
||||
}).refine(d => d.password === d.confirmPassword, {
|
||||
message: 'Passwords do not match',
|
||||
path: ['confirmPassword'],
|
||||
|
|
@ -134,7 +137,7 @@ export default function Register() {
|
|||
|
||||
const form = useForm<RegisterFormValues>({
|
||||
resolver: zodResolver(registerSchema),
|
||||
defaultValues: { username: '', email: '', password: '', confirmPassword: '' },
|
||||
defaultValues: { username: '', email: '', password: '', confirmPassword: '', acceptTerms: undefined, acceptDataProcessing: undefined },
|
||||
});
|
||||
|
||||
async function onSubmit(values: RegisterFormValues) {
|
||||
|
|
@ -144,6 +147,8 @@ export default function Register() {
|
|||
username: values.username,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
accept_terms: values.acceptTerms,
|
||||
accept_data_processing: values.acceptDataProcessing,
|
||||
});
|
||||
setRegisteredEmail(values.email);
|
||||
setRegistered(true);
|
||||
|
|
@ -327,6 +332,65 @@ export default function Register() {
|
|||
)}
|
||||
/>
|
||||
|
||||
{/* Terms of Service */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acceptTerms"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value === true}
|
||||
onCheckedChange={checked => field.onChange(checked === true ? true : undefined)}
|
||||
disabled={isLoading}
|
||||
className="mt-0.5 border-border data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-snug">
|
||||
<FormLabel className="text-sm text-foreground/80 font-normal cursor-pointer">
|
||||
I agree to the{' '}
|
||||
<Link to="/terms" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
|
||||
Terms of Service
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link to="/privacy" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</FormLabel>
|
||||
<FormMessage className="text-destructive text-xs mt-0.5" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Data Processing Consent */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="acceptDataProcessing"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-start gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Checkbox
|
||||
checked={field.value === true}
|
||||
onCheckedChange={checked => field.onChange(checked === true ? true : undefined)}
|
||||
disabled={isLoading}
|
||||
className="mt-0.5 border-border data-[state=checked]:bg-primary data-[state=checked]:border-primary"
|
||||
/>
|
||||
</FormControl>
|
||||
<div className="leading-snug">
|
||||
<FormLabel className="text-sm text-foreground/80 font-normal cursor-pointer">
|
||||
I consent to the processing of my personal data in accordance with the{' '}
|
||||
<Link to="/privacy" target="_blank" className="text-primary hover:text-primary/80 underline underline-offset-2">
|
||||
Privacy Policy
|
||||
</Link>{' '}
|
||||
(UK GDPR / Data Protection Act 2018)
|
||||
</FormLabel>
|
||||
<FormMessage className="text-destructive text-xs mt-0.5" />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
|
|
@ -342,7 +406,7 @@ export default function Register() {
|
|||
<div className="flex items-center justify-center gap-4 text-xs text-muted-foreground pt-1">
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> 50 free credits</span>
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> No card required</span>
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> EU-hosted</span>
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3.5 w-3.5 text-primary" /> UK hosted</span>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue