feat(api): expose workspace branding to mobile clients
This commit is contained in:
@@ -111,7 +111,7 @@ Davet token'ının yalnızca hash'i saklanır. Eski Supabase Auth şifre/session
|
||||
|
||||
## Marka özelleştirmesi
|
||||
|
||||
Instance adı, kısa ad, açık/koyu logo, ikon, primary/accent renk, varsayılan görünüm ve radius yoğunluğu SQLite'ta tutulur ve root layout'a server-side uygulanır. Görseller yerel upload alanında saklanır. Branding mutation'ı yalnızca owner rolüne açıktır; portal aynı güvenli public marka çıktısını kullanır.
|
||||
`Ayarlar > Genel` alanındaki workspace adı, meta title, kısa uygulama adı, açık/koyu tema logoları, favicon, ana renk ve görünüm tercihi SQLite'ta tutulur ve root layout'a server-side uygulanır. Görseller yerel upload alanında saklanır. Branding mutation'ı yalnızca owner rolüne açıktır; portal aynı güvenli public marka çıktısını kullanır. `/api/v1/meta` bu markayı absolute asset URL'leriyle, `/api/v1/me` ise oturum sahibinin renk modu tercihiyle mobil istemcilere sunar.
|
||||
|
||||
## Backup ve restore
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { apiV1Error, apiV1Success } from "@/server/api/v1/responses";
|
||||
import { domainActorFromSession } from "@/server/auth/domain-actor";
|
||||
import { getSessionContextFromHeaders } from "@/server/auth/session";
|
||||
import { getServerConfig } from "@/server/config";
|
||||
import { DomainError } from "@/server/domain/errors";
|
||||
import { getUserPreferences } from "@/server/settings/preferences";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -12,6 +14,7 @@ export async function GET(request: Request) {
|
||||
if (!context) {
|
||||
throw new DomainError("UNAUTHENTICATED", "Geçerli bir oturum gerekli.");
|
||||
}
|
||||
const preferences = getUserPreferences(domainActorFromSession(context));
|
||||
|
||||
return apiV1Success({
|
||||
user: {
|
||||
@@ -25,6 +28,7 @@ export async function GET(request: Request) {
|
||||
session: {
|
||||
expiresAt: context.session.expiresAt.toISOString(),
|
||||
},
|
||||
preferences,
|
||||
});
|
||||
} catch (error) {
|
||||
return apiV1Error(error);
|
||||
|
||||
@@ -113,8 +113,8 @@ Mevcut hata kodları:
|
||||
- Protokol/discovery/API major sürümü
|
||||
- Neta server package sürümü
|
||||
- Kalıcı instance kimliği ve oluşturulma zamanı
|
||||
- Application/organization adı
|
||||
- Mobil için absolute logo/icon URL'leri ve semantic marka bilgisi
|
||||
- Workspace adı, meta title ve kısa uygulama adı
|
||||
- Mobil için ayrı light/dark logo, favicon absolute URL'leri ve semantic marka bilgisi
|
||||
- Minimum desteklenen mobil client sürümü
|
||||
- `ios` ve `android` platform listesi
|
||||
- Authentication durumu
|
||||
@@ -134,6 +134,14 @@ Capability kaydı:
|
||||
|
||||
İstemci bilinmeyen capability ID ve alanlarını yok saymalıdır. `status=planned`, endpoint'in kullanılabilir olduğu anlamına gelmez.
|
||||
|
||||
`instance.workspaceName` kullanıcıya gösterilecek firma/freelance çalışma alanı adıdır.
|
||||
`instance.metaTitle` web metadata başlığıdır. `branding.lightLogoUrl`,
|
||||
`branding.darkLogoUrl` ve `branding.faviconUrl` absolute URL döner; mobil istemci
|
||||
aktif renk moduna göre doğru logoyu seçmeli ve eksik yeni alanlarda eski
|
||||
`applicationName` / `iconUrl` alanlarına geriye uyumlu fallback uygulamalıdır.
|
||||
Authenticated `/api/v1/me` yanıtındaki `preferences.colorMode`, kullanıcının
|
||||
`light`, `dark` veya `system` tercihini taşır.
|
||||
|
||||
İlk capability seti:
|
||||
|
||||
- `instance.discovery`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
distDir: process.env.NEXT_DIST_DIR?.trim() || ".next",
|
||||
output: "standalone",
|
||||
experimental: {
|
||||
serverActions: {
|
||||
|
||||
@@ -10,6 +10,11 @@ const PNG_BYTES = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0
|
||||
|
||||
const dataDir = path.join(process.cwd(), ".data", `phase1-auth-smoke-${Date.now()}`);
|
||||
const databasePath = path.join(dataDir, "neta.db");
|
||||
const distDir = `.next-phase1-auth-smoke-${Date.now()}`;
|
||||
const nextGeneratedConfigFiles = ["next-env.d.ts", "tsconfig.json"].map((file) => ({
|
||||
file,
|
||||
content: fs.readFileSync(path.join(process.cwd(), file), "utf8"),
|
||||
}));
|
||||
const port = await getAvailablePort();
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
const env = {
|
||||
@@ -22,6 +27,7 @@ const env = {
|
||||
BETTER_AUTH_SECRET: "phase1-auth-smoke-secret-is-longer-than-32-characters",
|
||||
TRUSTED_ORIGINS: baseUrl,
|
||||
NETA_MINIMUM_MOBILE_VERSION: "1.2.3-smoke.1",
|
||||
NEXT_DIST_DIR: distDir,
|
||||
NEXT_TELEMETRY_DISABLED: "1",
|
||||
};
|
||||
|
||||
@@ -178,6 +184,7 @@ try {
|
||||
clientId: null,
|
||||
},
|
||||
);
|
||||
assert.equal(ownerMe.payload.data.preferences.colorMode, "system");
|
||||
assert.doesNotMatch(
|
||||
JSON.stringify(ownerMe.payload),
|
||||
/token|password/i,
|
||||
@@ -333,38 +340,83 @@ try {
|
||||
"File API errors must use the standard envelope",
|
||||
);
|
||||
|
||||
const logoUpload = await uploadFile("branding_logo", {
|
||||
const lightLogoUpload = await uploadFile("branding_logo", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "logo.png",
|
||||
fileName: "light-logo.png",
|
||||
});
|
||||
assert.equal(logoUpload.response.status, 201, JSON.stringify(logoUpload.payload));
|
||||
assert.equal(logoUpload.payload.ok, true, "File API success must use the standard envelope");
|
||||
const logoFileId = logoUpload.payload.data.id;
|
||||
const darkLogoUpload = await uploadFile("branding_logo", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "dark-logo.png",
|
||||
});
|
||||
const faviconUpload = await uploadFile("branding_icon", {
|
||||
cookie: ownerCookie,
|
||||
fileName: "favicon.png",
|
||||
});
|
||||
for (const upload of [lightLogoUpload, darkLogoUpload, faviconUpload]) {
|
||||
assert.equal(upload.response.status, 201, JSON.stringify(upload.payload));
|
||||
assert.equal(upload.payload.ok, true, "File API success must use the standard envelope");
|
||||
}
|
||||
const lightLogoFileId = lightLogoUpload.payload.data.id;
|
||||
const darkLogoFileId = darkLogoUpload.payload.data.id;
|
||||
const faviconFileId = faviconUpload.payload.data.id;
|
||||
const brandingUpdate = await jsonRequest("/api/branding", {
|
||||
method: "PATCH",
|
||||
cookie: ownerCookie,
|
||||
body: {
|
||||
applicationName: "Neta Smoke Studio",
|
||||
applicationName: "Neta Smoke Meta",
|
||||
organizationName: "Neta Smoke Studio",
|
||||
shortName: "Neta Smoke",
|
||||
primaryColor: "#336699",
|
||||
accentColor: "#F0CC22",
|
||||
lightLogoFileId: logoFileId,
|
||||
lightLogoFileId,
|
||||
darkLogoFileId,
|
||||
iconFileId: faviconFileId,
|
||||
},
|
||||
});
|
||||
assert.equal(brandingUpdate.response.ok, true, JSON.stringify(brandingUpdate.payload));
|
||||
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Studio");
|
||||
assert.equal(brandingUpdate.payload.data.applicationName, "Neta Smoke Meta");
|
||||
assert.equal(brandingUpdate.payload.data.organizationName, "Neta Smoke Studio");
|
||||
assert.notEqual(
|
||||
brandingUpdate.payload.data.lightLogoUrl,
|
||||
brandingUpdate.payload.data.darkLogoUrl,
|
||||
"Light and dark logo assets must remain distinct",
|
||||
);
|
||||
const brandedLoginHtml = await (await fetch(`${baseUrl}/login`)).text();
|
||||
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Branding metadata must be server-rendered");
|
||||
assert.match(brandedLoginHtml, /Neta Smoke Studio/, "Workspace branding must be server-rendered");
|
||||
assert.match(brandedLoginHtml, /Neta Smoke Meta/, "Meta title must be server-rendered");
|
||||
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${lightLogoFileId}`));
|
||||
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${darkLogoFileId}`));
|
||||
assert.match(brandedLoginHtml, new RegExp(`/api/branding/assets/${faviconFileId}`));
|
||||
assert.doesNotMatch(
|
||||
brandedLoginHtml,
|
||||
/href="\/favicon\.ico"/,
|
||||
"Static favicon metadata must not override the active branding favicon",
|
||||
);
|
||||
assert.match(brandedLoginHtml, /--poyraz-primary:#336699/, "Brand tokens must be present in first HTML response");
|
||||
const dynamicManifest = await (await fetch(`${baseUrl}/manifest.webmanifest`)).json();
|
||||
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use instance branding");
|
||||
assert.equal(dynamicManifest.name, "Neta Smoke Studio", "Manifest must use workspace branding");
|
||||
assert.equal(dynamicManifest.short_name, "Neta Smoke");
|
||||
assert.equal(dynamicManifest.icons[0].src, `/api/branding/assets/${faviconFileId}`);
|
||||
const brandedMeta = await jsonRequest("/api/v1/meta");
|
||||
assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Studio");
|
||||
assert.equal(brandedMeta.payload.data.instance.applicationName, "Neta Smoke Meta");
|
||||
assert.equal(brandedMeta.payload.data.instance.metaTitle, "Neta Smoke Meta");
|
||||
assert.equal(brandedMeta.payload.data.instance.workspaceName, "Neta Smoke Studio");
|
||||
assert.equal(
|
||||
brandedMeta.payload.data.branding.lightLogoUrl,
|
||||
`${baseUrl}/api/branding/assets/${logoFileId}`,
|
||||
"Mobile metadata must expose absolute branding asset URLs",
|
||||
`${baseUrl}/api/branding/assets/${lightLogoFileId}`,
|
||||
"Mobile metadata must expose the absolute light logo URL",
|
||||
);
|
||||
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${logoFileId}`);
|
||||
assert.equal(
|
||||
brandedMeta.payload.data.branding.darkLogoUrl,
|
||||
`${baseUrl}/api/branding/assets/${darkLogoFileId}`,
|
||||
"Mobile metadata must expose the absolute dark logo URL",
|
||||
);
|
||||
assert.equal(
|
||||
brandedMeta.payload.data.branding.faviconUrl,
|
||||
`${baseUrl}/api/branding/assets/${faviconFileId}`,
|
||||
"Mobile metadata must expose the absolute favicon URL",
|
||||
);
|
||||
const publicLogo = await fetch(`${baseUrl}/api/branding/assets/${lightLogoFileId}`);
|
||||
assert.equal(publicLogo.status, 200, "Referenced branding asset must be publicly readable");
|
||||
assert.equal(publicLogo.headers.get("x-content-type-options"), "nosniff");
|
||||
assert.deepEqual(new Uint8Array(await publicLogo.arrayBuffer()), PNG_BYTES);
|
||||
@@ -690,6 +742,10 @@ try {
|
||||
new Promise((resolve) => server.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 5000)),
|
||||
]);
|
||||
fs.rmSync(path.join(process.cwd(), distDir), { recursive: true, force: true });
|
||||
for (const snapshot of nextGeneratedConfigFiles) {
|
||||
fs.writeFileSync(path.join(process.cwd(), snapshot.file), snapshot.content);
|
||||
}
|
||||
}
|
||||
|
||||
async function acceptInvite(token) {
|
||||
|
||||
@@ -21,6 +21,9 @@ for (const value of [
|
||||
'"auth.device-pairing"',
|
||||
'status: "planned"',
|
||||
"minimumSupportedVersion",
|
||||
"workspaceName",
|
||||
"metaTitle",
|
||||
"faviconUrl",
|
||||
]) {
|
||||
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
|
||||
}
|
||||
@@ -38,6 +41,11 @@ assert.doesNotMatch(
|
||||
/getSession|requireSession|authorization/i,
|
||||
"Discovery must remain public and session-independent",
|
||||
);
|
||||
assert.match(
|
||||
read("app/api/v1/me/route.ts"),
|
||||
/getUserPreferences/,
|
||||
"Authenticated mobile metadata must expose the persisted user color mode",
|
||||
);
|
||||
|
||||
for (const route of requiredRoutes.slice(1)) {
|
||||
const content = read(route);
|
||||
|
||||
@@ -32,6 +32,7 @@ export type NetaDiscoveryDocument = {
|
||||
discoveryVersion: typeof NETA_DISCOVERY_VERSION;
|
||||
instanceId: string;
|
||||
applicationName: string;
|
||||
workspaceName: string;
|
||||
api: {
|
||||
version: typeof NETA_API_VERSION;
|
||||
baseUrl: string;
|
||||
@@ -57,6 +58,8 @@ export type NetaInstanceMetadata = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
applicationName: string;
|
||||
workspaceName: string;
|
||||
metaTitle: string;
|
||||
shortName: string;
|
||||
organizationName: string | null;
|
||||
};
|
||||
@@ -68,6 +71,7 @@ export type NetaInstanceMetadata = {
|
||||
lightLogoUrl: string | null;
|
||||
darkLogoUrl: string | null;
|
||||
iconUrl: string | null;
|
||||
faviconUrl: string | null;
|
||||
};
|
||||
client: {
|
||||
minimumSupportedVersion: string | null;
|
||||
@@ -103,6 +107,7 @@ export function buildDiscoveryDocument(
|
||||
discoveryVersion: NETA_DISCOVERY_VERSION,
|
||||
instanceId: input.identity.instanceId,
|
||||
applicationName: input.branding.applicationName,
|
||||
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
|
||||
api: {
|
||||
version: NETA_API_VERSION,
|
||||
baseUrl: apiBaseUrl,
|
||||
@@ -132,6 +137,8 @@ export function buildInstanceMetadata(
|
||||
id: input.identity.instanceId,
|
||||
createdAt: input.identity.createdAt,
|
||||
applicationName: input.branding.applicationName,
|
||||
workspaceName: input.branding.organizationName ?? input.branding.applicationName,
|
||||
metaTitle: input.branding.applicationName,
|
||||
shortName: input.branding.shortName,
|
||||
organizationName: input.branding.organizationName,
|
||||
},
|
||||
@@ -143,6 +150,7 @@ export function buildInstanceMetadata(
|
||||
lightLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.lightLogoUrl),
|
||||
darkLogoUrl: absoluteOptionalUrl(input.appUrl, input.branding.darkLogoUrl),
|
||||
iconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
||||
faviconUrl: absoluteOptionalUrl(input.appUrl, input.branding.iconUrl),
|
||||
},
|
||||
client: {
|
||||
minimumSupportedVersion: input.minimumMobileClientVersion,
|
||||
|
||||
Reference in New Issue
Block a user