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

View file

@ -12,6 +12,7 @@ import {
updateConnection as updateConnectionApi, updateConnection as updateConnectionApi,
refreshMicrosoftToken as refreshMicrosoftTokenApi, refreshMicrosoftToken as refreshMicrosoftTokenApi,
refreshGoogleToken as refreshGoogleTokenApi, refreshGoogleToken as refreshGoogleTokenApi,
submitInfomaniakToken as submitInfomaniakTokenApi,
type Connection, type Connection,
type AttributeDefinition, type AttributeDefinition,
type PaginationParams, type PaginationParams,
@ -138,10 +139,12 @@ export function useConnections() {
} }
}; };
// Connect to a service (initiate OAuth) // Connect to a service (initiate OAuth). Pass reauth=true to force the
const connectService = async (connectionId: string): Promise<ConnectResponse> => { // provider's consent screen so newly added scopes (e.g. Calendar/Contacts)
// actually land on the access token instead of being silently skipped.
const connectService = async (connectionId: string, reauth: boolean = false): Promise<ConnectResponse> => {
try { try {
const data = await connectServiceApi(request, connectionId); const data = await connectServiceApi(request, connectionId, reauth);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error connecting service:', error); console.error('Error connecting service:', error);
@ -237,13 +240,13 @@ export function useConnections() {
}; };
// Connect with popup (OAuth flow) // Connect with popup (OAuth flow)
const connectWithPopup = async (connectionId: string): Promise<void> => { const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
setIsConnecting(true); setIsConnecting(true);
setConnectError(null); setConnectError(null);
try { try {
// Get the OAuth URL from backend // Get the OAuth URL from backend
const response = await connectService(connectionId); const response = await connectService(connectionId, reauth);
if (!response.authUrl) { if (!response.authUrl) {
throw new Error('No OAuth URL received from backend'); throw new Error('No OAuth URL received from backend');
} }
@ -295,8 +298,7 @@ export function useConnections() {
if ( if (
event.data.type === 'msft_connection_success' || event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' || event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success' || event.data.type === 'clickup_connection_success'
event.data.type === 'infomaniak_connection_success'
) { ) {
// Clean up // Clean up
clearInterval(checkClosed); clearInterval(checkClosed);
@ -310,8 +312,7 @@ export function useConnections() {
} else if ( } else if (
event.data.type === 'msft_connection_error' || event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' || event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error' || event.data.type === 'clickup_connection_error'
event.data.type === 'infomaniak_connection_error'
) { ) {
// Handle error // Handle error
clearInterval(checkClosed); clearInterval(checkClosed);
@ -497,82 +498,24 @@ export function useConnections() {
} }
}; };
// Create Infomaniak connection and open OAuth popup // Infomaniak uses Personal Access Tokens (no OAuth). Two-step flow:
const createInfomaniakConnectionAndAuth = async (): Promise<void> => { // 1. createInfomaniakConnection() - creates a PENDING UserConnection row
if (isConnecting) return; // 2. submitInfomaniakToken(connectionId, pat) - validates the PAT against
setIsConnecting(true); // /1/profile, persists it as the connection's bearer token, and flips
try { // the row to ACTIVE.
const newConnection = await createConnection({ const createInfomaniakConnection = async (): Promise<Connection> => {
type: 'infomaniak', return await createConnection({
authority: 'infomaniak', type: 'infomaniak',
}); authority: 'infomaniak',
});
};
const connectResponse = await connectServiceApi(request, newConnection.id); const submitInfomaniakToken = async (
connectionId: string,
if (!connectResponse.authUrl) { token: string
throw new Error('No OAuth URL received from backend'); ): Promise<void> => {
} await submitInfomaniakTokenApi(request, connectionId, token);
await fetchConnections();
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;
}
}; };
// Create Microsoft connection and open OAuth popup // Create Microsoft connection and open OAuth popup
@ -781,7 +724,8 @@ export function useConnections() {
createGoogleConnectionAndAuth, createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth, createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth, createClickupConnectionAndAuth,
createInfomaniakConnectionAndAuth, createInfomaniakConnection,
submitInfomaniakToken,
isLoading, isLoading,
loading: isLoading, // Alias for FormGenerator compatibility loading: isLoading, // Alias for FormGenerator compatibility
isConnecting, isConnecting,
@ -807,13 +751,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');
} }
@ -866,8 +810,7 @@ export function useOAuthConnect() {
if ( if (
event.data.type === 'msft_connection_success' || event.data.type === 'msft_connection_success' ||
event.data.type === 'google_connection_success' || event.data.type === 'google_connection_success' ||
event.data.type === 'clickup_connection_success' || event.data.type === 'clickup_connection_success'
event.data.type === 'infomaniak_connection_success'
) { ) {
// Clean up - IMPORTANT: clear the checkClosed interval first // Clean up - IMPORTANT: clear the checkClosed interval first
clearInterval(checkClosed); clearInterval(checkClosed);
@ -881,8 +824,7 @@ export function useOAuthConnect() {
} else if ( } else if (
event.data.type === 'msft_connection_error' || event.data.type === 'msft_connection_error' ||
event.data.type === 'google_connection_error' || event.data.type === 'google_connection_error' ||
event.data.type === 'clickup_connection_error' || event.data.type === 'clickup_connection_error'
event.data.type === 'infomaniak_connection_error'
) { ) {
// Handle error - also clear the checkClosed interval // Handle error - also clear the checkClosed interval
clearInterval(checkClosed); clearInterval(checkClosed);

View file

@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect } 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, 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 { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css'; import styles from '../admin/Admin.module.css';
@ -35,7 +35,8 @@ export const ConnectionsPage: React.FC = () => {
createGoogleConnectionAndAuth, createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth, createMicrosoftConnectionAndAuth,
createClickupConnectionAndAuth, createClickupConnectionAndAuth,
createInfomaniakConnectionAndAuth, createInfomaniakConnection,
submitInfomaniakToken,
connectWithPopup, connectWithPopup,
refreshMicrosoftToken, refreshMicrosoftToken,
refreshGoogleToken, refreshGoogleToken,
@ -45,8 +46,18 @@ 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);
// 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(() => {
refetch(); 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 // Guards prevent double-trigger while the OAuth popup is open, which would
// otherwise create additional orphan PENDING connections on every click. // otherwise create additional orphan PENDING connections on every click.
const handleCreateGoogle = async () => { const handleCreateGoogle = async () => {
@ -205,15 +234,57 @@ export const ConnectionsPage: React.FC = () => {
}; };
const handleCreateInfomaniak = async () => { const handleCreateInfomaniak = async () => {
if (isConnecting) return; if (isConnecting || infomaniakModal) return;
try { try {
await createInfomaniakConnectionAndAuth(); const newConnection = await createInfomaniakConnection();
setInfomaniakModal({
connectionId: newConnection.id,
token: '',
submitting: false,
error: null,
});
refetch(); refetch();
} catch (error) { } catch (error) {
console.error('Error creating Infomaniak 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
);
}
};
// Open Microsoft Admin Consent flow in a popup // Open Microsoft Admin Consent flow in a popup
const handleAdminConsent = () => { const handleAdminConsent = () => {
setAdminConsentPending(true); setAdminConsentPending(true);
@ -360,9 +431,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={{
@ -411,6 +492,137 @@ 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>
)}
</div> </div>
); );
}; };

View file

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