Merge all role-dispatcher pages into single files; add FileBrowser with file-type icons
- DashboardPage, Projects, RequestsPage, ProjectDetailPage, RequestDetail: each now handles team/external/client in one file via role flags — removed 10 old role-specific sub-files - Layout: client Company nav link goes directly to /company/:id when user has a single company - FileBrowser: replace emoji icons with colored extension-text badges (square); folder icon stays 📁; Adobe/Figma/design-tool colors for design files - CompaniesPage: merged team Companies + client company routing (single-company redirect, multi-company list) - FileSharing: integrated FileBrowser component - Removed: seafile API + lib, old ServerStatus, TaskDetail, role-split page files Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
-515
@@ -1,515 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
|
||||
const DIRECTORY_USAGE_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const directoryUsageCache = new Map();
|
||||
|
||||
function json(res, status, body) {
|
||||
res.status(status).setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
res.send(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(url) {
|
||||
return String(url || '').trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function normalizePath(path) {
|
||||
const raw = String(path || '/').trim();
|
||||
const parts = raw.split('/').filter(Boolean);
|
||||
const clean = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === '.' || part === '') continue;
|
||||
if (part === '..') throw new Error('Invalid path');
|
||||
clean.push(part);
|
||||
}
|
||||
|
||||
return `/${clean.join('/')}`;
|
||||
}
|
||||
|
||||
function joinPath(...parts) {
|
||||
return normalizePath(parts.join('/'));
|
||||
}
|
||||
|
||||
function safeName(value, fallback) {
|
||||
const cleaned = String(value || '')
|
||||
.trim()
|
||||
.replace(/[\\/:*?"<>|#%{}^~[\]`]+/g, '-')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
function fillTemplate(template, profile) {
|
||||
const name = safeName(profile.name, profile.id);
|
||||
const companyName = safeName(profile.company?.name, name);
|
||||
const email = safeName(profile.email, profile.id);
|
||||
const emailName = safeName(String(profile.email || '').split('@')[0], profile.id);
|
||||
|
||||
return String(template || '')
|
||||
.replaceAll('{id}', profile.id)
|
||||
.replaceAll('{name}', name)
|
||||
.replaceAll('{companyName}', companyName)
|
||||
.replaceAll('{email}', email)
|
||||
.replaceAll('{emailName}', emailName);
|
||||
}
|
||||
|
||||
function getConfig() {
|
||||
const serverUrl = normalizeBaseUrl(process.env.SEAFILE_SERVER_URL);
|
||||
const apiToken = process.env.SEAFILE_API_TOKEN;
|
||||
const repoId = process.env.SEAFILE_REPO_ID;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
webUrl: normalizeBaseUrl(process.env.SEAFILE_WEB_URL || serverUrl),
|
||||
apiToken,
|
||||
repoId,
|
||||
teamRoot: normalizePath(process.env.SEAFILE_TEAM_ROOT_PATH || '/'),
|
||||
externalRoot: normalizePath(process.env.SEAFILE_EXTERNAL_ROOT_PATH || '/Subcontractors'),
|
||||
externalTemplate: process.env.SEAFILE_EXTERNAL_FOLDER_TEMPLATE || '{name}',
|
||||
clientRoot: normalizePath(process.env.SEAFILE_CLIENT_ROOT_PATH || '/Clients'),
|
||||
clientTemplate: process.env.SEAFILE_CLIENT_FOLDER_TEMPLATE || '{companyName}',
|
||||
configured: Boolean(serverUrl && apiToken && repoId),
|
||||
};
|
||||
}
|
||||
|
||||
async function createCallerClient(authHeader) {
|
||||
const supabaseUrl = process.env.VITE_SUPABASE_URL || process.env.SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.VITE_SUPABASE_ANON_KEY || process.env.SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
throw new Error('Supabase auth env is not configured on Vercel.');
|
||||
}
|
||||
|
||||
return createClient(supabaseUrl, supabaseAnonKey, {
|
||||
auth: { persistSession: false, autoRefreshToken: false },
|
||||
global: { headers: { Authorization: authHeader } },
|
||||
});
|
||||
}
|
||||
|
||||
async function requirePortalUser(authHeader) {
|
||||
const callerClient = await createCallerClient(authHeader);
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser();
|
||||
if (userError || !userData?.user) {
|
||||
return { ok: false, status: 401, message: 'Unauthorized' };
|
||||
}
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles')
|
||||
.select('id, name, role, company:companies(id, name)')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
if (profileError) {
|
||||
return { ok: false, status: 500, message: profileError.message };
|
||||
}
|
||||
|
||||
if (!['team', 'external', 'client'].includes(profile?.role)) {
|
||||
return { ok: false, status: 403, message: 'Forbidden' };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
callerClient,
|
||||
profile: {
|
||||
...profile,
|
||||
email: userData.user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getUserRoot(config, profile) {
|
||||
if (profile.role === 'team') return config.teamRoot;
|
||||
|
||||
if (profile.role === 'client') {
|
||||
const templated = fillTemplate(config.clientTemplate, profile);
|
||||
if (templated.startsWith('/')) return normalizePath(templated);
|
||||
|
||||
return joinPath(config.clientRoot, templated);
|
||||
}
|
||||
|
||||
const templated = fillTemplate(config.externalTemplate, profile);
|
||||
if (templated.startsWith('/')) return normalizePath(templated);
|
||||
|
||||
return joinPath(config.externalRoot, templated);
|
||||
}
|
||||
|
||||
function resolveSeafilePath(config, profile, requestedPath = '/') {
|
||||
const root = getUserRoot(config, profile);
|
||||
const virtualPath = normalizePath(requestedPath);
|
||||
return {
|
||||
root,
|
||||
virtualPath,
|
||||
seafilePath: joinPath(root, virtualPath),
|
||||
};
|
||||
}
|
||||
|
||||
async function seafileRequest(config, endpoint, options = {}) {
|
||||
const response = await fetch(`${config.serverUrl}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Token ${config.apiToken}`,
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
let body = text;
|
||||
try {
|
||||
body = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const message = typeof body === 'object' && body?.error_msg ? body.error_msg : text || `Seafile returned ${response.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
async function createSeafileFolder(config, path) {
|
||||
const body = new URLSearchParams({ operation: 'mkdir', create_parents: 'true' });
|
||||
await seafileRequest(config, `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
async function createSeafileFolderIfMissing(config, path) {
|
||||
try {
|
||||
await createSeafileFolder(config, path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const message = String(error.message || '').toLowerCase();
|
||||
if (error.status === 400 || message.includes('already') || message.includes('exist')) return false;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function getCachedDirectoryUsage(cacheKey) {
|
||||
const cached = directoryUsageCache.get(cacheKey);
|
||||
if (!cached) return null;
|
||||
if ((Date.now() - cached.timestamp) > DIRECTORY_USAGE_CACHE_TTL_MS) {
|
||||
directoryUsageCache.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.bytes;
|
||||
}
|
||||
|
||||
function setCachedDirectoryUsage(cacheKey, bytes) {
|
||||
directoryUsageCache.set(cacheKey, {
|
||||
bytes: Number(bytes) || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function listDirectoryEntries(config, path) {
|
||||
const entries = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(path)}`
|
||||
);
|
||||
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
async function getDirectoryUsageBytes(config, path, prefetchedEntries = null) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const cacheKey = `${config.repoId}:${normalizedPath}`;
|
||||
const cached = getCachedDirectoryUsage(cacheKey);
|
||||
if (cached != null) return cached;
|
||||
|
||||
const entries = prefetchedEntries || await listDirectoryEntries(config, normalizedPath);
|
||||
let total = 0;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type === 'file') {
|
||||
total += Number(entry.size || 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type === 'dir') {
|
||||
total += await getDirectoryUsageBytes(config, joinPath(normalizedPath, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
setCachedDirectoryUsage(cacheKey, total);
|
||||
return total;
|
||||
}
|
||||
|
||||
function clearDirectoryUsageCache(config, path) {
|
||||
const normalizedPath = normalizePath(path);
|
||||
const prefixes = [];
|
||||
let cursor = normalizedPath;
|
||||
|
||||
while (true) {
|
||||
prefixes.push(`${config.repoId}:${cursor}`);
|
||||
if (cursor === '/') break;
|
||||
cursor = parentDir(cursor);
|
||||
}
|
||||
|
||||
for (const key of prefixes) {
|
||||
directoryUsageCache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
function entryPath(parent, name) {
|
||||
return joinPath(parent, name);
|
||||
}
|
||||
|
||||
function parentDir(path) {
|
||||
const normalized = normalizePath(path);
|
||||
const parts = normalized.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
}
|
||||
|
||||
function basename(path) {
|
||||
const parts = normalizePath(path).split('/').filter(Boolean);
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
async function syncManagedFolders(config, auth) {
|
||||
if (auth.profile.role !== 'team') {
|
||||
const error = new Error('Team only');
|
||||
error.status = 403;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const [{ data: companies, error: companiesError }, { data: externals, error: externalsError }] = await Promise.all([
|
||||
auth.callerClient.from('companies').select('id, name').order('name'),
|
||||
auth.callerClient.from('profiles').select('id, name, email, role').eq('role', 'external').order('name'),
|
||||
]);
|
||||
|
||||
if (companiesError) throw new Error(companiesError.message);
|
||||
if (externalsError) throw new Error(externalsError.message);
|
||||
|
||||
const folderPaths = [
|
||||
config.clientRoot,
|
||||
config.externalRoot,
|
||||
...(companies || []).map(company => {
|
||||
const profile = { id: company.id, name: company.name, email: '', company };
|
||||
const templated = fillTemplate(config.clientTemplate, profile);
|
||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.clientRoot, templated);
|
||||
}),
|
||||
...(externals || []).map(profile => {
|
||||
const templated = fillTemplate(config.externalTemplate, profile);
|
||||
return templated.startsWith('/') ? normalizePath(templated) : joinPath(config.externalRoot, templated);
|
||||
}),
|
||||
];
|
||||
|
||||
const uniquePaths = [...new Set(folderPaths)].filter(path => path !== '/');
|
||||
let created = 0;
|
||||
|
||||
for (const path of uniquePaths) {
|
||||
if (await createSeafileFolderIfMissing(config, path)) created += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
checked: uniquePaths.length,
|
||||
clients: companies?.length || 0,
|
||||
subcontractors: externals?.length || 0,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function handler(req, res) {
|
||||
try {
|
||||
const authHeader = req.headers.authorization || '';
|
||||
if (!authHeader) return json(res, 401, { error: 'No authorization header' });
|
||||
|
||||
const auth = await requirePortalUser(authHeader);
|
||||
if (!auth.ok) return json(res, auth.status, { error: auth.message });
|
||||
|
||||
const config = getConfig();
|
||||
if (!config.configured) {
|
||||
return json(res, 200, {
|
||||
configured: false,
|
||||
error: 'Seafile is not configured yet.',
|
||||
requiredEnv: ['SEAFILE_SERVER_URL', 'SEAFILE_API_TOKEN', 'SEAFILE_REPO_ID'],
|
||||
});
|
||||
}
|
||||
|
||||
const action = req.query.action || (req.method === 'GET' ? 'list' : '');
|
||||
const requestedPath = req.query.path || req.body?.path;
|
||||
const resolved = resolveSeafilePath(config, auth.profile, requestedPath || '/');
|
||||
const invalidateUsage = req.query.invalidateUsage === '1';
|
||||
|
||||
if (req.method === 'POST' && action === 'sync-folders') {
|
||||
const result = await syncManagedFolders(config, auth);
|
||||
return json(res, 200, { success: true, ...result });
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'config') {
|
||||
return json(res, 200, {
|
||||
configured: true,
|
||||
role: auth.profile.role,
|
||||
root: resolved.root,
|
||||
webUrl: config.webUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'list') {
|
||||
if (invalidateUsage) clearDirectoryUsageCache(config, resolved.seafilePath);
|
||||
let entries;
|
||||
try {
|
||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
||||
} catch (error) {
|
||||
if (!['external', 'client'].includes(auth.profile.role) || resolved.virtualPath !== '/') throw error;
|
||||
await createSeafileFolder(config, resolved.root);
|
||||
entries = await listDirectoryEntries(config, resolved.seafilePath);
|
||||
}
|
||||
|
||||
const normalizedEntries = (Array.isArray(entries) ? entries : []).map((item) => {
|
||||
const itemPath = entryPath(resolved.virtualPath, item.name);
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
size: item.type === 'file' ? Number(item.size || 0) : 0,
|
||||
aggregateSize: item.type === 'file' ? Number(item.size || 0) : null,
|
||||
mtime: item.mtime || null,
|
||||
permission: item.permission || null,
|
||||
path: itemPath,
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'dir' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return json(res, 200, {
|
||||
configured: true,
|
||||
path: resolved.virtualPath,
|
||||
canGoUp: resolved.virtualPath !== '/',
|
||||
parentPath: parentDir(resolved.virtualPath),
|
||||
entries: normalizedEntries,
|
||||
webUrl: config.webUrl,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && action === 'download') {
|
||||
const url = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}&reuse=1`
|
||||
);
|
||||
|
||||
return json(res, 200, { url });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'mkdir') {
|
||||
const folderName = safeName(req.body?.name, '');
|
||||
if (!folderName) return json(res, 400, { error: 'Folder name is required.' });
|
||||
|
||||
const folderPath = joinPath(resolved.seafilePath, folderName);
|
||||
await createSeafileFolder(config, folderPath);
|
||||
clearDirectoryUsageCache(config, resolved.seafilePath);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'upload-link') {
|
||||
const uploadLink = await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/upload-link/?p=${encodeURIComponent(resolved.seafilePath)}`
|
||||
);
|
||||
|
||||
return json(res, 200, {
|
||||
uploadLink: typeof uploadLink === 'string' ? uploadLink : String(uploadLink || ''),
|
||||
parentDir: resolved.seafilePath,
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'rename') {
|
||||
const newName = safeName(req.body?.name, '');
|
||||
if (!newName) return json(res, 400, { error: 'New name is required.' });
|
||||
|
||||
const type = req.body?.type || 'file';
|
||||
const endpoint = type === 'dir'
|
||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolved.seafilePath)}`
|
||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolved.seafilePath)}`;
|
||||
|
||||
const body = new URLSearchParams({ operation: 'rename', newname: newName });
|
||||
await seafileRequest(config, endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && action === 'move') {
|
||||
const srcPath = req.body?.srcPath;
|
||||
const dstDir = req.body?.dstDir;
|
||||
if (!srcPath || !dstDir) return json(res, 400, { error: 'srcPath and dstDir are required.' });
|
||||
|
||||
const resolvedSrc = resolveSeafilePath(config, auth.profile, srcPath);
|
||||
const resolvedDst = resolveSeafilePath(config, auth.profile, dstDir);
|
||||
const itemName = basename(resolvedSrc.seafilePath);
|
||||
const srcDir = parentDir(resolvedSrc.seafilePath);
|
||||
if (!itemName) return json(res, 400, { error: 'Cannot move root.' });
|
||||
|
||||
const type = req.body?.type || 'file';
|
||||
const endpoint = type === 'dir'
|
||||
? `/api2/repos/${encodeURIComponent(config.repoId)}/dir/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`
|
||||
: `/api2/repos/${encodeURIComponent(config.repoId)}/file/?p=${encodeURIComponent(resolvedSrc.seafilePath)}`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
operation: 'move',
|
||||
dst_repo: config.repoId,
|
||||
dst_dir: resolvedDst.seafilePath,
|
||||
});
|
||||
|
||||
await seafileRequest(config, endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
clearDirectoryUsageCache(config, srcDir);
|
||||
clearDirectoryUsageCache(config, resolvedDst.seafilePath);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && action === 'delete') {
|
||||
const type = req.query.type || req.body?.type;
|
||||
if (!['file', 'dir'].includes(type)) return json(res, 400, { error: 'Valid item type is required.' });
|
||||
|
||||
const itemName = basename(resolved.seafilePath);
|
||||
const itemParent = parentDir(resolved.seafilePath);
|
||||
if (!itemName) return json(res, 400, { error: 'Cannot delete the root folder.' });
|
||||
|
||||
const body = new URLSearchParams({
|
||||
file_names: itemName,
|
||||
});
|
||||
|
||||
await seafileRequest(
|
||||
config,
|
||||
`/api2/repos/${encodeURIComponent(config.repoId)}/fileops/delete/?p=${encodeURIComponent(itemParent)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
}
|
||||
);
|
||||
|
||||
clearDirectoryUsageCache(config, itemParent);
|
||||
|
||||
return json(res, 200, { success: true });
|
||||
}
|
||||
|
||||
return json(res, 405, { error: 'Method not allowed' });
|
||||
} catch (error) {
|
||||
return json(res, error.status || 500, { error: error.message || 'Unexpected Seafile error' });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user