username change admin center
This commit is contained in:
@@ -74,6 +74,10 @@ connectivity tester: `GET /api/admin/mail`, `POST /api/admin/mail/verify`,
|
|||||||
- `reports_enabled` (default on): admin kill-switch for message reports —
|
- `reports_enabled` (default on): admin kill-switch for message reports —
|
||||||
the chat flag button turns grey with a "Report disabled" tooltip and
|
the chat flag button turns grey with a "Report disabled" tooltip and
|
||||||
`POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`.
|
`POST /api/reports` answers 403. Surfaced to clients via `GET /api/tools`.
|
||||||
|
- `username_change_enabled` (default on): lets users rename themselves
|
||||||
|
(`PATCH /api/me` `username`); off answers 403 and locks the Settings field.
|
||||||
|
Admins bypass it (`PATCH /api/admin/users/:id` still works). Surfaced to
|
||||||
|
clients via `GET /api/tools`.
|
||||||
- Flags live in the `settings` SQLite table, edited via
|
- Flags live in the `settings` SQLite table, edited via
|
||||||
`GET/PATCH /api/admin/settings` (admin Bearer only).
|
`GET/PATCH /api/admin/settings` (admin Bearer only).
|
||||||
|
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function App() {
|
|||||||
const [previewBusy, setPreviewBusy] = useState(false);
|
const [previewBusy, setPreviewBusy] = useState(false);
|
||||||
const [ocrAvailable, setOcrAvailable] = useState(false);
|
const [ocrAvailable, setOcrAvailable] = useState(false);
|
||||||
const [reportsEnabled, setReportsEnabled] = useState(true);
|
const [reportsEnabled, setReportsEnabled] = useState(true);
|
||||||
|
const [usernameChangeEnabled, setUsernameChangeEnabled] = useState(true);
|
||||||
// Model-error retry: which trailing message is the error bubble (if any),
|
// Model-error retry: which trailing message is the error bubble (if any),
|
||||||
// its conversation, and how many times this prompt was already retried.
|
// its conversation, and how many times this prompt was already retried.
|
||||||
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
|
const [failed, setFailed] = useState<{ index: number; convId: string } | null>(null);
|
||||||
@@ -185,10 +186,12 @@ export function App() {
|
|||||||
.catch(() => ({
|
.catch(() => ({
|
||||||
tools: [] as { name: string; available: boolean }[],
|
tools: [] as { name: string; available: boolean }[],
|
||||||
reportsEnabled: true,
|
reportsEnabled: true,
|
||||||
|
usernameChangeEnabled: true,
|
||||||
})),
|
})),
|
||||||
]);
|
]);
|
||||||
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
setReportsEnabled(t.reportsEnabled ?? true);
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
setModels(m.models);
|
setModels(m.models);
|
||||||
if (m.models.length > 0) setModel(m.models[0]!.id);
|
if (m.models.length > 0) setModel(m.models[0]!.id);
|
||||||
setConvs(c.conversations);
|
setConvs(c.conversations);
|
||||||
@@ -312,6 +315,7 @@ export function App() {
|
|||||||
.then((t) => {
|
.then((t) => {
|
||||||
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
setOcrAvailable(t.tools.some((x) => x.name === "ocr" && x.available));
|
||||||
setReportsEnabled(t.reportsEnabled ?? true);
|
setReportsEnabled(t.reportsEnabled ?? true);
|
||||||
|
setUsernameChangeEnabled(t.usernameChangeEnabled ?? true);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
// Land directly on the latest conversation's own URL.
|
// Land directly on the latest conversation's own URL.
|
||||||
@@ -722,6 +726,7 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
<SettingsModal
|
<SettingsModal
|
||||||
user={settingsOpen ? user : null}
|
user={settingsOpen ? user : null}
|
||||||
|
usernameChangeEnabled={usernameChangeEnabled}
|
||||||
onClose={() => setSettingsOpen(false)}
|
onClose={() => setSettingsOpen(false)}
|
||||||
onSaved={(u) => {
|
onSaved={(u) => {
|
||||||
if (u.theme !== user?.theme) {
|
if (u.theme !== user?.theme) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export function AdminCenter() {
|
|||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
const [allowed, setAllowed] = useState<boolean | null>(null);
|
const [allowed, setAllowed] = useState<boolean | null>(null);
|
||||||
const [tab, setTab] = useState<Tab>("accounts");
|
const [tab, setTab] = useState<Tab>("accounts");
|
||||||
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } | null>(null);
|
const [settings, setSettings] = useState<{ registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } | null>(null);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [openAccessCount, setOpenAccessCount] = useState(0);
|
const [openAccessCount, setOpenAccessCount] = useState(0);
|
||||||
@@ -100,7 +100,7 @@ export function AdminCenter() {
|
|||||||
});
|
});
|
||||||
}, [allowed]);
|
}, [allowed]);
|
||||||
|
|
||||||
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) {
|
async function toggle(patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
@@ -251,6 +251,13 @@ export function AdminCenter() {
|
|||||||
</div>
|
</div>
|
||||||
<Switch label={t("center.reportsTitle")} checked={settings?.reportsEnabled ?? false} onChange={(v) => toggle({ reportsEnabled: v })} />
|
<Switch label={t("center.reportsTitle")} checked={settings?.reportsEnabled ?? false} onChange={(v) => toggle({ reportsEnabled: v })} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-4 rounded-3xl border border-stone-200/70 bg-white p-5 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium">{t("center.usernameChangeTitle")}</p>
|
||||||
|
<p className="text-sm opacity-60">{t("center.usernameChangeDesc")}</p>
|
||||||
|
</div>
|
||||||
|
<Switch label={t("center.usernameChangeTitle")} checked={settings?.usernameChangeEnabled ?? false} onChange={(v) => toggle({ usernameChangeEnabled: v })} />
|
||||||
|
</div>
|
||||||
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
{saving && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.saving")}</p>}
|
||||||
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
{!settings && !error && <p className="flex items-center gap-2 text-sm opacity-60"><Spinner size={14} /> {t("common.loading")}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ export function AdminPanel({ open, onClose, bare }: { open: boolean; onClose: ()
|
|||||||
|
|
||||||
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose: () => void; onSaved: () => void }) {
|
||||||
const { t } = useT();
|
const { t } = useT();
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [avatarUrl, setAvatarUrl] = useState("");
|
const [avatarUrl, setAvatarUrl] = useState("");
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -425,6 +426,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
|
setUsername(user.username);
|
||||||
setDisplayName(user.displayName);
|
setDisplayName(user.displayName);
|
||||||
setAvatarUrl(user.avatarUrl);
|
setAvatarUrl(user.avatarUrl);
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
@@ -437,7 +439,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
try {
|
try {
|
||||||
await api.adminPatch(user.id, { displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
|
await api.adminPatch(user.id, { username: username.trim().toLowerCase(), displayName: displayName.trim(), avatarUrl: avatarUrl.trim(), email: email.trim(), theme });
|
||||||
onSaved();
|
onSaved();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -448,6 +450,9 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose
|
|||||||
return (
|
return (
|
||||||
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
|
<Modal open={user !== null} onClose={onClose} title={t("admin.editUserTitle", { user: user?.username ?? "" })} icon={Pencil}>
|
||||||
<form onSubmit={save} className="space-y-4">
|
<form onSubmit={save} className="space-y-4">
|
||||||
|
<Field label={t("settings.username")} hint={t("settings.usernameHint")}>
|
||||||
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} />
|
||||||
|
</Field>
|
||||||
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
<Field label={t("common.displayName")}><Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required /></Field>
|
||||||
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
<Field label={t("admin.avatarUrl")}><Input value={avatarUrl} onChange={(e) => setAvatarUrl(e.target.value)} placeholder="https://…" /></Field>
|
||||||
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
<Field label={t("admin.email")}><Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="[email protected]" inputMode="email" /></Field>
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export function ShareModal({ conv, onClose }: { conv: Conversation | null; onClo
|
|||||||
|
|
||||||
export function SettingsModal({
|
export function SettingsModal({
|
||||||
user,
|
user,
|
||||||
|
usernameChangeEnabled = true,
|
||||||
onClose,
|
onClose,
|
||||||
onSaved,
|
onSaved,
|
||||||
onKeyRotated,
|
onKeyRotated,
|
||||||
@@ -267,6 +268,7 @@ export function SettingsModal({
|
|||||||
onProvidersChanged,
|
onProvidersChanged,
|
||||||
}: {
|
}: {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
|
usernameChangeEnabled?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: (u: User) => void;
|
onSaved: (u: User) => void;
|
||||||
onKeyRotated?: () => void;
|
onKeyRotated?: () => void;
|
||||||
@@ -533,8 +535,8 @@ export function SettingsModal({
|
|||||||
/>
|
/>
|
||||||
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
|
{uploadError && <p className="mt-1.5 text-sm text-red-600 dark:text-red-400">{uploadError}</p>}
|
||||||
</div>
|
</div>
|
||||||
<Field label={t("settings.username")} hint={t("settings.usernameHint")}>
|
<Field label={t("settings.username")} hint={usernameChangeEnabled ? t("settings.usernameHint") : t("settings.usernameDisabled")}>
|
||||||
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin"} />
|
<Input value={username} onChange={(e) => setUsername(e.target.value)} maxLength={32} required autoComplete="username" disabled={user?.username === "admin" || !usernameChangeEnabled} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label={t("common.displayName")}>
|
<Field label={t("common.displayName")}>
|
||||||
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" />
|
<Input value={displayName} onChange={(e) => setDisplayName(e.target.value)} maxLength={60} required autoComplete="nickname" />
|
||||||
|
|||||||
@@ -185,12 +185,12 @@ export const api = {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify({ model, ...patch }),
|
body: JSON.stringify({ model, ...patch }),
|
||||||
}),
|
}),
|
||||||
adminPatch: (id: string, patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
adminPatch: (id: string, patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string }) =>
|
||||||
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ user: User }>(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings"),
|
adminGetSettings: () => req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings"),
|
||||||
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean }) =>
|
adminPatchSettings: (patch: { registrationEnabled?: boolean; accessRequestEnabled?: boolean; ocrEnabled?: boolean; reportsEnabled?: boolean; usernameChangeEnabled?: boolean }) =>
|
||||||
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
req<{ settings: { registrationEnabled: boolean; accessRequestEnabled: boolean; ocrEnabled: boolean; reportsEnabled: boolean; usernameChangeEnabled: boolean } }>("/api/admin/settings", { method: "PATCH", body: JSON.stringify(patch) }),
|
||||||
|
|
||||||
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
/** Admin: SMTP credentials viewer (password included, blur it by default) + connectivity. */
|
||||||
adminMailStatus: () =>
|
adminMailStatus: () =>
|
||||||
@@ -328,7 +328,7 @@ export const api = {
|
|||||||
deleteProvider: (provider: string) =>
|
deleteProvider: (provider: string) =>
|
||||||
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
req<{ providers: UserProvider[] }>(`/api/me/providers/${provider}`, { method: "DELETE" }),
|
||||||
|
|
||||||
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean }>("/api/tools"),
|
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean }>("/api/tools"),
|
||||||
|
|
||||||
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
/** Send an image to the platform OCR tool. Returns editable text (never the raw image). */
|
||||||
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
ocrImage: async (file: File): Promise<{ text: string; truncated: boolean; chars: number }> => {
|
||||||
|
|||||||
@@ -345,6 +345,10 @@ const en = {
|
|||||||
"center.reportsTitle": "Message reports",
|
"center.reportsTitle": "Message reports",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Flag button under assistant responses. Off = the flag turns grey with a “Report disabled” tooltip and new reports are rejected.",
|
"Flag button under assistant responses. Off = the flag turns grey with a “Report disabled” tooltip and new reports are rejected.",
|
||||||
|
"center.usernameChangeTitle": "Username changes",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Lets users rename themselves in Settings → Profile. Off = the field is locked (admins can still rename accounts).",
|
||||||
|
"settings.usernameDisabled": "Username changes are disabled by the administrator.",
|
||||||
"center.mailTitle": "Key recovery emails",
|
"center.mailTitle": "Key recovery emails",
|
||||||
"center.mailChecking": "Checking…",
|
"center.mailChecking": "Checking…",
|
||||||
"center.mailFrom": "Enabled — sent from {from}",
|
"center.mailFrom": "Enabled — sent from {from}",
|
||||||
@@ -913,6 +917,10 @@ const es: Dict = {
|
|||||||
"center.reportsTitle": "Denuncias de mensajes",
|
"center.reportsTitle": "Denuncias de mensajes",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Botón de bandera bajo las respuestas. Off = bandera gris con aviso y se rechazan nuevas denuncias.",
|
"Botón de bandera bajo las respuestas. Off = bandera gris con aviso y se rechazan nuevas denuncias.",
|
||||||
|
"center.usernameChangeTitle": "Cambios de nombre",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Permite renombrarse en Ajustes → Perfil. Off = campo bloqueado (el admin aún puede renombrar).",
|
||||||
|
"settings.usernameDisabled": "El administrador ha desactivado los cambios de nombre.",
|
||||||
"center.mailTitle": "Emails de recuperación",
|
"center.mailTitle": "Emails de recuperación",
|
||||||
"center.mailChecking": "Verificando…",
|
"center.mailChecking": "Verificando…",
|
||||||
"center.mailFrom": "Activado — enviados desde {from}",
|
"center.mailFrom": "Activado — enviados desde {from}",
|
||||||
@@ -1475,6 +1483,10 @@ const fr: Dict = {
|
|||||||
"center.reportsTitle": "Signalements de messages",
|
"center.reportsTitle": "Signalements de messages",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Bouton drapeau sous les réponses. Off = drapeau grisé avec infobulle et nouveaux signalements refusés.",
|
"Bouton drapeau sous les réponses. Off = drapeau grisé avec infobulle et nouveaux signalements refusés.",
|
||||||
|
"center.usernameChangeTitle": "Changements de pseudo",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Permet de se renommer dans Réglages → Profil. Off = champ verrouillé (l'admin peut toujours renommer).",
|
||||||
|
"settings.usernameDisabled": "Changements de pseudo désactivés par l'administrateur.",
|
||||||
"center.mailTitle": "Emails de récupération",
|
"center.mailTitle": "Emails de récupération",
|
||||||
"center.mailChecking": "Vérification…",
|
"center.mailChecking": "Vérification…",
|
||||||
"center.mailFrom": "Activé — envoyés depuis {from}",
|
"center.mailFrom": "Activé — envoyés depuis {from}",
|
||||||
@@ -2028,6 +2040,10 @@ const ru: Dict = {
|
|||||||
"center.reportsTitle": "Жалобы на сообщения",
|
"center.reportsTitle": "Жалобы на сообщения",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Кнопка-флажок под ответами. Выкл = серый флажок с подсказкой, новые жалобы отклоняются.",
|
"Кнопка-флажок под ответами. Выкл = серый флажок с подсказкой, новые жалобы отклоняются.",
|
||||||
|
"center.usernameChangeTitle": "Смена имени",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Позволяет переименоваться в Настройки → Профиль. Выкл = поле закрыто (админ всё равно может).",
|
||||||
|
"settings.usernameDisabled": "Смена имени отключена администратором.",
|
||||||
"center.mailTitle": "Письма восстановления",
|
"center.mailTitle": "Письма восстановления",
|
||||||
"center.mailChecking": "Проверяем…",
|
"center.mailChecking": "Проверяем…",
|
||||||
"center.mailFrom": "Включено — шлём от {from}",
|
"center.mailFrom": "Включено — шлём от {from}",
|
||||||
@@ -2581,6 +2597,10 @@ const it: Dict = {
|
|||||||
"center.reportsTitle": "Segnalazioni messaggi",
|
"center.reportsTitle": "Segnalazioni messaggi",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Bandierina sotto le risposte. Off = bandierina grigia con tooltip e nuove segnalazioni rifiutate.",
|
"Bandierina sotto le risposte. Off = bandierina grigia con tooltip e nuove segnalazioni rifiutate.",
|
||||||
|
"center.usernameChangeTitle": "Cambi di nome",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Permette di rinominarsi in Impostazioni → Profilo. Off = campo bloccato (l'admin può ancora rinominare).",
|
||||||
|
"settings.usernameDisabled": "Cambi di nome disabilitati dall'amministratore.",
|
||||||
"center.mailTitle": "Email di recupero",
|
"center.mailTitle": "Email di recupero",
|
||||||
"center.mailChecking": "Verifica…",
|
"center.mailChecking": "Verifica…",
|
||||||
"center.mailFrom": "Attivo — inviate da {from}",
|
"center.mailFrom": "Attivo — inviate da {from}",
|
||||||
@@ -3129,6 +3149,10 @@ const pt: Dict = {
|
|||||||
"center.reportsTitle": "Denúncias de mensagens",
|
"center.reportsTitle": "Denúncias de mensagens",
|
||||||
"center.reportsDesc":
|
"center.reportsDesc":
|
||||||
"Bandeira sob as respostas. Off = bandeira cinzenta com aviso e novas denúncias recusadas.",
|
"Bandeira sob as respostas. Off = bandeira cinzenta com aviso e novas denúncias recusadas.",
|
||||||
|
"center.usernameChangeTitle": "Mudanças de nome",
|
||||||
|
"center.usernameChangeDesc":
|
||||||
|
"Permite renomear-se em Definições → Perfil. Off = campo bloqueado (o admin continua a poder renomear).",
|
||||||
|
"settings.usernameDisabled": "Mudanças de nome desativadas pelo administrador.",
|
||||||
"center.mailTitle": "Emails de recuperação",
|
"center.mailTitle": "Emails de recuperação",
|
||||||
"center.mailChecking": "A verificar…",
|
"center.mailChecking": "A verificar…",
|
||||||
"center.mailFrom": "Ativo — enviados de {from}",
|
"center.mailFrom": "Ativo — enviados de {from}",
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ export function isReportsEnabled(): boolean {
|
|||||||
return getSetting("reports_enabled", "1") === "1";
|
return getSetting("reports_enabled", "1") === "1";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin kill-switch for self-service username changes (default on; admins bypass). */
|
||||||
|
export function isUsernameChangeEnabled(): boolean {
|
||||||
|
return getSetting("username_change_enabled", "1") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
// --- public shares ---
|
// --- public shares ---
|
||||||
|
|
||||||
export function ensureShare(convId: string): string {
|
export function ensureShare(convId: string): string {
|
||||||
|
|||||||
+18
-2
@@ -34,6 +34,7 @@ import {
|
|||||||
getUserByUsername,
|
getUserByUsername,
|
||||||
isRegistrationEnabled,
|
isRegistrationEnabled,
|
||||||
isReportsEnabled,
|
isReportsEnabled,
|
||||||
|
isUsernameChangeEnabled,
|
||||||
listConversations,
|
listConversations,
|
||||||
listArchivedConversations,
|
listArchivedConversations,
|
||||||
listUsers,
|
listUsers,
|
||||||
@@ -625,6 +626,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
||||||
if (body.username !== undefined) {
|
if (body.username !== undefined) {
|
||||||
if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
|
if (user.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
|
||||||
|
if (!isUsernameChangeEnabled()) return json({ error: "username changes are disabled" }, 403);
|
||||||
const u = validUsername(body.username);
|
const u = validUsername(body.username);
|
||||||
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
||||||
const taken = getUserByUsername(u);
|
const taken = getUserByUsername(u);
|
||||||
@@ -806,7 +808,15 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (req.method === "PATCH" && !adminUserMatch[2]) {
|
if (req.method === "PATCH" && !adminUserMatch[2]) {
|
||||||
const { ok, body } = await readJson(req);
|
const { ok, body } = await readJson(req);
|
||||||
if (!ok) return json({ error: "invalid JSON" }, 400);
|
if (!ok) return json({ error: "invalid JSON" }, 400);
|
||||||
const patch: { displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
const patch: { username?: string; displayName?: string; avatarUrl?: string; theme?: string; email?: string } = {};
|
||||||
|
if (body.username !== undefined) {
|
||||||
|
if (target.username === "admin") return json({ error: "the admin account cannot be renamed" }, 403);
|
||||||
|
const u = validUsername(body.username);
|
||||||
|
if (!u) return json({ error: "username: 2-32 chars [a-z0-9._-], 'admin' reserved" }, 400);
|
||||||
|
const taken = getUserByUsername(u);
|
||||||
|
if (taken && taken.id !== target.id) return json({ error: "username taken" }, 409);
|
||||||
|
patch.username = u;
|
||||||
|
}
|
||||||
if (body.displayName !== undefined) {
|
if (body.displayName !== undefined) {
|
||||||
const d = cleanStr(body.displayName, 60);
|
const d = cleanStr(body.displayName, 60);
|
||||||
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
if (d === null) return json({ error: "displayName: 1-60 chars" }, 400);
|
||||||
@@ -841,6 +851,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
||||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||||
reportsEnabled: isReportsEnabled(),
|
reportsEnabled: isReportsEnabled(),
|
||||||
|
usernameChangeEnabled: isUsernameChangeEnabled(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -872,12 +883,17 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400);
|
if (typeof body.reportsEnabled !== "boolean") return json({ error: "reportsEnabled must be boolean" }, 400);
|
||||||
setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
|
setSetting("reports_enabled", body.reportsEnabled ? "1" : "0");
|
||||||
}
|
}
|
||||||
|
if (body.usernameChangeEnabled !== undefined) {
|
||||||
|
if (typeof body.usernameChangeEnabled !== "boolean") return json({ error: "usernameChangeEnabled must be boolean" }, 400);
|
||||||
|
setSetting("username_change_enabled", body.usernameChangeEnabled ? "1" : "0");
|
||||||
|
}
|
||||||
return json({
|
return json({
|
||||||
settings: {
|
settings: {
|
||||||
registrationEnabled: isRegistrationEnabled(),
|
registrationEnabled: isRegistrationEnabled(),
|
||||||
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
accessRequestEnabled: getSetting("registration_request_enabled", "1") === "1",
|
||||||
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
ocrEnabled: getSetting("tools_ocr_enabled", "1") === "1",
|
||||||
reportsEnabled: isReportsEnabled(),
|
reportsEnabled: isReportsEnabled(),
|
||||||
|
usernameChangeEnabled: isUsernameChangeEnabled(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1368,7 +1384,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
|
|
||||||
// --- platform tools (uploads always go through tools) ---
|
// --- platform tools (uploads always go through tools) ---
|
||||||
if (path === "/api/tools" && req.method === "GET") {
|
if (path === "/api/tools" && req.method === "GET") {
|
||||||
return json({ tools: tools.list(), reportsEnabled: isReportsEnabled() });
|
return json({ tools: tools.list(), reportsEnabled: isReportsEnabled(), usernameChangeEnabled: isUsernameChangeEnabled() });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === "/api/tools/ocr" && req.method === "POST") {
|
if (path === "/api/tools/ocr" && req.method === "POST") {
|
||||||
|
|||||||
Reference in New Issue
Block a user