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>
@@ -257,6 +257,25 @@ export interface Notification {
createdAt: string;
}
export interface AppSettings {
siteNameAr: string;
siteNameEn: string;
updatedAt?: string;
}
export interface UpdateAppSettingsBody {
/**
* @minLength 1
* @maxLength 200
*/
siteNameAr?: string;
/**
* @minLength 1
* @maxLength 200
*/
siteNameEn?: string;
}
export interface RequestUploadUrlBody {
/** @minLength 1 */
name: string;
+163
View File
@@ -18,6 +18,7 @@ import type {
import type {
App,
AppSettings,
AuthUser,
ConversationWithDetails,
CreateAppBody,
@@ -37,6 +38,7 @@ import type {
ServiceCategory,
SuccessResponse,
UpdateAppBody,
UpdateAppSettingsBody,
UpdateLanguageBody,
UpdateServiceBody,
UpdateUserBody,
@@ -926,6 +928,167 @@ export const useDeleteApp = <
return useMutation(getDeleteAppMutationOptions(options));
};
/**
* @summary Get application settings (public)
*/
export const getGetAppSettingsUrl = () => {
return `/api/settings`;
};
export const getAppSettings = async (
options?: RequestInit,
): Promise<AppSettings> => {
return customFetch<AppSettings>(getGetAppSettingsUrl(), {
...options,
method: "GET",
});
};
export const getGetAppSettingsQueryKey = () => {
return [`/api/settings`] as const;
};
export const getGetAppSettingsQueryOptions = <
TData = Awaited<ReturnType<typeof getAppSettings>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAppSettings>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}) => {
const { query: queryOptions, request: requestOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetAppSettingsQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getAppSettings>>> = ({
signal,
}) => getAppSettings({ signal, ...requestOptions });
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getAppSettings>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetAppSettingsQueryResult = NonNullable<
Awaited<ReturnType<typeof getAppSettings>>
>;
export type GetAppSettingsQueryError = ErrorType<unknown>;
/**
* @summary Get application settings (public)
*/
export function useGetAppSettings<
TData = Awaited<ReturnType<typeof getAppSettings>>,
TError = ErrorType<unknown>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getAppSettings>>,
TError,
TData
>;
request?: SecondParameter<typeof customFetch>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetAppSettingsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Update application settings (admin)
*/
export const getUpdateAppSettingsUrl = () => {
return `/api/settings`;
};
export const updateAppSettings = async (
updateAppSettingsBody: UpdateAppSettingsBody,
options?: RequestInit,
): Promise<AppSettings> => {
return customFetch<AppSettings>(getUpdateAppSettingsUrl(), {
...options,
method: "PATCH",
headers: { "Content-Type": "application/json", ...options?.headers },
body: JSON.stringify(updateAppSettingsBody),
});
};
export const getUpdateAppSettingsMutationOptions = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateAppSettings>>,
TError,
{ data: BodyType<UpdateAppSettingsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateAppSettings>>,
TError,
{ data: BodyType<UpdateAppSettingsBody> },
TContext
> => {
const mutationKey = ["updateAppSettings"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateAppSettings>>,
{ data: BodyType<UpdateAppSettingsBody> }
> = (props) => {
const { data } = props ?? {};
return updateAppSettings(data, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateAppSettingsMutationResult = NonNullable<
Awaited<ReturnType<typeof updateAppSettings>>
>;
export type UpdateAppSettingsMutationBody = BodyType<UpdateAppSettingsBody>;
export type UpdateAppSettingsMutationError = ErrorType<ErrorResponse>;
/**
* @summary Update application settings (admin)
*/
export const useUpdateAppSettings = <
TError = ErrorType<ErrorResponse>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateAppSettings>>,
TError,
{ data: BodyType<UpdateAppSettingsBody> },
TContext
>;
request?: SecondParameter<typeof customFetch>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateAppSettings>>,
TError,
{ data: BodyType<UpdateAppSettingsBody> },
TContext
> => {
return useMutation(getUpdateAppSettingsMutationOptions(options));
};
/**
* @summary Request a presigned URL for file upload
*/
+61
View File
@@ -236,6 +236,43 @@ paths:
"204":
description: Deleted
# Settings
/settings:
get:
operationId: getAppSettings
tags: [settings]
summary: Get application settings (public)
responses:
"200":
description: Current settings
content:
application/json:
schema:
$ref: "#/components/schemas/AppSettings"
patch:
operationId: updateAppSettings
tags: [settings]
summary: Update application settings (admin)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateAppSettingsBody"
responses:
"200":
description: Updated settings
content:
application/json:
schema:
$ref: "#/components/schemas/AppSettings"
"400":
description: Invalid input
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# Storage
/storage/uploads/request-url:
post:
@@ -1140,6 +1177,30 @@ components:
- isRead
- createdAt
AppSettings:
type: object
required: [siteNameAr, siteNameEn]
properties:
siteNameAr:
type: string
siteNameEn:
type: string
updatedAt:
type: string
format: date-time
UpdateAppSettingsBody:
type: object
properties:
siteNameAr:
type: string
minLength: 1
maxLength: 200
siteNameEn:
type: string
minLength: 1
maxLength: 200
RequestUploadUrlBody:
type: object
required: [name, size, contentType]
+35
View File
@@ -197,6 +197,41 @@ export const DeleteAppParams = zod.object({
id: zod.coerce.number(),
});
/**
* @summary Get application settings (public)
*/
export const GetAppSettingsResponse = zod.object({
siteNameAr: zod.string(),
siteNameEn: zod.string(),
updatedAt: zod.coerce.date().optional(),
});
/**
* @summary Update application settings (admin)
*/
export const updateAppSettingsBodySiteNameArMax = 200;
export const updateAppSettingsBodySiteNameEnMax = 200;
export const UpdateAppSettingsBody = zod.object({
siteNameAr: zod
.string()
.min(1)
.max(updateAppSettingsBodySiteNameArMax)
.optional(),
siteNameEn: zod
.string()
.min(1)
.max(updateAppSettingsBodySiteNameEnMax)
.optional(),
});
export const UpdateAppSettingsResponse = zod.object({
siteNameAr: zod.string(),
siteNameEn: zod.string(),
updatedAt: zod.coerce.date().optional(),
});
/**
* @summary Request a presigned URL for file upload
*/
+1
View File
@@ -4,3 +4,4 @@ export * from "./apps";
export * from "./services";
export * from "./conversations";
export * from "./notifications";
export * from "./settings";
+16
View File
@@ -0,0 +1,16 @@
import { pgTable, varchar, integer, timestamp } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { z } from "zod/v4";
export const appSettingsTable = pgTable("app_settings", {
id: integer("id").primaryKey().default(1),
siteNameAr: varchar("site_name_ar", { length: 200 }).notNull().default("نظام TeaBoy"),
siteNameEn: varchar("site_name_en", { length: 200 }).notNull().default("TeaBoy OS"),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),
});
export const updateAppSettingsSchema = createInsertSchema(appSettingsTable)
.omit({ id: true, updatedAt: true })
.partial();
export type AppSettings = typeof appSettingsTable.$inferSelect;
export type UpdateAppSettings = z.infer<typeof updateAppSettingsSchema>;
+1 -1
View File
@@ -1,2 +1,2 @@
export { ObjectUploader } from "./ObjectUploader";
export { useUpload } from "./use-upload";
export { useUpload, type UploadResponse } from "./use-upload";
+1 -1
View File
@@ -7,7 +7,7 @@ interface UploadMetadata {
contentType: string;
}
interface UploadResponse {
export interface UploadResponse {
uploadURL: string;
objectPath: string;
metadata: UploadMetadata;
+1
View File
@@ -1,6 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "dist",
"rootDir": "src",
"jsx": "react-jsx",