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_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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 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',
|
||||
|
|
|
|||
|
|
@ -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