Add editable site name and finish service image upload

User asked to remove the hardcoded "TeaBoy" branding and let admins
change the system name. Also completes the in-progress service image
upload work via App Storage.

Changes:
- New app_settings table (single row id=1) with siteNameAr/siteNameEn
- New /settings endpoints: GET (public) + PATCH (admin)
- Atomic ensureSettingsRow via INSERT ... ON CONFLICT DO NOTHING
  to avoid race conditions
- New SiteSettingsPanel tab in admin page (Arabic + English inputs)
- New useAppName hook reads settings, updates document.title, falls
  back to defaults while loading
- Login + register pages now display the dynamic site name
- Service image upload (App Storage) wired via useUpload + presigned
  GCS URL flow; admin component ServiceImageUploader
- Storage routes: /storage/uploads/request-url and /storage/objects/*
  now require auth (closes previously-open endpoints flagged by review)
- Added AppSettings/UpdateAppSettingsBody + storage schemas to
  openapi.yaml; regenerated client and zod
- Exposed UploadResponse from @workspace/object-storage-web; added
  composite:true so it can be referenced by teaboy-os tsconfig

Validation: typechecks pass for api-server and teaboy-os; settings GET
returns row; upload URL endpoint returns 401 without auth.
This commit is contained in:
riyadhafraa
2026-04-20 11:06:15 +00:00
parent 8df5e76d29
commit 397a384785
19 changed files with 446 additions and 29 deletions
@@ -262,6 +262,6 @@ async function signObjectURL({
);
}
const { signed_url: signedURL } = await response.json();
return signedURL;
const data = (await response.json()) as { signed_url: string };
return data.signed_url;
}
+2
View File
@@ -8,6 +8,7 @@ import notificationsRouter from "./notifications";
import usersRouter from "./users";
import statsRouter from "./stats";
import storageRouter from "./storage";
import settingsRouter from "./settings";
const router: IRouter = Router();
@@ -20,5 +21,6 @@ router.use(notificationsRouter);
router.use(usersRouter);
router.use(statsRouter);
router.use(storageRouter);
router.use(settingsRouter);
export default router;
@@ -0,0 +1,42 @@
import { Router, type IRouter } from "express";
import { eq } from "drizzle-orm";
import { db } from "@workspace/db";
import { appSettingsTable } from "@workspace/db";
import { requireAdmin } from "../middlewares/auth";
import { UpdateAppSettingsBody } from "@workspace/api-zod";
const router: IRouter = Router();
async function ensureSettingsRow() {
await db
.insert(appSettingsTable)
.values({ id: 1 })
.onConflictDoNothing({ target: appSettingsTable.id });
}
async function readSettings() {
await ensureSettingsRow();
const [row] = await db.select().from(appSettingsTable).where(eq(appSettingsTable.id, 1));
return row;
}
router.get("/settings", async (_req, res): Promise<void> => {
res.json(await readSettings());
});
router.patch("/settings", requireAdmin, async (req, res): Promise<void> => {
const parsed = UpdateAppSettingsBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}
await ensureSettingsRow();
const [updated] = await db
.update(appSettingsTable)
.set(parsed.data)
.where(eq(appSettingsTable.id, 1))
.returning();
res.json(updated);
});
export default router;
+3 -18
View File
@@ -5,7 +5,7 @@ import {
RequestUploadUrlResponse,
} from "@workspace/api-zod";
import { ObjectStorageService, ObjectNotFoundError } from "../lib/objectStorage";
import { ObjectPermission } from "../lib/objectAcl";
import { requireAuth } from "../middlewares/auth";
const router: IRouter = Router();
const objectStorageService = new ObjectStorageService();
@@ -17,7 +17,7 @@ const objectStorageService = new ObjectStorageService();
* The client sends JSON metadata (name, size, contentType) — NOT the file.
* Then uploads the file directly to the returned presigned URL.
*/
router.post("/storage/uploads/request-url", async (req: Request, res: Response) => {
router.post("/storage/uploads/request-url", requireAuth, async (req: Request, res: Response) => {
const parsed = RequestUploadUrlBody.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Missing or invalid required fields" });
@@ -84,28 +84,13 @@ router.get("/storage/public-objects/*filePath", async (req: Request, res: Respon
* These are served from a separate path from /public-objects and can optionally
* be protected with authentication or ACL checks based on the use case.
*/
router.get("/storage/objects/*path", async (req: Request, res: Response) => {
router.get("/storage/objects/*path", requireAuth, async (req: Request, res: Response) => {
try {
const raw = req.params.path;
const wildcardPath = Array.isArray(raw) ? raw.join("/") : raw;
const objectPath = `/objects/${wildcardPath}`;
const objectFile = await objectStorageService.getObjectEntityFile(objectPath);
// --- Protected route example (uncomment when using replit-auth) ---
// if (!req.isAuthenticated()) {
// res.status(401).json({ error: "Unauthorized" });
// return;
// }
// const canAccess = await objectStorageService.canAccessObjectEntity({
// userId: req.user.id,
// objectFile,
// requestedPermission: ObjectPermission.READ,
// });
// if (!canAccess) {
// res.status(403).json({ error: "Forbidden" });
// return;
// }
const response = await objectStorageService.downloadObject(objectFile);
res.status(response.status);
@@ -0,0 +1,22 @@
import { useGetAppSettings, getGetAppSettingsQueryKey } from "@workspace/api-client-react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
export function useAppSettings() {
const query = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
return query;
}
export function useAppName() {
const { i18n } = useTranslation();
const { data } = useAppSettings();
const isAr = i18n.language === "ar";
const fallback = isAr ? "نظام TeaBoy" : "TeaBoy OS";
const name = data ? (isAr ? data.siteNameAr : data.siteNameEn) : fallback;
useEffect(() => {
if (typeof document !== "undefined" && data) {
document.title = name;
}
}, [name, data]);
return name;
}
+4 -1
View File
@@ -90,7 +90,10 @@
"email": "البريد الإلكتروني",
"password": "كلمة المرور",
"roles": "الصلاحيات",
"active": "نشط"
"active": "نشط",
"siteSettings": "إعدادات الموقع",
"siteNameAr": "اسم الموقع (عربي)",
"siteNameEn": "اسم الموقع (إنجليزي)"
},
"notFound": {
"title": "404 — الصفحة غير موجودة",
+4 -1
View File
@@ -90,7 +90,10 @@
"email": "Email",
"password": "Password",
"roles": "Roles",
"active": "Active"
"active": "Active",
"siteSettings": "Site Settings",
"siteNameAr": "Site Name (Arabic)",
"siteNameEn": "Site Name (English)"
},
"notFound": {
"title": "404 — Page Not Found",
+63 -3
View File
@@ -17,10 +17,13 @@ import {
useCreateUser,
useUpdateUser,
useDeleteUser,
useGetAppSettings,
getGetAppSettingsQueryKey,
useUpdateAppSettings,
} from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useUpload } from "@workspace/object-storage-web";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
@@ -306,6 +309,7 @@ export default function AdminPage() {
<TabsTrigger value="apps" className="flex-1">{t("admin.manageApps")}</TabsTrigger>
<TabsTrigger value="services" className="flex-1">{t("admin.manageServices")}</TabsTrigger>
<TabsTrigger value="users" className="flex-1">{t("admin.manageUsers")}</TabsTrigger>
<TabsTrigger value="settings" className="flex-1">{t("admin.siteSettings")}</TabsTrigger>
</TabsList>
{/* Apps Tab */}
@@ -488,12 +492,68 @@ export default function AdminPage() {
</div>
))}
</TabsContent>
<TabsContent value="settings" className="space-y-3">
<SiteSettingsPanel />
</TabsContent>
</Tabs>
</div>
</div>
);
}
function SiteSettingsPanel() {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const { data } = useGetAppSettings({ query: { queryKey: getGetAppSettingsQueryKey() } });
const update = useUpdateAppSettings();
const [form, setForm] = useState({ siteNameAr: "", siteNameEn: "" });
useEffect(() => {
if (data) setForm({ siteNameAr: data.siteNameAr, siteNameEn: data.siteNameEn });
}, [data]);
const handleSave = () => {
update.mutate(
{ data: form },
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: getGetAppSettingsQueryKey() });
toast({ title: t("common.success") });
},
onError: () => toast({ title: t("common.error"), variant: "destructive" }),
},
);
};
return (
<div className="glass-panel rounded-2xl p-4 space-y-4">
<div className="space-y-1">
<Label>{t("admin.siteNameAr")}</Label>
<Input
value={form.siteNameAr}
onChange={(e) => setForm({ ...form, siteNameAr: e.target.value })}
className="bg-white/70 border-slate-200"
dir="rtl"
/>
</div>
<div className="space-y-1">
<Label>{t("admin.siteNameEn")}</Label>
<Input
value={form.siteNameEn}
onChange={(e) => setForm({ ...form, siteNameEn: e.target.value })}
className="bg-white/70 border-slate-200"
dir="ltr"
/>
</div>
<Button onClick={handleSave} disabled={update.isPending} className="w-full">
{update.isPending ? t("common.loading") : t("common.save")}
</Button>
</div>
);
}
function ServiceImageUploader({
value,
onChange,
@@ -504,10 +564,10 @@ function ServiceImageUploader({
const { t } = useTranslation();
const { toast } = useToast();
const { uploadFile, isUploading, progress } = useUpload({
onSuccess: (resp) => {
onSuccess: (resp: UploadResponse) => {
onChange(`/api/storage${resp.objectPath}`);
},
onError: (err) => {
onError: (err: Error) => {
toast({ title: t("admin.uploadFailed"), description: err.message, variant: "destructive" });
},
});
+3 -1
View File
@@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { useAppName } from "@/hooks/use-app-settings";
import i18n from "@/i18n";
export default function LoginPage() {
const { t } = useTranslation();
const appName = useAppName();
const [, setLocation] = useLocation();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -46,7 +48,7 @@ export default function LoginPage() {
<div className="min-h-screen os-bg flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<div className="text-4xl font-bold text-primary mb-2">{t("common.appName")}</div>
<div className="text-4xl font-bold text-primary mb-2">{appName}</div>
<h1 className="text-xl font-semibold text-foreground">{t("auth.welcomeBack")}</h1>
</div>
+3 -1
View File
@@ -8,10 +8,12 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { useAppName } from "@/hooks/use-app-settings";
import i18n from "@/i18n";
export default function RegisterPage() {
const { t } = useTranslation();
const appName = useAppName();
const [, setLocation] = useLocation();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -48,7 +50,7 @@ export default function RegisterPage() {
<div className="min-h-screen os-bg flex items-center justify-center p-4">
<div className="glass-panel rounded-3xl p-8 w-full max-w-sm space-y-6">
<div className="text-center space-y-1">
<div className="text-4xl font-bold text-primary mb-2">{t("common.appName")}</div>
<div className="text-4xl font-bold text-primary mb-2">{appName}</div>
<h1 className="text-xl font-semibold text-foreground">{t("auth.createAccount")}</h1>
</div>