//? intent: Account self-service — change your own credentials, theme, or delete the account. import { faMoon, faRightFromBracket, faSun, faTrash, faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Avatar from "src/_components/Avatar"; import Dropdown, { type DropdownItem } from "src/_components/dropdown/Dropdown"; import { i18nNotify as notify, useSession, useTheme, useTranslator, useUpdateLanguage, } from "@luckystack/core/client"; import type { PageMiddleware } from "@luckystack/core/client"; import { menuHandler } from "src/_functions/menuHandler"; import { apiRequest } from "src/_sockets/apiRequest"; import TwoFactorSection from "./_components/TwoFactorSection"; import { backendUrl, SessionLayout } from "../../config"; const stripAvatarVersion = (url: string) => url.replace(/[?&]v=\d+/, ''); //? Keep in sync with the locale files under `src/_locales/`. Each code here //? must match both a key in `settings.language.*` AND a `.json` locale file. const LANGUAGES = ['nl', 'en', 'de', 'fr'] as const; type Language = typeof LANGUAGES[number]; const THEMES = [ { value: 'light', icon: faSun }, { value: 'dark', icon: faMoon }, ] as const; type Theme = typeof THEMES[number]['value']; interface UserPreferences { notifyOnNewSignIn?: boolean; notifyOnPasswordChange?: boolean; } interface ActiveSession { handle: string; expiresInSeconds: number | null; isCurrent: boolean; } export const template = 'home'; //? Per-page route guard. Example of the framework's per-page middleware //? pattern (see `docs/luckystack/ARCHITECTURE_ROUTING.md`). Auto-registered //? by `src/main.tsx`'s `getRoutes()` loop. To require additional checks //? (admin role, paid tier, etc.) extend the function body — return //? `{ success: false, redirect: '/...' }` to bounce, or `undefined` to //? send the user back in browser history with an optional toast. export const middleware: PageMiddleware = ({ session }) => { if (!session) return { success: false, redirect: '/login' }; return { success: true }; }; function Section({ title, children }: { title: string; children: React.ReactNode }) { return (

{title}

{children}
); } const segmentedClass = (active: boolean) => `flex-1 h-9 rounded-md text-sm font-medium border transition-colors cursor-pointer ${active ? 'bg-primary border-primary text-white' : 'bg-container2 border-container2-border text-common hover:bg-container2-hover hover:text-title'}`; export default function Home() { const { session } = useSession(); const { setTheme: updateTheme } = useTheme(); const setLanguage = useUpdateLanguage(); const translate = useTranslator(); const fileInputRef = useRef(null); const [newLanguage, setNewLanguage] = useState(session?.language ?? 'en'); const [newName, setNewName] = useState(session?.name ?? ''); const [newTheme, setNewTheme] = useState(session?.theme ?? 'dark'); const [newEmail, setNewEmail] = useState(session?.email ?? ''); const [emailChangePending, setEmailChangePending] = useState(false); const [emailChangePassword, setEmailChangePassword] = useState(''); const [activeSessions, setActiveSessions] = useState([]); const [preferences, setPreferences] = useState( (session?.preferences as UserPreferences | undefined) ?? {}, ); //? Email change routes through a confirmation email + one-shot token //? rather than being applied directly. The actual write happens on the //? user clicking the link sent to the new address. const handleRequestEmailChange = useCallback(async () => { if (!session) return; const trimmed = newEmail.trim(); if (!trimmed || trimmed.toLowerCase() === session.email.toLowerCase()) return; //? Only credentials accounts can change their email, and they must confirm //? their current password — the route rejects the change otherwise. OAuth //? accounts never see the form (their email belongs to the provider). if (session.provider !== 'credentials') return; if (!emailChangePassword) { notify.error({ key: 'settings.emailChange.currentPasswordRequired' }); return; } setEmailChangePending(true); const response = await apiRequest({ name: 'settings/requestEmailChange', version: 'v1', data: { newEmail: trimmed, currentPassword: emailChangePassword }, }); setEmailChangePending(false); if (response.status === 'success') { setEmailChangePassword(''); notify.info({ key: 'settings.emailChange.checkInbox' }); } else { notify.error({ key: response.errorCode }); } }, [newEmail, session, emailChangePassword]); const saveProfile = useCallback(async (newAvatar?: string) => { if (!session) return; const avatarChanged = newAvatar ? stripAvatarVersion(newAvatar) !== stripAvatarVersion(session.avatar) : false; const avatarToSave = avatarChanged ? newAvatar : undefined; if ( newLanguage === session.language && newName === session.name && newTheme === session.theme && !newAvatar ) { notify.info({ key: 'settings.noChangesMade' }); return; } const response = await apiRequest({ name: "settings/updateUser", version: 'v1', data: { language: newLanguage === session.language ? undefined : newLanguage, avatar: avatarToSave, name: newName === session.name ? undefined : newName, theme: newTheme === session.theme ? undefined : newTheme, }, }); if (response.status === 'success') { notify.success({ key: 'settings.updatedUser' }); } else { notify.error({ key: 'settings.failedUpdateUser' }); } }, [newLanguage, newName, newTheme, session]); const handleAvatarFile = useCallback((event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; if (!file) return; const maxSize = 4 * 1024 * 1024; if (file.size > maxSize) { notify.error({ key: 'settings.sizeToLarge' }); return; } notify.info({ key: 'settings.loadingImg' }); const reader = new FileReader(); reader.addEventListener('load', () => { const result = reader.result; if (typeof result === 'string') { void saveProfile(`${result}?v=${String(Date.now())}`); } }); reader.readAsDataURL(file); }, [saveProfile]); const refreshSessions = useCallback(async () => { const response = await apiRequest({ name: 'settings/listSessions', version: 'v1', data: {}, }); if (response.status === 'success') { setActiveSessions(response.result.sessions); } }, []); useEffect(() => { void refreshSessions(); }, [refreshSessions]); const languageItems: DropdownItem[] = useMemo(() => LANGUAGES.map((lang) => ({ id: lang, value: lang, placeholder: translate({ key: `settings.language.${lang}` }), })), [translate]); const selectedLanguageItem = languageItems.find((item) => item.id === newLanguage); // ------- password change ------- const passwordCurrentRef = useRef(null); const passwordNewRef = useRef(null); const passwordConfirmRef = useRef(null); const [passwordLoading, setPasswordLoading] = useState(false); //? Credentials accounts must reconfirm with their password before the //? server (`deleteAccount_v1`) will erase them — collected here and sent as //? `data.password`. OAuth-only accounts have no hash, so the field is hidden //? and the server skips the check. const deletePasswordRef = useRef(null); const handleChangePassword = async (event: React.SyntheticEvent) => { event.preventDefault(); if (passwordLoading) return; setPasswordLoading(true); const response = await apiRequest({ name: 'settings/changePassword', version: 'v1', data: { currentPassword: passwordCurrentRef.current?.value ?? '', newPassword: passwordNewRef.current?.value ?? '', confirmPassword: passwordConfirmRef.current?.value ?? '', }, }); setPasswordLoading(false); if (response.status === 'success') { notify.success({ key: 'settings.passwordChanged' }); if (passwordCurrentRef.current) passwordCurrentRef.current.value = ''; if (passwordNewRef.current) passwordNewRef.current.value = ''; if (passwordConfirmRef.current) passwordConfirmRef.current.value = ''; } else { notify.error({ key: response.errorCode }); } }; // ------- sessions ------- const handleRevokeSession = async (handle: string) => { const response = await apiRequest({ name: 'settings/revokeSession', version: 'v1', data: { handle }, }); if (response.status === 'success') { notify.success({ key: 'settings.sessionRevoked' }); void refreshSessions(); } else { notify.error({ key: response.errorCode }); } }; // ------- preferences ------- const togglePreference = async (key: keyof UserPreferences) => { const next = { ...preferences, [key]: !preferences[key] }; setPreferences(next); const response = await apiRequest({ name: 'settings/updatePreferences', version: 'v1', data: { preferences: next }, }); if (response.status === 'success') { notify.success({ key: 'settings.preferencesSaved' }); } else { // Roll back on error setPreferences(preferences); notify.error({ key: response.errorCode }); } }; // ------- danger zone ------- const handleSignOutEverywhere = async () => { const confirmed = await menuHandler.confirm({ title: translate({ key: 'settings.signOutEverywhere' }), content: translate({ key: 'settings.signOutEverywhereConfirm' }), }); if (!confirmed) return; const response = await apiRequest({ name: 'settings/signOutEverywhere', version: 'v1', data: {}, }); if (response.status === 'success') { notify.success({ key: 'settings.signOutEverywhereDone' }); // Server will close our socket; redirect happens via session update } else { notify.error({ key: response.errorCode }); } }; const handleDeleteAccount = async () => { const confirmed = await menuHandler.confirm({ title: translate({ key: 'settings.deleteAccount' }), content: translate({ key: 'settings.deleteAccountConfirm' }), input: 'DELETE', }); if (!confirmed) return; //? Credentials accounts must re-enter their password — the server //? (`deleteAccount_v1`) rejects with `login.wrongPassword` otherwise. //? OAuth-only accounts have no hash, so the field is hidden and we send //? `undefined` (server skips the check). const isCredentials = session?.provider === 'credentials'; const password = deletePasswordRef.current?.value ?? ''; const response = await apiRequest({ name: 'settings/deleteAccount', version: 'v1', data: { confirmation: 'DELETE', password: isCredentials ? password : undefined }, }); if (response.status === 'success') { notify.success({ key: 'settings.deleteAccountDone' }); } else { notify.error({ key: response.errorCode }); } }; if (!session) return null; const displayUrl = session.avatar.startsWith('http') ? session.avatar : `${backendUrl}/uploads/${session.avatar}`; const inputClass = "w-full h-9 bg-container2 border border-container2-border rounded-md px-3 text-sm focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/30 transition-colors"; return (
{/* Profile section */}
{translate({ key: 'settings.changeAvatarDescription' })}
{ setNewName(e.target.value); }} />
{/* Only credentials accounts can change their email; for OAuth accounts the email belongs to the provider (read-only here). */} {session.provider === 'credentials' ? ( <>
{ setNewEmail(e.target.value); }} disabled={emailChangePending} />
{ setEmailChangePassword(e.target.value); }} disabled={emailChangePending} />

{translate({ key: 'settings.emailChange.label' })}

) : ( <>

{translate({ key: 'settings.emailChange.managedByProvider' })}

)}
{translate({ key: 'settings.language.title' })}
{ const lang = item.value as Language; setNewLanguage(lang); setLanguage(lang); }} />
{translate({ key: 'settings.theme.title' })}
{THEMES.map(({ value, icon }) => ( ))}
{/* Password change — only relevant for credentials accounts */} {session.provider === 'credentials' && (
void handleChangePassword(e)} className="flex flex-col gap-3">
)} {/* Two-factor authentication (ADR 0024) — credentials accounts only: the 2FA challenge gates the password + email-code login flows. */} {session.provider === 'credentials' && (
)} {/* Active sessions */}
{activeSessions.length === 0 ?
{translate({ key: 'settings.sessionsEmpty' })}
: (
    {activeSessions.map((s) => (
  • {s.isCurrent ? translate({ key: 'settings.currentSession' }) : `…${s.handle.slice(-8)}`}
    {s.expiresInSeconds !== null && (
    {translate({ key: 'settings.sessionExpiresIn', params: [{ key: 'hours', value: String(Math.round(s.expiresInSeconds / 3600)) }] })}
    )}
    {!s.isCurrent && ( )}
  • ))}
)}
{/* Notification preferences */}
void togglePreference('notifyOnNewSignIn')} /> void togglePreference('notifyOnPasswordChange')} />
{/* Danger zone */}
{session.provider === 'credentials' && (
)}

{translate({ key: 'settings.deleteAccountConfirm' })}

); } interface PreferenceToggleProps { label: string; checked: boolean; onToggle: () => void; } function PreferenceToggle({ label, checked, onToggle }: PreferenceToggleProps) { return ( ); }