o365 integration v1
This commit is contained in:
parent
b7da3b3ffc
commit
2f9218ce0e
|
|
@ -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 duration in hours (default: 24, use 168 for 7 days)
|
||||||
# AI_LOCK_EXPIRY_HOURS=168
|
# 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:
|
# Client needs VITE_ prefix — create client/.env with:
|
||||||
# VITE_AZURE_CLIENT_ID=<same as above>
|
# VITE_AZURE_CLIENT_ID=<same as above>
|
||||||
# VITE_AZURE_TENANT_ID=<same as above>
|
# VITE_AZURE_TENANT_ID=<same as above>
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte';
|
import { useUnsavedGuard } from '$lib/utils/unsavedGuard.svelte';
|
||||||
import EventCard from './EventCard.svelte';
|
import EventCard from './EventCard.svelte';
|
||||||
import { eventsForDate } from '$lib/stores/agenda';
|
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 {
|
interface Props {
|
||||||
contextId: string;
|
contextId: string;
|
||||||
|
|
@ -211,6 +213,47 @@
|
||||||
let newEventTitle = $state('');
|
let newEventTitle = $state('');
|
||||||
let newEventTime = $state('');
|
let newEventTime = $state('');
|
||||||
let newEventParticipants = $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));
|
const dateEvents = $derived(eventsForDate(selectedDate));
|
||||||
|
|
||||||
|
|
@ -236,10 +279,14 @@
|
||||||
const title = newEventTitle.trim();
|
const title = newEventTitle.trim();
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
const participants = parseEventParticipants(newEventParticipants);
|
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 = '';
|
newEventTitle = '';
|
||||||
newEventTime = '';
|
newEventTime = '';
|
||||||
newEventParticipants = '';
|
newEventParticipants = '';
|
||||||
|
pendingBodyPreview = '';
|
||||||
showNewEventForm = false;
|
showNewEventForm = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -380,8 +427,41 @@
|
||||||
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
|
class="rounded bg-[#444] px-3 py-1 text-sm text-white hover:bg-[#555]"
|
||||||
onclick={() => (showNewEventForm = false)}
|
onclick={() => (showNewEventForm = false)}
|
||||||
>Abbrechen</button>
|
>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>
|
||||||
</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}
|
{/if}
|
||||||
{#each $dateEvents ?? [] as event (event.id)}
|
{#each $dateEvents ?? [] as event (event.id)}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
@ -16,6 +16,7 @@ import pushRoutes from './routes/push.js';
|
||||||
import backupRoutes from './routes/backup.js';
|
import backupRoutes from './routes/backup.js';
|
||||||
import apiKeyRoutes from './routes/api-keys.js';
|
import apiKeyRoutes from './routes/api-keys.js';
|
||||||
import searchRoutes from './routes/search.js';
|
import searchRoutes from './routes/search.js';
|
||||||
|
import calendarRoutes from './routes/calendar.js';
|
||||||
import { runScheduledBackup, runIfMissed, checkIntegrity } from './lib/backup-service.js';
|
import { runScheduledBackup, runIfMissed, checkIntegrity } from './lib/backup-service.js';
|
||||||
import { sqlite } from './db/connection.js';
|
import { sqlite } from './db/connection.js';
|
||||||
|
|
||||||
|
|
@ -100,6 +101,9 @@ app.use('/api/search/*', authMiddleware);
|
||||||
app.use('/api/search', authMiddleware);
|
app.use('/api/search', authMiddleware);
|
||||||
app.route('/api/search', searchRoutes);
|
app.route('/api/search', searchRoutes);
|
||||||
|
|
||||||
|
app.use('/api/calendar/*', authMiddleware);
|
||||||
|
app.route('/api/calendar', calendarRoutes);
|
||||||
|
|
||||||
// OpenAPI spec + Scalar UI
|
// OpenAPI spec + Scalar UI
|
||||||
app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', {
|
app.openAPIRegistry.registerComponent('securitySchemes', 'BearerAuth', {
|
||||||
type: 'http',
|
type: 'http',
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue