Repository Public
1"use client";2import { Suspense, useEffect, useMemo, useRef, useState } from "react";3import { useSearchParams } from "next/navigation";4import { useLocale, useT } from "@/i18n";5import { adminConfig } from "@/i18n/admin-config";6import { api } from "@/lib/client-api";7import { Badge, Button, cx, errorText, Field, Notice, toast } from "@/components/ui";8import { Loadable, PageHeader, useLoad } from "@/components/admin/shell";9import { numOrEmpty, Section } from "./common";10import { NotificationsTab } from "./settings-mail";11import { IntegrationsTab } from "./settings-integrations";12import { AuditTab, SecurityTab } from "./settings-security";1314const TABS = ["profile", "notifications", "integrations", "security", "audit"] as const;15type Tab = (typeof TABS)[number];1617export function SettingsScreen() {18 return <Suspense fallback={<div className="skeleton h-40" />}><SettingsInner /></Suspense>;19}2021function SettingsInner() {22 const t = useT(adminConfig);23 const raw = useSearchParams().get("tab");24 const tab: Tab = TABS.includes(raw as Tab) ? (raw as Tab) : "profile";25 const label: Record<Tab, string> = { profile: t("stTabProfile"), notifications: t("stTabMail"), integrations: t("stTabIntegrations"), security: t("stTabSecurity"), audit: t("stTabAudit") };26 return (27 <>28 <PageHeader title={t("stTitle")} />29 <nav aria-label={t("stTitle")} className="seg mb-5 max-sm:flex max-sm:-mx-1">30 {TABS.map((x) => (31 <a key={x} href={`/admin/settings?tab=${x}`} aria-current={tab === x ? "page" : undefined}32 className="seg-item">{label[x]}</a>33 ))}34 </nav>35 {tab === "profile" ? <ProfileTab /> : tab === "notifications" ? <NotificationsTab /> : tab === "integrations" ? <IntegrationsTab /> : tab === "security" ? <SecurityTab /> : <AuditTab />}36 </>37 );38}3940interface SettingsRow {41 timezone: string; locale: "fr" | "en"; public_name: string; public_headline: string; public_headline_en: string; public_bio: string; public_bio_en: string;42 avatar_mime: string | null; brand_color: string; notify_email: string | null; reply_to: string | null; retention_days: number;43 allowed_email_domains: string[]; blocked_email_domains: string[]; external_stale_minutes: number; external_stale_policy: "conservative" | "ignore";44}45interface Form {46 timezone: string; locale: "fr" | "en"; public_name: string; public_headline: string; public_headline_en: string; public_bio: string; public_bio_en: string; brand_color: string;47 notify_email: string; reply_to: string; retention_days: number | ""; allowed: string; blocked: string; external_stale_minutes: number | ""; external_stale_policy: "conservative" | "ignore";48}49const DOMAIN = /^[a-z0-9.-]+\.[a-z]{2,}$/;50const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;51const lines = (s: string) => s.split(/[\n,;]+/).map((x) => x.trim().toLowerCase().replace(/^@/, "")).filter(Boolean);5253function ProfileTab() {54 const t = useT(adminConfig);55 const locale = useLocale();56 const state = useLoad<{ settings: SettingsRow }>("/api/v1/admin/settings");57 const [f, setF] = useState<Form | null>(null);58 const [dirty, setDirty] = useState(false);59 const [errors, setErrors] = useState<Record<string, string>>({});60 const [saving, setSaving] = useState(false);61 const [tzFilter, setTzFilter] = useState("");62 const [avatarV, setAvatarV] = useState(0);63 const [avatarBusy, setAvatarBusy] = useState(false);64 const [hasAvatar, setHasAvatar] = useState(false);65 const fileRef = useRef<HTMLInputElement>(null);66 const s = state.data?.settings;67 useEffect(() => {68 if (!s) return;69 setF({70 timezone: s.timezone, locale: s.locale, public_name: s.public_name, public_headline: s.public_headline, public_headline_en: s.public_headline_en, public_bio: s.public_bio, public_bio_en: s.public_bio_en,71 brand_color: s.brand_color, notify_email: s.notify_email ?? "", reply_to: s.reply_to ?? "", retention_days: s.retention_days, allowed: s.allowed_email_domains.join("\n"), blocked: s.blocked_email_domains.join("\n"),72 external_stale_minutes: s.external_stale_minutes, external_stale_policy: s.external_stale_policy,73 });74 setHasAvatar(!!s.avatar_mime); setDirty(false);75 }, [s]);7677 const zones = useMemo(() => { try { return Intl.supportedValuesOf("timeZone"); } catch { return ["America/Toronto", "UTC"]; } }, []);78 const shown = useMemo(() => {79 const q = tzFilter.trim().toLowerCase().replaceAll(" ", "_");80 const list = q ? zones.filter((z) => z.toLowerCase().includes(q)) : zones;81 return f && !list.includes(f.timezone) ? [f.timezone, ...list] : list;82 }, [zones, tzFilter, f]);83 const set = <K extends keyof Form>(k: K, v: Form[K]) => { setF((c) => (c ? { ...c, [k]: v } : c)); setDirty(true); };8485 const save = async (ev: React.FormEvent) => {86 ev.preventDefault();87 if (!f) return;88 const e: Record<string, string> = {};89 if (!f.public_name.trim()) e.public_name = t("stErrName");90 if (f.notify_email && !EMAIL.test(f.notify_email.trim())) e.notify_email = t("stErrEmail");91 if (f.reply_to && !EMAIL.test(f.reply_to.trim())) e.reply_to = t("stErrEmail");92 if (f.retention_days === "" || f.retention_days < 30 || f.retention_days > 3650 || !Number.isInteger(f.retention_days)) e.retention_days = t("cErrRange", { min: 30, max: 3650 });93 if (f.external_stale_minutes === "" || f.external_stale_minutes < 5 || f.external_stale_minutes > 10080 || !Number.isInteger(f.external_stale_minutes)) e.external_stale_minutes = t("cErrRange", { min: 5, max: 10080 });94 const allowed = lines(f.allowed), blocked = lines(f.blocked);95 if (allowed.some((d) => !DOMAIN.test(d)) || allowed.length > 50) e.allowed = t("stErrDomains");96 if (blocked.some((d) => !DOMAIN.test(d)) || blocked.length > 200) e.blocked = t("stErrDomains");97 if (!/^#[0-9a-fA-F]{6}$/.test(f.brand_color)) e.brand_color = t("stErrColor");98 setErrors(e);99 if (Object.keys(e).length) { toast.error(t("cErrForm")); return; }100 setSaving(true);101 try {102 await api("/api/v1/admin/settings", { method: "PUT", body: {103 timezone: f.timezone, locale: f.locale, public_name: f.public_name.trim(), public_headline: f.public_headline, public_headline_en: f.public_headline_en, public_bio: f.public_bio, public_bio_en: f.public_bio_en,104 brand_color: f.brand_color, notify_email: f.notify_email.trim() || null, reply_to: f.reply_to.trim() || null, retention_days: f.retention_days, allowed_email_domains: allowed, blocked_email_domains: blocked,105 external_stale_minutes: f.external_stale_minutes, external_stale_policy: f.external_stale_policy,106 } });107 toast.ok(t("stSaved"));108 if (s && f.timezone !== s.timezone) { window.location.reload(); return; } // the shell shows the owner's zone109 await state.reload();110 } catch (err) { toast.error(errorText(err, locale)); } finally { setSaving(false); }111 };112 const upload = async (file: File | undefined) => {113 if (!file) return;114 if (!["image/png", "image/jpeg", "image/webp"].includes(file.type) || file.size > 1_000_000) { toast.error(t("stAvatarErr")); return; }115 setAvatarBusy(true);116 try { await api("/api/v1/admin/settings/avatar", { method: "PUT", raw: file, headers: { "Content-Type": file.type } }); setHasAvatar(true); setAvatarV(Date.now()); toast.ok(t("stAvatarSaved")); }117 catch (e) { toast.error(errorText(e, locale)); } finally { setAvatarBusy(false); if (fileRef.current) fileRef.current.value = ""; }118 };119 const removeAvatar = async () => {120 setAvatarBusy(true);121 try { await api("/api/v1/admin/settings/avatar", { method: "DELETE" }); setHasAvatar(false); toast.ok(t("stAvatarRemoved")); }122 catch (e) { toast.error(errorText(e, locale)); } finally { setAvatarBusy(false); }123 };124125 return (126 <Loadable state={state}>127 {f ? (128 <form onSubmit={save} className="grid gap-5" noValidate>129 <Section title={t("stPublicTitle")} lead={t("stPublicLead")}>130 <div className="grid gap-4">131 <div className="flex flex-wrap items-center gap-4">132 {hasAvatar133 ? <img src={`/api/v1/public/avatar?v=${avatarV}`} alt={t("stAvatarAlt")} width={72} height={72} className="w-[72px] h-[72px] rounded-full object-cover border border-line" />134 : <span className="w-[72px] h-[72px] rounded-full bg-elev flex items-center justify-center text-2xl font-semibold" aria-hidden>{(f.public_name || "?").charAt(0).toUpperCase()}</span>}135 <div className="grid gap-2">136 <div className="flex flex-wrap gap-2">137 <Button small busy={avatarBusy} onClick={() => fileRef.current?.click()}>{hasAvatar ? t("stAvatarChange") : t("stAvatarAdd")}</Button>138 {hasAvatar ? <Button small variant="ghost" disabled={avatarBusy} onClick={removeAvatar}>{t("stAvatarRemove")}</Button> : null}139 </div>140 <p className="hint !mt-0">{t("stAvatarHint")}</p>141 <input ref={fileRef} type="file" accept="image/png,image/jpeg,image/webp" className="sr-only" aria-label={t("stAvatarAdd")} tabIndex={-1} onChange={(e) => upload(e.target.files?.[0])} />142 </div>143 </div>144 <Field label={t("stName")} error={errors.public_name}>{(p) => <input {...p} className="input" value={f.public_name} maxLength={80} onChange={(e) => set("public_name", e.target.value)} />}</Field>145 <div className="grid sm:grid-cols-2 gap-4">146 <Field label={t("stHeadline")} optional={t("cOptional")}>{(p) => <input {...p} className="input" value={f.public_headline} maxLength={160} onChange={(e) => set("public_headline", e.target.value)} />}</Field>147 <Field label={t("stHeadlineEn")} optional={t("cOptional")}>{(p) => <input {...p} className="input" lang="en" value={f.public_headline_en} maxLength={160} onChange={(e) => set("public_headline_en", e.target.value)} />}</Field>148 <Field label={t("stBio")} optional={t("cOptional")}>{(p) => <textarea {...p} className="input" value={f.public_bio} maxLength={1500} onChange={(e) => set("public_bio", e.target.value)} />}</Field>149 <Field label={t("stBioEn")} optional={t("cOptional")}>{(p) => <textarea {...p} className="input" lang="en" value={f.public_bio_en} maxLength={1500} onChange={(e) => set("public_bio_en", e.target.value)} />}</Field>150 </div>151 <Field label={t("stBrand")} hint={t("stBrandHint")} error={errors.brand_color}>152 {(p) => <span className="flex items-center gap-3"><input {...p} type="color" className="w-11 h-11 rounded-xl border border-line-strong bg-surface cursor-pointer" value={f.brand_color} onChange={(e) => set("brand_color", e.target.value)} /><span className="font-mono text-sm t-mute">{f.brand_color}</span></span>}153 </Field>154 </div>155 </Section>156157 <Section title={t("stRegionTitle")}>158 <div className="grid sm:grid-cols-2 gap-4">159 <div className="grid gap-2">160 <Field label={t("stTzFilter")}>{(p) => <input {...p} type="search" className="input" value={tzFilter} placeholder="Toronto, Paris…" onChange={(e) => setTzFilter(e.target.value)} />}</Field>161 <Field label={t("stTz")} hint={t("stTzHint")}>162 {(p) => <select {...p} className="input" value={f.timezone} onChange={(e) => set("timezone", e.target.value)}>{shown.map((z) => <option key={z} value={z}>{z.replaceAll("_", " ")}</option>)}</select>}163 </Field>164 </div>165 <Field label={t("stLocale")} hint={t("stLocaleHint")}>166 {(p) => <select {...p} className="input" value={f.locale} onChange={(e) => set("locale", e.target.value as "fr" | "en")}><option value="fr">Français</option><option value="en">English</option></select>}167 </Field>168 </div>169 {s && f.timezone !== s.timezone ? <div className="mt-4"><Notice tone="warn">{t("stTzWarn")}</Notice></div> : null}170 </Section>171172 <Section title={t("stMailTitle")} lead={t("stMailLead")}>173 <div className="grid sm:grid-cols-2 gap-4">174 <Field label={t("stNotify")} optional={t("cOptional")} hint={t("stNotifyHint")} error={errors.notify_email}>{(p) => <input {...p} type="email" className="input" value={f.notify_email} maxLength={254} onChange={(e) => set("notify_email", e.target.value)} />}</Field>175 <Field label={t("stReplyTo")} optional={t("cOptional")} hint={t("stReplyToHint")} error={errors.reply_to}>{(p) => <input {...p} type="email" className="input" value={f.reply_to} maxLength={254} onChange={(e) => set("reply_to", e.target.value)} />}</Field>176 </div>177 </Section>178179 <Section title={t("stDomainsTitle")} lead={t("stDomainsLead")}>180 <div className="grid sm:grid-cols-2 gap-4">181 <Field label={t("stAllowed")} optional={t("cOptional")} hint={t("stAllowedHint")} error={errors.allowed}>{(p) => <textarea {...p} className="input font-mono !text-[14px]" spellCheck={false} autoCapitalize="none" value={f.allowed} placeholder="uqo.ca" onChange={(e) => set("allowed", e.target.value)} />}</Field>182 <Field label={t("stBlocked")} optional={t("cOptional")} hint={t("stBlockedHint")} error={errors.blocked}>{(p) => <textarea {...p} className="input font-mono !text-[14px]" spellCheck={false} autoCapitalize="none" value={f.blocked} onChange={(e) => set("blocked", e.target.value)} />}</Field>183 </div>184 </Section>185186 <Section title={t("stPrivacyTitle")} lead={t("stPrivacyLead")}>187 <div className="max-w-xs">188 <Field label={t("stRetention")} hint={t("stRetentionHint")} error={errors.retention_days}>{(p) => <input {...p} type="number" inputMode="numeric" className="input" min={30} max={3650} value={f.retention_days} onChange={(e) => set("retention_days", numOrEmpty(e.target.value))} />}</Field>189 </div>190 </Section>191192 <Section title={t("stExternalTitle")} lead={t("stExternalLead")}>193 <div className="grid sm:grid-cols-2 gap-4">194 <Field label={t("stStaleMinutes")} hint={t("stStaleMinutesHint")} error={errors.external_stale_minutes}>{(p) => <input {...p} type="number" inputMode="numeric" className="input" min={5} max={10080} value={f.external_stale_minutes} onChange={(e) => set("external_stale_minutes", numOrEmpty(e.target.value))} />}</Field>195 <Field label={t("stStalePolicy")} hint={f.external_stale_policy === "conservative" ? t("stStaleConservativeHint") : t("stStaleIgnoreHint")}>196 {(p) => <select {...p} className="input" value={f.external_stale_policy} onChange={(e) => set("external_stale_policy", e.target.value as Form["external_stale_policy"])}><option value="conservative">{t("stStaleConservative")}</option><option value="ignore">{t("stStaleIgnore")}</option></select>}197 </Field>198 </div>199 </Section>200201 <div className="sticky bottom-[calc(var(--tabbar-h)+22px+env(safe-area-inset-bottom))] lg:bottom-4 z-30 flex items-center justify-end gap-3 glass border border-[var(--glass-line)] rounded-[22px] px-4 py-3 shadow-e2">202 {dirty ? <Badge tone="pending">{t("cUnsaved")}</Badge> : null}203 <Button type="submit" variant="primary" busy={saving} disabled={!dirty}>{t("cSave")}</Button>204 </div>205 </form>206 ) : null}207 </Loadable>208 );209}210