Compare commits
11 commits
2994f3a090
...
b61544d8b1
| Author | SHA1 | Date | |
|---|---|---|---|
| b61544d8b1 | |||
|
|
26958d1e16 | ||
|
|
28951a7d22 | ||
|
|
9e08953c44 | ||
|
|
a0c2323fe6 | ||
|
|
34d6c2b83d | ||
|
|
3f80d6d434 | ||
|
|
3016806db9 | ||
|
|
974c48e24d | ||
|
|
fe857d5ade | ||
|
|
a9e8e8cddd |
10 changed files with 1581 additions and 52 deletions
|
|
@ -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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
521
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal file
521
src/components/AddConnectionWizard/AddConnectionWizard.tsx
Normal 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!]}
|
||||||
|
{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;
|
||||||
|
|
@ -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 ───────────────────────────────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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[]>([]);
|
||||||
|
|
|
||||||
90
src/pages/basedata/ConnectionsPage.module.css
Normal file
90
src/pages/basedata/ConnectionsPage.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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]);
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue