Skip to content

Commit bf82b0b

Browse files
Improve registration flow for parental controls and fix min age on setitngs update.
1 parent 21cdb06 commit bf82b0b

5 files changed

Lines changed: 53 additions & 18 deletions

File tree

backend/src/handlers/userHandler.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { OutboundEmail } from '../models/outboundEmail.entity';
66
import { User } from '../models/user.entity';
77
import { validateUserRegistration } from '../middleware/validation';
88
import { hashPassword, comparePassword } from '../utils/password';
9-
import { canRegister, getGeoBlockLevel } from '../utils/eu';
9+
import { canRegister, getGeoBlockLevel, getMinimumAgeForCountry } from '../utils/eu';
1010
import { authenticate } from '../middleware/auth';
1111
import { UserLog } from '../models/userLog.entity';
1212
import { ParentLinkRequest } from '../models/parentLinkRequest.entity';
@@ -208,6 +208,7 @@ export async function userRoutes(app: any, prefix = '') {
208208
: undefined;
209209

210210
const inviteRepo = AppDataSource.getRepository(ParentRegistrationInvite);
211+
const userRepo = AppDataSource.getRepository(User);
211212
let parentRegistrationInvite: ParentRegistrationInvite | null = null;
212213
if (parentRegistrationToken) {
213214
parentRegistrationInvite = await inviteRepo.findOneBy({ token: parentRegistrationToken, used: false });
@@ -221,6 +222,18 @@ export async function userRoutes(app: any, prefix = '') {
221222
return { error: 'email_mismatch', message: 'The child email does not match the parent registration invite.' };
222223
}
223224
body.parentId = parentRegistrationInvite.parentId;
225+
226+
const parentUser = await userRepo.findOneBy({ id: parentRegistrationInvite.parentId });
227+
if (parentUser) {
228+
if (!body.phone && parentUser.phone) body.phone = parentUser.phone;
229+
if (!body.address && parentUser.address) body.address = parentUser.address;
230+
if (!body.address2 && parentUser.address2) body.address2 = parentUser.address2;
231+
if (!body.billingCompany && parentUser.billingCompany) body.billingCompany = parentUser.billingCompany;
232+
if (!body.billingCity && parentUser.billingCity) body.billingCity = parentUser.billingCity;
233+
if (!body.billingState && parentUser.billingState) body.billingState = parentUser.billingState;
234+
if (!body.billingZip && parentUser.billingZip) body.billingZip = parentUser.billingZip;
235+
if (!body.billingCountry && parentUser.billingCountry) body.billingCountry = parentUser.billingCountry;
236+
}
224237
}
225238

226239
const valid = await validateUserRegistration(ctx, ctx, { skipMinimumAge: !!parentRegistrationInvite });
@@ -243,7 +256,6 @@ export async function userRoutes(app: any, prefix = '') {
243256
ctx.set.status = 400;
244257
return { error: 'registration_email_reserved', message: 'That email is reserved by the panel and cannot be used for registration.' };
245258
}
246-
const userRepo = AppDataSource.getRepository(User);
247259

248260
if (body.parentId != null) {
249261
const parentId = Number(body.parentId);
@@ -568,7 +580,7 @@ export async function userRoutes(app: any, prefix = '') {
568580
}, {
569581
beforeHandle: authenticate,
570582
body: t.Object({ childEmail: t.Optional(t.String()) }),
571-
response: { 200: t.Object({ success: t.Boolean(), invite: t.Object({ id: t.Number(), token: t.String(), childEmail: t.Optional(t.String()), link: t.String(), createdAt: t.String(), used: t.Boolean() }) }), 400: t.Object({ error: t.String() }), 401: t.Object({ error: t.String() }), 403: t.Object({ error: t.String() }) },
583+
response: { 200: t.Object({ success: t.Boolean(), invite: t.Object({ id: t.Number(), token: t.String(), childEmail: t.Union([t.String(), t.Null()]), link: t.String(), createdAt: t.String(), used: t.Boolean() }) }), 400: t.Object({ error: t.String() }), 401: t.Object({ error: t.String() }), 403: t.Object({ error: t.String() }) },
572584
detail: { summary: 'Create a parent registration invite for a child account', tags: ['Users'] }
573585
});
574586

@@ -599,7 +611,7 @@ export async function userRoutes(app: any, prefix = '') {
599611
};
600612
}, {
601613
beforeHandle: authenticate,
602-
response: { 200: t.Object({ success: t.Boolean(), invites: t.Array(t.Object({ id: t.Number(), token: t.String(), childEmail: t.Optional(t.String()), link: t.String(), createdAt: t.String(), used: t.Boolean(), usedAt: t.Optional(t.Union([t.String(), t.Null()])), expiresAt: t.Optional(t.Union([t.String(), t.Null()])) })) }), 401: t.Object({ error: t.String() }) },
614+
response: { 200: t.Object({ success: t.Boolean(), invites: t.Array(t.Object({ id: t.Number(), token: t.String(), childEmail: t.Union([t.String(), t.Null()]), link: t.String(), createdAt: t.String(), used: t.Boolean(), usedAt: t.Optional(t.Union([t.String(), t.Null()])), expiresAt: t.Optional(t.Union([t.String(), t.Null()])) })) }), 401: t.Object({ error: t.String() }) },
603615
detail: { summary: 'List parent registration invites for the current parent', tags: ['Users'] }
604616
});
605617

@@ -1991,14 +2003,16 @@ export async function userRoutes(app: any, prefix = '') {
19912003
return { error: 'invalid_date_of_birth', message: 'dateOfBirth must be a valid date string in YYYY-MM-DD format.' };
19922004
}
19932005
const updatedAge = getAgeFromDate(dateOfBirth);
1994-
if (updatedAge !== null && updatedAge < 14) {
2006+
const effectiveCountry = typeof payload.billingCountry === 'string' ? payload.billingCountry : user.billingCountry;
2007+
const minimumAge = await getMinimumAgeForCountry(effectiveCountry);
2008+
if (updatedAge !== null && updatedAge < minimumAge) {
19952009
if (!isAdmin) {
19962010
ctx.set.status = 400;
1997-
return { error: 'minimum_age', message: 'Users must be at least 14 years old.' };
2011+
return { error: 'minimum_age', message: `Users must be at least ${minimumAge} years old.` };
19982012
}
19992013
user.suspended = true;
20002014
user.fraudFlag = true;
2001-
user.fraudReason = 'Underage account (<14 years)';
2015+
user.fraudReason = `Underage account (<${minimumAge} years)`;
20022016
}
20032017
user.dateOfBirth = dateOfBirth;
20042018
delete payload.dateOfBirth;

frontend/app/register/page.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -606,9 +606,11 @@ export default function RegisterPage() {
606606
if (name === "password") setPasswordStrength(getPasswordStrength(value))
607607
}
608608

609+
const isParentInvite = Boolean(form.parentRegistrationToken)
610+
609611
/* ─── Step validation ─── */
610-
const canProceedStep0 = form.firstName && form.lastName && form.email && form.password && form.phone && form.dateOfBirth && passwordStrength >= 0.55
611-
const canProceedStep1 = form.address && form.billingCity && form.billingState && form.billingZip && form.billingCountry
612+
const canProceedStep0 = Boolean(form.firstName && form.lastName && form.email && form.password && form.dateOfBirth && passwordStrength >= 0.55 && (isParentInvite || form.phone))
613+
const canProceedStep1 = Boolean(isParentInvite || (form.address && form.billingCity && form.billingState && form.billingZip && form.billingCountry))
612614

613615
const nextStep = () => {
614616
if (step === 0 && !canProceedStep0) {
@@ -897,7 +899,7 @@ export default function RegisterPage() {
897899
label={t("phoneNumber")}
898900
value={form.phone}
899901
onChange={handleChange}
900-
required
902+
required={!isParentInvite}
901903
autoComplete="tel"
902904
/>
903905

@@ -934,7 +936,7 @@ export default function RegisterPage() {
934936
label={t("streetAddress")}
935937
value={form.address}
936938
onChange={handleChange}
937-
required
939+
required={!isParentInvite}
938940
autoComplete="address-line1"
939941
/>
940942

@@ -947,14 +949,20 @@ export default function RegisterPage() {
947949
autoComplete="address-line2"
948950
/>
949951

952+
{isParentInvite && (
953+
<p className="text-sm text-muted-foreground">
954+
{t("parentInviteHint")}
955+
</p>
956+
)}
957+
950958
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
951959
<InputField
952960
name="billingCity"
953961
placeholder={t("cityPlaceholder")}
954962
label={t("city")}
955963
value={form.billingCity}
956964
onChange={handleChange}
957-
required
965+
required={!isParentInvite}
958966
autoComplete="address-level2"
959967
/>
960968
<InputField
@@ -963,7 +971,7 @@ export default function RegisterPage() {
963971
label={t("stateProvince")}
964972
value={form.billingState}
965973
onChange={handleChange}
966-
required
974+
required={!isParentInvite}
967975
autoComplete="address-level1"
968976
/>
969977
</div>
@@ -975,7 +983,7 @@ export default function RegisterPage() {
975983
label={t("zipPostal")}
976984
value={form.billingZip}
977985
onChange={handleChange}
978-
required
986+
required={!isParentInvite}
979987
autoComplete="postal-code"
980988
/>
981989
<SelectField
@@ -984,7 +992,7 @@ export default function RegisterPage() {
984992
label={t("country")}
985993
value={form.billingCountry}
986994
onChange={handleChange}
987-
required
995+
required={!isParentInvite}
988996
>
989997
<option value="" className="bg-background text-muted-foreground">
990998
{t("selectCountry")}

frontend/lib/api-client.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,17 @@ export async function apiFetch(
9898
let msg = text;
9999
try {
100100
const json = JSON.parse(text);
101-
msg = json.error || JSON.stringify(json);
101+
if (json?.error) {
102+
msg = json.error;
103+
} else if (json?.message) {
104+
msg = json.message;
105+
} else if (json?.type === 'validation' && json?.found) {
106+
msg = Object.entries(json.found)
107+
.map(([field, value]) => `${field}: ${value}`)
108+
.join('; ');
109+
} else {
110+
msg = JSON.stringify(json);
111+
}
102112
} catch {}
103113
throw new Error(msg || `HTTP error ${res.status}`);
104114
}

frontend/messages/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,8 @@
269269
"title": "LANGUAGE",
270270
"current": "current",
271271
"footer": "Use arrow keys to navigate, ENTER to confirm.",
272-
"mobileHint": "Tap to select on mobile."
272+
"mobileHint": "Tap to select on mobile.",
273+
"skipHint": "Press any key or click/tap to skip the typing animation."
273274
},
274275
"legal": {
275276
"line1": "Choose a document to review.",

frontend/messages/ru.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,7 +260,8 @@
260260
"title": "ЯЗЫК",
261261
"current": "текущий",
262262
"footer": "Используйте стрелки для навигации, ENTER для подтверждения.",
263-
"mobileHint": "Нажмите, чтобы выбрать на мобильном."
263+
"mobileHint": "Нажмите, чтобы выбрать на мобильном.",
264+
"skipHint": "Нажмите любую кнопку или коснитесь экрана, чтобы пропустить анимацию набора."
264265
},
265266
"legal": {
266267
"line1": "Выберите документ для просмотра.",
@@ -774,6 +775,7 @@
774775
"securityNote": "Защищено шифрованием промышленного стандарта",
775776
"requiredFieldsStep0": "Пожалуйста, заполните все обязательные поля и убедитесь, что пароль имеет хотя бы среднюю надёжность.",
776777
"requiredFieldsStep1": "Пожалуйста, заполните все обязательные поля адреса.",
778+
"parentInviteHint": "Если вы регистрируетесь по приглашению родителя, данные телефонов и плательщика могут быть унаследованы от родительского аккаунта, если оставить их пустыми.",
777779
"captchaChallengeOrWait": "Пожалуйста, решите капчу или подождите, пока невидимая капча станет активной.",
778780
"captchaExpired": "Капча не загружена или истекла. Пожалуйста, обновите капчу и попробуйте снова.",
779781
"invisibleCaptchaMissing": "Отсутствует токен невидимой капчи. Попробуйте обновить страницу.",

0 commit comments

Comments
 (0)