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:
Vadym Samoilenko 2026-05-23 22:06:43 +01:00
parent fe59c42f0a
commit f66d726e07
4 changed files with 126 additions and 5 deletions

View file

@ -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

View file

@ -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()

View file

@ -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}>

View file

@ -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>