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:
Riyadh
2026-04-20 11:06:15 +00:00
parent 0efc4032be
commit 9d353ede69
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);