diff --git a/app/actions/auth-legacy.ts b/app/actions/auth-legacy.ts
index 0e689f5..0ab9727 100644
--- a/app/actions/auth-legacy.ts
+++ b/app/actions/auth-legacy.ts
@@ -84,6 +84,9 @@ type RegisterUserInput = {
recoveryAnswer?: string;
captchaToken?: string;
email?: string;
+ birthDay?: number;
+ birthMonth?: number;
+ birthYear?: number;
};
type GetRecoveryQuestionInput = {
@@ -286,6 +289,9 @@ export async function registerUser(formData: RegisterUserInput) {
const recoveryAnswer = typeof formData.recoveryAnswer === 'string' ? formData.recoveryAnswer : '';
const captchaToken = asTrimmedString(formData.captchaToken);
const email = typeof formData.email === 'string' ? formData.email.trim().toLowerCase() : '';
+ const birthDay = Number.isInteger(formData.birthDay) ? Number(formData.birthDay) : NaN;
+ const birthMonth = Number.isInteger(formData.birthMonth) ? Number(formData.birthMonth) : NaN;
+ const birthYear = Number.isInteger(formData.birthYear) ? Number(formData.birthYear) : NaN;
if (!username || !password || !confirmPassword || !identityKeyPublic || !signedPreKey || !signedPreKeySig) {
return { error: 'Missing required registration fields.' };
@@ -365,6 +371,26 @@ export async function registerUser(formData: RegisterUserInput) {
return { error: 'Please enter a valid email address.' };
}
+ if (!Number.isInteger(birthDay) || !Number.isInteger(birthMonth) || !Number.isInteger(birthYear)) {
+ return { error: 'Birth date is required.' };
+ }
+
+ const birthDate = new Date(Date.UTC(birthYear, birthMonth - 1, birthDay));
+ if (
+ birthDate.getUTCFullYear() !== birthYear ||
+ birthDate.getUTCMonth() !== birthMonth - 1 ||
+ birthDate.getUTCDate() !== birthDay
+ ) {
+ return { error: 'Birth date is invalid.' };
+ }
+
+ const minAllowedBirthDate = new Date(Date.UTC(1900, 0, 1));
+ const today = new Date();
+ const maxAllowedBirthDate = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()));
+ if (birthDate < minAllowedBirthDate || birthDate > maxAllowedBirthDate) {
+ return { error: 'Birth date is out of allowed range.' };
+ }
+
try {
const existingUser = await prisma.user.findUnique({
where: { username },
@@ -412,6 +438,7 @@ export async function registerUser(formData: RegisterUserInput) {
recoveryQuestion: recoveryQuestion || null,
recoveryAnswerHash,
...(email ? { email, emailVerified: false } : {}),
+ birthDate,
},
});
diff --git a/app/actions/profile.actions.ts b/app/actions/profile.actions.ts
index 00c97a0..d2ea86c 100644
--- a/app/actions/profile.actions.ts
+++ b/app/actions/profile.actions.ts
@@ -23,6 +23,8 @@ type PrivateSelfProfile = {
badge: string | null;
isVerified: boolean;
totpEnabled: boolean;
+ birthDate: Date | null;
+ showAge: boolean;
};
type PublicUserProfile = {
@@ -35,6 +37,19 @@ type PublicUserProfile = {
role: string;
badge: string | null;
isVerified: boolean;
+ age: number | null;
+};
+
+const computeAge = (birthDate: Date | null) => {
+ if (!birthDate) return null;
+ const today = new Date();
+ let age = today.getUTCFullYear() - birthDate.getUTCFullYear();
+ const monthDiff = today.getUTCMonth() - birthDate.getUTCMonth();
+ const dayDiff = today.getUTCDate() - birthDate.getUTCDate();
+ if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
+ age -= 1;
+ }
+ return age >= 0 ? age : null;
};
async function logAuditAction(
@@ -86,6 +101,8 @@ export async function getUserProfile() {
badge: true,
isVerified: true,
totpEnabled: true,
+ birthDate: true,
+ showAge: true,
},
});
@@ -116,6 +133,7 @@ export async function updateUserProfile(formData: {
displayName?: string;
bio?: string;
profilePhoto?: string | null;
+ showAge?: boolean;
}) {
const session = await getSessionFromCookies();
if (!session) {
@@ -127,6 +145,7 @@ export async function updateUserProfile(formData: {
const bio = asTrimmedString(formData.bio);
const profilePhoto =
typeof formData.profilePhoto === 'string' ? formData.profilePhoto.trim() : formData.profilePhoto;
+ const showAge = typeof formData.showAge === 'boolean' ? formData.showAge : undefined;
if (displayName && displayName.length > 50) {
return { error: 'Display name must be 50 characters or less.' };
@@ -161,6 +180,7 @@ export async function updateUserProfile(formData: {
displayName: displayName || null,
bio: bio || null,
profilePhoto: profilePhoto ?? null,
+ ...(typeof showAge === 'boolean' ? { showAge } : {}),
},
select: {
id: true,
@@ -172,6 +192,8 @@ export async function updateUserProfile(formData: {
role: true,
badge: true,
isVerified: true,
+ birthDate: true,
+ showAge: true,
},
});
@@ -179,6 +201,7 @@ export async function updateUserProfile(formData: {
hasDisplayName: Boolean(displayName),
hasBio: Boolean(bio),
hasProfilePhoto: Boolean(profilePhoto),
+ showAgeUpdated: typeof showAge === 'boolean',
});
return { success: true, user: updatedUser };
@@ -212,6 +235,8 @@ export async function getPublicUserProfile(userId: string) {
role: true,
badge: true,
isVerified: true,
+ birthDate: true,
+ showAge: true,
},
});
@@ -219,7 +244,21 @@ export async function getPublicUserProfile(userId: string) {
return { error: 'User not found.' };
}
- return { success: true, user: user as PublicUserProfile };
+ return {
+ success: true,
+ user: {
+ id: user.id,
+ username: user.username,
+ numericId: user.numericId,
+ displayName: user.displayName,
+ bio: user.bio,
+ profilePhoto: user.profilePhoto,
+ role: user.role,
+ badge: user.badge,
+ isVerified: user.isVerified,
+ age: user.showAge ? computeAge(user.birthDate) : null,
+ } as PublicUserProfile,
+ };
} catch (error) {
logger.error('Get profile error.', {
error: error instanceof Error ? error.message : String(error),
diff --git a/app/api/e2ee/register/route.ts b/app/api/e2ee/register/route.ts
index 73bd94d..34517a3 100644
--- a/app/api/e2ee/register/route.ts
+++ b/app/api/e2ee/register/route.ts
@@ -18,6 +18,9 @@ export async function POST(request: Request) {
const recoveryAnswer = typeof body?.recoveryAnswer === 'string' ? body.recoveryAnswer : '';
const captchaToken = typeof body?.captchaToken === 'string' ? body.captchaToken : '';
const email = typeof body?.email === 'string' ? body.email.trim().toLowerCase() : '';
+ const birthDay = Number.isInteger(body?.birthDay) ? body.birthDay : undefined;
+ const birthMonth = Number.isInteger(body?.birthMonth) ? body.birthMonth : undefined;
+ const birthYear = Number.isInteger(body?.birthYear) ? body.birthYear : undefined;
if (!agreementPublicKey || !signingPublicKey || !signedPreKey || !signedPreKeySig) {
return NextResponse.json({ error: 'Missing v2 registration bundle.' }, { status: 400 });
@@ -40,6 +43,9 @@ export async function POST(request: Request) {
recoveryAnswer,
captchaToken,
email,
+ birthDay,
+ birthMonth,
+ birthYear,
});
if ('error' in result) {
diff --git a/app/chat/ChatDashboardClient.tsx b/app/chat/ChatDashboardClient.tsx
index 1741c18..0c9379f 100644
--- a/app/chat/ChatDashboardClient.tsx
+++ b/app/chat/ChatDashboardClient.tsx
@@ -718,15 +718,28 @@ function ChatDashboardContent() {
if (!currentUser) return;
setIsLoadingAdmin(true);
try {
+ const withTimeout = async @{currentUser?.username || 'user'}
+ Age: {(() => {
+ const birthDate = new Date(currentUser.birthDate);
+ const now = new Date();
+ let age = now.getUTCFullYear() - birthDate.getUTCFullYear();
+ const monthDiff = now.getUTCMonth() - birthDate.getUTCMonth();
+ const dayDiff = now.getUTCDate() - birthDate.getUTCDate();
+ if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) age -= 1;
+ return age;
+ })()}
+
Birth date
+{birthDateError}
+ ) : ( +Required for age-based profile visibility control.
+ )} +