Compare commits

..

11 commits

Author SHA1 Message Date
Ida
b61544d8b1 feat: rag extension frontend consent 2026-04-29 14:53:38 +02:00
Patrick Motsch
26958d1e16
Merge pull request #65 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-29 01:56:07 +02:00
ValueOn AG
28951a7d22 fixes infomaniac different than in doc 2026-04-29 00:57:24 +02:00
ValueOn AG
9e08953c44 kdrive fix 2026-04-29 00:35:11 +02:00
Patrick Motsch
a0c2323fe6
Merge pull request #63 from valueonag/feat/demo-system-readieness
fix build
2026-04-27 07:25:50 +02:00
ValueOn AG
34d6c2b83d fix build 2026-04-27 07:25:17 +02:00
Patrick Motsch
3f80d6d434
Merge pull request #62 from valueonag/feat/demo-system-readieness
Feat/demo system readieness
2026-04-27 00:00:49 +02:00
ValueOn AG
3016806db9 added infomaniak 2026-04-26 23:59:14 +02:00
Patrick Motsch
974c48e24d
Merge pull request #61 from valueonag/int
Int
2026-04-26 23:16:01 +02:00
Patrick Motsch
fe857d5ade
Merge pull request #59 from valueonag/feat/demo-system-readieness
build fix
2026-04-26 23:06:47 +02:00
ValueOn AG
a9e8e8cddd build fix 2026-04-26 23:05:15 +02:00
10 changed files with 1581 additions and 52 deletions

View file

@ -4,10 +4,26 @@ import { ApiRequestOptions } from '../hooks/useApi';
// TYPES & INTERFACES // TYPES & INTERFACES
// ============================================================================ // ============================================================================
export interface KnowledgePreferences {
schemaVersion?: number;
neutralizeBeforeEmbed?: boolean;
mailContentDepth?: 'metadata' | 'snippet' | 'full';
mailIndexAttachments?: boolean;
filesIndexBinaries?: boolean;
mimeAllowlist?: string[];
clickupScope?: 'titles' | 'title_description' | 'with_comments';
clickupIndexAttachments?: boolean;
surfaceToggles?: {
google?: { gmail?: boolean; drive?: boolean };
msft?: { sharepoint?: boolean; outlook?: boolean };
};
maxAgeDays?: number;
}
export interface Connection { export interface Connection {
id: string; id: string;
userId: string; userId: string;
authority: 'local' | 'google' | 'msft' | 'clickup'; authority: 'local' | 'google' | 'msft' | 'clickup' | 'infomaniak';
externalId: string; externalId: string;
externalUsername: string; externalUsername: string;
externalEmail?: string; externalEmail?: string;
@ -15,6 +31,8 @@ export interface Connection {
connectedAt: number; // Backend uses float for UTC timestamp in seconds connectedAt: number; // Backend uses float for UTC timestamp in seconds
lastChecked: number; // Backend uses float for UTC timestamp in seconds lastChecked: number; // Backend uses float for UTC timestamp in seconds
expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
[key: string]: any; // Allow additional properties [key: string]: any; // Allow additional properties
} }
@ -52,12 +70,14 @@ export interface PaginatedResponse<T> {
export interface CreateConnectionData { export interface CreateConnectionData {
id?: string; id?: string;
userId?: string; userId?: string;
authority?: 'msft' | 'google' | 'clickup'; authority?: 'msft' | 'google' | 'clickup' | 'infomaniak';
type?: 'msft' | 'google' | 'clickup'; // Backend maps type → authority type?: 'msft' | 'google' | 'clickup' | 'infomaniak'; // Backend maps type → authority
externalId?: string; externalId?: string;
externalUsername?: string; externalUsername?: string;
externalEmail?: string; externalEmail?: string;
status?: 'active' | 'expired' | 'revoked' | 'pending'; status?: 'active' | 'expired' | 'revoked' | 'pending';
knowledgeIngestionEnabled?: boolean;
knowledgePreferences?: KnowledgePreferences | null;
connectedAt?: number; connectedAt?: number;
lastChecked?: number; lastChecked?: number;
expiresAt?: number; expiresAt?: number;
@ -136,14 +156,20 @@ export async function createConnection(
/** /**
* Connect to a service (initiate OAuth) * Connect to a service (initiate OAuth)
* Endpoint: POST /api/connections/{connectionId}/connect * Endpoint: POST /api/connections/{connectionId}/connect
*
* @param reauth If true, forces the OAuth provider to re-show the consent screen.
* Required when newly added scopes (e.g. Calendar/Contacts after a
* feature rollout) need to be granted on top of the existing token.
*/ */
export async function connectService( export async function connectService(
request: ApiRequestFunction, request: ApiRequestFunction,
connectionId: string connectionId: string,
reauth: boolean = false
): Promise<ConnectResponse> { ): Promise<ConnectResponse> {
return await request({ return await request({
url: `/api/connections/${connectionId}/connect`, url: `/api/connections/${connectionId}/connect`,
method: 'post' method: 'post',
data: reauth ? { reauth: true } : undefined,
}); });
} }
@ -221,3 +247,28 @@ export async function refreshGoogleToken(
}); });
} }
/**
* Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing
* UserConnection. The backend validates the token via /1/profile and stores it
* as the connection's data-access bearer token.
* Endpoint: POST /api/infomaniak/connections/{connectionId}/token
*/
export async function submitInfomaniakToken(
request: ApiRequestFunction,
connectionId: string,
token: string
): Promise<{
id: string;
status: string;
type: string;
externalUsername: string;
externalEmail?: string | null;
lastChecked: number;
}> {
return await request({
url: `/api/infomaniak/connections/${connectionId}/token`,
method: 'post',
data: { token }
});
}

View file

@ -0,0 +1,467 @@
/* AddConnectionWizard styles */
.stepper {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: 1rem 1.5rem 0;
border-bottom: 1px solid var(--border-color);
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 600;
background: var(--bg-secondary, #f0f0f0);
color: var(--text-secondary, #666);
border: 2px solid var(--border-color, #ddd);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.stepDotActive {
background: var(--primary-color, #f25843);
border-color: var(--primary-color, #f25843);
color: white;
}
.stepDotDone {
background: var(--success-color, #22c55e);
border-color: var(--success-color, #22c55e);
color: white;
}
.stepDotHidden {
opacity: 0.3;
}
.body {
padding: 1.5rem;
overflow-y: auto;
}
.stepContent {
display: flex;
flex-direction: column;
gap: 1rem;
min-height: 220px;
}
.stepTitle {
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
margin: 0;
}
.stepBody {
font-size: 0.9375rem;
color: var(--text-primary);
line-height: 1.6;
margin: 0;
}
.stepHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Connector grid (Step 0) */
.connectorGrid {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.connectorCard {
flex: 1 1 140px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.625rem;
padding: 1.25rem 1rem;
background: var(--surface-color);
border: 2px solid var(--border-color, #ddd);
border-radius: 10px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s;
}
.connectorCard:hover {
border-color: var(--primary-color, #f25843);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
transform: translateY(-2px);
}
.connectorIcon {
font-size: 1.75rem;
}
.connectorLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary);
}
/* Consent step (Step 1) */
.consentIcon {
display: flex;
justify-content: center;
color: var(--primary-color, #f25843);
}
.consentButtons {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.consentButtonYes {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.consentButtonYes:hover {
background: var(--primary-dark, #d94d3a);
}
.consentButtonNo {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: var(--surface-color);
color: var(--text-primary);
border: 2px solid var(--border-color, #ddd);
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.consentButtonNo:hover {
border-color: var(--text-secondary, #888);
background: var(--bg-secondary, #f5f5f5);
}
/* Preferences step (Step 2) */
.prefGroup {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--border-color, #eee);
}
.prefGroup:last-of-type {
border-bottom: none;
}
.prefLabel {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 0.9375rem;
color: var(--text-primary);
cursor: pointer;
font-weight: 500;
}
.prefLabelRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
color: var(--text-primary);
font-weight: 500;
}
.prefIcon {
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.prefCheck {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: var(--primary-color, #f25843);
}
.prefSelect {
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
min-width: 200px;
}
.prefNumber {
width: 80px;
padding: 0.375rem 0.5rem;
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
background: var(--surface-color);
color: var(--text-primary);
text-align: right;
}
.prefHint {
font-size: 0.8125rem;
color: var(--text-secondary, #666);
margin: 0;
}
/* Summary step (Step 3) */
.summary {
display: flex;
flex-direction: column;
gap: 0;
border: 1px solid var(--border-color, #ddd);
border-radius: 8px;
overflow: hidden;
}
.summaryRow {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.625rem 1rem;
gap: 1rem;
border-bottom: 1px solid var(--border-color, #eee);
}
.summaryRow:last-child {
border-bottom: none;
}
.summaryKey {
font-size: 0.875rem;
color: var(--text-secondary, #666);
font-weight: 500;
}
.summaryVal {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
color: var(--text-primary);
font-weight: 500;
}
/* Back button (step 1 consent screen) */
.stepNavLeft {
margin-top: 0.75rem;
display: flex;
}
.navBack {
background: none;
border: none;
padding: 0.25rem 0;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
cursor: pointer;
text-decoration: underline;
}
.navBack:hover {
color: var(--text-primary);
}
/* Cost estimate hint */
.costHint {
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.75rem 1rem;
background: var(--info-bg, #eff6ff);
border: 1px solid var(--info-border, #bfdbfe);
border-radius: 8px;
font-size: 0.8125rem;
}
.costHintIcon {
flex-shrink: 0;
margin-top: 2px;
color: var(--info-color, #3b82f6);
}
.costHint > div {
display: flex;
flex-direction: column;
gap: 0.25rem;
width: 100%;
}
.costHintTitle {
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.125rem;
}
.costTable {
border-collapse: collapse;
width: 100%;
font-size: 0.8125rem;
}
.costLabel {
color: var(--text-secondary, #555);
padding-right: 1rem;
white-space: nowrap;
}
.costVal {
font-weight: 600;
color: var(--info-color, #1d4ed8);
}
.costRowNeut .costLabel,
.costRowNeut .costVal {
padding-top: 0.125rem;
}
.costRowNeut .costVal {
color: #b45309;
}
.costHintWarn {
font-size: 0.75rem;
color: #b45309;
font-weight: 500;
line-height: 1.4;
}
.costHintNote {
color: var(--text-secondary, #555);
font-size: 0.75rem;
}
:global(.dark-theme) .costHint {
background: rgba(59, 130, 246, 0.08);
border-color: rgba(59, 130, 246, 0.3);
}
:global(.dark-theme) .costVal {
color: #93c5fd;
}
:global(.dark-theme) .costRowNeut .costVal,
:global(.dark-theme) .costHintWarn {
color: #fbbf24;
}
/* Navigation */
.stepNav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-top: 0.5rem;
gap: 0.75rem;
}
.navBack {
padding: 0.5rem 1rem;
background: var(--surface-color);
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #ddd);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navBack:hover {
background: var(--bg-secondary, #f5f5f5);
}
.navNext {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.navNext:hover {
background: var(--primary-dark, #d94d3a);
}
.navConnect {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.5rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.navConnect:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.navConnect:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Dark theme */
:global(.dark-theme) .connectorCard {
background: var(--surface-color);
}
:global(.dark-theme) .prefSelect,
:global(.dark-theme) .prefNumber {
background: var(--surface-color);
color: var(--text-primary);
}
:global(.dark-theme) .summary {
border-color: var(--border-color);
}
:global(.dark-theme) .summaryRow {
border-color: var(--border-color);
}

View file

@ -0,0 +1,521 @@
/**
* AddConnectionWizard
*
* Multi-step modal for adding a new connector with optional knowledge
* ingestion consent and per-connection preferences (§2.6).
*
* Steps:
* 0 Connector wählen
* 1 Consent (Wissensdatenbank Ja/Nein)
* 2 Präferenzen (nur wenn Ja)
* 3 Zusammenfassung + OAuth starten
*/
import React, { useState } from 'react';
import { Modal } from '../UiComponents/Modal/Modal';
import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa';
import type { KnowledgePreferences } from '../../api/connectionApi';
import styles from './AddConnectionWizard.module.css';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type ConnectorType = 'google' | 'msft' | 'clickup';
interface WizardState {
step: 0 | 1 | 2 | 3;
connector: ConnectorType | null;
knowledgeEnabled: boolean;
prefs: KnowledgePreferences;
}
const DEFAULT_PREFS: KnowledgePreferences = {
schemaVersion: 1,
neutralizeBeforeEmbed: false,
mailContentDepth: 'full',
mailIndexAttachments: false,
filesIndexBinaries: true,
clickupScope: 'title_description',
clickupIndexAttachments: false,
maxAgeDays: 90,
};
const CONNECTOR_LABELS: Record<ConnectorType, string> = {
google: 'Google',
msft: 'Microsoft 365',
clickup: 'ClickUp',
};
const CONNECTOR_ICONS: Record<ConnectorType, React.ReactNode> = {
google: <FaGoogle style={{ color: '#4285f4' }} />,
msft: <FaMicrosoft style={{ color: '#00a4ef' }} />,
clickup: <FaTasks style={{ color: '#7b68ee' }} />,
};
// ---------------------------------------------------------------------------
// Cost estimate helper
// ---------------------------------------------------------------------------
/**
* Returns a cost estimate broken into two lines:
*
* 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) always tiny.
* 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call)
* this is the DOMINANT cost when enabled. One call per email/task for
* short content; several calls for long threads or files.
*
* Numbers are conservative ranges. Subsequent syncs are cheaper because
* unchanged content is deduplicated before any LLM/embedding call.
*/
function computeCostEstimate(
connector: ConnectorType | null,
prefs: KnowledgePreferences,
): {
embeddingLow: string;
embeddingHigh: string;
neutralizationLow: string | null;
neutralizationHigh: string | null;
note: string;
} | null {
if (!connector) return null;
// ---- Embedding (OpenAI, USD) ----
const EMBED_USD_PER_M = 0.02;
const tokensPerMail: Record<string, number> = { metadata: 30, snippet: 120, full: 500 };
const depth = prefs.mailContentDepth ?? 'full';
const maxAge = prefs.maxAgeDays ?? 90;
const mailCount = Math.min(500, Math.round((maxAge / 90) * 500));
const taskCount = Math.min(500, Math.round((maxAge / 90) * 300));
let embedLowTokens = 0;
let embedHighTokens = 0;
if (connector === 'google' || connector === 'msft') {
const mailTokens = mailCount * tokensPerMail[depth];
embedLowTokens += mailTokens * 0.6;
embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint
if (prefs.mailIndexAttachments) embedHighTokens += 200_000;
} else if (connector === 'clickup') {
const scope = prefs.clickupScope ?? 'title_description';
const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400;
embedLowTokens += taskCount * tpt * 0.6;
embedHighTokens += taskCount * tpt * 1.5;
}
const fmtUsd = (tokens: number) => {
const usd = (tokens / 1_000_000) * EMBED_USD_PER_M;
if (usd < 0.001) return '< 0.01 $';
if (usd < 0.10) return `~${usd.toFixed(3)} $`;
return `~${usd.toFixed(2)} $`;
};
// ---- Neutralization (Private LLM, CHF 0.01/call) ----
// Each item (email / task / file) = 1 LLM call for short content,
// 2-4 for long threads/documents.
const NEUT_CHF_PER_CALL = 0.01;
let neutLow: string | null = null;
let neutHigh: string | null = null;
if (prefs.neutralizeBeforeEmbed) {
let lowCalls = 0;
let highCalls = 0;
if (connector === 'google' || connector === 'msft') {
lowCalls += mailCount * 1; // 1 call / short email
highCalls += mailCount * 3; // up to 3 calls / long thread
lowCalls += 20; // Drive/SharePoint files (low)
highCalls += 200; // Drive/SharePoint files (high, large PDFs)
} else if (connector === 'clickup') {
lowCalls += taskCount * 1;
highCalls += taskCount * 2;
}
const fmtChf = (calls: number) => {
const chf = calls * NEUT_CHF_PER_CALL;
if (chf < 0.01) return '< 0.01 CHF';
return `~${chf.toFixed(2)} CHF`;
};
neutLow = fmtChf(lowCalls);
neutHigh = fmtChf(highCalls);
}
return {
embeddingLow: fmtUsd(embedLowTokens),
embeddingHigh: fmtUsd(embedHighTokens),
neutralizationLow: neutLow,
neutralizationHigh: neutHigh,
note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).',
};
}
// ---------------------------------------------------------------------------
// Props
// ---------------------------------------------------------------------------
interface AddConnectionWizardProps {
open: boolean;
onClose: () => void;
onConnect: (
type: ConnectorType,
knowledgeEnabled: boolean,
prefs: KnowledgePreferences | null,
) => Promise<void>;
isConnecting?: boolean;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export const AddConnectionWizard: React.FC<AddConnectionWizardProps> = ({
open,
onClose,
onConnect,
isConnecting = false,
}) => {
const [state, setState] = useState<WizardState>({
step: 0,
connector: null,
knowledgeEnabled: false,
prefs: { ...DEFAULT_PREFS },
});
const reset = () =>
setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } });
const handleClose = () => {
reset();
onClose();
};
const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step }));
const setConnector = (connector: ConnectorType) =>
setState(s => ({ ...s, connector, step: 1 }));
const setKnowledgeEnabled = (v: boolean) =>
setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 }));
const updatePref = <K extends keyof KnowledgePreferences>(key: K, value: KnowledgePreferences[K]) =>
setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } }));
const handleConnect = async () => {
if (!state.connector) return;
await onConnect(
state.connector,
state.knowledgeEnabled,
state.knowledgeEnabled ? state.prefs : null,
);
reset();
onClose();
};
const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht'];
const visibleSteps = state.knowledgeEnabled
? [0, 1, 2, 3]
: [0, 1, 3];
return (
<Modal
open={open}
onClose={handleClose}
title="Verbindung hinzufügen"
size="md"
closeOnEscape
>
{/* Stepper */}
<div className={styles.stepper}>
{[0, 1, 2, 3].map(i => (
<div
key={i}
className={[
styles.stepDot,
state.step === i ? styles.stepDotActive : '',
state.step > i ? styles.stepDotDone : '',
!visibleSteps.includes(i) ? styles.stepDotHidden : '',
].join(' ')}
>
{state.step > i ? <FaCheck size={10} /> : i + 1}
</div>
))}
</div>
<div className={styles.body}>
{/* ---- Step 0: Connector ---- */}
{state.step === 0 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Anbieter wählen</h3>
<p className={styles.stepHint}>Welchen Dienst möchtest du verbinden?</p>
<div className={styles.connectorGrid}>
{(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => (
<button
key={type}
type="button"
className={styles.connectorCard}
onClick={() => setConnector(type)}
>
<span className={styles.connectorIcon}>{CONNECTOR_ICONS[type]}</span>
<span className={styles.connectorLabel}>{CONNECTOR_LABELS[type]}</span>
</button>
))}
</div>
</div>
)}
{/* ---- Step 1: Consent ---- */}
{state.step === 1 && (
<div className={styles.stepContent}>
<div className={styles.consentIcon}><FaDatabase size={32} /></div>
<h3 className={styles.stepTitle}>Wissensdatenbank</h3>
<p className={styles.stepBody}>
Möchtest du Inhalte aus dieser Verbindung in deine persönliche
Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen
aus{' '}
{state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '}
zurückgreifen kann?
</p>
<p className={styles.stepHint}>
Du kannst diese Einstellung später in den Verbindungsdetails ändern.
</p>
<div className={styles.consentButtons}>
<button
type="button"
className={styles.consentButtonYes}
onClick={() => setKnowledgeEnabled(true)}
>
<FaCheck /> Ja, aufnehmen
</button>
<button
type="button"
className={styles.consentButtonNo}
onClick={() => setKnowledgeEnabled(false)}
>
Nein, überspringen
</button>
</div>
<div className={styles.stepNavLeft}>
<button type="button" className={styles.navBack} onClick={() => setStep(0)}>
Zurück
</button>
</div>
</div>
)}
{/* ---- Step 2: Preferences ---- */}
{state.step === 2 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Einstellungen</h3>
<p className={styles.stepHint}>
Steuere, welche Inhalte und in welcher Form sie indexiert werden.
</p>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
<FaShieldAlt className={styles.prefIcon} />
Anonymisierung vor dem Indexieren
<input
type="checkbox"
checked={!!state.prefs.neutralizeBeforeEmbed}
onChange={e => updatePref('neutralizeBeforeEmbed', e.target.checked)}
className={styles.prefCheck}
/>
</label>
<p className={styles.prefHint}>
Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt.
</p>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<>
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
E-Mail-Inhalt
<select
value={state.prefs.mailContentDepth ?? 'full'}
onChange={e => updatePref('mailContentDepth', e.target.value as any)}
className={styles.prefSelect}
>
<option value="metadata">Nur Metadaten (Betreff, Absender, Datum)</option>
<option value="snippet">Vorschautext (ca. 250 Zeichen)</option>
<option value="full">Vollständiger Text</option>
</select>
</label>
</div>
<div className={styles.prefGroup}>
<label className={styles.prefLabel}>
E-Mail-Anhänge indexieren
<input
type="checkbox"
checked={!!state.prefs.mailIndexAttachments}
onChange={e => updatePref('mailIndexAttachments', e.target.checked)}
className={styles.prefCheck}
/>
</label>
</div>
</>
)}
{state.connector === 'clickup' && (
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Aufgaben-Inhalt
<select
value={state.prefs.clickupScope ?? 'title_description'}
onChange={e => updatePref('clickupScope', e.target.value as any)}
className={styles.prefSelect}
>
<option value="titles">Nur Aufgabentitel</option>
<option value="title_description">Titel + Beschreibung</option>
<option value="with_comments">Titel + Beschreibung + Kommentare</option>
</select>
</label>
</div>
)}
<div className={styles.prefGroup}>
<label className={styles.prefLabelRow}>
Zeitfenster (Tage)
<input
type="number"
min={0}
max={3650}
value={state.prefs.maxAgeDays ?? 90}
onChange={e => updatePref('maxAgeDays', parseInt(e.target.value, 10) || 0)}
className={styles.prefNumber}
/>
</label>
<p className={styles.prefHint}>0 = kein Limit</p>
</div>
<div className={styles.stepNav}>
<button type="button" className={styles.navBack} onClick={() => setStep(1)}>
Zurück
</button>
<button type="button" className={styles.navNext} onClick={() => setStep(3)}>
Weiter <FaArrowRight size={12} />
</button>
</div>
</div>
)}
{/* ---- Step 3: Summary ---- */}
{state.step === 3 && (
<div className={styles.stepContent}>
<h3 className={styles.stepTitle}>Zusammenfassung</h3>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anbieter</span>
<span className={styles.summaryVal}>
{CONNECTOR_ICONS[state.connector!]}&nbsp;
{state.connector ? CONNECTOR_LABELS[state.connector] : '—'}
</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Wissensdatenbank</span>
<span className={styles.summaryVal}>
{state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'}
</span>
</div>
{state.knowledgeEnabled && (
<>
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Anonymisierung</span>
<span className={styles.summaryVal}>
{state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'}
</span>
</div>
{(state.connector === 'google' || state.connector === 'msft') && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>E-Mail-Tiefe</span>
<span className={styles.summaryVal}>
{{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[
state.prefs.mailContentDepth ?? 'full'
] ?? state.prefs.mailContentDepth}
</span>
</div>
)}
{state.connector === 'clickup' && (
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Aufgaben-Inhalt</span>
<span className={styles.summaryVal}>
{{
titles: 'Nur Titel',
title_description: 'Titel + Beschreibung',
with_comments: 'Titel + Beschreibung + Kommentare',
}[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope}
</span>
</div>
)}
<div className={styles.summaryRow}>
<span className={styles.summaryKey}>Zeitfenster</span>
<span className={styles.summaryVal}>
{state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'}
</span>
</div>
</>
)}
</div>
{/* Cost estimate — only shown when knowledge ingestion is enabled */}
{state.knowledgeEnabled && (() => {
const est = computeCostEstimate(state.connector, state.prefs);
if (!est) return null;
return (
<div className={styles.costHint}>
<FaInfoCircle className={styles.costHintIcon} />
<div>
<span className={styles.costHintTitle}>Geschätzte Kosten (erster Sync)</span>
<table className={styles.costTable}>
<tbody>
<tr>
<td className={styles.costLabel}>Embedding</td>
<td className={styles.costVal}>
{est.embeddingLow} {est.embeddingHigh}
</td>
</tr>
{est.neutralizationLow && (
<tr className={styles.costRowNeut}>
<td className={styles.costLabel}>Anonymisierung (Private LLM)</td>
<td className={styles.costVal}>
{est.neutralizationLow} {est.neutralizationHigh}
</td>
</tr>
)}
</tbody>
</table>
{est.neutralizationLow && (
<span className={styles.costHintWarn}>
Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise).
</span>
)}
<span className={styles.costHintNote}>{est.note}</span>
</div>
</div>
);
})()}
<div className={styles.stepNav}>
<button
type="button"
className={styles.navBack}
onClick={() => setStep(state.knowledgeEnabled ? 2 : 1)}
>
Zurück
</button>
<button
type="button"
className={styles.navConnect}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? 'Verbinden…' : `Mit ${state.connector ? CONNECTOR_LABELS[state.connector] : '…'} verbinden`}
{!isConnecting && <FaArrowRight size={12} />}
</button>
</div>
</div>
)}
</div>
</Modal>
);
};
export default AddConnectionWizard;

View file

@ -126,6 +126,7 @@ const _AUTHORITY_ICONS: Record<string, string> = {
msft: '\uD83D\uDFE6', msft: '\uD83D\uDFE6',
google: '\uD83D\uDFE9', google: '\uD83D\uDFE9',
clickup: '\uD83D\uDCCB', clickup: '\uD83D\uDCCB',
infomaniak: '\uD83D\uDFE5',
'local:ftp': '\uD83D\uDD17', 'local:ftp': '\uD83D\uDD17',
'local:jira': '\uD83D\uDD27', 'local:jira': '\uD83D\uDD27',
}; };
@ -138,6 +139,11 @@ const _SERVICE_ICONS: Record<string, string> = {
drive: '\uD83D\uDCC2', drive: '\uD83D\uDCC2',
gmail: '\uD83D\uDCE8', gmail: '\uD83D\uDCE8',
files: '\uD83D\uDCC2', files: '\uD83D\uDCC2',
clickup: '\uD83D\uDCCB',
kdrive: '\uD83D\uDCC2',
mail: '\uD83D\uDCE7',
calendar: '\uD83D\uDCC5',
contact: '\uD83D\uDC64',
}; };
/* ─── Source colors & icons ──────────────────────────────────────────── */ /* ─── Source colors & icons ──────────────────────────────────────────── */
@ -158,6 +164,14 @@ const _SOURCE_COLORS: Record<string, string> = {
'local:ftp': '#795548', 'local:ftp': '#795548',
'local:jira': '#0052CC', 'local:jira': '#0052CC',
clickup: '#7b68ee', clickup: '#7b68ee',
kdriveFolder: '#0098FF',
kdrive: '#0098FF',
mailFolder: '#0098FF',
mail: '#0098FF',
calendarFolder: '#0098FF',
calendar: '#0098FF',
contactFolder: '#0098FF',
contact: '#0098FF',
}; };
function _getSourceColor(sourceType: string): string { function _getSourceColor(sourceType: string): string {
@ -188,6 +202,11 @@ const _SERVICE_TO_SOURCE_TYPE: Record<string, string> = {
drive: 'googleDriveFolder', drive: 'googleDriveFolder',
gmail: 'gmailFolder', gmail: 'gmailFolder',
files: 'ftpFolder', files: 'ftpFolder',
clickup: 'clickup',
kdrive: 'kdriveFolder',
mail: 'mailFolder',
calendar: 'calendarFolder',
contact: 'contactFolder',
}; };
/* ─── Tree helpers ───────────────────────────────────────────────────── */ /* ─── Tree helpers ───────────────────────────────────────────────────── */

View file

@ -12,6 +12,7 @@ import {
updateConnection as updateConnectionApi, updateConnection as updateConnectionApi,
refreshMicrosoftToken as refreshMicrosoftTokenApi, refreshMicrosoftToken as refreshMicrosoftTokenApi,
refreshGoogleToken as refreshGoogleTokenApi, refreshGoogleToken as refreshGoogleTokenApi,
submitInfomaniakToken as submitInfomaniakTokenApi,
type Connection, type Connection,
type AttributeDefinition, type AttributeDefinition,
type PaginationParams, type PaginationParams,
@ -138,10 +139,12 @@ export function useConnections() {
} }
}; };
// Connect to a service (initiate OAuth) // Connect to a service (initiate OAuth). Pass reauth=true to force the
const connectService = async (connectionId: string): Promise<ConnectResponse> => { // provider's consent screen so newly added scopes (e.g. Calendar/Contacts)
// actually land on the access token instead of being silently skipped.
const connectService = async (connectionId: string, reauth: boolean = false): Promise<ConnectResponse> => {
try { try {
const data = await connectServiceApi(request, connectionId); const data = await connectServiceApi(request, connectionId, reauth);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error connecting service:', error); console.error('Error connecting service:', error);
@ -237,13 +240,13 @@ export function useConnections() {
}; };
// Connect with popup (OAuth flow) // Connect with popup (OAuth flow)
const connectWithPopup = async (connectionId: string): Promise<void> => { const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true); setIsConnecting(true);
setConnectError(null); setConnectError(null);
try { try {
// Get the OAuth URL from backend // Get the OAuth URL from backend
const response = await connectService(connectionId); const response = await connectService(connectionId, reauth);
if (!response.authUrl) { if (!response.authUrl) {
throw new Error('No OAuth URL received from backend'); throw new Error('No OAuth URL received from backend');
} }
@ -495,6 +498,26 @@ export function useConnections() {
} }
}; };
// Infomaniak uses Personal Access Tokens (no OAuth). Two-step flow:
// 1. createInfomaniakConnection() - creates a PENDING UserConnection row
// 2. submitInfomaniakToken(connectionId, pat) - validates the PAT against
// /1/profile, persists it as the connection's bearer token, and flips
// the row to ACTIVE.
const createInfomaniakConnection = async (): Promise<Connection> => {
return await createConnection({
type: 'infomaniak',
authority: 'infomaniak',
});
};
const submitInfomaniakToken = async (
connectionId: string,
token: string
): Promise<void> => {
await submitInfomaniakTokenApi(request, connectionId, token);
await fetchConnections();
};
// Create Microsoft connection and open OAuth popup // Create Microsoft connection and open OAuth popup
const createMicrosoftConnectionAndAuth = async (): Promise<void> => { const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return; if (isConnecting) return;
@ -685,6 +708,90 @@ export function useConnections() {
} }
}, [connections, request]); }, [connections, request]);
/**
* Generic wizard entry-point: create a connection of any supported type with
* optional knowledge consent + preferences, then immediately open the OAuth
* popup. The three individual `create*ConnectionAndAuth` methods are preserved
* for backward-compat but new wizard code should call this.
*/
const createConnectionAndAuth = async (
type: 'google' | 'msft' | 'clickup',
knowledgeIngestionEnabled: boolean,
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
const newConnection = await createConnection({
type,
authority: type,
knowledgeIngestionEnabled,
knowledgePreferences: knowledgePreferences ?? null,
});
const connectResponse = await connectServiceApi(request, newConnection.id);
if (!connectResponse.authUrl) {
throw new Error('No OAuth URL received from backend');
}
const apiBaseUrl = getApiBaseUrl();
let authUrl = connectResponse.authUrl;
if (authUrl.startsWith('/')) authUrl = `${apiBaseUrl}${authUrl}`;
return await new Promise<void>((resolve, reject) => {
const popup = window.open(authUrl, `${type}-wizard`, 'width=500,height=600,scrollbars=yes,resizable=yes');
if (!popup) {
setIsConnecting(false);
reject(new Error('Popup was blocked. Please allow popups and try again.'));
return;
}
const SUCCESS_TYPES = new Set([
'google_connection_success', 'msft_connection_success', 'clickup_connection_success',
'google_auth_success',
]);
const ERROR_TYPES = new Set([
'google_connection_error', 'msft_connection_error', 'clickup_connection_error',
]);
const checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
fetchConnections();
resolve();
}
}, 1000);
const messageListener = (event: MessageEvent) => {
const apiUrl = new URL(apiBaseUrl);
if (event.origin !== apiUrl.origin) return;
if (SUCCESS_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
fetchConnections();
resolve();
} else if (ERROR_TYPES.has(event.data.type)) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || `${type} connection failed`));
}
};
window.addEventListener('message', messageListener);
});
} catch (error: any) {
setIsConnecting(false);
throw error;
}
};
return { return {
connections, connections,
data: connections, // Alias for FormGenerator compatibility data: connections, // Alias for FormGenerator compatibility
@ -701,6 +808,9 @@ export function useConnections() {
createGoogleConnectionAndAuth, createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth, createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth, createClickupConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
createConnectionAndAuth,
isLoading, isLoading,
loading: isLoading, // Alias for FormGenerator compatibility loading: isLoading, // Alias for FormGenerator compatibility
isConnecting, isConnecting,
@ -726,13 +836,13 @@ export function useOAuthConnect() {
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [connectError, setConnectError] = useState<string | null>(null); const [connectError, setConnectError] = useState<string | null>(null);
const connectWithPopup = async (connectionId: string): Promise<void> => { const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true); setIsConnecting(true);
setConnectError(null); setConnectError(null);
try { try {
// Get the OAuth URL from backend // Get the OAuth URL from backend
const response = await connectService(connectionId); const response = await connectService(connectionId, reauth);
if (!response.authUrl) { if (!response.authUrl) {
throw new Error('No OAuth URL received from backend'); throw new Error('No OAuth URL received from backend');
} }

View file

@ -90,6 +90,10 @@ export interface AttributeDefinition {
required?: boolean; required?: boolean;
default?: any; default?: any;
options?: any[] | string; options?: any[] | string;
/** Backend: FK label column (e.g. userId -> userIdLabel). */
displayField?: string;
frontendFormat?: string;
frontendFormatLabels?: string[];
readonly?: boolean; readonly?: boolean;
editable?: boolean; editable?: boolean;
visible?: boolean; visible?: boolean;

View file

@ -14,8 +14,6 @@ import { FormGeneratorForm, type AttributeDefinition } from '../../components/Fo
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa'; import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable'; import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import { ChatbotConfigSection } from './ChatbotConfigSection'; import { ChatbotConfigSection } from './ChatbotConfigSection';
@ -46,7 +44,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
const { fetchMandates } = useUserMandates(); const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const { loadFeatures } = useFeatureStore(); const { loadFeatures } = useFeatureStore();
const { request } = useApiRequest();
// State // State
const [mandates, setMandates] = useState<Mandate[]>([]); const [mandates, setMandates] = useState<Mandate[]>([]);

View file

@ -0,0 +1,90 @@
/* ConnectionsPage — supplemental styles for sync banner */
.syncBanner {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.875rem 1rem 0.875rem 1.125rem;
margin: 0 0 1rem;
background: linear-gradient(135deg, #fffbeb, #fef3c7);
border: 1px solid #fcd34d;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
animation: slidein 0.25s ease;
}
@keyframes slidein {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.syncSpinner {
flex-shrink: 0;
margin-top: 3px;
color: #d97706;
font-size: 1rem;
animation: spin 1.4s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.syncText {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.syncTitle {
font-weight: 600;
font-size: 0.9375rem;
color: #92400e;
}
.syncDetail {
font-size: 0.8125rem;
color: #78350f;
line-height: 1.5;
}
.syncDismiss {
flex-shrink: 0;
background: none;
border: none;
cursor: pointer;
color: #b45309;
padding: 2px 4px;
border-radius: 4px;
font-size: 0.875rem;
display: flex;
align-items: center;
transition: background 0.15s;
}
.syncDismiss:hover {
background: rgba(0, 0, 0, 0.06);
}
/* Dark theme */
:global(.dark-theme) .syncBanner {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.3);
}
:global(.dark-theme) .syncTitle {
color: #fcd34d;
}
:global(.dark-theme) .syncDetail {
color: #fde68a;
}
:global(.dark-theme) .syncDismiss {
color: #fbbf24;
}
:global(.dark-theme) .syncSpinner {
color: #fbbf24;
}

View file

@ -5,17 +5,22 @@
* Follows the pattern established in AdminUsersPage/WorkflowsPage. * Follows the pattern established in AdminUsersPage/WorkflowsPage.
*/ */
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections'; import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks } from 'react-icons/fa'; import { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config'; import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
import bannerStyles from './ConnectionsPage.module.css';
import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard';
import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard';
import type { KnowledgePreferences } from '../../api/connectionApi';
import { useLanguage } from '../../providers/language/LanguageContext'; import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { resolveColumnTypes } from '../../utils/columnTypeResolver';
const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap
export const ConnectionsPage: React.FC = () => { export const ConnectionsPage: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
@ -32,9 +37,9 @@ export const ConnectionsPage: React.FC = () => {
updateOptimistically, updateOptimistically,
deleteConnection, deleteConnection,
handleInlineUpdate, handleInlineUpdate,
createGoogleConnectionAndAuth, createConnectionAndAuth,
createMicrosoftConnectionAndAuth, createInfomaniakConnection,
createClickupConnectionAndAuth, submitInfomaniakToken,
connectWithPopup, connectWithPopup,
refreshMicrosoftToken, refreshMicrosoftToken,
refreshGoogleToken, refreshGoogleToken,
@ -44,7 +49,34 @@ export const ConnectionsPage: React.FC = () => {
const [editingConnection, setEditingConnection] = useState<Connection | null>(null); const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set()); const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(new Set());
const [adminConsentPending, setAdminConsentPending] = useState(false); const [adminConsentPending, setAdminConsentPending] = useState(false);
const [wizardOpen, setWizardOpen] = useState(false);
// Banner shown while knowledge bootstrap is running in the background
const [syncBanner, setSyncBanner] = useState<{
connector: string;
startedAt: number;
} | null>(null);
const syncBannerTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const showSyncBanner = (connector: string) => {
if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current);
setSyncBanner({ connector, startedAt: Date.now() });
syncBannerTimer.current = setTimeout(() => setSyncBanner(null), SYNC_BANNER_TTL_MS);
};
const dismissSyncBanner = () => {
if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current);
setSyncBanner(null);
};
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
// user only commits if they actually paste a valid token; on cancel we delete it).
const [infomaniakModal, setInfomaniakModal] = useState<{
connectionId: string;
token: string;
submitting: boolean;
error: string | null;
} | null>(null);
// Initial fetch // Initial fetch
useEffect(() => { useEffect(() => {
@ -106,7 +138,8 @@ export const ConnectionsPage: React.FC = () => {
data.authority === 'local' || data.authority === 'local' ||
data.authority === 'google' || data.authority === 'google' ||
data.authority === 'msft' || data.authority === 'msft' ||
data.authority === 'clickup' data.authority === 'clickup' ||
data.authority === 'infomaniak'
) { ) {
updateData.authority = data.authority; updateData.authority = data.authority;
} else { } else {
@ -170,35 +203,90 @@ export const ConnectionsPage: React.FC = () => {
} }
}; };
// Guards prevent double-trigger while the OAuth popup is open, which would // Handle reconnect (full OAuth re-consent so newly added scopes -- e.g.
// otherwise create additional orphan PENDING connections on every click. // Calendar/Contacts -- are actually granted on top of existing tokens).
const handleCreateGoogle = async () => { const handleReconnect = async (connection: Connection) => {
if (isConnecting) return; setReconnectingConnections(prev => new Set(prev).add(connection.id));
try { try {
await createGoogleConnectionAndAuth(); await connectWithPopup(connection.id, true);
refetch(); refetch();
} catch (error) { } catch (error) {
console.error('Error creating Google connection:', error); console.error('Error reconnecting:', error);
} finally {
setReconnectingConnections(prev => {
const newSet = new Set(prev);
newSet.delete(connection.id);
return newSet;
});
} }
}; };
const handleCreateMicrosoft = async () => { const handleWizardConnect = async (
if (isConnecting) return; type: ConnectorType,
knowledgeEnabled: boolean,
knowledgePreferences: KnowledgePreferences | null,
) => {
try { try {
await createMicrosoftConnectionAndAuth(); await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences);
refetch(); refetch();
if (knowledgeEnabled) {
const LABELS: Record<ConnectorType, string> = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' };
showSyncBanner(LABELS[type] ?? type);
}
} catch (error) { } catch (error) {
console.error('Error creating Microsoft connection:', error); console.error('Error creating connection via wizard:', error);
} }
}; };
const handleCreateClickup = async () => { const handleCreateInfomaniak = async () => {
if (isConnecting) return; if (isConnecting || infomaniakModal) return;
try { try {
await createClickupConnectionAndAuth(); const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch(); refetch();
} catch (error) { } catch (error) {
console.error('Error creating ClickUp connection:', error); console.error('Error creating Infomaniak connection:', error);
}
};
const handleInfomaniakCancel = async () => {
if (!infomaniakModal) return;
const { connectionId, submitting } = infomaniakModal;
if (submitting) return;
setInfomaniakModal(null);
try {
await deleteConnection(connectionId);
refetch();
} catch (error) {
console.error('Error rolling back pending Infomaniak connection:', error);
}
};
const handleInfomaniakSubmit = async () => {
if (!infomaniakModal) return;
const trimmed = infomaniakModal.token.trim();
if (!trimmed) {
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
return;
}
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
try {
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
setInfomaniakModal(null);
refetch();
} catch (error: any) {
const detail =
error?.response?.data?.detail ||
error?.message ||
t('Token konnte nicht gespeichert werden');
setInfomaniakModal((prev) =>
prev ? { ...prev, submitting: false, error: String(detail) } : prev
);
} }
}; };
@ -252,7 +340,7 @@ export const ConnectionsPage: React.FC = () => {
<div> <div>
<h1 className={styles.pageTitle}>{t('Verbindungen')}</h1> <h1 className={styles.pageTitle}>{t('Verbindungen')}</h1>
<p className={styles.pageSubtitle}> <p className={styles.pageSubtitle}>
{t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)')} {t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp, Infomaniak)')}
</p> </p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
@ -273,34 +361,54 @@ export const ConnectionsPage: React.FC = () => {
</button> </button>
{canCreate && ( {canCreate && (
<> <>
<button <button
className={styles.googleButton} type="button"
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Google
</button>
<button
className={styles.primaryButton} className={styles.primaryButton}
onClick={handleCreateMicrosoft} onClick={() => setWizardOpen(true)}
disabled={isConnecting} disabled={isConnecting}
> >
<FaMicrosoft /> Microsoft <FaPlus /> {t('Verbindung hinzufügen')}
</button> </button>
<button <button
type="button" type="button"
className={styles.clickupButton} className={styles.secondaryButton}
onClick={handleCreateClickup} onClick={handleCreateInfomaniak}
disabled={isConnecting} disabled={isConnecting}
title={t('ClickUp-Konto verbinden (OAuth oder Personal Token nach Anmeldung)')} title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
> >
<FaTasks /> ClickUp <FaCloud /> Infomaniak
</button> </button>
</> </>
)} )}
</div> </div>
</div> </div>
{/* Sync-in-progress banner */}
{syncBanner && (
<div className={bannerStyles.syncBanner}>
<FaSpinner className={bannerStyles.syncSpinner} />
<div className={bannerStyles.syncText}>
<span className={bannerStyles.syncTitle}>
{t('Wissensdatenbank wird synchronisiert')}
</span>
<span className={bannerStyles.syncDetail}>
{t(
'Inhalte aus {connector} werden im Hintergrund indexiert. Das kann je nach Datenmenge einige Minuten dauern. Die Wissensdatenbank steht danach vollständig zur Verfügung.',
{ connector: syncBanner.connector },
)}
</span>
</div>
<button
type="button"
className={bannerStyles.syncDismiss}
onClick={dismissSyncBanner}
aria-label={t('Hinweis schließen')}
>
<FaTimes />
</button>
</div>
)}
<div className={styles.tableContainer}> <div className={styles.tableContainer}>
<FormGeneratorTable <FormGeneratorTable
data={connections} data={connections}
@ -339,9 +447,19 @@ export const ConnectionsPage: React.FC = () => {
icon: <FaRedo />, icon: <FaRedo />,
onClick: handleRefresh, onClick: handleRefresh,
title: t('Token aktualisieren'), title: t('Token aktualisieren'),
visible: (row: Connection) => row.status === 'active', visible: (row: Connection) => row.status === 'active' && (row.authority === 'msft' || row.authority === 'google'),
loading: (row: Connection) => refreshingConnections.has(row.id), loading: (row: Connection) => refreshingConnections.has(row.id),
}, },
{
id: 'reconnect',
icon: <FaSyncAlt />,
onClick: handleReconnect,
title: t('Erneut verbinden (neue Scopes erteilen)'),
visible: (row: Connection) =>
row.status === 'active' &&
(row.authority === 'msft' || row.authority === 'google' || row.authority === 'clickup'),
loading: (row: Connection) => reconnectingConnections.has(row.id),
},
]} ]}
onDelete={handleDelete} onDelete={handleDelete}
hookData={{ hookData={{
@ -390,6 +508,144 @@ export const ConnectionsPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Infomaniak Personal Access Token Modal */}
{infomaniakModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: 640 }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
<button
className={styles.modalClose}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
</button>
</div>
<div className={styles.modalContent}>
<p style={{ marginTop: 0 }}>
{t(
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
)}
</p>
<ol style={{ paddingLeft: 20 }}>
<li>
{t('Öffne den Infomaniak-Manager:')}{' '}
<a
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
target="_blank"
rel="noopener noreferrer"
>
manager.infomaniak.com API-Tokens
</a>
</li>
<li>
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
<code>PowerOn</code>.{' '}
{t('Application bleibt auf')} <code>Default application</code>.
</li>
<li>
{t('Suche im Scope-Feld nach')}{' '}
<strong>{t('allen vier')}</strong>{' '}
{t('Berechtigungen und kreuze sie an:')}
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
<li>
<code>drive</code> {t('kDrive (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:calendar</code> {' '}
{t('Kalender (Pflicht, heute aktiv)')}
</li>
<li>
<code>workspace:contact</code> {' '}
{t('Kontakte (heute aktiv)')}
</li>
<li>
<code>workspace:mail</code> {' '}
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
</li>
</ul>
<em>
{t(
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
)}
</em>
</li>
<li>
{t(
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
)}
</li>
</ol>
<input
type="password"
value={infomaniakModal.token}
onChange={(e) =>
setInfomaniakModal((prev) =>
prev ? { ...prev, token: e.target.value, error: null } : prev
)
}
placeholder={t('Personal Access Token einfügen')}
disabled={infomaniakModal.submitting}
autoFocus
style={{
width: '100%',
padding: '8px 10px',
fontFamily: 'monospace',
fontSize: 13,
border: '1px solid var(--border, #ccc)',
borderRadius: 4,
marginBottom: 12,
}}
/>
{infomaniakModal.error && (
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
{infomaniakModal.error}
</div>
)}
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
{t(
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
)}
</p>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: 8,
marginTop: 16,
}}
>
<button
type="button"
className={styles.secondaryButton}
onClick={handleInfomaniakCancel}
disabled={infomaniakModal.submitting}
>
{t('Abbrechen')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleInfomaniakSubmit}
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
>
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
</button>
</div>
</div>
</div>
</div>
)}
<AddConnectionWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect}
isConnecting={isConnecting}
/>
</div> </div>
); );
}; };

View file

@ -225,6 +225,20 @@ export const FilesPage: React.FC = () => {
maxWidth: 250, maxWidth: 250,
displayField: 'sysCreatedByLabel', displayField: 'sysCreatedByLabel',
} as any); } as any);
// sysModifiedAt is marked frontend_visible=false in PowerOnModel so it
// never reaches us via the /api/attributes endpoint - declare type
// explicitly so the FormGenerator renders it as a timestamp.
cols.push({
key: 'sysModifiedAt',
label: t('Geaendert am'),
type: 'timestamp',
sortable: true,
filterable: true,
searchable: false,
width: 170,
minWidth: 130,
maxWidth: 220,
} as any);
return resolveColumnTypes(cols, attributes || []); return resolveColumnTypes(cols, attributes || []);
}, [attributes, t]); }, [attributes, t]);