- Add client/src/themes/ with tokens, built-in skins (iistwin, twenty, omegaprom) - Add ThemeProvider with data-skin/data-theme and runtime CSS for custom skins - Add npm run skins:generate script for build-time CSS generation - Add organization_skins table, migration and CRUD API - Add activeSkinId to organizations and expose it in /api/auth/me - Add skin switcher in /profile and admin skin editor in /settings/appearance - Adapt index.css hardcoded colors to skin tokens - Update AGENTS.md and Obsidian notes
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
import { Router } from 'express';
|
||
import { eq, and } from 'drizzle-orm';
|
||
import { db } from '../db';
|
||
import { organizationSkins, organizations } from '@shared/schema';
|
||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware';
|
||
import { tenantIsolation } from '../middleware/tenant.middleware';
|
||
import { BUILT_IN_SKINS, getBuiltInSkinById } from '../../client/src/themes';
|
||
import type { Skin } from '../../client/src/themes/tokens';
|
||
|
||
const router = Router();
|
||
|
||
interface SkinListItem {
|
||
id: string;
|
||
name: string;
|
||
description?: string;
|
||
isEditable: boolean;
|
||
isBuiltIn: boolean;
|
||
dbId?: number;
|
||
}
|
||
|
||
function serializeSkinListItem(skin: Skin, isBuiltIn: boolean, dbId?: number): SkinListItem {
|
||
return {
|
||
id: skin.id,
|
||
name: skin.name,
|
||
description: skin.description,
|
||
isEditable: isBuiltIn ? false : true,
|
||
isBuiltIn,
|
||
dbId,
|
||
};
|
||
}
|
||
|
||
// GET /api/organization/skins — список скинов (встроенные + пользовательские)
|
||
router.get('/api/organization/skins', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const customRows = await db
|
||
.select()
|
||
.from(organizationSkins)
|
||
.where(eq(organizationSkins.organizationId, orgId));
|
||
|
||
const builtInList = BUILT_IN_SKINS.map((skin) => serializeSkinListItem(skin, true));
|
||
const customList = customRows.map((row) =>
|
||
serializeSkinListItem({
|
||
id: row.skinId,
|
||
name: row.name,
|
||
description: row.description ?? undefined,
|
||
tokens: row.tokens as Skin['tokens'],
|
||
} as Skin, false, row.id)
|
||
);
|
||
|
||
return res.json({ success: true, skins: [...builtInList, ...customList] });
|
||
} catch (error) {
|
||
console.error('Get organization skins error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// GET /api/organization/skins/:id — детали скина
|
||
router.get('/api/organization/skins/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const skinId = req.params.id;
|
||
|
||
const builtIn = getBuiltInSkinById(skinId);
|
||
if (builtIn) {
|
||
return res.json({ success: true, skin: builtIn });
|
||
}
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(organizationSkins)
|
||
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
|
||
|
||
if (!row) {
|
||
return res.status(404).json({ success: false, error: 'Скин не найден' });
|
||
}
|
||
|
||
const skin: Skin = {
|
||
id: row.skinId,
|
||
name: row.name,
|
||
description: row.description ?? undefined,
|
||
isEditable: row.isEditable ?? true,
|
||
tokens: row.tokens as Skin['tokens'],
|
||
};
|
||
return res.json({ success: true, skin });
|
||
} catch (error) {
|
||
console.error('Get organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// POST /api/organization/skins — создать пользовательский скин
|
||
router.post('/api/organization/skins', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const { skinId, name, description, tokens, baseSkinId } = req.body;
|
||
|
||
if (!skinId || typeof skinId !== 'string') {
|
||
return res.status(400).json({ success: false, error: 'skinId обязателен' });
|
||
}
|
||
if (!name || typeof name !== 'string') {
|
||
return res.status(400).json({ success: false, error: 'Название скина обязательно' });
|
||
}
|
||
if (getBuiltInSkinById(skinId)) {
|
||
return res.status(400).json({ success: false, error: 'Нельзя создать скин с id встроенного скина' });
|
||
}
|
||
|
||
let finalTokens = tokens;
|
||
if (!finalTokens && baseSkinId) {
|
||
const base = getBuiltInSkinById(baseSkinId) || await db.query.organizationSkins.findFirst({
|
||
where: and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, baseSkinId)),
|
||
});
|
||
if (base) {
|
||
finalTokens = (base as any).tokens ?? (base as Skin).tokens;
|
||
}
|
||
}
|
||
if (!finalTokens) {
|
||
return res.status(400).json({ success: false, error: 'Токены скина обязательны' });
|
||
}
|
||
|
||
const [row] = await db
|
||
.insert(organizationSkins)
|
||
.values({
|
||
organizationId: orgId,
|
||
skinId,
|
||
name: name.trim(),
|
||
description: description ?? null,
|
||
tokens: finalTokens,
|
||
isEditable: true,
|
||
isActive: false,
|
||
createdBy: req.user!.id,
|
||
})
|
||
.returning();
|
||
|
||
const skin: Skin = {
|
||
id: row.skinId,
|
||
name: row.name,
|
||
description: row.description ?? undefined,
|
||
isEditable: true,
|
||
tokens: row.tokens as Skin['tokens'],
|
||
};
|
||
return res.status(201).json({ success: true, skin, dbId: row.id });
|
||
} catch (error: any) {
|
||
if (error?.code === '23505') {
|
||
return res.status(409).json({ success: false, error: 'Скин с таким id уже существует' });
|
||
}
|
||
console.error('Create organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// PATCH /api/organization/skins/:id — обновить пользовательский скин
|
||
router.patch('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const skinId = req.params.id;
|
||
const { name, description, tokens } = req.body;
|
||
|
||
if (getBuiltInSkinById(skinId)) {
|
||
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя редактировать' });
|
||
}
|
||
|
||
const updates: Partial<{ name: string; description: string | null; tokens: object }> = {};
|
||
if (name !== undefined) updates.name = name.trim();
|
||
if (description !== undefined) updates.description = description ?? null;
|
||
if (tokens !== undefined) updates.tokens = tokens;
|
||
|
||
const [row] = await db
|
||
.update(organizationSkins)
|
||
.set({ ...updates, updatedAt: new Date() })
|
||
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
|
||
.returning();
|
||
|
||
if (!row) {
|
||
return res.status(404).json({ success: false, error: 'Скин не найден' });
|
||
}
|
||
|
||
const skin: Skin = {
|
||
id: row.skinId,
|
||
name: row.name,
|
||
description: row.description ?? undefined,
|
||
isEditable: row.isEditable ?? true,
|
||
tokens: row.tokens as Skin['tokens'],
|
||
};
|
||
return res.json({ success: true, skin });
|
||
} catch (error) {
|
||
console.error('Update organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// DELETE /api/organization/skins/:id — удалить пользовательский скин
|
||
router.delete('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const skinId = req.params.id;
|
||
|
||
if (getBuiltInSkinById(skinId)) {
|
||
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя удалять' });
|
||
}
|
||
|
||
const [row] = await db
|
||
.delete(organizationSkins)
|
||
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
|
||
.returning();
|
||
|
||
if (!row) {
|
||
return res.status(404).json({ success: false, error: 'Скин не найден' });
|
||
}
|
||
|
||
// Если удалён активный скин — сбрасываем на iistwin
|
||
await db
|
||
.update(organizations)
|
||
.set({ activeSkinId: 'iistwin', updatedAt: new Date() })
|
||
.where(and(eq(organizations.id, orgId), eq(organizations.activeSkinId, skinId)));
|
||
|
||
return res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('Delete organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// GET /api/organization/current-skin — текущий активный скин организации
|
||
router.get('/api/organization/current-skin', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const [org] = await db
|
||
.select({ activeSkinId: organizations.activeSkinId })
|
||
.from(organizations)
|
||
.where(eq(organizations.id, orgId));
|
||
|
||
const activeSkinId = org?.activeSkinId ?? 'iistwin';
|
||
const builtIn = getBuiltInSkinById(activeSkinId);
|
||
if (builtIn) {
|
||
return res.json({ success: true, skinId: activeSkinId, skin: builtIn });
|
||
}
|
||
|
||
const [row] = await db
|
||
.select()
|
||
.from(organizationSkins)
|
||
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, activeSkinId)));
|
||
|
||
if (!row) {
|
||
return res.json({ success: true, skinId: 'iistwin', skin: getBuiltInSkinById('iistwin') });
|
||
}
|
||
|
||
const skin: Skin = {
|
||
id: row.skinId,
|
||
name: row.name,
|
||
description: row.description ?? undefined,
|
||
isEditable: row.isEditable ?? true,
|
||
tokens: row.tokens as Skin['tokens'],
|
||
};
|
||
return res.json({ success: true, skinId: activeSkinId, skin });
|
||
} catch (error) {
|
||
console.error('Get current organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
// PUT /api/organization/current-skin — установить активный скин
|
||
router.put('/api/organization/current-skin', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const orgId = req.organizationId!;
|
||
const { skinId } = req.body;
|
||
if (!skinId || typeof skinId !== 'string') {
|
||
return res.status(400).json({ success: false, error: 'skinId обязателен' });
|
||
}
|
||
|
||
const isBuiltIn = !!getBuiltInSkinById(skinId);
|
||
if (!isBuiltIn) {
|
||
const [custom] = await db
|
||
.select({ id: organizationSkins.id })
|
||
.from(organizationSkins)
|
||
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
|
||
if (!custom) {
|
||
return res.status(404).json({ success: false, error: 'Скин не найден' });
|
||
}
|
||
}
|
||
|
||
await db
|
||
.update(organizations)
|
||
.set({ activeSkinId: skinId, updatedAt: new Date() })
|
||
.where(eq(organizations.id, orgId));
|
||
|
||
return res.json({ success: true, skinId });
|
||
} catch (error) {
|
||
console.error('Set current organization skin error:', error);
|
||
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
|
||
}
|
||
});
|
||
|
||
export default router;
|