Initial Commit 🚀 🚀

This commit is contained in:
Abhishek Kumar 2025-09-09 14:37:32 +05:30
commit 4f2a629340
444 changed files with 76863 additions and 0 deletions

14
ui/src/lib/apiClient.ts Normal file
View file

@ -0,0 +1,14 @@
import type { CreateClientConfig } from '@/client/client.gen';
export const createClientConfig: CreateClientConfig = (config) => {
// Use different URLs for server-side vs client-side
const isServer = typeof window === 'undefined';
const baseUrl = isServer
? process.env.BACKEND_URL || process.env.NEXT_PUBLIC_BACKEND_URL
: process.env.NEXT_PUBLIC_BACKEND_URL;
return {
...config,
baseUrl,
};
};

View file

@ -0,0 +1,69 @@
'use client';
import React from 'react';
import { useAuthContext } from '../providers/AuthProvider';
export function useAuth() {
const context = useAuthContext();
// Memoize functions that are recreated on every render
const logout = React.useCallback(() => context.service.logout(), [context.service]);
const redirectToLogin = React.useCallback(() => context.service.redirectToLogin(), [context.service]);
const getSelectedTeam = React.useCallback(() => context.service.getSelectedTeam?.(), [context.service]);
const listPermissions = React.useCallback(
(team?: unknown) => context.service.listPermissions?.(team) || Promise.resolve([]),
[context.service]
);
return React.useMemo(() => ({
// Core functionality
getAccessToken: context.getAccessToken,
user: context.user, // This is now AuthUser (CurrentUser | LocalUser)
isAuthenticated: context.isAuthenticated,
loading: context.loading,
// Service methods
logout,
redirectToLogin,
// Provider info
provider: context.provider,
// Stack-specific methods (optional)
getSelectedTeam,
listPermissions,
}), [
context.getAccessToken,
context.user,
context.isAuthenticated,
context.loading,
context.provider,
logout,
redirectToLogin,
getSelectedTeam,
listPermissions,
]);
}
// Compatibility wrapper for gradual migration from useUser
export function useUser(options?: { or?: 'redirect' }) {
const auth = useAuth();
// Handle redirect option
if (options?.or === 'redirect' && !auth.isAuthenticated && !auth.loading) {
auth.redirectToLogin();
}
// Return Stack-compatible interface
return {
...auth.user,
getAuthJson: async () => ({
accessToken: await auth.getAccessToken(),
}),
selectedTeam: auth.getSelectedTeam(),
listPermissions: auth.listPermissions,
signOut: auth.logout,
};
}

11
ui/src/lib/auth/index.ts Normal file
View file

@ -0,0 +1,11 @@
export { useAuth, useUser } from './hooks/useAuth';
export { AuthProvider } from './providers/AuthProvider';
export type {
AuthProvider as AuthProviderType,
AuthToken,
AuthUser,
BaseUser,
LocalUser,
TeamPermission
} from './types';

View file

@ -0,0 +1,140 @@
'use client';
import { Loader2 } from 'lucide-react';
import React, { createContext, lazy, Suspense, useContext, useEffect, useMemo, useState } from 'react';
import { createAuthService, IAuthService, StackAuthService } from '../services';
import type { AuthUser } from '../types';
interface AuthContextType {
service: IAuthService;
user: AuthUser | null; // Union type: CurrentUser | LocalUser
isAuthenticated: boolean;
loading: boolean;
getAccessToken: () => Promise<string>;
provider: string;
}
const AuthContext = createContext<AuthContextType | null>(null);
interface AuthContextProviderProps {
service: IAuthService;
children: React.ReactNode;
}
// Lazy load Stack components only when needed
const StackProviderWrapper = lazy(() =>
import('./StackProviderWrapper').then(module => ({
default: module.StackProviderWrapper
}))
);
// Generic context provider for non-Stack providers
function GenericAuthContextProvider({ service, children }: AuthContextProviderProps) {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// Fetch current user
const fetchUser = async () => {
setLoading(true);
try {
const currentUser = await service.getCurrentUser();
setUser(currentUser);
} catch (error) {
console.error('Failed to fetch user:', error);
setUser(null);
} finally {
setLoading(false);
}
};
fetchUser();
}, [service]);
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
const contextValue: AuthContextType = React.useMemo(() => ({
service,
user,
isAuthenticated: service.isAuthenticated(),
loading,
getAccessToken,
provider: service.getProviderName(),
}), [service, user, loading, getAccessToken]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
const authService = useMemo(() => createAuthService(authProvider), [authProvider]);
// For Stack provider, wrap with StackProvider and use Stack-specific context
if (authProvider === 'stack' && authService instanceof StackAuthService) {
return (
<Suspense fallback={
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="w-8 h-8 animate-spin text-gray-600" />
</div>
}>
<StackProviderWrapper service={authService}>
{children}
</StackProviderWrapper>
</Suspense>
);
}
// For other providers, use generic context provider
return (
<GenericAuthContextProvider service={authService}>
{children}
</GenericAuthContextProvider>
);
}
// Export the context for Stack-specific provider
export { AuthContext };
// Stack-specific context provider that uses the useUser hook
export function StackAuthContextProvider({ service, children }: AuthContextProviderProps) {
const [loading, setLoading] = useState(true);
const stackUser: AuthUser | null = null;
useEffect(() => {
// For Stack provider, we'll get the user from the StackProviderWrapper
// This is a placeholder that will be overridden by the actual implementation
if (service instanceof StackAuthService) {
setLoading(false);
}
}, [service]);
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
const contextValue: AuthContextType = React.useMemo(() => ({
service,
user: stackUser,
isAuthenticated: service.isAuthenticated(),
loading,
getAccessToken,
provider: service.getProviderName(),
}), [service, loading, getAccessToken]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export function useAuthContext() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuthContext must be used within AuthProvider');
}
return context;
}

View file

@ -0,0 +1,66 @@
'use client';
import { StackClientApp,StackProvider, StackTheme, useUser as useStackUser } from '@stackframe/stack';
import React, { useEffect, useState } from 'react';
import { StackAuthService } from '../services';
import type { AuthUser } from '../types';
import { AuthContext } from './AuthProvider';
interface StackProviderWrapperProps {
service: StackAuthService;
children: React.ReactNode;
}
// Stack-specific context provider that uses the useUser hook
function StackAuthContextProvider({ service, children }: { service: StackAuthService; children: React.ReactNode }) {
const [loading, setLoading] = useState(true);
const stackUser = useStackUser(); // Always call the hook
useEffect(() => {
// Set the user instance in the service
if (service instanceof StackAuthService && stackUser) {
service.setUserInstance(stackUser);
setLoading(false);
} else if (!stackUser) {
setLoading(false);
}
}, [service, stackUser]);
const getAccessToken = React.useCallback(() => service.getAccessToken(), [service]);
const contextValue = React.useMemo(() => ({
service,
user: stackUser as AuthUser, // Pass the actual Stack CurrentUser
isAuthenticated: service.isAuthenticated(),
loading,
getAccessToken,
provider: service.getProviderName(),
}), [service, stackUser, loading, getAccessToken]);
return (
<AuthContext.Provider value={contextValue}>
{children}
</AuthContext.Provider>
);
}
export function StackProviderWrapper({ service, children }: StackProviderWrapperProps) {
// Create the Stack client app here, only when actually needed
const stackClientApp = new StackClientApp({
tokenStore: "nextjs-cookie",
urls: {
afterSignIn: "/after-sign-in"
}
});
return (
<StackProvider app={stackClientApp}>
<StackTheme>
<StackAuthContextProvider service={service}>
{children}
</StackAuthContextProvider>
</StackTheme>
</StackProvider>
);
}

150
ui/src/lib/auth/server.ts Normal file
View file

@ -0,0 +1,150 @@
import "server-only";
import type { CurrentUser, StackServerApp } from '@stackframe/stack';
import { cookies } from 'next/headers';
import logger from '@/lib/logger';
import type { LocalUser } from './types';
// Server-side auth utilities for SSR pages
// This file should only be imported in server components
let stackServerApp: StackServerApp<boolean, string> | null = null;
const OSS_TOKEN_COOKIE = 'dograh_oss_token';
const OSS_USER_COOKIE = 'dograh_oss_user';
// Lazy load and cache the stack server app
async function getStackServerApp(): Promise<StackServerApp<boolean, string> | null> {
if (!stackServerApp) {
// Only import if using Stack provider
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
if (authProvider === 'stack') {
const stackModule = await import('@stackframe/stack');
const { StackServerApp } = stackModule;
stackServerApp = new StackServerApp({
tokenStore: "nextjs-cookie",
urls: {
afterSignIn: "/after-sign-in"
}
});
}
}
return stackServerApp;
}
/**
* Get the current user on the server side (for SSR)
* Returns CurrentUser for stack, LocalUser for OSS, or null if not authenticated
*/
export async function getServerUser(): Promise<CurrentUser | LocalUser | null> {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
if (authProvider === 'stack') {
const app = await getStackServerApp();
if (app) {
try {
const user = await app.getUser();
return user;
} catch (error) {
logger.error('Error getting user from Stack:', error);
return null;
}
}
} else if (authProvider === 'local') {
// For OSS mode, get user from cookies (created by middleware)
const user = await getOSSUser();
return user;
}
return null;
}
/**
* Check if user is authenticated on the server side
* For local provider, always returns true in development
*/
export async function isServerAuthenticated(): Promise<boolean> {
const authProvider = process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
if (authProvider === 'stack') {
const user = await getServerUser();
return !!user;
}
// For local provider, consider authenticated in development
if (authProvider === 'local') {
return process.env.NODE_ENV === 'development';
}
return false;
}
/**
* Get provider name for server-side rendering
*/
export function getServerAuthProvider(): string {
return process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
}
/**
* Get OSS token from cookies (read-only)
* Token creation happens in middleware
*/
export async function getOSSToken(): Promise<string | null> {
const cookieStore = await cookies();
return cookieStore.get(OSS_TOKEN_COOKIE)?.value || null;
}
/**
* Get OSS user from cookies
*/
export async function getOSSUser(): Promise<LocalUser | null> {
const cookieStore = await cookies();
const userCookie = cookieStore.get(OSS_USER_COOKIE)?.value;
if (userCookie) {
try {
return JSON.parse(userCookie);
} catch (error) {
logger.error('Error listing permissions:', error);
return null;
}
}
// If no user cookie, but we have a token, create user
const token = cookieStore.get(OSS_TOKEN_COOKIE)?.value;
if (token) {
const user: LocalUser = {
id: token,
name: 'Local User',
provider: 'local',
organizationId: `org_${token}`,
};
return user;
}
return null;
}
/**
* Get access token for API calls
*/
export async function getServerAccessToken(): Promise<string | null> {
const authProvider = getServerAuthProvider();
if (authProvider === 'stack') {
const user = await getServerUser();
if (user && 'getAuthJson' in user) {
const auth = await user.getAuthJson();
return auth?.accessToken ?? null;
}
} else if (authProvider === 'local') {
// Get token from cookies (created by middleware)
const oss_token = await getOSSToken();
logger.debug(`oss_token: ${oss_token}`);
return oss_token;
}
return null;
}

View file

@ -0,0 +1,28 @@
import type { AuthProvider } from '../types';
import type { IAuthService } from './interface';
import { LocalAuthService } from './localAuthService';
import { StackAuthService } from './stackAuthService';
export function createAuthService(provider?: AuthProvider | string): IAuthService {
const authProvider = provider || process.env.NEXT_PUBLIC_AUTH_PROVIDER || 'stack';
switch (authProvider) {
case 'stack':
return new StackAuthService();
case 'local':
return new LocalAuthService();
// Future providers can be added here
// case 'auth0':
// return new Auth0Service();
// case 'supabase':
// return new SupabaseService();
default:
console.warn(`Unknown auth provider: ${authProvider}, falling back to local`);
return new LocalAuthService();
}
}
export type { IAuthService } from './interface';
export { LocalAuthService } from './localAuthService';
export { StackAuthService } from './stackAuthService';

View file

@ -0,0 +1,23 @@
import type { AuthUser } from '../types';
export interface IAuthService {
// Token management
getAccessToken(): Promise<string>;
refreshToken(): Promise<string>;
// User management
getCurrentUser(): Promise<AuthUser | null>;
isAuthenticated(): boolean;
// Navigation
redirectToLogin(): void;
logout(): Promise<void>;
// Team/Organization management (optional for some providers)
getSelectedTeam?(): unknown;
listPermissions?(team?: unknown): Promise<Array<{ id: string }>>;
// Provider-specific
getProviderName(): string;
}

View file

@ -0,0 +1,109 @@
'use client';
import logger from '@/lib/logger';
import type { LocalUser } from '../types';
import type { IAuthService } from './interface';
export class LocalAuthService implements IAuthService {
private currentUser: LocalUser | null = null;
private currentToken: string | null = null;
private authPromise: Promise<void> | null = null;
private static instance: LocalAuthService | null = null;
constructor() {
// Singleton pattern to ensure single initialization
if (LocalAuthService.instance) {
return LocalAuthService.instance;
}
LocalAuthService.instance = this;
// Initialize auth on creation
if (typeof window !== 'undefined') {
this.authPromise = this.initializeAuth();
}
}
private async initializeAuth(): Promise<void> {
try {
const response = await fetch('/api/auth/oss');
if (response.ok) {
const data = await response.json();
this.currentToken = data.token;
this.currentUser = data.user;
logger.info('OSS auth initialized', { user: data.user });
} else {
logger.error('Failed to initialize OSS auth');
}
} catch (error) {
logger.error('Error initializing OSS auth', error);
}
}
private async ensureAuth(): Promise<void> {
if (this.authPromise) {
await this.authPromise;
} else if (!this.currentToken && typeof window !== 'undefined') {
this.authPromise = this.initializeAuth();
await this.authPromise;
}
}
async getAccessToken(): Promise<string> {
if (typeof window === 'undefined') {
// SSR: Server will handle this
return 'ssr-placeholder-token';
}
await this.ensureAuth();
if (!this.currentToken) {
logger.warn('No OSS token available after initialization');
return '';
}
return this.currentToken;
}
async refreshToken(): Promise<string> {
// For local mode, just return the same token
return this.getAccessToken();
}
async getCurrentUser(): Promise<LocalUser | null> {
if (typeof window === 'undefined') {
// SSR: Server will handle this
return null;
}
await this.ensureAuth();
if (!this.currentUser) {
logger.warn('No OSS user available after initialization');
return null;
}
return this.currentUser;
}
isAuthenticated(): boolean {
// In local mode, always authenticated
return true;
}
redirectToLogin(): void {
// No-op for local mode
logger.info('Login redirect not needed in local mode');
}
async logout(): Promise<void> {
// In OSS mode, logout would require server-side cookie clearing
// For now, just clear the cached user
this.currentUser = null;
logger.info('Logout requested in OSS mode - server cookies need to be cleared');
}
getProviderName(): string {
return 'local';
}
}

View file

@ -0,0 +1,86 @@
'use client';
import type { CurrentUser } from '@stackframe/stack';
import logger from '@/lib/logger';
import type { IAuthService } from './interface';
export class StackAuthService implements IAuthService {
private userInstance: CurrentUser | null = null;
// Set the user instance from the Stack useUser hook
setUserInstance(user: CurrentUser) {
this.userInstance = user;
}
async getAccessToken(): Promise<string> {
if (!this.userInstance) {
throw new Error('User not initialized');
}
const authJson = await this.userInstance.getAuthJson();
if (!authJson.accessToken) {
throw new Error('No access token available');
}
return authJson.accessToken;
}
async refreshToken(): Promise<string> {
if (!this.userInstance) {
throw new Error('User not initialized');
}
// Stack handles token refresh internally
const authJson = await this.userInstance.getAuthJson();
if (!authJson.accessToken) {
throw new Error('No access token available');
}
return authJson.accessToken;
}
async getCurrentUser(): Promise<CurrentUser | null> {
// Return the actual Stack user instance
return this.userInstance;
}
isAuthenticated(): boolean {
return !!this.userInstance;
}
redirectToLogin(): void {
if (typeof window !== 'undefined') {
window.location.href = '/handler/sign-in';
}
}
async logout(): Promise<void> {
if (this.userInstance && this.userInstance.signOut) {
await this.userInstance.signOut();
}
}
getSelectedTeam(): unknown {
return this.userInstance?.selectedTeam;
}
async listPermissions(team?: unknown): Promise<Array<{ id: string }>> {
if (!this.userInstance || !this.userInstance.listPermissions) {
return [];
}
const targetTeam = team || this.userInstance.selectedTeam;
if (!targetTeam) {
return [];
}
try {
const perms = await this.userInstance.listPermissions(targetTeam);
return Array.isArray(perms) ? perms : [];
} catch (error) {
logger.error('Error listing permissions:', error);
return [];
}
}
getProviderName(): string {
return 'stack';
}
}

38
ui/src/lib/auth/types.ts Normal file
View file

@ -0,0 +1,38 @@
import type { CurrentUser } from '@stackframe/stack';
// Base user interface that all providers must support
export interface BaseUser {
id: string;
email?: string;
name?: string;
image?: string;
}
// Local/OSS user type
export interface LocalUser extends BaseUser {
provider: 'local';
organizationId?: string;
}
// Union type for all user types
export type AuthUser = CurrentUser | LocalUser;
export interface AuthToken {
accessToken: string;
refreshToken?: string;
expiresAt?: number;
}
export interface TeamPermission {
id: string;
}
export type AuthProvider = 'stack' | 'local';
export interface AuthConfig {
provider: AuthProvider;
// Provider-specific configuration
[key: string]: string | number | boolean;
}

View file

@ -0,0 +1,17 @@
// Color variants for disposition code
export const getDispositionBadgeVariant = (code: string | undefined): "default" | "secondary" | "destructive" | "outline" | "success" => {
if (!code) return "outline";
const upperCode = code.toUpperCase();
switch (upperCode) {
case "XFER":
return "success"; // Green color for transfers
case "HU":
case "NIBP":
return "destructive"; // Red color for hang up and NIBP
case "VM":
return "secondary";
default:
return "default"; // Default color for all other codes
}
};

52
ui/src/lib/files.ts Normal file
View file

@ -0,0 +1,52 @@
import { getSignedUrlApiV1S3SignedUrlGet } from "@/client/sdk.gen";
/**
* Get a signed URL and download a file
*/
export async function downloadFile(url: string | null, accessToken: string) {
if (!url || !accessToken) return;
try {
const response = await getSignedUrlApiV1S3SignedUrlGet({
query: {
key: url
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.url) {
window.open(response.data.url, '_blank');
}
} catch (error) {
console.error('Error downloading file:', error);
}
}
/**
* Return a signed URL for a given S3 key without triggering a download.
* Useful for previewing media (audio or transcript) in-browser first.
*/
export async function getSignedUrl(url: string | null, accessToken: string, inline: boolean = false): Promise<string | null> {
if (!url || !accessToken) return null;
try {
const response = await getSignedUrlApiV1S3SignedUrlGet({
query: {
key: url,
inline: inline,
},
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (response.data?.url) {
return response.data.url as string;
}
} catch (error) {
console.error('Error getting signed URL:', error);
}
return null;
}

View file

@ -0,0 +1,192 @@
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
import { FilterAttribute } from "@/types/filters";
// Shared filter attribute definitions
export const baseFilterAttributes: Record<string, Omit<FilterAttribute, "id">> = {
dateRange: {
type: "dateRange",
label: "Date and Time Range",
config: {
maxRangeDays: 30,
datePresets: ["today", "yesterday", "last7days", "last30days"],
},
},
dispositionCode: {
type: "multiSelect",
label: "Disposition Code",
config: {
options: [...DISPOSITION_CODES], // Use centralized disposition codes
searchable: true,
maxSelections: 10,
showSelectAll: true,
},
},
duration: {
type: "numberRange",
label: "Call Duration",
config: {
min: 0,
max: 86400,
step: 1,
unit: "seconds",
numberPresets: [
{ label: "< 1 min", min: 0, max: 60 },
{ label: "1-5 min", min: 60, max: 300 },
{ label: "> 5 min", min: 300, max: 86400 },
],
},
},
status: {
type: "radio",
label: "Completion Status",
config: {
radioOptions: [
{ label: "Completed", value: "completed" },
{ label: "In Progress", value: "in_progress" },
{ label: "All", value: "all" },
],
defaultValue: "all",
},
},
callTags: {
type: "tags",
label: "Tags",
config: {
placeholder: "Enter tags",
},
},
tokenUsage: {
type: "numberRange",
label: "Token Usage",
config: {
min: 0,
max: 10000,
step: 0.01,
unit: "tokens",
},
},
runId: {
type: "number",
label: "Workflow Run ID",
config: {
placeholder: "Enter run ID",
min: 1,
max: 9999999,
step: 1,
},
},
workflowId: {
type: "number",
label: "Workflow ID",
config: {
placeholder: "Enter workflow ID",
min: 1,
max: 999999,
step: 1,
},
},
phoneNumber: {
type: "text",
label: "Phone Number",
config: {
placeholder: "Enter phone number (partial match)",
maxLength: 20,
},
},
};
// Helper function to create filter attributes with proper IDs
export function createFilterAttributes(
attributeKeys: string[],
overrides?: Record<string, Partial<Omit<FilterAttribute, "id">>>
): FilterAttribute[] {
return attributeKeys.map((key) => {
const baseAttr = baseFilterAttributes[key];
if (!baseAttr) {
throw new Error(`Unknown filter attribute key: ${key}`);
}
const override = overrides?.[key] || {};
return {
id: key,
...baseAttr,
...override,
config: {
...baseAttr.config,
...(override.config || {}),
},
};
});
}
// Default workflow filter attributes
export const workflowFilterAttributes = createFilterAttributes([
"dateRange",
"dispositionCode",
"duration",
"status",
"tokenUsage",
]);
// Superadmin filter attributes (includes additional fields)
export const superadminFilterAttributes = createFilterAttributes(
[
"dateRange",
"runId",
"workflowId",
"phoneNumber",
"dispositionCode",
"status",
"duration",
"tokenUsage",
"callTags",
],
{
// dispositionCode uses the default DISPOSITION_CODES from baseFilterAttributes
}
);
// Usage page filter attributes (simplified for regular users)
export const usageFilterAttributes = createFilterAttributes(
[
"dateRange",
"duration",
"dispositionCode",
"phoneNumber",
],
{
dateRange: {
label: "Date Range",
config: {
maxRangeDays: 90,
datePresets: ["today", "yesterday", "last7days", "last30days"],
},
},
dispositionCode: {
label: "Disposition",
config: {
options: [...DISPOSITION_CODES], // Use centralized disposition codes
searchable: false,
maxSelections: 5, // Allow multiple selections
showSelectAll: true, // Show select all option
},
},
duration: {
label: "Duration",
config: {
min: 0,
max: 3600, // Up to 1 hour
step: 1,
unit: "seconds",
numberPresets: [
{ label: "< 30 sec", min: 0, max: 30 },
{ label: "30 sec - 1 min", min: 30, max: 60 },
{ label: "1-3 min", min: 60, max: 180 },
{ label: "3-5 min", min: 180, max: 300 },
{ label: "> 5 min", min: 300, max: 3600 },
],
},
},
}
);

228
ui/src/lib/filters.ts Normal file
View file

@ -0,0 +1,228 @@
import { ActiveFilter, DateRangeValue, FilterAttribute, FilterValue, MultiSelectValue, NumberRangeValue, NumberValue, RadioValue, TextValue } from "@/types/filters";
// Get default value based on attribute type
export const getDefaultValue = (type: FilterAttribute["type"]): FilterValue => {
switch (type) {
case "dateRange":
return { from: null, to: null };
case "multiSelect":
return { codes: [] };
case "number":
return { value: null };
case "numberRange":
return { min: null, max: null };
case "radio":
return { status: "all" };
case "tags":
return { codes: [] };
case "text":
return { value: "" };
default:
throw new Error(`Unknown filter type: ${type}`);
}
};
// Validate filter based on attribute type
export const validateFilter = (filter: ActiveFilter): string | null => {
switch (filter.attribute.type) {
case "dateRange": {
const value = filter.value as DateRangeValue;
if (!value.from || !value.to) {
return "Both dates are required";
}
if (value.to < value.from) {
return "End date must be after start date";
}
// Check max range if configured
const config = filter.attribute.config;
if (config.maxRangeDays) {
const daysDiff = Math.ceil((value.to.getTime() - value.from.getTime()) / (1000 * 60 * 60 * 24));
if (daysDiff > config.maxRangeDays) {
return `Date range cannot exceed ${config.maxRangeDays} days`;
}
}
break;
}
case "multiSelect": {
const value = filter.value as MultiSelectValue;
if (!value.codes.length) {
return "At least one option must be selected";
}
const config = filter.attribute.config;
if (config.maxSelections && value.codes.length > config.maxSelections) {
return `Cannot select more than ${config.maxSelections} options`;
}
break;
}
case "numberRange": {
const value = filter.value as NumberRangeValue;
if (value.min === null || value.max === null) {
return "Both values are required";
}
if (value.min > value.max) {
return "Minimum must be less than maximum";
}
const config = filter.attribute.config;
if (config.min !== undefined && value.min < config.min) {
return `Minimum value cannot be less than ${config.min}`;
}
if (config.max !== undefined && value.max > config.max) {
return `Maximum value cannot be greater than ${config.max}`;
}
break;
}
case "number": {
const value = filter.value as NumberValue;
if (value.value === null) {
return "A value is required";
}
const config = filter.attribute.config;
if (config.min !== undefined && value.value < config.min) {
return `Value cannot be less than ${config.min}`;
}
if (config.max !== undefined && value.value > config.max) {
return `Value cannot be greater than ${config.max}`;
}
break;
}
case "radio": {
const value = filter.value as RadioValue;
if (!value.status) {
return "A status must be selected";
}
break;
}
case "tags": {
const value = filter.value as MultiSelectValue;
if (!value.codes.length) {
return "At least one tag must be entered";
}
break;
}
case "text": {
const value = filter.value as TextValue;
if (!value.value || value.value.trim() === "") {
return "Text value is required";
}
break;
}
}
return null;
};
// Encode filters to URL parameters
export const encodeFiltersToURL = (filters: ActiveFilter[]): string => {
const params = new URLSearchParams();
if (filters.length > 0) {
const filterData = filters.map(filter => ({
id: filter.attribute.id,
value: filter.value
}));
params.set("filters", JSON.stringify(filterData));
}
return params.toString();
};
// Decode filters from URL parameters
export const decodeFiltersFromURL = (
params: URLSearchParams,
availableAttributes: FilterAttribute[]
): ActiveFilter[] => {
const filtersParam = params.get("filters");
if (!filtersParam) return [];
try {
const filterData = JSON.parse(filtersParam) as Array<{
id: string;
value: FilterValue;
}>;
return filterData.map(item => {
const attribute = availableAttributes.find(attr => attr.id === item.id);
if (!attribute) {
throw new Error(`Unknown filter attribute: ${item.id}`);
}
// Convert date strings back to Date objects for dateRange filters
let value = item.value;
if (attribute.type === "dateRange" && value) {
const dateValue = value as { from: string | null; to: string | null };
value = {
from: dateValue.from ? new Date(dateValue.from) : null,
to: dateValue.to ? new Date(dateValue.to) : null,
};
}
const filter: ActiveFilter = {
attribute,
value,
isValid: false
};
// Validate the filter
filter.isValid = validateFilter(filter) === null;
return filter;
});
} catch (error) {
console.error("Failed to decode filters from URL:", error);
return [];
}
};
// Format date range for display
export const formatDateRange = (value: DateRangeValue): string => {
if (!value.from || !value.to) return "No date range selected";
const formatDate = (date: Date) => {
return date.toLocaleDateString() + " " + date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
};
return `${formatDate(value.from)} to ${formatDate(value.to)}`;
};
// Format number range for display
export const formatNumberRange = (value: NumberRangeValue, unit?: string): string => {
if (value.min === null || value.max === null) return "No range selected";
const unitSuffix = unit ? ` ${unit}` : "";
return `${value.min}${unitSuffix} - ${value.max}${unitSuffix}`;
};
// Get date preset value
export const getDatePresetValue = (preset: string): DateRangeValue => {
const now = new Date();
// Start of today (00:00:00.000)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0, 0);
// End of today (23:59:59.999)
const todayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
switch (preset) {
case "today":
return { from: todayStart, to: todayEnd };
case "yesterday": {
const yesterdayStart = new Date(todayStart);
yesterdayStart.setDate(yesterdayStart.getDate() - 1);
const yesterdayEnd = new Date(todayStart);
yesterdayEnd.setMilliseconds(-1); // One millisecond before today start
return { from: yesterdayStart, to: yesterdayEnd };
}
case "last7days": {
const last7DaysStart = new Date(todayStart);
last7DaysStart.setDate(last7DaysStart.getDate() - 6); // -6 because today is included
return { from: last7DaysStart, to: todayEnd };
}
case "last30days": {
const last30DaysStart = new Date(todayStart);
last30DaysStart.setDate(last30DaysStart.getDate() - 29); // -29 because today is included
return { from: last30DaysStart, to: todayEnd };
}
default:
return { from: null, to: null };
}
};

222
ui/src/lib/logger.ts Normal file
View file

@ -0,0 +1,222 @@
// logger.ts
// Determine if we're in browser or server environment
const isBrowser = typeof window !== 'undefined';
const isDevelopment = process.env.NODE_ENV === 'development';
// Helper to get timestamp
function getTimestamp(): string {
const now = new Date();
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const ms = String(now.getMilliseconds()).padStart(3, '0');
return `${hours}:${minutes}:${seconds}.${ms}`;
}
// Helper to get clean caller info (for development)
function getCallerInfo(): string {
if (!isDevelopment) return '';
try {
const err = new Error();
const stack = err.stack?.split('\n');
if (!stack || stack.length < 4) return '';
// Look for the first non-logger file in the stack
for (let i = 3; i < Math.min(stack.length, 10); i++) {
const line = stack[i];
// Skip logger.ts itself and node internals
if (line.includes('logger.ts') ||
line.includes('logger.js') ||
line.includes('node_modules') ||
line.includes('node:') ||
line.includes('webpack-internal') ||
line.includes('<anonymous>')) continue;
// Try multiple patterns to extract file info
const patterns = [
// Standard stack trace pattern
/(?:at\s+.*?\s+\(|at\s+)(.*?):(\d+):(\d+)\)?/,
// Webpack pattern
/at\s+(?:async\s+)?(?:.*?\s+)?(?:\()?webpack-internal:\/\/\/(?:\.\/)?(.+?):(\d+):(\d+)/,
// Next.js server component pattern
/at\s+(?:async\s+)?(?:.*?\s+)?(?:\()?(.+?):(\d+):(\d+)/
];
for (const pattern of patterns) {
const match = line.match(pattern);
if (match) {
let filePath = match[1];
const lineNum = match[2];
// Clean up various path formats
filePath = filePath
// Handle file:// URLs from source maps
.replace(/^file:\/\//, '')
// Remove webpack prefixes
.replace(/^webpack-internal:\/\/\/(?:\.\/)?\(rsc\)\/(?:\.\/)/, '')
.replace(/^webpack-internal:\/\/\/(?:\.\/)/, '')
// Remove absolute paths to make them relative
.replace(/^.*\/dograh\/ui\//, '')
// Remove .next build paths
.replace(/.*\.next\/server\/app\//, 'app/')
.replace(/.*\.next\/server\//, '')
// Clean up app directory paths
.replace(/^app\//, '')
// Remove query strings
.replace(/\?.*$/, '')
// Clean up compiled chunk names - extract the likely source
.replace(/chunks\/ssr\/_?[a-f0-9]+_?\.?/, '')
.replace(/_[a-f0-9]{6,}\./, '.')
// Try to infer original file from common patterns
.replace(/^([a-z0-9]+)\._.js$/, (_, name) => {
// Common Next.js page mappings
if (name === 'page') return 'page.tsx';
return `${name}.ts`;
});
// If we still have a chunk-like name, try to make it more readable
if (filePath.match(/^_?[a-f0-9]+_?\./)) {
// This is likely a compiled chunk, extract what we can
const cleanMatch = line.match(/at\s+(?:async\s+)?(\w+)/);
if (cleanMatch && cleanMatch[1] !== 'async') {
// Use the function name as a hint
const funcName = cleanMatch[1];
if (funcName === 'Home') filePath = 'page.tsx';
else if (funcName === 'AfterSignInPage') filePath = 'after-sign-in/page.tsx';
else if (funcName.includes('Page')) filePath = `${funcName.replace('Page', '').toLowerCase()}/page.tsx`;
else filePath = `${funcName}.tsx`;
}
}
// No need to add prefixes since we have clean relative paths from source maps
return `${filePath}:${lineNum}`;
}
}
}
return '';
} catch {
return '';
}
}
// Color codes for terminal output
const colors = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bright: '\x1b[1m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
gray: '\x1b[90m',
};
// Format log level with color
function formatLevel(level: string): string {
if (!isDevelopment || isBrowser) return level.toUpperCase();
switch (level) {
case 'debug':
return `${colors.gray}DEBUG${colors.reset}`;
case 'info':
return `${colors.cyan}INFO${colors.reset}`;
case 'warn':
return `${colors.yellow}WARN${colors.reset}`;
case 'error':
return `${colors.red}ERROR${colors.reset}`;
default:
return level.toUpperCase();
}
}
// Wrapper interface that matches existing usage
interface Logger {
debug: (...args: unknown[]) => void;
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
}
// Server-side logging function
function serverLog(level: string, args: unknown[]): void {
const timestamp = getTimestamp();
const caller = getCallerInfo();
const levelStr = formatLevel(level);
// Format the message
const message = args.map(arg => {
if (arg instanceof Error) {
return `${arg.message}\n${arg.stack}`;
}
return typeof arg === 'object' ? JSON.stringify(arg, null, 2) : String(arg);
}).join(' ');
// Build the log line
const prefix = `${colors.gray}[${timestamp}]${colors.reset} ${levelStr}`;
const callerInfo = caller ? ` ${colors.dim}[${caller}]${colors.reset}` : '';
// Use appropriate console method
switch (level) {
case 'debug':
console.debug(`${prefix}${callerInfo} ${message}`);
break;
case 'info':
console.info(`${prefix}${callerInfo} ${message}`);
break;
case 'warn':
console.warn(`${prefix}${callerInfo} ${message}`);
break;
case 'error':
console.error(`${prefix}${callerInfo} ${message}`);
break;
}
}
// Create a wrapper that adds caller info and handles multiple arguments
const logger: Logger = {
debug: (...args: unknown[]): void => {
if (!isDevelopment) return;
if (isBrowser) {
const caller = getCallerInfo();
console.debug(`[DEBUG] [${caller}]`, ...args);
} else {
serverLog('debug', args);
}
},
info: (...args: unknown[]): void => {
if (isBrowser) {
const caller = getCallerInfo();
console.info(`[INFO] [${caller}]`, ...args);
} else {
serverLog('info', args);
}
},
warn: (...args: unknown[]): void => {
if (isBrowser) {
const caller = getCallerInfo();
console.warn(`[WARN] [${caller}]`, ...args);
} else {
serverLog('warn', args);
}
},
error: (...args: unknown[]): void => {
if (isBrowser) {
const caller = getCallerInfo();
console.error(`[ERROR] [${caller}]`, ...args);
} else {
serverLog('error', args);
}
},
};
export default logger;

148
ui/src/lib/utils.ts Normal file
View file

@ -0,0 +1,148 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
import { getAuthUserApiV1UserAuthUserGet } from "@/client/sdk.gen";
import { impersonateApiV1SuperuserImpersonatePost } from "@/client/sdk.gen";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getRandomId() {
return Math.floor(Math.random() * 10_000);
}
export function debounce<T extends (...args: unknown[]) => unknown>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function (...args: Parameters<T>) {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func(...args);
}, wait);
};
}
export async function getRedirectUrl(token: string, permissions: { id: string }[] = []) {
try {
const authUser = await getAuthUserApiV1UserAuthUserGet({
headers: {
Authorization: `Bearer ${token}`,
},
});
if (authUser.data?.is_superuser) {
return "/superadmin";
}
const hasAdminPermission = permissions.some(p => p.id === 'admin');
// If the user doesn't have admin permissions, redirect them to
// usage page
if (!hasAdminPermission) {
return "/usage";
}
return "/create-workflow";
} catch (error) {
console.error("Failed to fetch auth user:", error);
// Re-throw the error so the caller can handle it
throw error;
}
}
/**
* --------------------------------------------------------------------------
* Cookie helpers
* --------------------------------------------------------------------------
*/
export function setStackRefreshCookie(refreshToken: string) {
const expiryDate = new Date();
expiryDate.setFullYear(expiryDate.getFullYear() + 1);
const isDograhDomain = window.location.hostname.endsWith('.dograh.com');
const cookieDomainPart = isDograhDomain ? '; domain=.dograh.com' : '';
document.cookie =
`stack-refresh-${process.env.NEXT_PUBLIC_STACK_PROJECT_ID}=${refreshToken}; ` +
`expires=${expiryDate.toUTCString()}; path=/` +
`${cookieDomainPart}; secure; samesite=lax`;
}
/**
* Centralised impersonation logic to avoid code duplication between pages.
*
* It performs the super-admin impersonate request, sets the cross-sub-domain
* refresh cookie and optionally redirects the browser to the supplied path.
*/
export async function impersonateAsSuperadmin(params: {
accessToken: string;
userId?: number;
providerUserId?: string;
redirectPath?: string;
/**
* If true the browser opens the impersonated session in a **new tab**
* (via `window.open`). Defaults to `false` which navigates in the current tab.
*/
openInNewTab?: boolean;
}): Promise<void> {
const { accessToken, userId, providerUserId, redirectPath, openInNewTab = false } = params;
// Build request body depending on which identifier we have.
const body: Record<string, unknown> = {};
if (userId !== undefined) {
body.user_id = userId;
}
if (providerUserId !== undefined) {
body.provider_user_id = providerUserId;
}
if (Object.keys(body).length === 0) {
throw new Error('Either userId or providerUserId must be provided');
}
const resp = await impersonateApiV1SuperuserImpersonatePost({
body,
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
const refreshToken = resp.data?.refresh_token;
if (!refreshToken) {
throw new Error('No refresh token returned from impersonate');
}
// ---------------------------------------------------------------------------------
// Instead of setting the cookie here (which would also affect the superadmin
// sub-domain), redirect the browser to the dedicated impersonation helper route
// (served from the target sub-domain, e.g. app.dograh.com). The route will set the
// cookie for the *current* sub-domain only and then forward the user to the final
// destination.
// ---------------------------------------------------------------------------------
// Determine the base URL that should handle the impersonation cookie. If we are on
// superadmin.dograh.com we want to switch to app.dograh.com. For any other domain
// (e.g. localhost, staging, or already on the app) we just keep the same origin.
const appBaseUrl = window.location.origin.includes('superadmin.')
? window.location.origin.replace('superadmin.', 'app.')
: window.location.origin;
const finalRedirect = redirectPath ?? '/workflow';
// Build the redirect URL to the helper route, passing along the refresh token and
// the final destination.
const impersonateUrl = `${appBaseUrl}/impersonate?refresh_token=${encodeURIComponent(
refreshToken,
)}&redirect_path=${encodeURIComponent(finalRedirect)}`;
if (openInNewTab) {
window.open(impersonateUrl, '_blank');
} else {
window.location.href = impersonateUrl;
}
}