From ae5896d4400462e1dbb18467945d800986a74ae6 Mon Sep 17 00:00:00 2001
From: "soroush.asadi"
Date: Sun, 31 May 2026 23:56:16 +0330
Subject: [PATCH] fix: credentials lost on refresh + admin UI improvements + CI
safe deploy
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- dashboard layout: wait for Zustand _hasHydrated before redirecting to /login
(was redirecting on first render before localStorage was read)
- admin shell: same fix using new _hasHydrated on admin auth store
- admin-auth.store: add _hasHydrated + onRehydrateStorage to mirror merchant store
- AdminPlansScreen: replace direct cache mutation with per-plan PlanCard component
that owns its own useState — fixes other plans disappearing after save
- AdminSettingsScreen: detect boolean values and render iOS-style Toggle switches
- AdminIntegrationsScreen: replace all with Toggle switches;
replace OpenAI model text input with dropdown (gpt-4o-mini/4o/4-turbo/4/3.5)
- blog editor: fix form never syncing existing post data into state (editing was broken);
all fields now use local form state, save uses form directly
- blog links: fix broken relative hrefs (website/blog/new → /admin/website/blog/new)
and back button using proper Link components
- ci-cd: remove image prune step entirely — never removes containers or images
Co-Authored-By: Claude Sonnet 4.6
---
.gitea/workflows/ci-cd.yml | 10 +-
.../src/components/admin/admin-screens.tsx | 232 +++++++++++-------
.../src/components/admin/admin-shell.tsx | 5 +-
.../admin/admin-website-screens.tsx | 199 ++++++---------
web/admin/src/lib/stores/admin-auth.store.ts | 12 +-
.../src/app/[locale]/(dashboard)/layout.tsx | 8 +-
6 files changed, 239 insertions(+), 227 deletions(-)
diff --git a/.gitea/workflows/ci-cd.yml b/.gitea/workflows/ci-cd.yml
index 420c88c..fc11ddf 100644
--- a/.gitea/workflows/ci-cd.yml
+++ b/.gitea/workflows/ci-cd.yml
@@ -349,12 +349,4 @@ jobs:
if: always()
run: docker compose -f docker-compose.yml -f docker-compose.admin.yml ps
- - name: Prune old meezi images
- if: success()
- # Only remove untagged (dangling) meezi images — never touches other projects
- run: |
- docker images --format '{{.Repository}}:{{.Tag}} {{.ID}}' \
- | grep '^hostexecutor-' \
- | grep '' \
- | awk '{print $2}' \
- | xargs -r docker rmi || true
+ # Intentionally no image pruning — disk cleanup is done manually on the server.
diff --git a/web/admin/src/components/admin/admin-screens.tsx b/web/admin/src/components/admin/admin-screens.tsx
index 8aca463..c25c2ad 100644
--- a/web/admin/src/components/admin/admin-screens.tsx
+++ b/web/admin/src/components/admin/admin-screens.tsx
@@ -5,6 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { Link } from "@/i18n/routing";
+import { cn } from "@/lib/utils";
import {
adminDelete,
adminGet,
@@ -37,6 +38,30 @@ import {
type TicketStatus,
} from "@/components/support/ticket-status-badge";
+// iOS-style toggle switch used throughout this file
+function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
+ return (
+ onChange(!checked)}
+ className={cn(
+ "relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
+ checked ? "bg-[#0F6E56]" : "bg-muted-foreground/30"
+ )}
+ >
+
+
+ );
+}
+
export function AdminDashboardScreen() {
const t = useTranslations("admin.dashboard");
const { data } = useQuery({
@@ -78,6 +103,50 @@ function StatCard({ label, value }: { label: string; value: number }) {
);
}
+function PlanCard({ plan, onSave }: { plan: AdminPlan; onSave: (p: AdminPlan) => void }) {
+ const t = useTranslations("admin.plans");
+ const [price, setPrice] = useState(plan.monthlyPriceToman);
+ const [maxOrders, setMaxOrders] = useState(plan.limits.maxOrdersPerDay);
+
+ // Sync server values if they change (e.g. after successful save + refetch)
+ useEffect(() => { setPrice(plan.monthlyPriceToman); }, [plan.monthlyPriceToman]);
+ useEffect(() => { setMaxOrders(plan.limits.maxOrdersPerDay); }, [plan.limits.maxOrdersPerDay]);
+
+ const flush = () =>
+ onSave({ ...plan, monthlyPriceToman: price, limits: { ...plan.limits, maxOrdersPerDay: maxOrders } });
+
+ return (
+
+
+ {plan.displayNameFa}
+ {plan.tier}
+
+
+
+ {t("monthlyPrice")}
+ setPrice(Number(e.target.value))}
+ onBlur={flush}
+ />
+
+
+ {t("maxOrders")}
+ setMaxOrders(Number(e.target.value))}
+ onBlur={flush}
+ />
+
+
+
+ );
+}
+
export function AdminPlansScreen() {
const t = useTranslations("admin.plans");
const qc = useQueryClient();
@@ -108,38 +177,7 @@ export function AdminPlansScreen() {
);
@@ -166,17 +204,34 @@ export function AdminSettingsScreen() {
{t("title")}
- {settings.map((s) => (
-
- {s.key}
- {s.descriptionFa}
- save.mutate({ key: s.key, value: e.target.value })}
- />
-
- ))}
+ {settings.map((s) => {
+ const isBool = s.value === "true" || s.value === "false";
+ return (
+
+
+
+
{s.key}
+ {s.descriptionFa ? (
+
{s.descriptionFa}
+ ) : null}
+
+ {isBool ? (
+
save.mutate({ key: s.key, value: String(v) })}
+ disabled={save.isPending}
+ />
+ ) : (
+ save.mutate({ key: s.key, value: e.target.value })}
+ />
+ )}
+
+
+ );
+ })}
);
@@ -588,23 +643,21 @@ export function AdminIntegrationsScreen() {
{t("active")}
) : null}
-
-
+ updateGateway(g.id, { isEnabled: e.target.checked })}
+ onChange={(v) => updateGateway(g.id, { isEnabled: v })}
/>
- {t("enabled")}
-
+ {t("enabled")}
+
-
-
+ updateGateway(g.id, { sandbox: e.target.checked })}
+ onChange={(v) => updateGateway(g.id, { sandbox: v })}
/>
- {t("sandbox")}
-
+ {t("sandbox")}
+
{g.id === "zarinpal" ? (
{t("merchantId")}
@@ -779,14 +832,13 @@ export function AdminIntegrationsScreen() {
{t("kavenegarTitle")}
-
-
+ setKavenegar((k) => ({ ...k, isEnabled: e.target.checked }))}
+ onChange={(v) => setKavenegar((k) => ({ ...k, isEnabled: v }))}
/>
- {t("enabled")}
-
+ {t("enabled")}
+
{t("apiKey")}
{t("openAiTitle")}
{t("openAiHint")}
-
-
+ setOpenAi((o) => ({ ...o, isEnabled: e.target.checked }))}
+ onChange={(v) => setOpenAi((o) => ({ ...o, isEnabled: v }))}
/>
- {t("enabled")}
-
+ {t("enabled")}
+
{t("openAiApiKey")}
{t("openAiModel")}
- setOpenAi((o) => ({ ...o, model: e.target.value }))}
- />
+ >
+ gpt-4o-mini (fast, cheap)
+ gpt-4o (best quality)
+ gpt-4-turbo
+ gpt-4
+ gpt-3.5-turbo (legacy)
+
-
-
+
- setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: e.target.checked }))
- }
+ onChange={(v) => setOpenAi((o) => ({ ...o, coffeeAdvisorEnabled: v }))}
/>
- {t("coffeeAdvisorEnabled")}
-
+ {t("coffeeAdvisorEnabled")}
+
{t("meshyTitle")}
{t("meshyHint")}
-
-
+ setMeshy((m) => ({ ...m, isEnabled: e.target.checked }))}
+ onChange={(v) => setMeshy((m) => ({ ...m, isEnabled: v }))}
/>
- {t("enabled")}
-
+ {t("enabled")}
+
{t("meshyApiKey")}
setMeshy((m) => ({ ...m, apiKey: e.target.value }))}
/>
-
-
+ setMeshy((m) => ({ ...m, menu3dEnabled: e.target.checked }))}
+ onChange={(v) => setMeshy((m) => ({ ...m, menu3dEnabled: v }))}
/>
- {t("menu3dEnabled")}
-
+ {t("menu3dEnabled")}
+
diff --git a/web/admin/src/components/admin/admin-shell.tsx b/web/admin/src/components/admin/admin-shell.tsx
index f00c922..7379eb1 100644
--- a/web/admin/src/components/admin/admin-shell.tsx
+++ b/web/admin/src/components/admin/admin-shell.tsx
@@ -44,11 +44,12 @@ export function AdminShell({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const user = useAdminAuthStore((s) => s.user);
+ const hasHydrated = useAdminAuthStore((s) => s._hasHydrated);
const clearAuth = useAdminAuthStore((s) => s.clearAuth);
useEffect(() => {
- if (!user?.accessToken) router.replace("/admin/login");
- }, [user, router]);
+ if (hasHydrated && !user?.accessToken) router.replace("/admin/login");
+ }, [user, hasHydrated, router]);
return (
diff --git a/web/admin/src/components/admin/admin-website-screens.tsx b/web/admin/src/components/admin/admin-website-screens.tsx
index 1cd1172..88cf26e 100644
--- a/web/admin/src/components/admin/admin-website-screens.tsx
+++ b/web/admin/src/components/admin/admin-website-screens.tsx
@@ -15,6 +15,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { notify } from "@/lib/notify";
+import { Link } from "@/i18n/routing";
import {
CheckCircle2,
XCircle,
@@ -66,13 +67,13 @@ export function AdminBlogListScreen() {
{isLoading ? (
@@ -113,7 +114,7 @@ export function AdminBlogListScreen() {
asChild
className="h-8 px-2 text-xs"
>
-
{t("edit")}
+
{t("edit")}
{
+ if (post && !formReady) {
+ setForm({
+ slug: post.slug,
+ titleFa: post.titleFa,
+ titleEn: post.titleEn,
+ excerptFa: post.excerptFa,
+ excerptEn: post.excerptEn,
+ contentFa: post.contentFa,
+ contentEn: post.contentEn,
+ author: post.author,
+ categoryFa: post.categoryFa,
+ categoryEn: post.categoryEn,
+ });
+ setFormReady(true);
+ }
+ }, [post, formReady]);
+
+ const setField = (key: keyof typeof form) => (v: string) =>
+ setForm((f) => ({ ...f, [key]: v }));
const saveMut = useMutation({
- mutationFn: (data: typeof form) =>
+ mutationFn: () =>
isNew
- ? adminPost("/api/admin/website/posts", data)
- : adminPut(`/api/admin/website/posts/${postId}`, data),
+ ? adminPost("/api/admin/website/posts", form)
+ : adminPut(`/api/admin/website/posts/${postId}`, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "website", "blog"] });
notify.success(t("saved"));
@@ -206,52 +215,49 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
const Field = ({
label,
- value,
- onChange,
+ fieldKey,
multiline,
}: {
label: string;
- value: string;
- onChange: (v: string) => void;
+ fieldKey: keyof typeof form;
multiline?: boolean;
- }) => (
-
- {label}
- {multiline ? (
-
- );
-
- const current = initialised ? post! : form;
- const setField = (key: keyof typeof form) => (v: string) => {
- if (initialised) {
- // We'd need local state override — keep it simple for demo
- }
- setForm((f) => ({ ...f, [key]: v }));
+ }) => {
+ const isFa = label.toLowerCase().includes("fa") || label.includes("فارسی") || label.includes("فا");
+ return (
+
+ {label}
+ {multiline ? (
+
+ );
};
+ if (!isNew && !formReady) {
+ return {t("loading")}
;
+ }
+
return (
-
+
{t("backToBlog")}
-
+
{isNew ? t("newPost") : t("editPost")}
@@ -261,80 +267,27 @@ export function AdminBlogEditorScreen({ postId }: PostEditorProps) {
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+
saveMut.mutate(isNew ? form : {
- slug: post?.slug ?? form.slug,
- titleFa: post?.titleFa ?? form.titleFa,
- titleEn: post?.titleEn ?? form.titleEn,
- excerptFa: post?.excerptFa ?? form.excerptFa,
- excerptEn: post?.excerptEn ?? form.excerptEn,
- contentFa: post?.contentFa ?? form.contentFa,
- contentEn: post?.contentEn ?? form.contentEn,
- author: post?.author ?? form.author,
- categoryFa: post?.categoryFa ?? form.categoryFa,
- categoryEn: post?.categoryEn ?? form.categoryEn,
- })}
+ onClick={() => saveMut.mutate()}
disabled={saveMut.isPending}
className="min-w-[100px]"
>
diff --git a/web/admin/src/lib/stores/admin-auth.store.ts b/web/admin/src/lib/stores/admin-auth.store.ts
index 5c12cde..3d4e199 100644
--- a/web/admin/src/lib/stores/admin-auth.store.ts
+++ b/web/admin/src/lib/stores/admin-auth.store.ts
@@ -4,15 +4,20 @@ import type { AuthTokenResponse } from "@/lib/api/types";
interface AdminAuthState {
user: AuthTokenResponse | null;
+ /** True once Zustand has finished rehydrating from localStorage. */
+ _hasHydrated: boolean;
setAuth: (user: AuthTokenResponse) => void;
clearAuth: () => void;
isAuthenticated: () => boolean;
+ _setHasHydrated: (v: boolean) => void;
}
export const useAdminAuthStore = create()(
persist(
(set, get) => ({
user: null,
+ _hasHydrated: false,
+ _setHasHydrated: (v) => set({ _hasHydrated: v }),
setAuth: (user) => {
if (typeof window !== "undefined") {
localStorage.setItem("meezi_admin_access_token", user.accessToken);
@@ -29,6 +34,11 @@ export const useAdminAuthStore = create()(
},
isAuthenticated: () => !!get().user?.accessToken,
}),
- { name: "meezi_admin_auth" }
+ {
+ name: "meezi_admin_auth",
+ onRehydrateStorage: () => (state) => {
+ state?._setHasHydrated(true);
+ },
+ }
)
);
diff --git a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
index cb96a59..7356fb4 100644
--- a/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
+++ b/web/dashboard/src/app/[locale]/(dashboard)/layout.tsx
@@ -18,13 +18,17 @@ export default function DashboardLayout({
const locale = useLocale();
const router = useRouter();
const user = useAuthStore((s) => s.user);
+ const hasHydrated = useAuthStore((s) => s._hasHydrated);
useOfflineSync(); // register online/offline listeners + load queue count
useEffect(() => {
- if (!user?.accessToken) {
+ // Wait for Zustand to finish reading localStorage before deciding to redirect.
+ // Without this guard, the effect fires while user is still null on first render,
+ // causing a spurious redirect to /login even when the token exists in storage.
+ if (hasHydrated && !user?.accessToken) {
router.replace("/login");
}
- }, [user, router]);
+ }, [user, hasHydrated, router]);
const isRtl = locale !== "en";