Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions app/actions/auth-legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ type RegisterUserInput = {
recoveryAnswer?: string;
captchaToken?: string;
email?: string;
birthDay?: number;
birthMonth?: number;
birthYear?: number;
};

type GetRecoveryQuestionInput = {
Expand Down Expand Up @@ -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.' };
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -412,6 +438,7 @@ export async function registerUser(formData: RegisterUserInput) {
recoveryQuestion: recoveryQuestion || null,
recoveryAnswerHash,
...(email ? { email, emailVerified: false } : {}),
birthDate,
},
});

Expand Down
41 changes: 40 additions & 1 deletion app/actions/profile.actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ type PrivateSelfProfile = {
badge: string | null;
isVerified: boolean;
totpEnabled: boolean;
birthDate: Date | null;
showAge: boolean;
};

type PublicUserProfile = {
Expand All @@ -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(
Expand Down Expand Up @@ -86,6 +101,8 @@ export async function getUserProfile() {
badge: true,
isVerified: true,
totpEnabled: true,
birthDate: true,
showAge: true,
},
});

Expand Down Expand Up @@ -116,6 +133,7 @@ export async function updateUserProfile(formData: {
displayName?: string;
bio?: string;
profilePhoto?: string | null;
showAge?: boolean;
}) {
const session = await getSessionFromCookies();
if (!session) {
Expand All @@ -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.' };
Expand Down Expand Up @@ -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,
Expand All @@ -172,13 +192,16 @@ export async function updateUserProfile(formData: {
role: true,
badge: true,
isVerified: true,
birthDate: true,
showAge: true,
},
});

await logAuditAction('PROFILE_UPDATED', userId, userId, {
hasDisplayName: Boolean(displayName),
hasBio: Boolean(bio),
hasProfilePhoto: Boolean(profilePhoto),
showAgeUpdated: typeof showAge === 'boolean',
});

return { success: true, user: updatedUser };
Expand Down Expand Up @@ -212,14 +235,30 @@ export async function getPublicUserProfile(userId: string) {
role: true,
badge: true,
isVerified: true,
birthDate: true,
showAge: true,
},
});

if (!user) {
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),
Expand Down
6 changes: 6 additions & 0 deletions app/api/e2ee/register/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -40,6 +43,9 @@ export async function POST(request: Request) {
recoveryAnswer,
captchaToken,
email,
birthDay,
birthMonth,
birthYear,
});

if ('error' in result) {
Expand Down
33 changes: 23 additions & 10 deletions app/chat/ChatDashboardClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,15 +718,28 @@ function ChatDashboardContent() {
if (!currentUser) return;
setIsLoadingAdmin(true);
try {
const withTimeout = async <T,>(task: Promise<T>, label: string, timeoutMs = 12000): Promise<T | null> => {
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<null>((resolve) => {
timeoutHandle = setTimeout(() => {
console.warn(`Admin panel ${label} request timed out.`);
resolve(null);
}, timeoutMs);
});
const result = await Promise.race([task, timeoutPromise]);
if (timeoutHandle) clearTimeout(timeoutHandle);
return result as T | null;
};

const [usersRes, settingsRes, reportsRes, auditRes, overviewRes] = await Promise.all([
getAllUsers(),
getAdminSettings(),
getAllReports(),
getAuditLogs(),
getSystemOverview(),
withTimeout(getAllUsers(), 'users'),
withTimeout(getAdminSettings(), 'settings'),
withTimeout(getAllReports(), 'reports'),
withTimeout(getAuditLogs(), 'audit'),
withTimeout(getSystemOverview(), 'overview'),
]);
if ('success' in usersRes && usersRes.success) setAdminUsers(usersRes.users);
if ('success' in settingsRes && settingsRes.success) {
if (usersRes && 'success' in usersRes && usersRes.success) setAdminUsers(usersRes.users);
if (settingsRes && 'success' in settingsRes && settingsRes.success) {
setAdminSettings((prev) => ({
...settingsRes.settings,
oauthGoogleEnabled: typeof settingsRes.settings.oauthGoogleEnabled === 'boolean'
Expand All @@ -740,9 +753,9 @@ function ChatDashboardContent() {
: (prev?.oauthOidcEnabled ?? false),
}));
}
if ('success' in reportsRes && reportsRes.success) setAdminReports(reportsRes.reports);
if ('success' in auditRes && auditRes.success) setAdminAuditLogs(auditRes.logs);
if ('success' in overviewRes && overviewRes.success) setAdminOverview(overviewRes.stats);
if (reportsRes && 'success' in reportsRes && reportsRes.success) setAdminReports(reportsRes.reports);
if (auditRes && 'success' in auditRes && auditRes.success) setAdminAuditLogs(auditRes.logs);
if (overviewRes && 'success' in overviewRes && overviewRes.success) setAdminOverview(overviewRes.stats);
} catch (error) {
console.error('Fetch admin data error:', error);
} finally {
Expand Down
28 changes: 28 additions & 0 deletions app/chat/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export default function UserProfile() {
const [displayName, setDisplayName] = useState('');
const [bio, setBio] = useState('');
const [profilePhoto, setProfilePhoto] = useState<string | null>(null);
const [showAge, setShowAge] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [statusMessage, setStatusMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null);
Expand Down Expand Up @@ -53,6 +54,7 @@ export default function UserProfile() {
setDisplayName(result.user.displayName || result.user.username || '');
setBio(result.user.bio || '');
setProfilePhoto(result.user.profilePhoto || null);
setShowAge(Boolean((result.user as any).showAge));
setIs2FAEnabled(!!(result.user as any).totpEnabled);
const mergedUser = { ...user, ...result.user };
setCurrentUser(mergedUser);
Expand Down Expand Up @@ -103,6 +105,7 @@ export default function UserProfile() {
displayName,
bio,
profilePhoto,
showAge,
});

setIsSaving(false);
Expand Down Expand Up @@ -204,6 +207,19 @@ export default function UserProfile() {
{currentUser?.role === 'ADMIN' && <div title="Admin"><Shield className="w-5 h-5 text-brand-gold" /></div>}
</div>
<p className="text-sm text-zinc-400 font-mono mt-1">@{currentUser?.username || 'user'}</p>
{currentUser?.birthDate ? (
<p className="text-xs text-zinc-500 mt-1">
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;
})()}
</p>
) : null}
</div>
</div>

Expand All @@ -228,6 +244,18 @@ export default function UserProfile() {
</p>
</div>

<div>
<label className="flex items-center gap-3 rounded-xl border border-zinc-800 bg-zinc-950 px-4 py-3">
<input
type="checkbox"
checked={showAge}
onChange={(e) => setShowAge(e.target.checked)}
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-brand-gold focus:ring-brand-gold"
/>
<span className="text-sm text-zinc-300">Show my age on public profile</span>
</label>
</div>

<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Bio
Expand Down
Loading
Loading