diff --git a/server.ts b/server.ts index e612c10..7c6287f 100644 --- a/server.ts +++ b/server.ts @@ -1,4 +1,3 @@ -import type { ServeOptions, Server } from 'bun' import path from 'node:path' const SERVER_PORT = Number(process.env.PORT ?? 3000) @@ -254,7 +253,7 @@ async function initializeServer() { log.error(`Uncaught server error: ${error.message}`) return new Response('Internal Server Error', { status: 500 }) }, - } as unknown as ServeOptions) as Server + }) log.success(`Server listening on http://localhost:${String(server.port)}`) } diff --git a/src/client/i18n.ts b/src/client/i18n.ts index 5846d52..ad88fc7 100644 --- a/src/client/i18n.ts +++ b/src/client/i18n.ts @@ -1,6 +1,7 @@ import i18n from "i18next"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; +import { defaultLanguageCode, supportedLanguageCodes } from "./languages"; import { en } from "./locales/en"; import { fr } from "./locales/fr"; @@ -12,9 +13,9 @@ i18n en, fr, }, - lng: typeof window === "undefined" ? "en" : undefined, // Force English on server - fallbackLng: "en", - supportedLngs: ["en", "fr"], + lng: typeof window === "undefined" ? defaultLanguageCode : undefined, // Force English on server + fallbackLng: defaultLanguageCode, + supportedLngs: supportedLanguageCodes, load: "languageOnly", interpolation: { escapeValue: false, // react already safes from xss diff --git a/src/client/languages.ts b/src/client/languages.ts new file mode 100644 index 0000000..ec42c04 --- /dev/null +++ b/src/client/languages.ts @@ -0,0 +1,28 @@ +export const languageOptions = [ + { code: "en", labelKey: "languages.en" }, + { code: "fr", labelKey: "languages.fr" }, +] as const; + +export type LanguageCode = (typeof languageOptions)[number]["code"]; + +export const languageCodeTuple = languageOptions.map( + (option) => option.code, +) as [LanguageCode, ...LanguageCode[]]; + +export const supportedLanguageCodes = languageOptions.map( + (option) => option.code, +); + +export const defaultLanguageCode: LanguageCode = "en"; + +export const normalizeLanguageCode = ( + language: string | null | undefined, +): LanguageCode => { + const normalizedLanguage = (language ?? defaultLanguageCode).split("-")[0]; + + if (supportedLanguageCodes.includes(normalizedLanguage as LanguageCode)) { + return normalizedLanguage as LanguageCode; + } + + return defaultLanguageCode; +}; diff --git a/src/client/locales/en.ts b/src/client/locales/en.ts index ec72b30..4adb2bd 100644 --- a/src/client/locales/en.ts +++ b/src/client/locales/en.ts @@ -11,6 +11,7 @@ export const en = { saveChanges: "Save changes", savedSuccess: "Settings saved successfully", savedError: "Failed to save settings", + genericError: "Something went wrong", }, search: { placeholder: "What are you looking for?", @@ -113,5 +114,42 @@ export const en = { en: "English", fr: "Français", }, + setup: { + title: "Initial Configuration", + steps: { + language: "Language", + languageDescription: "Choose your preferred language", + safeSearch: "Content Filtering", + safeSearchDescription: "Configure content filtering", + admin: "Admin Account", + adminDescription: "Create your administrator account", + }, + language: { + title: "Language", + description: "Please select your preferred language for the interface.", + continue: "Continue", + }, + admin: { + name: "Name", + email: "Email", + password: "Password", + confirmPassword: "Confirm Password", + createAccount: "Create Account", + nameRequired: "Name is required", + emailInvalid: "Invalid email", + passwordMinLength: "Password must be at least 8 characters", + passwordsDoNotMatch: "Passwords do not match", + }, + safeSearch: { + title: "Safe Search", + description: "Select the level of content filtering you prefer.", + }, + }, + validation: { + required: "This field is required", + invalidEmail: "Invalid email", + minLength: "Must be at least {{count}} characters", + invalid: "Invalid value", + }, }, }; diff --git a/src/client/locales/fr.ts b/src/client/locales/fr.ts index 6a20139..2550806 100644 --- a/src/client/locales/fr.ts +++ b/src/client/locales/fr.ts @@ -11,6 +11,7 @@ export const fr = { saveChanges: "Enregistrer les modifications", savedSuccess: "Paramètres enregistrés avec succès", savedError: "Échec de l'enregistrement des paramètres", + genericError: "Une erreur est survenue", }, search: { placeholder: "Que recherchez-vous ?", @@ -114,5 +115,45 @@ export const fr = { en: "English", fr: "Français", }, + setup: { + title: "Configuration initiale", + steps: { + language: "Langue", + languageDescription: "Choisissez votre langue préférée", + safeSearch: "Filtrage", + safeSearchDescription: "Configurez le filtrage de contenu", + admin: "Compte Admin", + adminDescription: "Créez votre compte administrateur", + }, + language: { + title: "Langue", + description: + "Veuillez sélectionner votre langue préférée pour l'interface.", + continue: "Continuer", + }, + admin: { + name: "Nom", + email: "Email", + password: "Mot de passe", + confirmPassword: "Confirmer le mot de passe", + createAccount: "Créer le compte", + nameRequired: "Le nom est requis", + emailInvalid: "Email invalide", + passwordMinLength: + "Le mot de passe doit contenir au moins 8 caractères", + passwordsDoNotMatch: "Les mots de passe ne correspondent pas", + }, + safeSearch: { + title: "Safe Search", + description: + "Sélectionnez le niveau de filtrage de contenu que vous préférez.", + }, + }, + validation: { + required: "Ce champ est requis", + invalidEmail: "Email invalide", + minLength: "Doit contenir au moins {{count}} caractères", + invalid: "Valeur invalide", + }, }, }; diff --git a/src/client/zod.ts b/src/client/zod.ts new file mode 100644 index 0000000..5e672ee --- /dev/null +++ b/src/client/zod.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import i18n from "./i18n"; +import { normalizeLanguageCode } from "./languages"; + +const applyZodLocale = () => { + const language = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); + const locale = language === "fr" ? z.locales.fr() : z.locales.en(); + z.config(locale); +}; + +applyZodLocale(); +i18n.on("initialized", applyZodLocale); +i18n.on("languageChanged", applyZodLocale); diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 1b08628..85a104b 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -12,6 +12,7 @@ import { getPublicConfig } from "@/server/infrastructure/functions/instance-conf import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; import appCss from "../styles.css?url"; import "@/client/i18n"; +import "@/client/zod"; import { useTranslation } from "react-i18next"; import { LanguageSync } from "@/client/components/language-sync"; diff --git a/src/routes/_authed/-components/settings/general-section.tsx b/src/routes/_authed/-components/settings/general-section.tsx index c53c158..c8462b7 100644 --- a/src/routes/_authed/-components/settings/general-section.tsx +++ b/src/routes/_authed/-components/settings/general-section.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "@/client/components/ui/card"; +import type { LanguageCode } from "@/client/languages"; import { LanguageSwitcher } from "@/routes/_authed/-components/settings/language-switcher"; import { useSettingsLayout } from "@/routes/_authed/settings"; @@ -30,7 +31,7 @@ export function GeneralSection() { - handleUserSettingChange("language", val as "en" | "fr") + handleUserSettingChange("language", val as LanguageCode) } /> diff --git a/src/routes/_authed/-components/settings/language-switcher.tsx b/src/routes/_authed/-components/settings/language-switcher.tsx index 8b369d3..0131524 100644 --- a/src/routes/_authed/-components/settings/language-switcher.tsx +++ b/src/routes/_authed/-components/settings/language-switcher.tsx @@ -7,6 +7,7 @@ import { SelectTrigger, SelectValue, } from "@/client/components/ui/select"; +import { languageOptions, normalizeLanguageCode } from "@/client/languages"; import { authClient } from "@/server/infrastructure/auth/client"; import { getUserSettings, @@ -26,9 +27,10 @@ export function LanguageSwitcher({ const { data: session } = authClient.useSession(); const changeLanguage = async (lng: string) => { + const normalizedLng = normalizeLanguageCode(lng); // Controlled mode: just bubble up the change if (onValueChange) { - onValueChange(lng); + onValueChange(normalizedLng); return; } @@ -37,7 +39,6 @@ export function LanguageSwitcher({ try { // Fetch current settings first to merge const currentSettings = await getUserSettings(); - const normalizedLng = lng.split("-")[0] as "en" | "fr"; // Save to server first await saveUserSettings({ @@ -45,22 +46,20 @@ export function LanguageSwitcher({ }); // Then update client state - i18n.changeLanguage(lng); + i18n.changeLanguage(normalizedLng); } catch (error) { console.error("Failed to save language preference:", error); } } else { // Guest user - just change language - i18n.changeLanguage(lng); + i18n.changeLanguage(normalizedLng); } }; // Ensure we have a valid language selection, fallback to 'en' // Use resolvedLanguage if available, otherwise language, otherwise 'en' const currentLanguage = - value || - (i18n.resolvedLanguage || i18n.language || "en").split("-")[0] || - "en"; + value || normalizeLanguageCode(i18n.resolvedLanguage || i18n.language); return (
@@ -70,8 +69,11 @@ export function LanguageSwitcher({ - {t("languages.en")} - {t("languages.fr")} + {languageOptions.map((option) => ( + + {t(option.labelKey)} + + ))}
diff --git a/src/routes/_authed/settings.tsx b/src/routes/_authed/settings.tsx index b8a809c..e9af1d6 100644 --- a/src/routes/_authed/settings.tsx +++ b/src/routes/_authed/settings.tsx @@ -6,6 +6,7 @@ import React, { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Button } from "@/client/components/ui/button"; +import { normalizeLanguageCode } from "@/client/languages"; import { sessionQueryOptions } from "@/routes/_authed"; import { getInstanceConfig, @@ -150,11 +151,9 @@ function SettingsLayout() { }); // Apply language change if needed - const currentLng = ( - i18n.resolvedLanguage || - i18n.language || - "en" - ).split("-")[0]; + const currentLng = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); if (userSettings.language !== currentLng) { i18n.changeLanguage(userSettings.language); } diff --git a/src/routes/setup.tsx b/src/routes/setup.tsx index 4ebd52f..fe8d615 100644 --- a/src/routes/setup.tsx +++ b/src/routes/setup.tsx @@ -1,10 +1,11 @@ import { defineStepper } from "@stepperize/react"; -import { useForm } from "@tanstack/react-form"; import { useMutation } from "@tanstack/react-query"; import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; import { useServerFn } from "@tanstack/react-start"; import { Check } from "lucide-react"; import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { normalizeLanguageCode } from "@/client/languages"; import { cn } from "@/client/utils"; import { SafeSearch } from "@/server/domain/value-objects"; import { @@ -13,21 +14,29 @@ import { } from "@/server/infrastructure/functions/setup"; import { AdminStep, + adminFormOpts, + LanguageStep, + languageFormOpts, SafeSearchStep, - step2Schema, - stepSafeSearchSchema, + safeSearchFormOpts, } from "./setup/-components"; +import { useSetupForm } from "./setup/-components/setup-form"; const { useStepper } = defineStepper( + { + id: "language", + title: "setup.steps.language", + description: "setup.steps.languageDescription", + }, { id: "safe-search", - title: "Filtrage", - description: "Configurez le filtrage de contenu", + title: "setup.steps.safeSearch", + description: "setup.steps.safeSearchDescription", }, { id: "admin", - title: "Compte Admin", - description: "Créez votre compte administrateur", + title: "setup.steps.admin", + description: "setup.steps.adminDescription", }, ); @@ -48,11 +57,15 @@ export const Route = createFileRoute("/setup")({ }); function SetupPage() { + const { t, i18n } = useTranslation(); const navigate = useNavigate(); const stepper = useStepper(); const [error, setError] = useState(null); const [hasAttemptedSubmit, setHasAttemptedSubmit] = useState(false); const [mounted, setMounted] = useState(false); + const normalizedLanguage = normalizeLanguageCode( + i18n.resolvedLanguage || i18n.language, + ); useEffect(() => { setMounted(true); @@ -65,39 +78,38 @@ function SetupPage() { name: string; email: string; password: string; + language: string; }) => finalizeSetupFn({ data }), onSuccess: () => { navigate({ to: "/", replace: true }); }, onError: (error) => { setError( - error instanceof Error ? error.message : "Une erreur est survenue", + error instanceof Error ? error.message : t("common.genericError"), ); }, }); - const safeSearchForm = useForm({ + const languageForm = useSetupForm({ + ...languageFormOpts, defaultValues: { - safeSearch: SafeSearch.MODERATE as SafeSearch, - }, - validators: { - onChange: stepSafeSearchSchema, + ...languageFormOpts.defaultValues, + language: normalizedLanguage, }, onSubmit: async () => { stepper.navigation.next(); }, }); - const adminForm = useForm({ - defaultValues: { - name: "", - email: "", - password: "", - confirmPassword: "", - }, - validators: { - onChange: step2Schema, + const safeSearchForm = useSetupForm({ + ...safeSearchFormOpts, + onSubmit: async () => { + stepper.navigation.next(); }, + }); + + const adminForm = useSetupForm({ + ...adminFormOpts, onSubmit: async ({ value }) => { const rawSafeSearch = safeSearchForm.state.values.safeSearch; @@ -114,12 +126,19 @@ function SetupPage() { name: value.name, email: value.email, password: value.password, + language: languageForm.state.values.language, }; setupMutation.mutate(payload); }, }); + const handleAdminBack = () => { + setHasAttemptedSubmit(false); + adminForm.reset(adminForm.state.values, { keepDefaultValues: true }); + stepper.navigation.prev(); + }; + return (
@@ -127,7 +146,7 @@ function SetupPage() {

- Configuration initiale + {t("setup.title")}

@@ -178,7 +197,7 @@ function SetupPage() { : "text-muted-foreground", )} > - {step.title} + {t(step.title)}
@@ -199,32 +218,47 @@ function SetupPage() {

- {mounted - ? stepper.state.current.data.title - : stepper.state.all[0].title} + {t( + mounted + ? stepper.state.current.data.title + : stepper.state.all[0].title, + )} :{" "} - {mounted - ? stepper.state.current.data.description - : stepper.state.all[0].description} + {t( + mounted + ? stepper.state.current.data.description + : stepper.state.all[0].description, + )}

{stepper.flow.switch({ + language: () => ( + + languageForm.handleSubmit()} /> + + ), "safe-search": () => ( - safeSearchForm.handleSubmit()} - /> + + safeSearchForm.handleSubmit()} + onBack={() => stepper.navigation.prev()} + /> + ), admin: () => ( - stepper.navigation.prev()} - onSubmit={() => setHasAttemptedSubmit(true)} - /> + + { + setHasAttemptedSubmit(true); + adminForm.handleSubmit(); + }} + /> + ), })}
diff --git a/src/routes/setup/-components/admin-step.tsx b/src/routes/setup/-components/admin-step.tsx index c0c0fa1..c273d0b 100644 --- a/src/routes/setup/-components/admin-step.tsx +++ b/src/routes/setup/-components/admin-step.tsx @@ -1,28 +1,47 @@ +import { formOptions } from "@tanstack/react-form"; import { useId } from "react"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { Button } from "@/client/components/ui/button"; import { Input } from "@/client/components/ui/input"; import { Label } from "@/client/components/ui/label"; +import i18n from "@/client/i18n"; +import { useSetupTypedFormContext } from "./setup-form"; -export const step2Schema = z +export const adminSchema = z .object({ - name: z.string().min(1, "Le nom est requis"), - email: z.email("Email invalide"), + name: z + .string() + .trim() + .min(1, { error: () => i18n.t("setup.admin.nameRequired") }), + email: z.email({ error: () => i18n.t("setup.admin.emailInvalid") }), password: z .string() - .min(8, "Le mot de passe doit contenir au moins 8 caractères"), - confirmPassword: z.string(), + .min(8, { error: () => i18n.t("setup.admin.passwordMinLength") }), + confirmPassword: z + .string() + .min(1, { error: () => i18n.t("validation.required") }), }) .refine((data) => data.password === data.confirmPassword, { - message: "Les mots de passe ne correspondent pas", path: ["confirmPassword"], + error: () => i18n.t("setup.admin.passwordsDoNotMatch"), }); -export type AdminFormValues = z.infer; +export type AdminFormValues = z.infer; + +export const adminFormOpts = formOptions({ + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + }, + validators: { + onChange: adminSchema, + }, +}); export interface AdminStepProps { - // biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex - form: any; onBack: () => void; onSubmit?: () => void; isPending: boolean; @@ -30,12 +49,13 @@ export interface AdminStepProps { } export function AdminStep({ - form, onBack, onSubmit, isPending, hasAttemptedSubmit, }: AdminStepProps) { + const form = useSetupTypedFormContext(adminFormOpts); + const { t } = useTranslation(); const nameId = useId(); const emailId = useId(); const passwordId = useId(); @@ -46,15 +66,13 @@ export function AdminStep({ onSubmit={(e) => { e.preventDefault(); onSubmit?.(); - form.handleSubmit(); }} className="space-y-4" >
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <>
- + - {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( <> - Retour + {t("common.back")}
diff --git a/src/routes/setup/-components/index.ts b/src/routes/setup/-components/index.ts index ea92331..c5df273 100644 --- a/src/routes/setup/-components/index.ts +++ b/src/routes/setup/-components/index.ts @@ -1,6 +1,18 @@ -export { type AdminFormValues, AdminStep, step2Schema } from "./admin-step"; +export { + type AdminFormValues, + AdminStep, + adminFormOpts, + adminSchema, +} from "./admin-step"; +export { + LanguageStep, + type LanguageStepFormValues, + languageFormOpts, + stepLanguageSchema, +} from "./language-step"; export { type SafeSearchFormValues, SafeSearchStep, + safeSearchFormOpts, stepSafeSearchSchema, } from "./safe-search-step"; diff --git a/src/routes/setup/-components/language-step.tsx b/src/routes/setup/-components/language-step.tsx new file mode 100644 index 0000000..25f3cd4 --- /dev/null +++ b/src/routes/setup/-components/language-step.tsx @@ -0,0 +1,90 @@ +import { formOptions } from "@tanstack/react-form"; +import { Languages } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import { z } from "zod"; +import { Button } from "@/client/components/ui/button"; +import { Label } from "@/client/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/client/components/ui/radio-group"; +import { + type LanguageCode, + languageCodeTuple, + languageOptions, +} from "@/client/languages"; +import { useSetupTypedFormContext } from "./setup-form"; + +export const stepLanguageSchema = z.object({ + language: z.enum(languageCodeTuple), +}); + +export type LanguageStepFormValues = z.infer; + +export const languageFormOpts = formOptions({ + defaultValues: { + language: "en" as LanguageStepFormValues["language"], + }, + validators: { + onChange: stepLanguageSchema, + }, +}); + +export interface LanguageStepProps { + onSubmit: () => void; +} + +export function LanguageStep({ onSubmit }: LanguageStepProps) { + const form = useSetupTypedFormContext(languageFormOpts); + const { t, i18n } = useTranslation(); + + const handleLanguageChange = (value: LanguageCode) => { + i18n.changeLanguage(value); + }; + + return ( +
{ + e.preventDefault(); + onSubmit(); + }} + className="space-y-6" + > +
+ + {(field) => ( + { + field.handleChange(value as LanguageStepFormValues["language"]); + handleLanguageChange(value as LanguageCode); + }} + className="gap-3" + > + {languageOptions.map((option) => ( + + ))} + + )} + +
+ + +
+ ); +} diff --git a/src/routes/setup/-components/safe-search-step.tsx b/src/routes/setup/-components/safe-search-step.tsx index 77a55ca..1d609f0 100644 --- a/src/routes/setup/-components/safe-search-step.tsx +++ b/src/routes/setup/-components/safe-search-step.tsx @@ -1,9 +1,12 @@ +import { formOptions } from "@tanstack/react-form"; import { Shield, ShieldAlert, ShieldOff } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { z } from "zod"; import { Button } from "@/client/components/ui/button"; import { Label } from "@/client/components/ui/label"; import { RadioGroup, RadioGroupItem } from "@/client/components/ui/radio-group"; import { SafeSearch } from "@/server/domain/value-objects"; +import { useSetupTypedFormContext } from "./setup-form"; export const stepSafeSearchSchema = z.object({ safeSearch: z.enum([SafeSearch.OFF, SafeSearch.MODERATE, SafeSearch.STRICT]), @@ -11,40 +14,45 @@ export const stepSafeSearchSchema = z.object({ export type SafeSearchFormValues = z.infer; +export const safeSearchFormOpts = formOptions({ + defaultValues: { + safeSearch: SafeSearch.MODERATE as SafeSearchFormValues["safeSearch"], + }, + validators: { + onChange: stepSafeSearchSchema, + }, +}); + export interface SafeSearchStepProps { - // biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex - form: any; onSubmit: () => void; onBack?: () => void; } -const safeSearchOptions = [ - { - value: SafeSearch.OFF, - label: "Désactivé", - description: "Aucun filtrage - tous les résultats sont affichés", - icon: ShieldOff, - }, - { - value: SafeSearch.MODERATE, - label: "Modéré", - description: "Filtre le contenu explicite le plus sensible", - icon: Shield, - }, - { - value: SafeSearch.STRICT, - label: "Strict", - description: - "Filtre tout le contenu explicite (recommandé pour les familles)", - icon: ShieldAlert, - }, -]; +export function SafeSearchStep({ onSubmit, onBack }: SafeSearchStepProps) { + const form = useSetupTypedFormContext(safeSearchFormOpts); + const { t } = useTranslation(); + + const safeSearchOptions = [ + { + value: SafeSearch.OFF, + label: t("safeSearch.off"), + description: t("safeSearch.offDescription"), + icon: ShieldOff, + }, + { + value: SafeSearch.MODERATE, + label: t("safeSearch.moderate"), + description: t("safeSearch.moderateDescription"), + icon: Shield, + }, + { + value: SafeSearch.STRICT, + label: t("safeSearch.strict"), + description: t("safeSearch.strictDescription"), + icon: ShieldAlert, + }, + ]; -export function SafeSearchStep({ - form, - onSubmit, - onBack, -}: SafeSearchStepProps) { return (
{ @@ -55,8 +63,7 @@ export function SafeSearchStep({ >
- {/* biome-ignore lint/suspicious/noExplicitAny: FormApi type is complex */} - {(field: any) => ( + {(field) => ( field.handleChange(value as SafeSearch)} @@ -100,11 +107,11 @@ export function SafeSearchStep({ onClick={onBack} className="flex-1" > - Retour + {t("common.back")} )}
diff --git a/src/routes/setup/-components/setup-form.ts b/src/routes/setup/-components/setup-form.ts new file mode 100644 index 0000000..b8a30e0 --- /dev/null +++ b/src/routes/setup/-components/setup-form.ts @@ -0,0 +1,13 @@ +import { createFormHook, createFormHookContexts } from "@tanstack/react-form"; + +const { fieldContext, formContext } = createFormHookContexts(); + +export const { + useAppForm: useSetupForm, + useTypedAppFormContext: useSetupTypedFormContext, +} = createFormHook({ + fieldComponents: {}, + formComponents: {}, + fieldContext, + formContext, +}); diff --git a/src/server/domain/value-objects/settings.vo.ts b/src/server/domain/value-objects/settings.vo.ts index d401c38..8e3b3cb 100644 --- a/src/server/domain/value-objects/settings.vo.ts +++ b/src/server/domain/value-objects/settings.vo.ts @@ -1,4 +1,5 @@ import * as z from "zod"; +import { languageCodeTuple } from "@/client/languages"; import { SafeSearch } from "./search.vo"; export const instanceConfigSchema = z.object({}); @@ -13,7 +14,7 @@ export const userSettingsSchema = z.object({ .default(SafeSearch.OFF), openInNewTab: z.boolean().default(true), theme: z.enum(["light", "dark", "system"]).default("system"), - language: z.enum(["en", "fr"]).default("en"), + language: z.enum(languageCodeTuple).default("en"), }); export type UserSettings = z.infer; diff --git a/src/server/infrastructure/functions/setup.ts b/src/server/infrastructure/functions/setup.ts index 29842ad..5aa938c 100644 --- a/src/server/infrastructure/functions/setup.ts +++ b/src/server/infrastructure/functions/setup.ts @@ -1,7 +1,13 @@ import { createServerFn } from "@tanstack/react-start"; -import { zodValidator } from "@tanstack/zod-adapter"; import { eq } from "drizzle-orm"; import { z } from "zod"; +import { + defaultLanguageCode, + languageCodeTuple, + normalizeLanguageCode, +} from "@/client/languages"; +import { getContainer } from "@/server/container"; +import { defaultUserSettings, SafeSearch } from "@/server/domain/value-objects"; export const getSetupStatus = createServerFn({ method: "GET" }).handler( async () => { @@ -27,14 +33,63 @@ export const getSetupStatus = createServerFn({ method: "GET" }).handler( ); const finalizeSetupSchema = z.object({ - name: z.string().min(1, "Le nom est requis"), - email: z.email(), + name: z.string().min(1), + email: z.string().email(), password: z.string().min(8), - safeSearch: z.string().optional(), + safeSearch: z + .enum([SafeSearch.OFF, SafeSearch.MODERATE, SafeSearch.STRICT]) + .optional(), + language: z.enum(languageCodeTuple).optional(), }); +interface FinalizeSetupInput { + name?: unknown; + email?: unknown; + password?: unknown; + safeSearch?: unknown; + language?: unknown; +} + +const normalizeFinalizeSetupInput = (data: unknown): FinalizeSetupInput => { + if (!data || typeof data !== "object") { + return {}; + } + + return data as FinalizeSetupInput; +}; + +const getSetupLocale = (input: FinalizeSetupInput) => { + const language = + typeof input.language === "string" + ? normalizeLanguageCode(input.language) + : defaultLanguageCode; + + return language === "fr" ? z.locales.fr() : z.locales.en(); +}; + +const getCreatedUserId = (createdUser: unknown): string | null => { + if (!createdUser || typeof createdUser !== "object") { + return null; + } + + const data = createdUser as { + user?: { id?: unknown }; + id?: unknown; + }; + + if (typeof data.user?.id === "string" && data.user.id.length > 0) { + return data.user.id; + } + + if (typeof data.id === "string" && data.id.length > 0) { + return data.id; + } + + return null; +}; + export const finalizeSetup = createServerFn({ method: "POST" }) - .inputValidator(zodValidator(finalizeSetupSchema)) + .inputValidator(normalizeFinalizeSetupInput) .handler(async ({ data }) => { const { db } = await import( "@/server/infrastructure/persistence/drizzle/connection" @@ -45,6 +100,14 @@ export const finalizeSetup = createServerFn({ method: "POST" }) const { auth } = await import("@/server/infrastructure/auth"); try { + const locale = getSetupLocale(data); + z.config(locale); + + const validation = finalizeSetupSchema.safeParse(data); + if (!validation.success) { + throw validation.error; + } + const existingAdmin = await db .select() .from(user) @@ -56,19 +119,45 @@ export const finalizeSetup = createServerFn({ method: "POST" }) throw new Error("Setup already completed"); } - await auth.api.createUser({ + const createdUser = await auth.api.createUser({ body: { - name: data.name, - email: data.email, - password: data.password, + name: validation.data.name, + email: validation.data.email, + password: validation.data.password, role: "admin", }, }); + let userId = getCreatedUserId(createdUser); + if (!userId) { + const createdAdmin = await db + .select({ id: user.id }) + .from(user) + .where(eq(user.email, validation.data.email)) + .limit(1); + + userId = createdAdmin[0]?.id ?? null; + } + + if (!userId) { + throw new Error("Unable to resolve created admin user ID"); + } + + const container = await getContainer(); + await container.usecases.saveUserSettings({ + userId, + settings: { + ...defaultUserSettings, + safeSearch: + validation.data.safeSearch ?? defaultUserSettings.safeSearch, + language: validation.data.language ?? defaultLanguageCode, + }, + }); + await auth.api.signInEmail({ body: { - email: data.email, - password: data.password, + email: validation.data.email, + password: validation.data.password, }, }); } catch (error) {