SPB Git

Repository Public

settings.tsx 15.1 KB 210 lines tsx

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
Files main
Menu

Theme