Fix admin settings save 400 and remove JSON socials editor.

Send only whitelisted fields and let operators edit social links as simple label/url rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-30 21:06:33 +03:30
co-authored by Cursor
parent 002deb5e13
commit bf75e52ad3
3 changed files with 103 additions and 24 deletions
+79 -20
View File
@@ -24,47 +24,106 @@
<input v-model="form.address" class="input" />
</div>
<div class="field">
<label class="label">Timezone</label>
<input v-model="form.timezone" class="input" dir="ltr" />
<label class="label">منطقه زمانی</label>
<input v-model="form.timezone" class="input" dir="ltr" placeholder="Asia/Tehran" />
</div>
<div class="field">
<label class="label">لوگو URL</label>
<input v-model="form.logoUrl" class="input" dir="ltr" />
<div class="field" style="grid-column: 1 / -1">
<label class="label">آدرس لوگو</label>
<input v-model="form.logoUrl" class="input" dir="ltr" placeholder="از بخش رسانه آپلود کنید و آدرس را اینجا بگذارید" />
</div>
</div>
<div class="field">
<label class="label">شبکه‌های اجتماعی (JSON)</label>
<textarea v-model="socialsText" class="textarea" dir="ltr" />
<h2 style="margin: 1.25rem 0 0.75rem; font-size: 1rem">شبکه‌های اجتماعی</h2>
<div v-for="(s, i) in socials" :key="i" class="item-box">
<div class="row" style="justify-content: space-between; margin-bottom: 0.5rem">
<strong>مورد {{ i + 1 }}</strong>
<button type="button" class="btn danger" @click="socials.splice(i, 1)">حذف</button>
</div>
<div class="grid-2">
<div class="field">
<label class="label">نام (مثلاً لینکدین)</label>
<input v-model="s.label" class="input" />
</div>
<div class="field">
<label class="label">آدرس لینک</label>
<input v-model="s.url" class="input" dir="ltr" placeholder="https://..." />
</div>
</div>
</div>
<p v-if="msg" class="muted">{{ msg }}</p>
<button class="btn">ذخیره</button>
<button type="button" class="btn ghost" @click="socials.push({ label: '', url: '' })">+ افزودن شبکه اجتماعی</button>
<p v-if="msg" class="muted" style="margin-top: 1rem">{{ msg }}</p>
<button class="btn" style="margin-top: 0.75rem">ذخیره</button>
</form>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { onMounted, reactive, ref } from "vue";
import { api } from "../lib/api";
const form = ref<any>(null);
const socialsText = ref("[]");
const form = ref<{
brandName: string;
tagline: string;
email: string;
phone: string;
address: string;
timezone: string;
logoUrl: string;
} | null>(null);
const socials = reactive<Array<{ label: string; url: string }>>([]);
const msg = ref("");
onMounted(async () => {
const { data } = await api.get("/admin/settings");
form.value = data;
socialsText.value = JSON.stringify(data.socials ?? [], null, 2);
form.value = {
brandName: data.brandName || "",
tagline: data.tagline || "",
email: data.email || "",
phone: data.phone || "",
address: data.address || "",
timezone: data.timezone || "Asia/Tehran",
logoUrl: data.logoUrl || "",
};
socials.splice(0, socials.length, ...(Array.isArray(data.socials) ? data.socials : []));
if (!socials.length) socials.push({ label: "", url: "" });
});
async function save() {
if (!form.value) return;
msg.value = "";
try {
form.value.socials = JSON.parse(socialsText.value);
const { data } = await api.put("/admin/settings", form.value);
form.value = data;
const payload = {
brandName: form.value.brandName,
tagline: form.value.tagline,
email: form.value.email,
phone: form.value.phone,
address: form.value.address,
timezone: form.value.timezone,
logoUrl: form.value.logoUrl || null,
socials: socials.filter((s) => s.label.trim() || s.url.trim()),
};
const { data } = await api.put("/admin/settings", payload);
form.value.logoUrl = data.logoUrl || "";
msg.value = "ذخیره شد.";
} catch {
msg.value = "خطا در ذخیره یا JSON نامعتبر.";
} catch (e: any) {
const detail = e?.response?.data?.message;
msg.value = Array.isArray(detail)
? `خطا: ${detail.join("، ")}`
: detail
? `خطا: ${detail}`
: "خطا در ذخیره.";
}
}
</script>
<style scoped>
.item-box {
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.9rem;
margin-bottom: 0.9rem;
background: #12151b;
}
</style>
+16 -3
View File
@@ -1,7 +1,16 @@
import { Body, Controller, Get, Param, Put, UseGuards } from "@nestjs/common";
import { ContentService } from "./content.service";
import { JwtAuthGuard } from "../auth/jwt-auth.guard";
import { IsObject, IsOptional, IsString } from "class-validator";
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
class SocialDto {
@IsString()
label!: string;
@IsString()
url!: string;
}
class UpdateSettingsDto {
@IsOptional() @IsString() brandName?: string;
@@ -10,8 +19,12 @@ class UpdateSettingsDto {
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() address?: string;
@IsOptional() @IsString() timezone?: string;
@IsOptional() socials?: object;
@IsOptional() @IsString() logoUrl?: string;
@IsOptional() @IsString() logoUrl?: string | null;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => SocialDto)
socials?: SocialDto[];
}
class UpsertSectionDto {
+8 -1
View File
@@ -19,7 +19,14 @@ export class ContentService {
socials?: object;
logoUrl?: string | null;
}) {
return this.prisma.siteSettings.update({ where: { id: "default" }, data });
const { logoUrl, ...rest } = data;
return this.prisma.siteSettings.update({
where: { id: "default" },
data: {
...rest,
...(logoUrl !== undefined ? { logoUrl: logoUrl || null } : {}),
},
});
}
async listSections() {