mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-01 08:59:46 +02:00
Initial Commit 🚀 🚀
This commit is contained in:
commit
4f2a629340
444 changed files with 76863 additions and 0 deletions
14
ui/src/lib/apiClient.ts
Normal file
14
ui/src/lib/apiClient.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
69
ui/src/lib/auth/hooks/useAuth.ts
Normal file
69
ui/src/lib/auth/hooks/useAuth.ts
Normal 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
11
ui/src/lib/auth/index.ts
Normal 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';
|
||||
|
||||
140
ui/src/lib/auth/providers/AuthProvider.tsx
Normal file
140
ui/src/lib/auth/providers/AuthProvider.tsx
Normal 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;
|
||||
}
|
||||
66
ui/src/lib/auth/providers/StackProviderWrapper.tsx
Normal file
66
ui/src/lib/auth/providers/StackProviderWrapper.tsx
Normal 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
150
ui/src/lib/auth/server.ts
Normal 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;
|
||||
}
|
||||
28
ui/src/lib/auth/services/index.ts
Normal file
28
ui/src/lib/auth/services/index.ts
Normal 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';
|
||||
|
||||
23
ui/src/lib/auth/services/interface.ts
Normal file
23
ui/src/lib/auth/services/interface.ts
Normal 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;
|
||||
}
|
||||
|
||||
109
ui/src/lib/auth/services/localAuthService.ts
Normal file
109
ui/src/lib/auth/services/localAuthService.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
||||
86
ui/src/lib/auth/services/stackAuthService.ts
Normal file
86
ui/src/lib/auth/services/stackAuthService.ts
Normal 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
38
ui/src/lib/auth/types.ts
Normal 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;
|
||||
}
|
||||
|
||||
17
ui/src/lib/dispositionBadgeVariant.ts
Normal file
17
ui/src/lib/dispositionBadgeVariant.ts
Normal 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
52
ui/src/lib/files.ts
Normal 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;
|
||||
}
|
||||
192
ui/src/lib/filterAttributes.ts
Normal file
192
ui/src/lib/filterAttributes.ts
Normal 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
228
ui/src/lib/filters.ts
Normal 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
222
ui/src/lib/logger.ts
Normal 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
148
ui/src/lib/utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue