o365 integration v1

This commit is contained in:
beo3000 2026-02-26 22:17:28 +01:00
parent b7da3b3ffc
commit 2f9218ce0e
8 changed files with 255 additions and 2 deletions

View File

@ -7,6 +7,14 @@ AZURE_TENANT_ID=<azure-ad-tenant-id>
# AI lock duration in hours (default: 24, use 168 for 7 days)
# AI_LOCK_EXPIRY_HOURS=168
# Graph API calendar integration (OBO flow)
# AZURE_CLIENT_SECRET=<client-secret-value>
# AZURE_OBO_CLIENT_ID=<client-id of the app registration the frontend uses>
# Only needed if VITE_AZURE_CLIENT_ID != AZURE_CLIENT_ID (separate frontend/backend registrations).
# The OBO assertion token's audience must match this client_id.
# Required: App Registration → API permissions → Microsoft Graph → Calendars.Read (delegated) → Grant admin consent
# Then: Certificates & secrets → New client secret → copy value here
# Client needs VITE_ prefix — create client/.env with:
# VITE_AZURE_CLIENT_ID=<same as above>
# VITE_AZURE_TENANT_ID=<same as above>

View File

@ -16,7 +16,9 @@
import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte';
import EventCard from './EventCard.svelte';
import { eventsForDate } from '$lib/stores/agenda';
import { createEvent } from '$lib/db/repositories';
import { createEvent, updateEventNotes } from '$lib/db/repositories';
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
import type { PersonMeta } from '@ka-note/shared';
interface Props {
contextId: string;
@ -211,6 +213,47 @@
let newEventTitle = $state('');
let newEventTime = $state('');
let newEventParticipants = $state('');
let pendingBodyPreview = $state('');
// Calendar picker
let calendarPickerOpen = $state(false);
let calendarLoading = $state(false);
let calendarError = $state<string | null>(null);
let calendarEvents = $state<CalendarEvent[]>([]);
async function openCalendarPicker() {
if (!showNewEventForm) openNewEventForm();
calendarLoading = true;
calendarError = null;
calendarPickerOpen = true;
calendarEvents = [];
try {
calendarEvents = await fetchCalendarEvents(selectedDate);
} catch {
calendarError = 'Kalender nicht verfügbar';
} finally {
calendarLoading = false;
}
}
async function selectCalendarEvent(ev: CalendarEvent) {
newEventTitle = ev.subject;
newEventTime = ev.start;
pendingBodyPreview = ev.bodyPreview;
const persons = await db.contexts
.filter(c => !c.deletedAt && c.type === 'person')
.toArray();
const mentions = ev.attendees.map(att => {
const match = persons.find(
p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase()
);
const name = match ? match.name : att.name;
return name.includes(' ') ? `@"${name}"` : `@${name}`;
});
newEventParticipants = mentions.join(' ');
calendarPickerOpen = false;
}
const dateEvents = $derived(eventsForDate(selectedDate));
@ -236,10 +279,14 @@
const title = newEventTitle.trim();
if (!title) return;
const participants = parseEventParticipants(newEventParticipants);
await createEvent(selectedDate, newEventTime || '00:00', title, participants);
const event = await createEvent(selectedDate, newEventTime || '00:00', title, participants);
if (pendingBodyPreview.trim()) {
await updateEventNotes(event.id, pendingBodyPreview.trim());
}
newEventTitle = '';
newEventTime = '';
newEventParticipants = '';
pendingBodyPreview = '';
showNewEventForm = false;
}
@ -380,8 +427,41 @@
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
onclick={() => (showNewEventForm = false)}
>Abbrechen</button>
<button
type="button"
class="ml-auto rounded border border-[#444] px-3 py-1 text-sm text-muted hover:border-[#666] hover:text-white"
onclick={openCalendarPicker}
>Aus Kalender</button>
</div>
</div>
{#if calendarPickerOpen}
<div class="mb-3 rounded border border-[#444] bg-[#1e1e1e] shadow-lg">
{#if calendarLoading}
<div class="px-3 py-2 text-sm text-muted">Lade...</div>
{:else if calendarError}
<div class="px-3 py-2 text-sm text-red-400">{calendarError}</div>
{:else if calendarEvents.length === 0}
<div class="px-3 py-2 text-sm text-muted">Keine Termine für diesen Tag</div>
{:else}
{#each calendarEvents as ev (ev.id)}
<button
class="w-full px-3 py-2 text-left text-sm hover:bg-[#2a2a2a]"
onclick={() => selectCalendarEvent(ev)}
>
<span class="font-mono text-[#aaa]">{ev.start}</span>
<span class="ml-2 text-white">{ev.subject}</span>
{#if ev.attendees.length > 0}
<span class="ml-2 text-xs text-muted">{ev.attendees.map(a => a.name).join(', ')}</span>
{/if}
</button>
{/each}
{/if}
<button
class="w-full border-t border-[#333] px-3 py-1.5 text-left text-xs text-muted hover:bg-[#2a2a2a]"
onclick={() => (calendarPickerOpen = false)}
>Schließen</button>
</div>
{/if}
{/if}
{#each $dateEvents ?? [] as event (event.id)}
<div class="mb-2">

View File

@ -0,0 +1,16 @@
import { authFetch } from '$lib/auth/apiClient';
export interface CalendarEvent {
id: string;
subject: string;
start: string; // "HH:MM"
end: string; // "HH:MM"
bodyPreview: string;
attendees: { name: string; email: string }[];
}
export async function fetchCalendarEvents(date: string): Promise<CalendarEvent[]> {
const res = await authFetch(`/api/calendar/events?date=${encodeURIComponent(date)}`);
if (!res.ok) throw new Error(`calendar fetch failed: ${res.status}`);
return res.json() as Promise<CalendarEvent[]>;
}

Binary file not shown.

Binary file not shown.

View File

@ -16,6 +16,7 @@ import pushRoutes from './routes/push.js';
import backupRoutes from './routes/backup.js';
import apiKeyRoutes from './routes/api-keys.js';
import searchRoutes from './routes/search.js';
import calendarRoutes from './routes/calendar.js';
import { runScheduledBackup, runIfMissed, checkIntegrity } from './lib/backup-service.js';
import { sqlite } from './db/connection.js';
@ -100,6 +101,9 @@ app.use('/api/search/*', authMiddleware);
app.use('/api/search', authMiddleware);
app.route('/api/search', searchRoutes);
app.use('/api/calendar/*', authMiddleware);
app.route('/api/calendar', calendarRoutes);
// OpenAPI spec + Scalar UI
app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', {
type: 'http',

View File

@ -0,0 +1,118 @@
export interface CalendarEvent {
id: string;
subject: string;
start: string; // "HH:MM"
end: string; // "HH:MM"
bodyPreview: string;
attendees: { name: string; email: string }[];
}
interface OboTokenEntry {
accessToken: string;
expiresAt: number; // ms
}
const tokenCache = new Map<string, OboTokenEntry>();
const tenantId = process.env.AZURE_TENANT_ID ?? '';
// OBO client_id must match the audience of the client's access token.
// Use AZURE_OBO_CLIENT_ID if the frontend uses a different app registration than the server.
const oboClientId = process.env.AZURE_OBO_CLIENT_ID ?? process.env.AZURE_CLIENT_ID ?? '';
const clientSecret = process.env.AZURE_CLIENT_SECRET ?? '';
async function getOboToken(userToken: string, userId: string): Promise<string> {
const cached = tokenCache.get(userId);
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.accessToken;
}
const body = new URLSearchParams({
client_id: oboClientId,
client_secret: clientSecret,
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: userToken,
requested_token_use: 'on_behalf_of',
scope: 'https://graph.microsoft.com/Calendars.Read',
});
const res = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body },
);
if (!res.ok) {
const detail = await res.text();
throw new Error(`OBO token exchange failed (${res.status}): ${detail}`);
}
const json = await res.json() as { access_token: string; expires_in: number };
const entry: OboTokenEntry = {
accessToken: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000,
};
tokenCache.set(userId, entry);
return entry.accessToken;
}
function toHHMM(dateTime: string): string {
// Graph returns "2026-02-26T09:30:00.0000000" (local TZ, no Z)
return dateTime.slice(11, 16);
}
export async function getCalendarEvents(
userToken: string,
userId: string,
userEmail: string,
date: string,
): Promise<CalendarEvent[]> {
const graphToken = await getOboToken(userToken, userId);
const start = `${date}T00:00:00`;
const end = `${date}T23:59:59`;
const select = 'id,subject,start,end,bodyPreview,attendees,isAllDay';
const url =
`https://graph.microsoft.com/v1.0/me/calendarView` +
`?startDateTime=${encodeURIComponent(start)}` +
`&endDateTime=${encodeURIComponent(end)}` +
`&$select=${select}` +
`&$top=50`;
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${graphToken}`,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`Graph calendarView failed (${res.status}): ${detail}`);
}
type GraphEvent = {
id: string;
subject: string;
start: { dateTime: string };
end: { dateTime: string };
bodyPreview: string;
isAllDay: boolean;
attendees: { emailAddress: { name: string; address: string }; type: string }[];
};
const data = await res.json() as { value: GraphEvent[] };
return data.value
.filter((e) => !e.isAllDay)
.map((e) => ({
id: e.id,
subject: e.subject ?? '',
start: toHHMM(e.start.dateTime),
end: toHHMM(e.end.dateTime),
bodyPreview: e.bodyPreview ?? '',
attendees: (e.attendees ?? [])
.filter((a) => a.emailAddress.address.toLowerCase() !== userEmail.toLowerCase())
.map((a) => ({ name: a.emailAddress.name, email: a.emailAddress.address })),
}))
.sort((a, b) => a.start.localeCompare(b.start));
}

View File

@ -0,0 +1,27 @@
import { Hono } from 'hono';
import type { AuthEnv } from '../middleware/auth.js';
import { getCalendarEvents } from '../lib/graph-service.js';
const calendar = new Hono<AuthEnv>();
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
calendar.get('/events', async (c) => {
const date = c.req.query('date') ?? '';
if (!DATE_RE.test(date)) {
return c.json({ error: 'date param required (YYYY-MM-DD)' }, 400);
}
const auth = c.get('auth');
const rawToken = c.req.header('Authorization')?.slice(7) ?? '';
try {
const events = await getCalendarEvents(rawToken, auth.userId, auth.email, date);
return c.json(events);
} catch (err) {
console.error('[calendar] Graph error:', err instanceof Error ? err.message : err);
return c.json({ error: 'graph_unavailable', detail: err instanceof Error ? err.message : String(err) }, 502);
}
});
export default calendar;