fix: Applied fix for name, the oracle team sharing, sentinel client list visibility
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -171,6 +171,10 @@ docker-compose.override.yml
|
|||||||
*.pem
|
*.pem
|
||||||
*.mp4
|
*.mp4
|
||||||
*.zip
|
*.zip
|
||||||
|
.local-dev/
|
||||||
|
runtime-snapshots/
|
||||||
|
local-dev-bundles/
|
||||||
|
scripts/tmp/
|
||||||
|
|
||||||
models/
|
models/
|
||||||
comfy_engine/test_outputs/
|
comfy_engine/test_outputs/
|
||||||
|
|||||||
4
app/dist/index.html
vendored
4
app/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Velocity WebOS</title>
|
<title>Velocity WebOS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-CV1YNwsn.js"></script>
|
<script type="module" crossorigin src="./assets/index-Bj2Xa_13.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-BDvIhi37.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-W2SBxMnB.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
getVelocityToken,
|
getVelocityToken,
|
||||||
isAdminRole,
|
isAdminRole,
|
||||||
normalizeVelocityRole,
|
normalizeVelocityRole,
|
||||||
type VelocityUserProfile,
|
resolveVelocityFirstName,
|
||||||
} from '@/lib/velocityPlatformClient';
|
} from '@/lib/velocityPlatformClient';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -52,8 +52,8 @@ export const MODULE_ROUTES: Array<{
|
|||||||
{ id: 'sentinel', path: '/sentinel', title: 'The Sentinel', component: Sentinel },
|
{ id: 'sentinel', path: '/sentinel', title: 'The Sentinel', component: Sentinel },
|
||||||
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
|
{ id: 'inventory', path: '/inventory', title: 'Inventory', component: Inventory },
|
||||||
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
|
{ id: 'catalyst', path: '/catalyst', title: 'The Catalyst', component: Catalyst },
|
||||||
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
|
|
||||||
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
|
{ id: 'crm', path: '/crm', title: 'CRM', component: CRM },
|
||||||
|
{ id: 'settings', path: '/settings', title: 'Settings', component: Settings },
|
||||||
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
|
{ id: 'admin', path: '/admin', title: 'Admin', component: AdminPage, adminOnly: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ function MainLayout() {
|
|||||||
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
|
const currentRoute = availableRoutes.find((r) => r.path === location.pathname);
|
||||||
const pageTitle = currentRoute?.title ?? 'Velocity';
|
const pageTitle = currentRoute?.title ?? 'Velocity';
|
||||||
const roleLabel = formatRoleLabel(user?.role);
|
const roleLabel = formatRoleLabel(user?.role);
|
||||||
const userLabel = user?.name?.trim() || user?.id || 'Authenticated User';
|
const userLabel = user?.name?.trim() || user?.fullName?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
|
||||||
const initials = userLabel
|
const initials = userLabel
|
||||||
.split(/\s+/)
|
.split(/\s+/)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
@@ -187,7 +187,13 @@ function MainLayout() {
|
|||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="bg-white/10" />
|
<DropdownMenuSeparator className="bg-white/10" />
|
||||||
<DropdownMenuItem className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer" onClick={() => logout()}>
|
<DropdownMenuItem
|
||||||
|
className="text-red-400 focus:text-red-400 focus:bg-red-500/10 cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
clearVelocityToken();
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="mr-2 h-4 w-4" />
|
||||||
<span>Log out</span>
|
<span>Log out</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -237,7 +243,7 @@ function MainLayout() {
|
|||||||
// ── Root App ──────────────────────────────────────────────────────────────────
|
// ── Root App ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const { isAuthenticated, login, logout } = useStore();
|
const { isAuthenticated, login, logout, user } = useStore();
|
||||||
const [authBootstrapped, setAuthBootstrapped] = useState(false);
|
const [authBootstrapped, setAuthBootstrapped] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -257,9 +263,18 @@ function App() {
|
|||||||
void getVelocityMe()
|
void getVelocityMe()
|
||||||
.then((me) => {
|
.then((me) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
|
||||||
|
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
|
||||||
login({
|
login({
|
||||||
id: me.user_id,
|
id: me.user_id,
|
||||||
name: resolveVelocityDisplayName(me),
|
name: resolveVelocityFirstName({
|
||||||
|
...me,
|
||||||
|
email: resolvedEmail ?? null,
|
||||||
|
full_name: resolvedFullName ?? null,
|
||||||
|
}),
|
||||||
|
fullName: resolvedFullName,
|
||||||
|
email: resolvedEmail,
|
||||||
|
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
|
||||||
role: normalizeVelocityRole(me.role),
|
role: normalizeVelocityRole(me.role),
|
||||||
});
|
});
|
||||||
setAuthBootstrapped(true);
|
setAuthBootstrapped(true);
|
||||||
@@ -286,9 +301,18 @@ function App() {
|
|||||||
void getVelocityMe()
|
void getVelocityMe()
|
||||||
.then((me) => {
|
.then((me) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
const resolvedEmail = me.email?.trim() || user?.email?.trim() || undefined;
|
||||||
|
const resolvedFullName = me.full_name?.trim() || user?.fullName?.trim() || undefined;
|
||||||
login({
|
login({
|
||||||
id: me.user_id,
|
id: me.user_id,
|
||||||
name: resolveVelocityDisplayName(me),
|
name: resolveVelocityFirstName({
|
||||||
|
...me,
|
||||||
|
email: resolvedEmail ?? null,
|
||||||
|
full_name: resolvedFullName ?? null,
|
||||||
|
}),
|
||||||
|
fullName: resolvedFullName,
|
||||||
|
email: resolvedEmail,
|
||||||
|
avatar: me.avatar_url?.trim() || user?.avatar?.trim() || undefined,
|
||||||
role: normalizeVelocityRole(me.role),
|
role: normalizeVelocityRole(me.role),
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -301,7 +325,7 @@ function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [authBootstrapped, isAuthenticated, login, logout]);
|
}, [authBootstrapped, isAuthenticated, login, logout, user]);
|
||||||
|
|
||||||
if (!authBootstrapped) {
|
if (!authBootstrapped) {
|
||||||
return (
|
return (
|
||||||
@@ -363,17 +387,3 @@ function formatRoleLabel(role: string | undefined) {
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(' ');
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
|
|
||||||
const fullName = profile.full_name?.trim();
|
|
||||||
if (fullName) {
|
|
||||||
return fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = profile.email?.trim();
|
|
||||||
if (email) {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile.user_id;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -135,10 +135,10 @@ export default function OraclePage() {
|
|||||||
|
|
||||||
// ── Share handler ───────────────────────────────────────────────────────────
|
// ── Share handler ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const handleShare = useCallback(async (params: { recipientEmail: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => {
|
||||||
if (!page) return;
|
if (!page) return;
|
||||||
await createFork(page.pageId, {
|
await createFork(page.pageId, {
|
||||||
recipientUserId: params.recipientEmail,
|
recipientUserId: params.recipientUserId,
|
||||||
sourceRevision: params.sourceRevision,
|
sourceRevision: params.sourceRevision,
|
||||||
visibility: params.visibility,
|
visibility: params.visibility,
|
||||||
message: params.message,
|
message: params.message,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
clearVelocityToken,
|
clearVelocityToken,
|
||||||
loginVelocity,
|
loginVelocity,
|
||||||
normalizeVelocityRole,
|
normalizeVelocityRole,
|
||||||
type VelocityUserProfile,
|
resolveVelocityFirstName,
|
||||||
} from '@/lib/velocityPlatformClient';
|
} from '@/lib/velocityPlatformClient';
|
||||||
|
|
||||||
export function LoginScreen() {
|
export function LoginScreen() {
|
||||||
@@ -22,9 +22,18 @@ export function LoginScreen() {
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const me = await loginVelocity(email.trim(), password);
|
const me = await loginVelocity(email.trim(), password);
|
||||||
|
const fallbackEmail = me.email?.trim() || email.trim();
|
||||||
|
const fallbackFullName = me.full_name?.trim() || undefined;
|
||||||
login({
|
login({
|
||||||
id: me.user_id,
|
id: me.user_id,
|
||||||
name: resolveVelocityDisplayName(me),
|
name: resolveVelocityFirstName({
|
||||||
|
...me,
|
||||||
|
email: fallbackEmail || null,
|
||||||
|
full_name: fallbackFullName || null,
|
||||||
|
}),
|
||||||
|
fullName: fallbackFullName,
|
||||||
|
email: fallbackEmail || undefined,
|
||||||
|
avatar: me.avatar_url?.trim() || undefined,
|
||||||
role: normalizeVelocityRole(me.role),
|
role: normalizeVelocityRole(me.role),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -172,17 +181,3 @@ export function LoginScreen() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
|
|
||||||
const fullName = profile.full_name?.trim();
|
|
||||||
if (fullName) {
|
|
||||||
return fullName;
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = profile.email?.trim();
|
|
||||||
if (email) {
|
|
||||||
return email;
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile.user_id;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { useRef, useState, type ChangeEvent } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
@@ -15,13 +15,20 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Pencil,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useStore } from '@/store/useStore';
|
import { useStore } from '@/store/useStore';
|
||||||
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
|
import { useCurrency, CURRENCY_OPTIONS } from '@/store/useCurrencyStore';
|
||||||
import type { CurrencyCode } from '@/store/useCurrencyStore';
|
import type { CurrencyCode } from '@/store/useCurrencyStore';
|
||||||
import { API_URL } from '@/lib/api';
|
import { API_URL } from '@/lib/api';
|
||||||
import { clearVelocityToken, getVelocityToken, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
|
import {
|
||||||
|
clearVelocityToken,
|
||||||
|
getVelocityToken,
|
||||||
|
normalizeVelocityRole,
|
||||||
|
resolveVelocityFirstName,
|
||||||
|
uploadVelocityAvatar,
|
||||||
|
} from '@/lib/velocityPlatformClient';
|
||||||
|
|
||||||
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
|
// ── Design tokens (matching inventory glassmorphism) ─────────────────────────
|
||||||
const GLASS = {
|
const GLASS = {
|
||||||
@@ -361,35 +368,104 @@ function CompanionSurfacesCard() {
|
|||||||
|
|
||||||
// ── Profile ──────────────────────────────────────────────────────────────────
|
// ── Profile ──────────────────────────────────────────────────────────────────
|
||||||
function ProfileSettings() {
|
function ProfileSettings() {
|
||||||
const { user } = useStore();
|
const { user, login } = useStore();
|
||||||
const initials = user?.name.split(' ').map((n) => n[0]).join('') ?? 'AU';
|
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const [avatarError, setAvatarError] = useState<string | null>(null);
|
||||||
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
||||||
|
const displayName = user?.fullName?.trim() || user?.name?.trim() || user?.email?.trim() || user?.id || 'Authenticated User';
|
||||||
|
const firstName = resolveVelocityFirstName({
|
||||||
|
user_id: user?.id ?? '',
|
||||||
|
full_name: user?.fullName ?? user?.name ?? null,
|
||||||
|
email: user?.email ?? null,
|
||||||
|
});
|
||||||
|
const initials = (user?.fullName || user?.name || user?.email || 'AU')
|
||||||
|
.split(/[\s@._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((n) => n[0]?.toUpperCase() ?? '')
|
||||||
|
.join('') || 'AU';
|
||||||
const roleLabel = normalizeVelocityRole(user?.role)
|
const roleLabel = normalizeVelocityRole(user?.role)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.split('_')
|
.split('_')
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(' ') || 'Authenticated User';
|
.join(' ') || 'Authenticated User';
|
||||||
|
|
||||||
|
const handleAvatarUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (!file || !user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarError(null);
|
||||||
|
setIsUploadingAvatar(true);
|
||||||
|
try {
|
||||||
|
const { avatar_url } = await uploadVelocityAvatar(file);
|
||||||
|
login({
|
||||||
|
...user,
|
||||||
|
avatar: avatar_url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setAvatarError(error instanceof Error ? error.message : 'Failed to upload profile picture.');
|
||||||
|
} finally {
|
||||||
|
setIsUploadingAvatar(false);
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlassCard delay={0.1}>
|
<GlassCard delay={0.1}>
|
||||||
<SectionHeader icon={User} title="Profile" />
|
<SectionHeader icon={User} title="Profile" />
|
||||||
<div className="px-6 pb-6">
|
<div className="px-6 pb-6">
|
||||||
<div className="flex items-center gap-4 mb-5 p-4 rounded-xl" style={INNER_SURFACE}>
|
<div className="flex items-center gap-4 mb-5 p-4 rounded-xl" style={INNER_SURFACE}>
|
||||||
<div
|
<div className="relative flex-shrink-0">
|
||||||
className="w-14 h-14 rounded-full flex items-center justify-center flex-shrink-0 text-white text-lg font-bold"
|
{user?.avatar ? (
|
||||||
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
|
<img
|
||||||
>
|
src={user.avatar}
|
||||||
{initials}
|
alt={displayName}
|
||||||
|
className="w-14 h-14 rounded-full object-cover border border-white/10 shadow-[0_0_20px_rgba(59,130,246,0.2)]"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="w-14 h-14 rounded-full flex items-center justify-center text-white text-lg font-bold"
|
||||||
|
style={{ background: 'hsl(var(--accent))', boxShadow: '0 0 20px hsl(var(--accent) / 0.3)' }}
|
||||||
|
>
|
||||||
|
{initials}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="absolute -right-1 -top-1 w-6 h-6 rounded-full flex items-center justify-center border border-white/10 bg-zinc-900/90 text-zinc-200 hover:text-white hover:bg-zinc-800 transition-colors"
|
||||||
|
title="Update profile picture"
|
||||||
|
>
|
||||||
|
<Pencil className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-white font-semibold">{user?.name}</p>
|
<p className="text-white font-semibold truncate">{displayName}</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
|
<p className="text-xs mt-0.5" style={{ color: 'hsl(var(--muted-fg))' }}>
|
||||||
{roleLabel}
|
{roleLabel}
|
||||||
</p>
|
</p>
|
||||||
|
{isUploadingAvatar && (
|
||||||
|
<p className="mt-3 text-xs text-zinc-400">Uploading profile picture...</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/png,image/jpeg,image/webp,image/jpg"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{avatarError && (
|
||||||
|
<p className="mb-4 text-xs text-red-400">{avatarError}</p>
|
||||||
|
)}
|
||||||
<div className="space-y-0 -mx-6">
|
<div className="space-y-0 -mx-6">
|
||||||
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
|
<SettingsRow label="Authenticated Name" description="Resolved from the active Velocity session">
|
||||||
<span className="text-sm text-white">{user?.name ?? 'Unavailable'}</span>
|
<span className="text-sm text-white">{firstName || 'Unavailable'}</span>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
<SettingsRow label="User ID" description="Backend principal identifier">
|
<SettingsRow label="User ID" description="Backend principal identifier">
|
||||||
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
|
<span className="text-sm font-mono text-white">{user?.id ?? 'Unavailable'}</span>
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export interface VelocityUserProfile {
|
|||||||
role: string;
|
role: string;
|
||||||
full_name?: string | null;
|
full_name?: string | null;
|
||||||
email?: string | null;
|
email?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VelocityActiveUser {
|
||||||
|
user_id: string;
|
||||||
|
role: string;
|
||||||
|
full_name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
avatar_url?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VelocityLoginResponse {
|
export interface VelocityLoginResponse {
|
||||||
@@ -189,6 +198,39 @@ export function normalizeVelocityRole(role: string | null | undefined): string {
|
|||||||
return (role ?? '').trim().toUpperCase();
|
return (role ?? '').trim().toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveVelocityFullName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||||
|
const fullName = profile.full_name?.trim();
|
||||||
|
if (fullName) {
|
||||||
|
return fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = profile.email?.trim();
|
||||||
|
if (email) {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVelocityFirstName(profile: Pick<VelocityUserProfile, 'full_name' | 'email' | 'user_id'>): string {
|
||||||
|
const fullName = profile.full_name?.trim();
|
||||||
|
if (fullName) {
|
||||||
|
return fullName.split(/\s+/)[0] ?? fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const email = profile.email?.trim();
|
||||||
|
if (email) {
|
||||||
|
const local = email.split('@')[0]?.trim() ?? '';
|
||||||
|
if (local) {
|
||||||
|
const normalized = local.replace(/[._-]+/g, ' ').trim();
|
||||||
|
const firstToken = normalized.split(/\s+/)[0] ?? normalized;
|
||||||
|
return firstToken.charAt(0).toUpperCase() + firstToken.slice(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return profile.user_id;
|
||||||
|
}
|
||||||
|
|
||||||
export function isAdminRole(role: string | null | undefined): boolean {
|
export function isAdminRole(role: string | null | undefined): boolean {
|
||||||
const normalized = normalizeVelocityRole(role);
|
const normalized = normalizeVelocityRole(role);
|
||||||
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
|
return normalized === 'ADMIN' || normalized === 'SUPERADMIN';
|
||||||
@@ -209,6 +251,36 @@ export async function getVelocityMe(): Promise<VelocityUserProfile> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function listVelocityUsers(): Promise<VelocityActiveUser[]> {
|
||||||
|
return platformFetch<VelocityActiveUser[]>('/api/auth/users', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadVelocityAvatar(file: File): Promise<{ avatar_url: string }> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
|
||||||
|
const response = await fetch(`${API_URL}/api/auth/profile/avatar`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: buildHeaders(undefined, false),
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(
|
||||||
|
typeof body?.detail === 'string'
|
||||||
|
? body.detail
|
||||||
|
: typeof body?.message === 'string'
|
||||||
|
? body.message
|
||||||
|
: `Request failed: ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<{ avatar_url: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
|
export async function getAdminHealth(): Promise<AdminHealthSnapshot> {
|
||||||
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
|
return platformFetch<AdminHealthSnapshot>('/api/admin-surface/health');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,99 @@
|
|||||||
/**
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
* ShareModal — Fork-based sharing workflow.
|
|
||||||
* Explains the direct_fork_only semantics (recipient gets an editable copy,
|
|
||||||
* not live edit access to owner's canvas).
|
|
||||||
*/
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
|
import { X, Share2, GitFork, Lock, Users, MessageSquare, ChevronDown } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import type { CanvasPage } from '../types/canvas';
|
import type { CanvasPage } from '../types/canvas';
|
||||||
|
import { listVelocityUsers, type VelocityActiveUser } from '@/lib/velocityPlatformClient';
|
||||||
|
|
||||||
interface ShareModalProps {
|
interface ShareModalProps {
|
||||||
page: CanvasPage | null;
|
page: CanvasPage | null;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onShare: (params: {
|
onShare: (params: {
|
||||||
recipientEmail: string;
|
recipientUserId: string;
|
||||||
visibility: 'private' | 'team';
|
visibility: 'private' | 'team';
|
||||||
message: string;
|
message: string;
|
||||||
sourceRevision: number;
|
sourceRevision: number;
|
||||||
}) => Promise<void>;
|
}) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TEAM_MEMBERS = [
|
function getDisplayName(member: VelocityActiveUser): string {
|
||||||
{ id: 'u2', name: 'Elena Rostova', email: 'elena@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
|
return member.full_name?.trim() || member.email?.trim() || member.user_id;
|
||||||
{ id: 'u3', name: 'Priya Sharma', email: 'priya@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1554151228-14d9def656e4?auto=format&fit=crop&w=80&q=80', role: 'Senior Broker' },
|
}
|
||||||
{ id: 'u4', name: 'Carlos Mendez', email: 'carlos@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
|
|
||||||
{ id: 'u5', name: 'Ravi Kapoor', email: 'ravi@binghatti.ae', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=80&q=80', role: 'Broker' },
|
function getRoleLabel(member: VelocityActiveUser): string {
|
||||||
];
|
return member.role
|
||||||
|
.toLowerCase()
|
||||||
|
.split('_')
|
||||||
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(member: VelocityActiveUser): string {
|
||||||
|
const basis = getDisplayName(member);
|
||||||
|
return basis
|
||||||
|
.split(/[\s@._-]+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((part) => part.charAt(0).toUpperCase())
|
||||||
|
.join('') || 'U';
|
||||||
|
}
|
||||||
|
|
||||||
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
|
export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => setMounted(true), []);
|
const [teamMembers, setTeamMembers] = useState<VelocityActiveUser[]>([]);
|
||||||
|
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||||
const [recipient, setRecipient] = useState<{ id: string; name: string; email: string } | null>(null);
|
const [membersError, setMembersError] = useState<string | null>(null);
|
||||||
|
const [recipient, setRecipient] = useState<VelocityActiveUser | null>(null);
|
||||||
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
|
const [visibility, setVisibility] = useState<'private' | 'team'>('private');
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [memberDropOpen, setMemberDropOpen] = useState(false);
|
const [memberDropOpen, setMemberDropOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setMemberDropOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setLoadingMembers(true);
|
||||||
|
setMembersError(null);
|
||||||
|
|
||||||
|
void listVelocityUsers()
|
||||||
|
.then((users) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setTeamMembers(users);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setMembersError(error instanceof Error ? error.message : 'Failed to load team members.');
|
||||||
|
setTeamMembers([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setLoadingMembers(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const selectedRecipientLabel = useMemo(
|
||||||
|
() => (recipient ? getDisplayName(recipient) : 'Select verified teammate...'),
|
||||||
|
[recipient],
|
||||||
|
);
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (!recipient || !page) return;
|
if (!recipient || !page) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await onShare({
|
await onShare({
|
||||||
recipientEmail: recipient.email,
|
recipientUserId: recipient.user_id,
|
||||||
visibility,
|
visibility,
|
||||||
message,
|
message,
|
||||||
sourceRevision: page.headRevision,
|
sourceRevision: page.headRevision,
|
||||||
@@ -56,9 +104,9 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
onClose();
|
onClose();
|
||||||
setRecipient(null);
|
setRecipient(null);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
}, 2000);
|
}, 1800);
|
||||||
} catch {
|
} catch {
|
||||||
// stay open on error
|
// keep modal open and let caller surface the error upstream
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -68,7 +116,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop */}
|
|
||||||
<motion.div
|
<motion.div
|
||||||
key="share-backdrop"
|
key="share-backdrop"
|
||||||
className="fixed inset-0 z-40"
|
className="fixed inset-0 z-40"
|
||||||
@@ -95,7 +142,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
|
boxShadow: '0 24px 80px rgba(0,0,0,0.8)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-xl bg-blue-500/15 border border-blue-500/25 flex items-center justify-center">
|
||||||
@@ -113,16 +159,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Fork explanation */}
|
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-3 p-3 rounded-xl mb-5"
|
className="flex items-start gap-3 p-3 rounded-xl mb-5"
|
||||||
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
|
style={{ background: 'rgba(59,130,246,0.07)', border: '1px solid rgba(59,130,246,0.18)' }}
|
||||||
>
|
>
|
||||||
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
<GitFork className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||||
<p className="text-xs text-zinc-300 leading-relaxed">
|
<p className="text-xs text-zinc-300 leading-relaxed">
|
||||||
The recipient gets a <span className="text-blue-300 font-medium">fork</span> — an editable copy
|
The recipient gets a <span className="text-blue-300 font-medium">fork</span> of this canvas at the selected revision.
|
||||||
of this canvas at revision {page?.headRevision}. They can build on it and open a merge
|
They can edit their copy and later open a merge request back into the source branch.
|
||||||
request to propose their changes back.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -131,17 +175,16 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-full bg-green-500/15 border border-green-500/30 flex items-center justify-center">
|
||||||
<Share2 className="w-6 h-6 text-green-400" />
|
<Share2 className="w-6 h-6 text-green-400" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-zinc-200 font-medium">Fork created successfully!</p>
|
<p className="text-sm text-zinc-200 font-medium">Fork created successfully.</p>
|
||||||
<p className="text-xs text-zinc-500">{recipient?.name} can now access their copy.</p>
|
<p className="text-xs text-zinc-500">{recipient ? getDisplayName(recipient) : 'Recipient'} can access the shared copy.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Recipient picker */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Recipient</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMemberDropOpen((p) => !p)}
|
onClick={() => setMemberDropOpen((prev) => !prev)}
|
||||||
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
|
className="w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm"
|
||||||
style={{
|
style={{
|
||||||
background: 'rgba(255,255,255,0.04)',
|
background: 'rgba(255,255,255,0.04)',
|
||||||
@@ -151,10 +194,14 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Users className="w-3.5 h-3.5 text-zinc-500" />
|
<Users className="w-3.5 h-3.5 text-zinc-500" />
|
||||||
<span>{recipient?.name ?? 'Select team member…'}</span>
|
<span>{selectedRecipientLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
<ChevronDown className="w-3.5 h-3.5 text-zinc-600" style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }} />
|
<ChevronDown
|
||||||
|
className="w-3.5 h-3.5 text-zinc-600"
|
||||||
|
style={{ transform: memberDropOpen ? 'rotate(180deg)' : 'none' }}
|
||||||
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{memberDropOpen && (
|
{memberDropOpen && (
|
||||||
<motion.div
|
<motion.div
|
||||||
@@ -164,16 +211,40 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
>
|
>
|
||||||
{TEAM_MEMBERS.map((m) => (
|
{loadingMembers && (
|
||||||
|
<div className="px-3 py-3 text-xs text-zinc-500">Loading verified accounts...</div>
|
||||||
|
)}
|
||||||
|
{!loadingMembers && membersError && (
|
||||||
|
<div className="px-3 py-3 text-xs text-red-400">{membersError}</div>
|
||||||
|
)}
|
||||||
|
{!loadingMembers && !membersError && teamMembers.length === 0 && (
|
||||||
|
<div className="px-3 py-3 text-xs text-zinc-500">No verified users available.</div>
|
||||||
|
)}
|
||||||
|
{!loadingMembers && !membersError && teamMembers.map((member) => (
|
||||||
<button
|
<button
|
||||||
key={m.id}
|
key={member.user_id}
|
||||||
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
className="w-full flex items-center gap-3 px-3 py-2.5 hover:bg-white/5 transition-colors text-left"
|
||||||
onClick={() => { setRecipient(m); setMemberDropOpen(false); }}
|
onClick={() => {
|
||||||
|
setRecipient(member);
|
||||||
|
setMemberDropOpen(false);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<img src={m.avatar} className="w-7 h-7 rounded-full" alt={m.name} />
|
{member.avatar_url ? (
|
||||||
<div>
|
<img
|
||||||
<p className="text-sm text-zinc-200">{m.name}</p>
|
src={member.avatar_url}
|
||||||
<p className="text-[10px] text-zinc-500">{m.role}</p>
|
alt={getDisplayName(member)}
|
||||||
|
className="w-7 h-7 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-7 h-7 rounded-full bg-blue-500/15 border border-blue-500/25 text-[10px] font-semibold text-blue-300 flex items-center justify-center">
|
||||||
|
{getInitials(member)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm text-zinc-200 truncate">{getDisplayName(member)}</p>
|
||||||
|
<p className="text-[10px] text-zinc-500 truncate">
|
||||||
|
{member.email || member.user_id} · {getRoleLabel(member)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -183,7 +254,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Visibility */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 block">Fork visibility</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
@@ -210,7 +280,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
|
<label className="text-xs font-medium text-zinc-400 mb-1.5 flex items-center gap-1.5">
|
||||||
<MessageSquare className="w-3 h-3" />
|
<MessageSquare className="w-3 h-3" />
|
||||||
@@ -219,7 +288,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
<textarea
|
<textarea
|
||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
placeholder="Add context for the recipient…"
|
placeholder="Add context for the recipient..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
|
className="w-full px-3 py-2.5 text-sm rounded-xl resize-none focus:outline-none"
|
||||||
style={{
|
style={{
|
||||||
@@ -230,7 +299,6 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="flex gap-2 pt-1">
|
<div className="flex gap-2 pt-1">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -244,7 +312,7 @@ export function ShareModal({ page, isOpen, onClose, onShare }: ShareModalProps)
|
|||||||
disabled={!recipient || submitting}
|
disabled={!recipient || submitting}
|
||||||
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
|
className="flex-1 bg-blue-600 hover:bg-blue-500 text-white"
|
||||||
>
|
>
|
||||||
{submitting ? 'Creating fork…' : 'Share (Create Fork)'}
|
{submitting ? 'Creating fork...' : 'Share (Create Fork)'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ export function useOraclePage(pageId: string | null): OraclePageState {
|
|||||||
}
|
}
|
||||||
const disconnect = connectPageSocket(pageId, {
|
const disconnect = connectPageSocket(pageId, {
|
||||||
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
|
onMessage: (msg: OracleWSMessage) => handleWSMessage(msg),
|
||||||
onReconnect: () => void load(),
|
onReconnect: () => undefined,
|
||||||
|
onOpen: () => setIsConnected(true),
|
||||||
onClose: () => setIsConnected(false),
|
onClose: () => setIsConnected(false),
|
||||||
});
|
});
|
||||||
disconnectRef.current = disconnect;
|
disconnectRef.current = disconnect;
|
||||||
setIsConnected(true);
|
|
||||||
return () => {
|
return () => {
|
||||||
disconnect();
|
disconnect();
|
||||||
disconnectRef.current = null;
|
disconnectRef.current = null;
|
||||||
|
|||||||
@@ -167,7 +167,8 @@ export function connectPageSocket(
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
handlers: {
|
handlers: {
|
||||||
onMessage: (msg: OracleWSMessage) => void;
|
onMessage: (msg: OracleWSMessage) => void;
|
||||||
onReconnect: () => void;
|
onReconnect?: () => void;
|
||||||
|
onOpen?: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
},
|
},
|
||||||
): () => void {
|
): () => void {
|
||||||
@@ -184,6 +185,10 @@ export function connectPageSocket(
|
|||||||
function connect() {
|
function connect() {
|
||||||
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
|
ws = new WebSocket(`${wsBase}/ws/oracle/canvas/${pageId}`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
handlers.onOpen?.();
|
||||||
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
|
handlers.onMessage(JSON.parse(event.data as string) as OracleWSMessage);
|
||||||
@@ -196,7 +201,7 @@ export function connectPageSocket(
|
|||||||
handlers.onClose();
|
handlers.onClose();
|
||||||
if (!stopped) {
|
if (!stopped) {
|
||||||
retryTimeout = setTimeout(() => {
|
retryTimeout = setTimeout(() => {
|
||||||
handlers.onReconnect();
|
handlers.onReconnect?.();
|
||||||
connect();
|
connect();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export interface NavItem {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
role: string;
|
role: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import react from "@vitejs/plugin-react"
|
|||||||
import { defineConfig } from "vite"
|
import { defineConfig } from "vite"
|
||||||
import { inspectAttr } from 'kimi-plugin-inspect-react'
|
import { inspectAttr } from 'kimi-plugin-inspect-react'
|
||||||
|
|
||||||
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://api.desineuron.in"
|
const backendProxyTarget = process.env.VITE_BACKEND_PROXY_TARGET?.trim() || "https://velocity.desineuron.in"
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -37,6 +37,12 @@ export default defineConfig({
|
|||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
secure: false,
|
secure: false,
|
||||||
},
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: backendProxyTarget,
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
ws: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,13 +34,19 @@ ROLE_HIERARCHY = {
|
|||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_bcrypt_input(value: str) -> str:
|
||||||
|
raw = value.encode("utf-8")
|
||||||
|
if len(raw) <= 72:
|
||||||
|
return value
|
||||||
|
return raw[:72].decode("utf-8", errors="ignore")
|
||||||
|
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
def hash_password(plain: str) -> str:
|
||||||
return pwd_context.hash(plain)
|
return pwd_context.hash(_truncate_bcrypt_input(plain))
|
||||||
|
|
||||||
|
|
||||||
def verify_password(plain: str, hashed: str) -> bool:
|
def verify_password(plain: str, hashed: str) -> bool:
|
||||||
# Truncate to 72 bytes to prevent bcrypt 500 errors
|
return pwd_context.verify(_truncate_bcrypt_input(plain), hashed)
|
||||||
return pwd_context.verify(plain[:72], hashed)
|
|
||||||
|
|
||||||
|
|
||||||
# ── JWT helpers ───────────────────────────────────────────────────────────────
|
# ── JWT helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
122
backend/main.py
122
backend/main.py
@@ -11,15 +11,50 @@ import os
|
|||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
from typing import Set
|
from typing import Set
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends, HTTPException, status, UploadFile, File
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
def _load_velocity_env() -> None:
|
||||||
|
repo_root = Path(__file__).resolve().parent.parent
|
||||||
|
backend_root = repo_root / "backend"
|
||||||
|
|
||||||
|
explicit_env = os.getenv("VELOCITY_ENV_FILE", "").strip()
|
||||||
|
candidate_paths = []
|
||||||
|
|
||||||
|
if explicit_env:
|
||||||
|
candidate_paths.append(Path(explicit_env))
|
||||||
|
|
||||||
|
candidate_paths.extend(
|
||||||
|
[
|
||||||
|
backend_root / ".env",
|
||||||
|
repo_root / ".env",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
loaded_any = False
|
||||||
|
seen: set[Path] = set()
|
||||||
|
for candidate in candidate_paths:
|
||||||
|
resolved = candidate.resolve()
|
||||||
|
if resolved in seen or not candidate.exists():
|
||||||
|
continue
|
||||||
|
load_dotenv(candidate, override=not loaded_any)
|
||||||
|
loaded_any = True
|
||||||
|
seen.add(resolved)
|
||||||
|
|
||||||
|
if not loaded_any:
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
_load_velocity_env()
|
||||||
|
|
||||||
from backend.api.routes_catalyst import router as catalyst_router
|
from backend.api.routes_catalyst import router as catalyst_router
|
||||||
from backend.api.routes_crm import crm_router, analytics_router
|
from backend.api.routes_crm import crm_router, analytics_router
|
||||||
from backend.api.routes_oracle import router as oracle_helper_router
|
from backend.api.routes_oracle import router as oracle_helper_router
|
||||||
@@ -39,8 +74,6 @@ from backend.routers.videos import router as videos_router
|
|||||||
from backend.routers.vault import router as vault_router
|
from backend.routers.vault import router as vault_router
|
||||||
from backend.routers.sentinel import router as sentinel_router, broadcast_sentinel_event
|
from backend.routers.sentinel import router as sentinel_router, broadcast_sentinel_event
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
logger = logging.getLogger("velocity.main")
|
logger = logging.getLogger("velocity.main")
|
||||||
|
|
||||||
@@ -91,6 +124,11 @@ ASSET_DIR = os.getenv("VELOCITY_ASSET_DIR", "/opt/dlami/nvme/assets")
|
|||||||
if os.path.isdir(ASSET_DIR):
|
if os.path.isdir(ASSET_DIR):
|
||||||
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
|
app.mount("/assets", StaticFiles(directory=ASSET_DIR), name="assets")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_filename(value: str) -> str:
|
||||||
|
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "_", value).strip("._")
|
||||||
|
return cleaned or "upload"
|
||||||
|
|
||||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||||
@@ -160,7 +198,7 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
|
|||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
row = await conn.fetchrow(
|
row = await conn.fetchrow(
|
||||||
"""
|
"""
|
||||||
SELECT full_name, email
|
SELECT full_name, email, avatar_url
|
||||||
FROM users_and_roles
|
FROM users_and_roles
|
||||||
WHERE id = $1::uuid
|
WHERE id = $1::uuid
|
||||||
""",
|
""",
|
||||||
@@ -172,9 +210,85 @@ async def me(user: UserPrincipal = Depends(get_current_user)):
|
|||||||
"role": user.role,
|
"role": user.role,
|
||||||
"full_name": row["full_name"] if row else None,
|
"full_name": row["full_name"] if row else None,
|
||||||
"email": row["email"] if row else None,
|
"email": row["email"] if row else None,
|
||||||
|
"avatar_url": row["avatar_url"] if row else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/auth/users", tags=["Auth"])
|
||||||
|
async def list_auth_users(_: UserPrincipal = Depends(get_current_user)):
|
||||||
|
pool = app.state.db_pool
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id::text AS user_id,
|
||||||
|
role,
|
||||||
|
full_name,
|
||||||
|
email,
|
||||||
|
avatar_url
|
||||||
|
FROM users_and_roles
|
||||||
|
WHERE is_active = TRUE
|
||||||
|
ORDER BY
|
||||||
|
COALESCE(NULLIF(full_name, ''), email, id::text) ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": row["user_id"],
|
||||||
|
"role": row["role"],
|
||||||
|
"full_name": row["full_name"],
|
||||||
|
"email": row["email"],
|
||||||
|
"avatar_url": row["avatar_url"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/auth/profile/avatar", tags=["Auth"])
|
||||||
|
async def upload_profile_avatar(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
user: UserPrincipal = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
pool = app.state.db_pool
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||||
|
|
||||||
|
allowed = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||||
|
if file.content_type not in allowed:
|
||||||
|
raise HTTPException(status_code=400, detail="Unsupported avatar format.")
|
||||||
|
|
||||||
|
extension = Path(file.filename or "avatar.png").suffix.lower() or ".png"
|
||||||
|
if extension not in {".png", ".jpg", ".jpeg", ".webp"}:
|
||||||
|
extension = ".png"
|
||||||
|
|
||||||
|
avatar_dir = Path(ASSET_DIR) / "profile_avatars"
|
||||||
|
avatar_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
filename = f"{user.user_id}_{_sanitize_filename(Path(file.filename or 'avatar').stem)}_{int(datetime.now(UTC).timestamp())}{extension}"
|
||||||
|
destination = avatar_dir / filename
|
||||||
|
contents = await file.read()
|
||||||
|
destination.write_bytes(contents)
|
||||||
|
|
||||||
|
avatar_url = f"/assets/profile_avatars/{filename}"
|
||||||
|
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users_and_roles
|
||||||
|
SET avatar_url = $2
|
||||||
|
WHERE id = $1::uuid
|
||||||
|
""",
|
||||||
|
user.user_id,
|
||||||
|
avatar_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"avatar_url": avatar_url}
|
||||||
|
|
||||||
|
|
||||||
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
|
# ── Catalyst WebSocket (preserved from v1) ────────────────────────────────────
|
||||||
|
|
||||||
class _CatalystManager:
|
class _CatalystManager:
|
||||||
|
|||||||
143
docs/LOCAL_DEV_SNAPSHOT.md
Normal file
143
docs/LOCAL_DEV_SNAPSHOT.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Velocity Local Dev Snapshot
|
||||||
|
|
||||||
|
This document defines the correct way to work against a local copy of the current Velocity backend state without polluting Git and without guessing whether a backend-only change will work in production.
|
||||||
|
|
||||||
|
## Why This Exists
|
||||||
|
|
||||||
|
Project Velocity now depends on live PostgreSQL-backed surfaces and runtime state that cannot be validated reliably from frontend-only local development.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- operator identity and profile hydration
|
||||||
|
- CRM-backed Sentinel client selection
|
||||||
|
- Oracle Canvas data access and sharing
|
||||||
|
- verified team-account lists for share flows
|
||||||
|
|
||||||
|
The correct solution is a repeatable local snapshot workflow.
|
||||||
|
|
||||||
|
## Design Rule
|
||||||
|
|
||||||
|
Local runtime copies must never be committed.
|
||||||
|
|
||||||
|
Everything produced by this workflow lives under gitignored paths:
|
||||||
|
|
||||||
|
- `.local-dev/`
|
||||||
|
- `runtime-snapshots/`
|
||||||
|
- `local-dev-bundles/`
|
||||||
|
|
||||||
|
## Bundle Shape
|
||||||
|
|
||||||
|
The Linux-origin export bundle contains:
|
||||||
|
|
||||||
|
- `backend.env.export`
|
||||||
|
- `velocity.dump`
|
||||||
|
- `restore.instructions.txt`
|
||||||
|
|
||||||
|
This is intentionally minimal. It gives local developers what they need to stand up a local database-backed verification surface without pushing runtime state into the repository.
|
||||||
|
|
||||||
|
## Export From Linux Box
|
||||||
|
|
||||||
|
Run this on the Linux-origin host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/desineuron-velocity-site/repo
|
||||||
|
bash scripts/export_velocity_local_bundle.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a tarball in `/tmp` named like:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
|
||||||
|
```
|
||||||
|
|
||||||
|
## Import On Windows Dev Machine
|
||||||
|
|
||||||
|
1. Copy the tarball to the Windows machine.
|
||||||
|
2. Extract it under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project_Velocity/.local-dev/source-bundles/<bundle-name>/
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\import_velocity_local_bundle.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This will:
|
||||||
|
|
||||||
|
- copy the exported env snapshot into `.local-dev/backend/.env.local`
|
||||||
|
- copy the PostgreSQL dump into `.local-dev/db/velocity.dump`
|
||||||
|
- generate a local Docker Compose file for PostgreSQL
|
||||||
|
- generate a restore script
|
||||||
|
|
||||||
|
## Restore Local Database
|
||||||
|
|
||||||
|
After import, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\.local-dev\db\restore_local_snapshot.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This restores the dump into a local PostgreSQL container on:
|
||||||
|
|
||||||
|
- host: `127.0.0.1`
|
||||||
|
- port: `54329`
|
||||||
|
- db: `velocity_local`
|
||||||
|
- user: `velocity_local`
|
||||||
|
- password: `velocity_local`
|
||||||
|
|
||||||
|
## Local Backend Wiring
|
||||||
|
|
||||||
|
The backend expects these env keys:
|
||||||
|
|
||||||
|
- `VELOCITY_DB_HOST`
|
||||||
|
- `VELOCITY_DB_PORT`
|
||||||
|
- `VELOCITY_DB_NAME`
|
||||||
|
- `VELOCITY_DB_USER`
|
||||||
|
- `VELOCITY_DB_PASSWORD`
|
||||||
|
- `DATABASE_URL`
|
||||||
|
- `CORS_ORIGINS`
|
||||||
|
- `VELOCITY_ASSET_DIR`
|
||||||
|
- `JWT_SECRET_KEY`
|
||||||
|
|
||||||
|
The exported env snapshot provides the production-shaped variable names. For local verification, point the DB values to the local PostgreSQL container rather than the production database.
|
||||||
|
|
||||||
|
## Create A Zip For Sayan And Sourik
|
||||||
|
|
||||||
|
Once `.local-dev/` is hydrated locally, run:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -ExecutionPolicy Bypass -File .\scripts\package_velocity_local_bundle.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates a gitignored zip under:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Project_Velocity/local-dev-bundles/
|
||||||
|
```
|
||||||
|
|
||||||
|
That zip is the correct handoff artifact for teammate local verification.
|
||||||
|
|
||||||
|
## What This Solves
|
||||||
|
|
||||||
|
This workflow allows local verification of:
|
||||||
|
|
||||||
|
- real account-backed login flows
|
||||||
|
- profile name/avatar hydration
|
||||||
|
- share-recipient lookup
|
||||||
|
- Sentinel CRM client selection
|
||||||
|
- Oracle-backed DB reads
|
||||||
|
|
||||||
|
## What It Does Not Solve By Itself
|
||||||
|
|
||||||
|
This does not automatically fix backend bugs.
|
||||||
|
|
||||||
|
If Oracle still returns `500` locally after restoring the snapshot, that is now a real reproducible backend issue rather than a blind production-only guess.
|
||||||
|
|
||||||
|
## Current Constraint
|
||||||
|
|
||||||
|
The current machine cannot SSH into the Linux box because the host is configured for publickey auth and this workstation is not authorized yet.
|
||||||
|
|
||||||
|
That means the export step must currently be triggered from a machine or shell that already has valid SSH/key access to the Linux-origin host.
|
||||||
122
scripts/export_velocity_local_bundle.sh
Normal file
122
scripts/export_velocity_local_bundle.sh
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Run this on the Linux origin host that currently serves velocity.desineuron.in.
|
||||||
|
# It exports a local-development bundle containing:
|
||||||
|
# - backend env snapshot (non-shell format)
|
||||||
|
# - PostgreSQL custom-format dump
|
||||||
|
# - app build metadata
|
||||||
|
# - optional asset snapshot manifest
|
||||||
|
#
|
||||||
|
# Output:
|
||||||
|
# /tmp/velocity-local-bundle-YYYYmmdd-HHMMSS.tar.gz
|
||||||
|
|
||||||
|
REPO_ROOT="${REPO_ROOT:-/opt/desineuron-velocity-site/repo}"
|
||||||
|
BACKEND_ENV_PATH="${BACKEND_ENV_PATH:-$REPO_ROOT/backend/.env}"
|
||||||
|
OUTPUT_ROOT="${OUTPUT_ROOT:-/tmp}"
|
||||||
|
DB_CONTAINER_NAME="${DB_CONTAINER_NAME:-desineuron-ops-db}"
|
||||||
|
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
WORKDIR="$OUTPUT_ROOT/velocity-local-bundle-$STAMP"
|
||||||
|
ARCHIVE="$OUTPUT_ROOT/velocity-local-bundle-$STAMP.tar.gz"
|
||||||
|
|
||||||
|
if [[ ! -f "$BACKEND_ENV_PATH" ]]; then
|
||||||
|
echo "Missing backend env file: $BACKEND_ENV_PATH" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$WORKDIR"
|
||||||
|
|
||||||
|
cp "$BACKEND_ENV_PATH" "$WORKDIR/backend.env.raw"
|
||||||
|
|
||||||
|
python3 - "$WORKDIR/backend.env.raw" "$WORKDIR/backend.env.export" <<'PY'
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
src = pathlib.Path(sys.argv[1])
|
||||||
|
dst = pathlib.Path(sys.argv[2])
|
||||||
|
|
||||||
|
allowed = {
|
||||||
|
"VELOCITY_DB_HOST",
|
||||||
|
"VELOCITY_DB_PORT",
|
||||||
|
"VELOCITY_DB_NAME",
|
||||||
|
"VELOCITY_DB_USER",
|
||||||
|
"VELOCITY_DB_PASSWORD",
|
||||||
|
"DATABASE_URL",
|
||||||
|
"CORS_ORIGINS",
|
||||||
|
"VELOCITY_ASSET_DIR",
|
||||||
|
"JWT_SECRET_KEY",
|
||||||
|
"JWT_ALGORITHM",
|
||||||
|
"JWT_EXP_MINUTES",
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for raw_line in src.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip()
|
||||||
|
if key in allowed:
|
||||||
|
lines.append(f"{key}={value.strip()}")
|
||||||
|
|
||||||
|
dst.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
PY
|
||||||
|
|
||||||
|
set -a
|
||||||
|
source "$BACKEND_ENV_PATH"
|
||||||
|
set +a
|
||||||
|
|
||||||
|
dump_with_local_pg_dump() {
|
||||||
|
PGPASSWORD="${VELOCITY_DB_PASSWORD}" pg_dump \
|
||||||
|
-h "${VELOCITY_DB_HOST}" \
|
||||||
|
-p "${VELOCITY_DB_PORT}" \
|
||||||
|
-U "${VELOCITY_DB_USER}" \
|
||||||
|
-d "${VELOCITY_DB_NAME}" \
|
||||||
|
-Fc \
|
||||||
|
-f "$WORKDIR/velocity.dump"
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_with_docker_exec() {
|
||||||
|
if ! command -v docker >/dev/null 2>&1; then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
sudo -n docker exec \
|
||||||
|
-e PGPASSWORD="${VELOCITY_DB_PASSWORD}" \
|
||||||
|
"$DB_CONTAINER_NAME" \
|
||||||
|
pg_dump \
|
||||||
|
-U "${VELOCITY_DB_USER}" \
|
||||||
|
-d "${VELOCITY_DB_NAME}" \
|
||||||
|
-Fc \
|
||||||
|
> "$WORKDIR/velocity.dump"
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v pg_dump >/dev/null 2>&1; then
|
||||||
|
dump_with_local_pg_dump
|
||||||
|
elif dump_with_docker_exec; then
|
||||||
|
:
|
||||||
|
else
|
||||||
|
echo "Neither host pg_dump nor docker-exec pg_dump is available." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > "$WORKDIR/README.txt" <<EOF
|
||||||
|
Velocity local bundle
|
||||||
|
Created: $(date --iso-8601=seconds)
|
||||||
|
Repo root: $REPO_ROOT
|
||||||
|
Bundle contents:
|
||||||
|
- backend.env.export
|
||||||
|
- velocity.dump
|
||||||
|
- restore.instructions.txt
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$WORKDIR/restore.instructions.txt" <<EOF
|
||||||
|
1. Copy this archive to the Windows development machine.
|
||||||
|
2. Extract it under Project_Velocity/.local-dev/source-bundles/<bundle-name>.
|
||||||
|
3. Run scripts/import_velocity_local_bundle.ps1 from the repo root.
|
||||||
|
EOF
|
||||||
|
|
||||||
|
tar -C "$OUTPUT_ROOT" -czf "$ARCHIVE" "$(basename "$WORKDIR")"
|
||||||
|
rm -rf "$WORKDIR"
|
||||||
|
|
||||||
|
echo "Created bundle: $ARCHIVE"
|
||||||
140
scripts/import_velocity_local_bundle.ps1
Normal file
140
scripts/import_velocity_local_bundle.ps1
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
param(
|
||||||
|
[string]$BundleRoot = "",
|
||||||
|
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
if (-not $BundleRoot) {
|
||||||
|
$candidate = Join-Path $RepoRoot ".local-dev\source-bundles"
|
||||||
|
if (-not (Test-Path $candidate)) {
|
||||||
|
throw "Bundle root not provided and no source bundles found at $candidate"
|
||||||
|
}
|
||||||
|
|
||||||
|
$latest = Get-ChildItem -Path $candidate -Directory | Sort-Object LastWriteTime -Descending | Select-Object -First 1
|
||||||
|
if (-not $latest) {
|
||||||
|
throw "No extracted bundle directories found under $candidate"
|
||||||
|
}
|
||||||
|
$BundleRoot = $latest.FullName
|
||||||
|
}
|
||||||
|
|
||||||
|
$envExport = Join-Path $BundleRoot "backend.env.export"
|
||||||
|
$dumpFile = Join-Path $BundleRoot "velocity.dump"
|
||||||
|
|
||||||
|
if (-not (Test-Path $envExport)) {
|
||||||
|
throw "Missing backend.env.export in $BundleRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $dumpFile)) {
|
||||||
|
throw "Missing velocity.dump in $BundleRoot"
|
||||||
|
}
|
||||||
|
|
||||||
|
$localRoot = Join-Path $RepoRoot ".local-dev"
|
||||||
|
$backendRoot = Join-Path $localRoot "backend"
|
||||||
|
$dbRoot = Join-Path $localRoot "db"
|
||||||
|
$assetsRoot = Join-Path $localRoot "assets"
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $localRoot, $backendRoot, $dbRoot, $assetsRoot | Out-Null
|
||||||
|
|
||||||
|
$targetEnv = Join-Path $backendRoot ".env.local"
|
||||||
|
$restoreScript = Join-Path $dbRoot "restore_local_snapshot.ps1"
|
||||||
|
$composeFile = Join-Path $dbRoot "docker-compose.local-db.yml"
|
||||||
|
$copiedDump = Join-Path $dbRoot "velocity.dump"
|
||||||
|
|
||||||
|
Copy-Item $dumpFile $copiedDump -Force
|
||||||
|
|
||||||
|
$rawEnv = @{}
|
||||||
|
Get-Content $envExport | ForEach-Object {
|
||||||
|
if ($_ -match '^\s*#' -or $_ -notmatch '=') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
$key, $value = $_ -split '=', 2
|
||||||
|
$rawEnv[$key.Trim()] = $value.Trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
$localEnvLines = [System.Collections.Generic.List[string]]::new()
|
||||||
|
$localEnvLines.Add("VELOCITY_DB_HOST=127.0.0.1")
|
||||||
|
$localEnvLines.Add("VELOCITY_DB_PORT=54329")
|
||||||
|
$localEnvLines.Add("VELOCITY_DB_NAME=velocity_local")
|
||||||
|
$localEnvLines.Add("VELOCITY_DB_USER=velocity_local")
|
||||||
|
$localEnvLines.Add("VELOCITY_DB_PASSWORD=velocity_local")
|
||||||
|
$localEnvLines.Add("DATABASE_URL=postgresql://velocity_local:velocity_local@127.0.0.1:54329/velocity_local")
|
||||||
|
$localEnvLines.Add("CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:3000,http://localhost:3000")
|
||||||
|
|
||||||
|
if ($rawEnv.ContainsKey("VELOCITY_JWT_SECRET")) {
|
||||||
|
$localEnvLines.Add("VELOCITY_JWT_SECRET=$($rawEnv['VELOCITY_JWT_SECRET'])")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawEnv.ContainsKey("JWT_SECRET_KEY")) {
|
||||||
|
$localEnvLines.Add("JWT_SECRET_KEY=$($rawEnv['JWT_SECRET_KEY'])")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawEnv.ContainsKey("JWT_ALGORITHM")) {
|
||||||
|
$localEnvLines.Add("JWT_ALGORITHM=$($rawEnv['JWT_ALGORITHM'])")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($rawEnv.ContainsKey("JWT_EXP_MINUTES")) {
|
||||||
|
$localEnvLines.Add("JWT_EXP_MINUTES=$($rawEnv['JWT_EXP_MINUTES'])")
|
||||||
|
}
|
||||||
|
|
||||||
|
$localAssetsRoot = Join-Path $localRoot "assets"
|
||||||
|
$localEnvLines.Add("VELOCITY_ASSET_DIR=$localAssetsRoot")
|
||||||
|
|
||||||
|
Set-Content -Path $targetEnv -Value ($localEnvLines -join "`r`n") -Encoding UTF8
|
||||||
|
|
||||||
|
$compose = @"
|
||||||
|
services:
|
||||||
|
velocity-postgres-local:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: velocity-postgres-local
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: velocity_local
|
||||||
|
POSTGRES_USER: velocity_local
|
||||||
|
POSTGRES_PASSWORD: velocity_local
|
||||||
|
ports:
|
||||||
|
- "54329:5432"
|
||||||
|
volumes:
|
||||||
|
- velocity_postgres_local_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
velocity_postgres_local_data:
|
||||||
|
"@
|
||||||
|
|
||||||
|
Set-Content -Path $composeFile -Value $compose -Encoding UTF8
|
||||||
|
|
||||||
|
$restoreTemplate = @'
|
||||||
|
param(
|
||||||
|
[string]$RepoRoot = "__REPO_ROOT__"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
$DbRoot = Join-Path $RepoRoot ".local-dev\db"
|
||||||
|
$Compose = Join-Path $DbRoot "docker-compose.local-db.yml"
|
||||||
|
$Dump = Join-Path $DbRoot "velocity.dump"
|
||||||
|
|
||||||
|
docker compose -f $Compose up -d
|
||||||
|
Start-Sleep -Seconds 4
|
||||||
|
docker exec -i velocity-postgres-local dropdb --if-exists -U velocity_local velocity_local
|
||||||
|
docker exec -i velocity-postgres-local createdb -U velocity_local velocity_local
|
||||||
|
docker cp $Dump velocity-postgres-local:/tmp/velocity.dump
|
||||||
|
docker exec -i velocity-postgres-local pg_restore -U velocity_local -d velocity_local --clean --if-exists --no-owner --no-privileges /tmp/velocity.dump
|
||||||
|
|
||||||
|
Write-Host "Local PostgreSQL restore complete."
|
||||||
|
Write-Host "Use these local DB env values:"
|
||||||
|
Write-Host "VELOCITY_DB_HOST=127.0.0.1"
|
||||||
|
Write-Host "VELOCITY_DB_PORT=54329"
|
||||||
|
Write-Host "VELOCITY_DB_NAME=velocity_local"
|
||||||
|
Write-Host "VELOCITY_DB_USER=velocity_local"
|
||||||
|
Write-Host "VELOCITY_DB_PASSWORD=velocity_local"
|
||||||
|
'@
|
||||||
|
|
||||||
|
$restore = $restoreTemplate.Replace("__REPO_ROOT__", $RepoRoot)
|
||||||
|
|
||||||
|
Set-Content -Path $restoreScript -Value $restore -Encoding UTF8
|
||||||
|
|
||||||
|
Write-Host "Imported local bundle from $BundleRoot"
|
||||||
|
Write-Host "Backend env snapshot: $targetEnv"
|
||||||
|
Write-Host "DB dump copied to: $copiedDump"
|
||||||
|
Write-Host "Run this next:"
|
||||||
|
Write-Host "powershell -ExecutionPolicy Bypass -File `"$restoreScript`""
|
||||||
25
scripts/package_velocity_local_bundle.ps1
Normal file
25
scripts/package_velocity_local_bundle.ps1
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
param(
|
||||||
|
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
|
||||||
|
[string]$OutputDir = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity\local-dev-bundles"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$localRoot = Join-Path $RepoRoot ".local-dev"
|
||||||
|
if (-not (Test-Path $localRoot)) {
|
||||||
|
throw "Missing .local-dev folder. Import a local bundle first."
|
||||||
|
}
|
||||||
|
|
||||||
|
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
|
||||||
|
|
||||||
|
$stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||||
|
$zipPath = Join-Path $OutputDir "project-velocity-local-dev-$stamp.zip"
|
||||||
|
|
||||||
|
if (Test-Path $zipPath) {
|
||||||
|
Remove-Item $zipPath -Force
|
||||||
|
}
|
||||||
|
|
||||||
|
Compress-Archive -Path (Join-Path $localRoot "*") -DestinationPath $zipPath -Force
|
||||||
|
|
||||||
|
Write-Host "Created local dev zip: $zipPath"
|
||||||
|
Write-Host "This zip is gitignored and can be handed to Sayan and Sourik for local verification."
|
||||||
28
scripts/start_velocity_backend_local.ps1
Normal file
28
scripts/start_velocity_backend_local.ps1
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
param(
|
||||||
|
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity",
|
||||||
|
[int]$Port = 8001
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$env:VELOCITY_ENV_FILE = Join-Path $RepoRoot ".local-dev\backend\.env.local"
|
||||||
|
if (-not (Test-Path $env:VELOCITY_ENV_FILE)) {
|
||||||
|
throw "Missing local backend env file at $($env:VELOCITY_ENV_FILE). Import a local bundle first."
|
||||||
|
}
|
||||||
|
|
||||||
|
$pythonExe = Join-Path $RepoRoot ".venv\Scripts\python.exe"
|
||||||
|
if (-not (Test-Path $pythonExe)) {
|
||||||
|
$pythonExe = Join-Path $RepoRoot "venv\Scripts\python.exe"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-Path $pythonExe)) {
|
||||||
|
$pythonExe = "python"
|
||||||
|
}
|
||||||
|
|
||||||
|
Push-Location $RepoRoot
|
||||||
|
try {
|
||||||
|
& $pythonExe -m uvicorn backend.main:app --reload --host 127.0.0.1 --port $Port
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
15
scripts/start_velocity_frontend_local.ps1
Normal file
15
scripts/start_velocity_frontend_local.ps1
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
param(
|
||||||
|
[string]$RepoRoot = "F:\Workin In Progress\DESINEURON\GITLAB\Project_Velocity"
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$env:VITE_BACKEND_PROXY_TARGET = "http://127.0.0.1:8001"
|
||||||
|
|
||||||
|
Push-Location (Join-Path $RepoRoot "app")
|
||||||
|
try {
|
||||||
|
npm run dev -- --host 127.0.0.1 --port 5173
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
Pop-Location
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user