feat: Add new UI components for various features including recording, notes, and analytics, and refactor integration configuration panels.
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
// Animated transcription component for streaming text display
|
||||
// Implements word-staggered materialization effect with entity highlighting
|
||||
|
||||
import { useState, useCallback, memo } from 'react';
|
||||
import { useAnimatedWords } from '@/hooks/ui/use-animated-words';
|
||||
import { findMatchingEntities } from '@/lib/state/entities';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { HighlightedTerm } from './entity-highlight';
|
||||
import type { Entity } from '@/types/entity';
|
||||
|
||||
/** Match result from findMatchingEntities */
|
||||
interface EntityMatch {
|
||||
entity: Entity;
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
}
|
||||
|
||||
export interface AnimatedTranscriptionProps {
|
||||
/** The text to display with animation */
|
||||
text: string;
|
||||
/** Unique identifier for animation tracking */
|
||||
blockId: string;
|
||||
/** Stagger delay between words in milliseconds. Default: 80 */
|
||||
staggerDelay?: number;
|
||||
/** Set of pinned entity IDs for highlighting */
|
||||
pinnedEntities: Set<string>;
|
||||
/** Callback when an entity is toggled */
|
||||
onTogglePin: (entityId: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
/** Whether to show the typing cursor. Default: false */
|
||||
showCursor?: boolean;
|
||||
}
|
||||
|
||||
interface AnimatedWordProps {
|
||||
word: string;
|
||||
shouldAnimate: boolean;
|
||||
delay: number;
|
||||
entityMatch: EntityMatch | null;
|
||||
pinnedEntities: Set<string>;
|
||||
onTogglePin: (entityId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single animated word component with two-phase entity highlighting.
|
||||
* Phase 1: Word materializes (blur → brightness pop → settle)
|
||||
* Phase 2: Entity highlight fades in after animation completes
|
||||
*/
|
||||
const AnimatedWord = memo(function AnimatedWord({
|
||||
word,
|
||||
shouldAnimate,
|
||||
delay,
|
||||
entityMatch,
|
||||
pinnedEntities,
|
||||
onTogglePin,
|
||||
}: AnimatedWordProps) {
|
||||
const [animationComplete, setAnimationComplete] = useState(!shouldAnimate);
|
||||
|
||||
const handleAnimationEnd = useCallback(() => {
|
||||
setAnimationComplete(true);
|
||||
}, []);
|
||||
|
||||
// Determine CSS classes based on animation state
|
||||
const animationClass = shouldAnimate
|
||||
? 'animate-reveal-chunk'
|
||||
: 'animate-reveal-complete';
|
||||
|
||||
// Render entity highlight only after animation completes
|
||||
if (entityMatch && animationComplete) {
|
||||
return (
|
||||
<span
|
||||
className={cn('inline', animationClass)}
|
||||
style={shouldAnimate ? { animationDelay: `${delay}ms` } : undefined}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
<HighlightedTerm
|
||||
text={word}
|
||||
entity={entityMatch.entity}
|
||||
pinnedEntities={pinnedEntities}
|
||||
onTogglePin={onTogglePin}
|
||||
className="animate-entity-highlight"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// Render plain word with animation
|
||||
return (
|
||||
<span
|
||||
className={cn('inline', animationClass)}
|
||||
style={shouldAnimate ? { animationDelay: `${delay}ms` } : undefined}
|
||||
onAnimationEnd={handleAnimationEnd}
|
||||
>
|
||||
{word}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
AnimatedWord.displayName = 'AnimatedWord';
|
||||
|
||||
/**
|
||||
* Animated transcription display component.
|
||||
*
|
||||
* Features:
|
||||
* - Word-staggered materialization animation (blur → brightness → settle)
|
||||
* - Incremental animation (only new words animate)
|
||||
* - Two-phase entity highlighting (animate first, then highlight)
|
||||
* - Optional typing cursor
|
||||
*/
|
||||
export const AnimatedTranscription = memo(function AnimatedTranscription({
|
||||
text,
|
||||
blockId,
|
||||
staggerDelay = 80,
|
||||
pinnedEntities,
|
||||
onTogglePin,
|
||||
className,
|
||||
showCursor = false,
|
||||
}: AnimatedTranscriptionProps) {
|
||||
const wordStates = useAnimatedWords(text, { staggerDelay, blockId });
|
||||
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find entity matches for the full text
|
||||
const entityMatches = findMatchingEntities(text);
|
||||
|
||||
// Build a map of word positions to entity matches
|
||||
const getEntityMatchForWord = (word: string, wordStartPos: number): EntityMatch | null => {
|
||||
const wordEndPos = wordStartPos + word.length;
|
||||
|
||||
for (const match of entityMatches) {
|
||||
// Check if this word overlaps with an entity match
|
||||
if (wordStartPos >= match.startIndex && wordEndPos <= match.endIndex) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Calculate word positions in the original text
|
||||
let currentPos = 0;
|
||||
const wordsWithPositions = text.split(/(\s+)/).map((segment) => {
|
||||
const startPos = currentPos;
|
||||
currentPos += segment.length;
|
||||
return { segment, startPos };
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={cn('inline', className)}>
|
||||
{wordsWithPositions.map(({ segment, startPos }, idx) => {
|
||||
// Handle whitespace
|
||||
if (/^\s+$/.test(segment)) {
|
||||
return <span key={`ws-${idx}`}>{segment}</span>;
|
||||
}
|
||||
|
||||
// Find corresponding word state
|
||||
const wordState = wordStates.find((ws) => ws.word === segment && ws.index === idx);
|
||||
|
||||
if (!wordState) {
|
||||
// Fallback for words not in state (shouldn't happen)
|
||||
return <span key={`word-${idx}`}>{segment}</span>;
|
||||
}
|
||||
|
||||
const entityMatch = getEntityMatchForWord(segment, startPos);
|
||||
|
||||
return (
|
||||
<AnimatedWord
|
||||
key={`word-${idx}-${blockId}`}
|
||||
word={wordState.word}
|
||||
shouldAnimate={wordState.shouldAnimate}
|
||||
delay={wordState.delay}
|
||||
entityMatch={entityMatch}
|
||||
pinnedEntities={pinnedEntities}
|
||||
onTogglePin={onTogglePin}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{showCursor && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary ml-0.5 animate-pulse" />
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
AnimatedTranscription.displayName = 'AnimatedTranscription';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* OAuth/SSO authentication configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
import { configPanelContentStyles, Field, SecretInput } from './shared';
|
||||
|
||||
interface AuthConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function AuthConfig({ integration, onUpdate, showSecrets, toggleSecret }: AuthConfigProps) {
|
||||
const config = integration.oauth_config || {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: '',
|
||||
scopes: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.client_id}
|
||||
onChange={(e) => onUpdate({ oauth_config: { ...config, client_id: e.target.value } })}
|
||||
placeholder="Enter client ID"
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={config.client_secret}
|
||||
onChange={(value) => onUpdate({ oauth_config: { ...config, client_secret: value } })}
|
||||
placeholder="Enter client secret"
|
||||
showSecret={showSecrets.client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.redirect_uri}
|
||||
onChange={(e) => onUpdate({ oauth_config: { ...config, redirect_uri: e.target.value } })}
|
||||
placeholder="https://your-app.com/auth/callback"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure this URL in your OAuth provider's settings
|
||||
</p>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Scopes</Label>
|
||||
<Input
|
||||
value={config.scopes.join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: {
|
||||
...config,
|
||||
scopes: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="openid, email, profile"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Calendar integration configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useWorkspace } from '@/contexts/workspace-state';
|
||||
import { configPanelContentStyles, Field, SecretInput } from './shared';
|
||||
|
||||
interface CalendarConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function CalendarConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: CalendarConfigProps) {
|
||||
const { currentWorkspace } = useWorkspace();
|
||||
const calConfig = integration.calendar_config || {
|
||||
sync_interval_minutes: 15,
|
||||
calendar_ids: [],
|
||||
};
|
||||
const oauthConfig = integration.oauth_config || {
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: '',
|
||||
scopes: [],
|
||||
};
|
||||
const overrideEnabled = integration.oauth_override_enabled ?? false;
|
||||
const overrideHasSecret = integration.oauth_override_has_secret ?? false;
|
||||
const canOverride =
|
||||
currentWorkspace?.role === 'owner' || currentWorkspace?.role === 'admin';
|
||||
const oauthFieldsDisabled = !overrideEnabled || !canOverride;
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">OAuth 2.0</Badge>
|
||||
<span className="text-xs text-muted-foreground">Requires OAuth authentication</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-background/50 p-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm">Use custom OAuth credentials</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overrideEnabled
|
||||
? 'Using custom credentials for this workspace'
|
||||
: 'Using server-provided credentials'}
|
||||
</p>
|
||||
{!canOverride ? (
|
||||
<p className="text-xs text-muted-foreground">Admin access required</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
checked={overrideEnabled}
|
||||
onCheckedChange={(value) => onUpdate({ oauth_override_enabled: value })}
|
||||
disabled={!canOverride}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={oauthConfig.client_id}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: { ...oauthConfig, client_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="Enter client ID"
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={oauthConfig.client_secret}
|
||||
onChange={(value) => onUpdate({ oauth_config: { ...oauthConfig, client_secret: value } })}
|
||||
placeholder={overrideHasSecret ? 'Stored on server' : 'Enter client secret'}
|
||||
showSecret={showSecrets.calendar_client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('calendar_client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</div>
|
||||
<Field label="Redirect URI" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={oauthConfig.redirect_uri}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oauth_config: { ...oauthConfig, redirect_uri: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://your-app.com/calendar/callback"
|
||||
disabled={oauthFieldsDisabled}
|
||||
/>
|
||||
</Field>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Sync Interval (minutes)</Label>
|
||||
<Select
|
||||
value={String(calConfig.sync_interval_minutes || 15)}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
calendar_config: { ...calConfig, sync_interval_minutes: parseInt(value, 10) },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 minutes</SelectItem>
|
||||
<SelectItem value="15">15 minutes</SelectItem>
|
||||
<SelectItem value="30">30 minutes</SelectItem>
|
||||
<SelectItem value="60">1 hour</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Calendar IDs (optional)</Label>
|
||||
<Input
|
||||
value={calConfig.calendar_ids?.join(', ') || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
calendar_config: {
|
||||
...calConfig,
|
||||
calendar_ids: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="primary, work@example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to sync all calendars, or specify calendar IDs
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Webhook URL (optional)</Label>
|
||||
<Input
|
||||
value={calConfig.webhook_url || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
calendar_config: { ...calConfig, webhook_url: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://your-app.com/webhooks/calendar"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Receive real-time calendar updates</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Email provider configuration.
|
||||
*/
|
||||
|
||||
import { Key, Lock, Mail, Server } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { IntegrationDefaults } from '@/lib/config';
|
||||
import { configPanelContentStyles, Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface EmailConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function EmailConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: EmailConfigProps) {
|
||||
const config = integration.email_config || {
|
||||
provider_type: 'api' as const,
|
||||
api_key: '',
|
||||
from_email: '',
|
||||
from_name: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={configPanelContentStyles}>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Provider Type</Label>
|
||||
<Select
|
||||
value={config.provider_type}
|
||||
onValueChange={(value: 'smtp' | 'api') =>
|
||||
onUpdate({
|
||||
email_config: { ...config, provider_type: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="api">API (SendGrid, Resend, etc.)</SelectItem>
|
||||
<SelectItem value="smtp">SMTP Server</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{config.provider_type === 'api' ? (
|
||||
<SecretInput
|
||||
label="API Key"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ email_config: { ...config, api_key: value } })}
|
||||
placeholder="Enter your API key"
|
||||
showSecret={showSecrets.email_api_key ?? false}
|
||||
onToggleSecret={() => toggleSecret('email_api_key')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="SMTP Host" icon={<Server className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.smtp_host || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_host: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="smtp.example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Port</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.smtp_port || IntegrationDefaults.SMTP_PORT}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: {
|
||||
...config,
|
||||
smtp_port: parseInt(e.target.value, 10) || IntegrationDefaults.SMTP_PORT,
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder={String(IntegrationDefaults.SMTP_PORT)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Username</Label>
|
||||
<Input
|
||||
value={config.smtp_username || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_username: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="username@example.com"
|
||||
/>
|
||||
</div>
|
||||
<SecretInput
|
||||
label="Password"
|
||||
value={config.smtp_password || ''}
|
||||
onChange={(value) => onUpdate({ email_config: { ...config, smtp_password: value } })}
|
||||
placeholder="SMTP password"
|
||||
showSecret={showSecrets.smtp_password ?? false}
|
||||
onToggleSecret={() => toggleSecret('smtp_password')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.smtp_secure ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, smtp_secure: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Use TLS/SSL</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="From Email" icon={<Mail className="h-4 w-4" />}>
|
||||
<Input
|
||||
type="email"
|
||||
value={config.from_email || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, from_email: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="noreply@example.com"
|
||||
/>
|
||||
</Field>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">From Name</Label>
|
||||
<Input
|
||||
value={config.from_name || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
email_config: { ...config, from_name: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="NoteFlow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Send Test Email" Icon={Mail} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Integration Configuration Panel Component.
|
||||
*
|
||||
* Renders configuration forms based on integration type.
|
||||
* Split into separate components for maintainability.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
|
||||
import { AuthConfig } from './auth-config';
|
||||
import { CalendarConfig } from './calendar-config';
|
||||
import { EmailConfig } from './email-config';
|
||||
import { OIDCConfig } from './oidc-config';
|
||||
import { PKMConfig } from './pkm-config';
|
||||
import { WebhookConfig } from './webhook-config';
|
||||
|
||||
export interface IntegrationConfigPanelProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting?: boolean;
|
||||
}
|
||||
|
||||
export function IntegrationConfigPanel({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting = false,
|
||||
}: IntegrationConfigPanelProps) {
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
const toggleSecret = (key: string) => setShowSecrets((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
// OAuth/SSO Configuration
|
||||
if (integration.type === 'auth') {
|
||||
return (
|
||||
<AuthConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Email Configuration
|
||||
if (integration.type === 'email') {
|
||||
return (
|
||||
<EmailConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Calendar Configuration
|
||||
if (integration.type === 'calendar') {
|
||||
return (
|
||||
<CalendarConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// PKM Configuration
|
||||
if (integration.type === 'pkm') {
|
||||
return (
|
||||
<PKMConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Custom/Webhook Configuration
|
||||
if (integration.type === 'custom') {
|
||||
return (
|
||||
<WebhookConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// OIDC Provider Configuration
|
||||
if (integration.type === 'oidc') {
|
||||
return (
|
||||
<OIDCConfig
|
||||
integration={integration}
|
||||
onUpdate={onUpdate}
|
||||
onTest={onTest}
|
||||
isTesting={isTesting}
|
||||
showSecrets={showSecrets}
|
||||
toggleSecret={toggleSecret}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-4 text-sm text-muted-foreground">
|
||||
No configuration options available for this integration.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* OIDC provider configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key, Lock } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { formatTimestamp } from '@/lib/utils/format';
|
||||
|
||||
import { Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface OIDCConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function OIDCConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: OIDCConfigProps) {
|
||||
const config = integration.oidc_config || {
|
||||
preset: 'custom' as const,
|
||||
issuer_url: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
scopes: ['openid', 'profile', 'email'],
|
||||
claim_mapping: {
|
||||
subject_claim: 'sub',
|
||||
email_claim: 'email',
|
||||
email_verified_claim: 'email_verified',
|
||||
name_claim: 'name',
|
||||
preferred_username_claim: 'preferred_username',
|
||||
groups_claim: 'groups',
|
||||
picture_claim: 'picture',
|
||||
},
|
||||
require_email_verified: true,
|
||||
allowed_groups: [],
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">OIDC</Badge>
|
||||
<span className="text-xs text-muted-foreground">OpenID Connect Provider</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Provider Preset</Label>
|
||||
<Select
|
||||
value={config.preset}
|
||||
onValueChange={(value) =>
|
||||
onUpdate({
|
||||
oidc_config: { ...config, preset: value as typeof config.preset },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="authentik">Authentik</SelectItem>
|
||||
<SelectItem value="authelia">Authelia</SelectItem>
|
||||
<SelectItem value="keycloak">Keycloak</SelectItem>
|
||||
<SelectItem value="auth0">Auth0</SelectItem>
|
||||
<SelectItem value="okta">Okta</SelectItem>
|
||||
<SelectItem value="azure_ad">Azure AD / Entra ID</SelectItem>
|
||||
<SelectItem value="custom">Custom OIDC</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<Field label="Issuer URL" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.issuer_url}
|
||||
onChange={(e) => onUpdate({ oidc_config: { ...config, issuer_url: e.target.value } })}
|
||||
placeholder="https://auth.example.com"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Base URL for OIDC discovery (/.well-known/openid-configuration)
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Client ID" icon={<Key className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.client_id}
|
||||
onChange={(e) => onUpdate({ oidc_config: { ...config, client_id: e.target.value } })}
|
||||
placeholder="noteflow-client"
|
||||
/>
|
||||
</Field>
|
||||
<SecretInput
|
||||
label="Client Secret"
|
||||
value={config.client_secret || ''}
|
||||
onChange={(value) => onUpdate({ oidc_config: { ...config, client_secret: value } })}
|
||||
placeholder="Enter client secret"
|
||||
showSecret={showSecrets.oidc_client_secret ?? false}
|
||||
onToggleSecret={() => toggleSecret('oidc_client_secret')}
|
||||
icon={<Lock className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Scopes</Label>
|
||||
<Input
|
||||
value={config.scopes.join(', ')}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
oidc_config: {
|
||||
...config,
|
||||
scopes: e.target.value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
},
|
||||
})
|
||||
}
|
||||
placeholder="openid, profile, email, groups"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Comma-separated list of OAuth scopes</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.require_email_verified}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
oidc_config: { ...config, require_email_verified: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Require verified email</Label>
|
||||
</div>
|
||||
|
||||
{config.discovery && (
|
||||
<div className="rounded-md bg-muted/50 p-3 space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Discovery Endpoints</p>
|
||||
<div className="text-xs space-y-1">
|
||||
<p>
|
||||
<span className="text-muted-foreground">Authorization:</span>{' '}
|
||||
{config.discovery.authorization_endpoint}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-muted-foreground">Token:</span>{' '}
|
||||
{config.discovery.token_endpoint}
|
||||
</p>
|
||||
{config.discovery.userinfo_endpoint && (
|
||||
<p>
|
||||
<span className="text-muted-foreground">UserInfo:</span>{' '}
|
||||
{config.discovery.userinfo_endpoint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{config.discovery_refreshed_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last refreshed: {formatTimestamp(config.discovery_refreshed_at)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Test OIDC Connection" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Personal Knowledge Management (PKM) configuration.
|
||||
*/
|
||||
|
||||
import { Database, FolderOpen, Key } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { EXTERNAL_LINK_REL } from '@/lib/ui/styles';
|
||||
import { Field, SecretInput } from './shared';
|
||||
|
||||
interface PKMConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function PKMConfig({ integration, onUpdate, showSecrets, toggleSecret }: PKMConfigProps) {
|
||||
const config = integration.pkm_config || { api_key: '', workspace_id: '', sync_enabled: false };
|
||||
const isNotion = integration.name.toLowerCase().includes('notion');
|
||||
const isObsidian = integration.name.toLowerCase().includes('obsidian');
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
{isNotion && (
|
||||
<>
|
||||
<SecretInput
|
||||
label="Integration Token"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
|
||||
placeholder="secret_xxxxxxxxxxxxxxxx"
|
||||
showSecret={showSecrets.notion_token ?? false}
|
||||
onToggleSecret={() => toggleSecret('notion_token')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Create an integration at{' '}
|
||||
<a
|
||||
href="https://www.notion.so/my-integrations"
|
||||
target="_blank"
|
||||
rel={EXTERNAL_LINK_REL}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
notion.so/my-integrations
|
||||
</a>
|
||||
</p>
|
||||
<Field label="Database ID" icon={<Database className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.database_id || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, database_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">The ID from your Notion database URL</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isObsidian && (
|
||||
<Field label="Vault Path" icon={<FolderOpen className="h-4 w-4" />}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={config.vault_path || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, vault_path: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="/path/to/obsidian/vault"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="outline" size="icon">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">Path to your Obsidian vault folder</p>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{!isNotion && !isObsidian && (
|
||||
<>
|
||||
<SecretInput
|
||||
label="API Key"
|
||||
value={config.api_key || ''}
|
||||
onChange={(value) => onUpdate({ pkm_config: { ...config, api_key: value } })}
|
||||
placeholder="Enter API key"
|
||||
showSecret={showSecrets.pkm_api_key ?? false}
|
||||
onToggleSecret={() => toggleSecret('pkm_api_key')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Workspace ID</Label>
|
||||
<Input
|
||||
value={config.workspace_id || ''}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, workspace_id: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="Enter workspace ID"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={config.sync_enabled ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
onUpdate({
|
||||
pkm_config: { ...config, sync_enabled: checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Label className="text-sm">Enable auto-sync</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Shared components for integration configuration panels.
|
||||
*/
|
||||
|
||||
import { Eye, EyeOff, Loader2, RefreshCw } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { iconWithMargin, labelStyles } from '@/lib/ui/styles';
|
||||
|
||||
/** Common container styles for config panel content sections. */
|
||||
export const configPanelContentStyles = 'space-y-4 pt-2';
|
||||
|
||||
/**
|
||||
* Reusable form field wrapper with label and icon.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
icon,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className={labelStyles.withIcon}>
|
||||
{icon}
|
||||
{label}
|
||||
</Label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Secret input field with show/hide toggle.
|
||||
*/
|
||||
export function SecretInput({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
showSecret,
|
||||
onToggleSecret,
|
||||
icon,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
showSecret: boolean;
|
||||
onToggleSecret: () => void;
|
||||
icon?: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Field label={label} icon={icon}>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showSecret ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="pr-10"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-3"
|
||||
onClick={onToggleSecret}
|
||||
disabled={disabled}
|
||||
>
|
||||
{showSecret ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection button.
|
||||
*/
|
||||
export function TestButton({
|
||||
onTest,
|
||||
isTesting,
|
||||
label = 'Test Connection',
|
||||
Icon = RefreshCw,
|
||||
}: {
|
||||
onTest?: () => void;
|
||||
isTesting?: boolean;
|
||||
label?: string;
|
||||
Icon?: React.ElementType;
|
||||
}) {
|
||||
if (!onTest) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Button variant="outline" onClick={onTest} disabled={isTesting}>
|
||||
{isTesting ? (
|
||||
<Loader2 className={`${iconWithMargin.md} animate-spin`} />
|
||||
) : (
|
||||
<Icon className={iconWithMargin.md} />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Custom/Webhook integration configuration.
|
||||
*/
|
||||
|
||||
import { Globe, Key } from 'lucide-react';
|
||||
|
||||
import type { Integration } from '@/api/types';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Field, SecretInput, TestButton } from './shared';
|
||||
|
||||
interface WebhookConfigProps {
|
||||
integration: Integration;
|
||||
onUpdate: (config: Partial<Integration>) => void;
|
||||
onTest?: () => void;
|
||||
isTesting: boolean;
|
||||
showSecrets: Record<string, boolean>;
|
||||
toggleSecret: (key: string) => void;
|
||||
}
|
||||
|
||||
export function WebhookConfig({
|
||||
integration,
|
||||
onUpdate,
|
||||
onTest,
|
||||
isTesting,
|
||||
showSecrets,
|
||||
toggleSecret,
|
||||
}: WebhookConfigProps) {
|
||||
const config = integration.webhook_config || {
|
||||
url: '',
|
||||
method: 'POST' as const,
|
||||
auth_type: 'none' as const,
|
||||
auth_value: '',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pt-2">
|
||||
<Field label="Webhook URL" icon={<Globe className="h-4 w-4" />}>
|
||||
<Input
|
||||
value={config.url}
|
||||
onChange={(e) =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, url: e.target.value },
|
||||
})
|
||||
}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">HTTP Method</Label>
|
||||
<Select
|
||||
value={config.method}
|
||||
onValueChange={(value: 'GET' | 'POST' | 'PUT') =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, method: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm">Authentication</Label>
|
||||
<Select
|
||||
value={config.auth_type || 'none'}
|
||||
onValueChange={(value: 'none' | 'bearer' | 'basic' | 'api_key') =>
|
||||
onUpdate({
|
||||
webhook_config: { ...config, auth_type: value },
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="api_key">API Key Header</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.auth_type && config.auth_type !== 'none' && (
|
||||
<SecretInput
|
||||
label={
|
||||
config.auth_type === 'bearer'
|
||||
? 'Bearer Token'
|
||||
: config.auth_type === 'basic'
|
||||
? 'Credentials (user:pass)'
|
||||
: 'API Key'
|
||||
}
|
||||
value={config.auth_value || ''}
|
||||
onChange={(value) => onUpdate({ webhook_config: { ...config, auth_value: value } })}
|
||||
placeholder={config.auth_type === 'basic' ? 'username:password' : 'Enter value'}
|
||||
showSecret={showSecrets.webhook_auth ?? false}
|
||||
onToggleSecret={() => toggleSecret('webhook_auth')}
|
||||
icon={<Key className="h-4 w-4" />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TestButton onTest={onTest} isTesting={isTesting} label="Test Webhook" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user