Merge pull request #65 from valueonag/feat/demo-system-readieness

Feat/demo system readieness
This commit is contained in:
Patrick Motsch 2026-04-29 01:56:07 +02:00 committed by GitHub
commit 26958d1e16
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 305 additions and 98 deletions

View file

@ -136,14 +136,20 @@ export async function createConnection(
/**
* Connect to a service (initiate OAuth)
* 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(
request: ApiRequestFunction,
connectionId: string
connectionId: string,
reauth: boolean = false
): Promise<ConnectResponse> {
return await request({
url: `/api/connections/${connectionId}/connect`,
method: 'post'
method: 'post',
data: reauth ? { reauth: true } : undefined,
});
}
@ -221,3 +227,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

@ -142,6 +142,8 @@ const _SERVICE_ICONS: Record<string, string> = {
clickup: '\uD83D\uDCCB',
kdrive: '\uD83D\uDCC2',
mail: '\uD83D\uDCE7',
calendar: '\uD83D\uDCC5',
contact: '\uD83D\uDC64',
};
/* ─── Source colors & icons ──────────────────────────────────────────── */
@ -166,6 +168,10 @@ const _SOURCE_COLORS: Record<string, string> = {
kdrive: '#0098FF',
mailFolder: '#0098FF',
mail: '#0098FF',
calendarFolder: '#0098FF',
calendar: '#0098FF',
contactFolder: '#0098FF',
contact: '#0098FF',
};
function _getSourceColor(sourceType: string): string {
@ -199,6 +205,8 @@ const _SERVICE_TO_SOURCE_TYPE: Record<string, string> = {
clickup: 'clickup',
kdrive: 'kdriveFolder',
mail: 'mailFolder',
calendar: 'calendarFolder',
contact: 'contactFolder',
};
/* ─── Tree helpers ───────────────────────────────────────────────────── */

View file

@ -12,6 +12,7 @@ import {
updateConnection as updateConnectionApi,
refreshMicrosoftToken as refreshMicrosoftTokenApi,
refreshGoogleToken as refreshGoogleTokenApi,
submitInfomaniakToken as submitInfomaniakTokenApi,
type Connection,
type AttributeDefinition,
type PaginationParams,
@ -138,10 +139,12 @@ export function useConnections() {
}
};
// Connect to a service (initiate OAuth)
const connectService = async (connectionId: string): Promise<ConnectResponse> => {
// Connect to a service (initiate OAuth). Pass reauth=true to force the
// 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 {
const data = await connectServiceApi(request, connectionId);
const data = await connectServiceApi(request, connectionId, reauth);
return data;
} catch (error) {
console.error('Error connecting service:', error);
@ -237,13 +240,13 @@ export function useConnections() {
};
// Connect with popup (OAuth flow)
const connectWithPopup = async (connectionId: string): Promise<void> => {
const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true);
setConnectError(null);
try {
// Get the OAuth URL from backend
const response = await connectService(connectionId);
const response = await connectService(connectionId, reauth);
if (!response.authUrl) {
throw new Error('No OAuth URL received from backend');
}
@ -295,8 +298,7 @@ export function useConnections() {
if (
event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success' ||
event.data.type === 'infomaniak_connection_success'
event.data.type === 'clickup_connection_success'
) {
// Clean up
clearInterval(checkClosed);
@ -310,8 +312,7 @@ export function useConnections() {
} else if (
event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error' ||
event.data.type === 'infomaniak_connection_error'
event.data.type === 'clickup_connection_error'
) {
// Handle error
clearInterval(checkClosed);
@ -497,82 +498,24 @@ export function useConnections() {
}
};
// Create Infomaniak connection and open OAuth popup
const createInfomaniakConnectionAndAuth = async (): Promise<void> => {
if (isConnecting) return;
setIsConnecting(true);
try {
const newConnection = await createConnection({
// 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 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,
'infomaniak-connection',
'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 checkClosed = setInterval(() => {
if (popup.closed) {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
setIsConnecting(false);
console.log('Infomaniak OAuth popup closed');
fetchConnections();
resolve();
}
}, 1000);
const messageListener = (event: MessageEvent) => {
const apiUrl = new URL(apiBaseUrl);
if (event.origin !== apiUrl.origin) {
return;
}
if (event.data.type === 'infomaniak_connection_success') {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
console.log('Infomaniak connection successful');
fetchConnections();
resolve();
} else if (event.data.type === 'infomaniak_connection_error') {
clearInterval(checkClosed);
window.removeEventListener('message', messageListener);
popup.close();
setIsConnecting(false);
reject(new Error(event.data.error || 'Infomaniak connection failed'));
}
};
window.addEventListener('message', messageListener);
});
} catch (error) {
setIsConnecting(false);
console.error('Error creating Infomaniak connection:', error);
throw error;
}
const submitInfomaniakToken = async (
connectionId: string,
token: string
): Promise<void> => {
await submitInfomaniakTokenApi(request, connectionId, token);
await fetchConnections();
};
// Create Microsoft connection and open OAuth popup
@ -781,7 +724,8 @@ export function useConnections() {
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
createInfomaniakConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
isLoading,
loading: isLoading, // Alias for FormGenerator compatibility
isConnecting,
@ -807,13 +751,13 @@ export function useOAuthConnect() {
const [isConnecting, setIsConnecting] = useState(false);
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);
setConnectError(null);
try {
// Get the OAuth URL from backend
const response = await connectService(connectionId);
const response = await connectService(connectionId, reauth);
if (!response.authUrl) {
throw new Error('No OAuth URL received from backend');
}
@ -866,8 +810,7 @@ export function useOAuthConnect() {
if (
event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success' ||
event.data.type === 'infomaniak_connection_success'
event.data.type === 'clickup_connection_success'
) {
// Clean up - IMPORTANT: clear the checkClosed interval first
clearInterval(checkClosed);
@ -881,8 +824,7 @@ export function useOAuthConnect() {
} else if (
event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error' ||
event.data.type === 'infomaniak_connection_error'
event.data.type === 'clickup_connection_error'
) {
// Handle error - also clear the checkClosed interval
clearInterval(checkClosed);

View file

@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect } from 'react';
import { useConnections, type Connection } from '../../hooks/useConnections';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud } from 'react-icons/fa';
import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud, FaSyncAlt } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
@ -35,7 +35,8 @@ export const ConnectionsPage: React.FC = () => {
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth,
createInfomaniakConnectionAndAuth,
createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
@ -45,8 +46,18 @@ export const ConnectionsPage: React.FC = () => {
const [editingConnection, setEditingConnection] = useState<Connection | null>(null);
const [deletingConnections, setDeletingConnections] = 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);
// 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
useEffect(() => {
refetch();
@ -172,6 +183,24 @@ export const ConnectionsPage: React.FC = () => {
}
};
// Handle reconnect (full OAuth re-consent so newly added scopes -- e.g.
// Calendar/Contacts -- are actually granted on top of existing tokens).
const handleReconnect = async (connection: Connection) => {
setReconnectingConnections(prev => new Set(prev).add(connection.id));
try {
await connectWithPopup(connection.id, true);
refetch();
} catch (error) {
console.error('Error reconnecting:', error);
} finally {
setReconnectingConnections(prev => {
const newSet = new Set(prev);
newSet.delete(connection.id);
return newSet;
});
}
};
// Guards prevent double-trigger while the OAuth popup is open, which would
// otherwise create additional orphan PENDING connections on every click.
const handleCreateGoogle = async () => {
@ -205,15 +234,57 @@ export const ConnectionsPage: React.FC = () => {
};
const handleCreateInfomaniak = async () => {
if (isConnecting) return;
if (isConnecting || infomaniakModal) return;
try {
await createInfomaniakConnectionAndAuth();
const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch();
} catch (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
);
}
};
// Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => {
setAdminConsentPending(true);
@ -360,9 +431,19 @@ export const ConnectionsPage: React.FC = () => {
icon: <FaRedo />,
onClick: handleRefresh,
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),
},
{
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}
hookData={{
@ -411,6 +492,137 @@ export const ConnectionsPage: React.FC = () => {
</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>
)}
</div>
);
};

View file

@ -225,6 +225,20 @@ export const FilesPage: React.FC = () => {
maxWidth: 250,
displayField: 'sysCreatedByLabel',
} 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 || []);
}, [attributes, t]);