upd cal import

This commit is contained in:
beo3000 2026-02-27 17:12:23 +01:00
parent b6f35431df
commit 41befde281
9 changed files with 43 additions and 57 deletions

View File

@ -7,12 +7,11 @@ AI_LOCK_EXPIRY_HOURS=168
AZURE_CLIENT_ID=<server-app-registration-client-id> AZURE_CLIENT_ID=<server-app-registration-client-id>
AZURE_TENANT_ID=<azure-ad-tenant-id> AZURE_TENANT_ID=<azure-ad-tenant-id>
# Graph / OBO — required for calendar integration # Graph — app-only calendar access (client credentials, independent of user auth)
# App Registration → API permissions → Graph → Calendars.Read (delegated) → grant admin consent # App Registration → API permissions → Graph → Calendars.Read (Application) → grant admin consent
# App Registration → Certificates & secrets → New client secret # App Registration → Certificates & secrets → New client secret
AZURE_CLIENT_SECRET=<client-secret-value> AZURE_GRAPH_CLIENT_ID=<graph-app-registration-client-id>
# aud of client token must match this. Only needed if frontend uses a different app registration. AZURE_GRAPH_CLIENT_SECRET=<graph-client-secret-value>
AZURE_OBO_CLIENT_ID=<frontend-app-registration-client-id>
# ── CLIENT (Vite — copy relevant lines to client/.env) ─────────────────────── # ── CLIENT (Vite — copy relevant lines to client/.env) ───────────────────────
# VITE_AZURE_CLIENT_ID=<frontend-app-registration-client-id> # VITE_AZURE_CLIENT_ID=<frontend-app-registration-client-id>

View File

@ -109,9 +109,10 @@ function showCreatePopup(name: string, type: 'person' | 'project' | 'company', x
btnRow.style.cssText = 'display: flex; gap: 6px;'; btnRow.style.cssText = 'display: flex; gap: 6px;';
async function create(asType: 'person' | 'project' | 'company') { async function create(asType: 'person' | 'project' | 'company') {
const slug = name.toLowerCase().replace(/\s+/g, '-'); const displayName = name.replace(/_/g, ' ');
const slug = displayName.toLowerCase().replace(/\s+/g, '-');
const id = asType === 'company' ? `f-${slug}` : asType === 'person' ? `u-${slug}` : `p-${slug}`; const id = asType === 'company' ? `f-${slug}` : asType === 'person' ? `u-${slug}` : `p-${slug}`;
const contextName = asType === 'company' ? `Firma ${name}` : asType === 'person' ? `Person ${name}` : `Project ${name}`; const contextName = asType === 'company' ? `Firma ${displayName}` : asType === 'person' ? `Person ${displayName}` : `Project ${displayName}`;
const meta = asType === 'company' const meta = asType === 'company'
? { website: '', address: '' } ? { website: '', address: '' }
: asType === 'person' : asType === 'person'
@ -156,7 +157,7 @@ function showCreatePopup(name: string, type: 'person' | 'project' | 'company', x
}; };
} }
async function handlePersonClick(name: string, event: MouseEvent, sourceEl: HTMLElement) { export async function handlePersonClick(name: string, event: MouseEvent, sourceEl: HTMLElement) {
const ctx = await findContextByMentionName(name, 'person'); const ctx = await findContextByMentionName(name, 'person');
if (ctx) { if (ctx) {
const isEmployee = (ctx.meta as Record<string, unknown> | null)?.personSubType === 'employee'; const isEmployee = (ctx.meta as Record<string, unknown> | null)?.personSubType === 'employee';

View File

@ -5,9 +5,10 @@
import { db } from '$lib/db/schema'; import { db } from '$lib/db/schema';
import MarkdownEditor from './MarkdownEditor.svelte'; import MarkdownEditor from './MarkdownEditor.svelte';
import RenderedMarkdown from './RenderedMarkdown.svelte'; import RenderedMarkdown from './RenderedMarkdown.svelte';
import { updateEvent, updateEventNotes, softDeleteContext, findContextByMentionName } from '$lib/db/repositories'; import { updateEvent, updateEventNotes, softDeleteContext } from '$lib/db/repositories';
import { notesTopicId } from '$lib/db/repositories'; import { notesTopicId } from '$lib/db/repositories';
import { mention } from '$lib/actions/mention'; import { mention } from '$lib/actions/mention';
import { handlePersonClick } from '$lib/actions/refClick';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
interface Props { interface Props {
@ -86,10 +87,6 @@
editingNotes = false; editingNotes = false;
} }
async function navigateToPerson(name: string) {
const ctx = await findContextByMentionName(name, 'person');
if (ctx) goto(`/context/${ctx.id}`);
}
async function handleDelete() { async function handleDelete() {
if (confirm(`Meeting "${event.name}" löschen?`)) { if (confirm(`Meeting "${event.name}" löschen?`)) {
@ -172,7 +169,7 @@
{#each meta.participants as p} {#each meta.participants as p}
<button <button
class="rounded-full bg-[#2a2a2a] px-2 py-0.5 text-xs text-[#aaa] hover:bg-accent/20 hover:text-accent transition-colors" class="rounded-full bg-[#2a2a2a] px-2 py-0.5 text-xs text-[#aaa] hover:bg-accent/20 hover:text-accent transition-colors"
onclick={(e) => { e.stopPropagation(); navigateToPerson(p); }} onclick={(e) => { e.stopPropagation(); handlePersonClick(p, e, e.currentTarget as HTMLElement); }}
title="Zur Person navigieren" title="Zur Person navigieren"
>{p}</button> >{p}</button>
{/each} {/each}

View File

@ -18,6 +18,7 @@
import { eventsForDate } from '$lib/stores/agenda'; import { eventsForDate } from '$lib/stores/agenda';
import { createEvent, updateEventNotes } from '$lib/db/repositories'; import { createEvent, updateEventNotes } from '$lib/db/repositories';
import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi'; import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi';
import { extractMentionName, quoteMention } from '$lib/actions/mentionCore';
import type { PersonMeta } from '@ka-note/shared'; import type { PersonMeta } from '@ka-note/shared';
interface Props { interface Props {
@ -248,8 +249,12 @@
const match = persons.find( const match = persons.find(
p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase() p => (p.meta as PersonMeta | null)?.email?.toLowerCase() === att.email.toLowerCase()
); );
const name = match ? match.name : att.name; if (match) {
return name.includes(' ') ? `@"${name}"` : `@${name}`; return quoteMention('@', extractMentionName(match));
}
// Strip company suffix e.g. "Lars Leifer (KRAH)" → "Lars Leifer"
const cleaned = att.name.replace(/\s*\(.*?\)\s*$/, '').trim();
return quoteMention('@', cleaned);
}); });
newEventParticipants = mentions.join(' '); newEventParticipants = mentions.join(' ');
calendarPickerOpen = false; calendarPickerOpen = false;

Binary file not shown.

Binary file not shown.

View File

@ -7,41 +7,33 @@ export interface CalendarEvent {
attendees: { name: string; email: string }[]; attendees: { name: string; email: string }[];
} }
interface OboTokenEntry { interface AppTokenEntry {
accessToken: string; accessToken: string;
expiresAt: number; // ms expiresAt: number; // ms
} }
const tokenCache = new Map<string, OboTokenEntry>(); let appTokenCache: AppTokenEntry | null = null;
const tenantId = process.env.AZURE_TENANT_ID ?? ''; const tenantId = process.env.AZURE_TENANT_ID ?? '';
// OBO client_id must match the audience of the client's access token. const graphClientId = process.env.AZURE_GRAPH_CLIENT_ID ?? '';
// Use AZURE_OBO_CLIENT_ID if the frontend uses a different app registration than the server. const graphClientSecret = process.env.AZURE_GRAPH_CLIENT_SECRET ?? '';
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> { async function getAppToken(): Promise<string> {
console.log(`[graph] getOboToken userId=${userId} tenantId=${tenantId || '(missing)'} oboClientId=${oboClientId || '(missing)'} secretSet=${!!clientSecret}`); if (appTokenCache && appTokenCache.expiresAt > Date.now() + 60_000) {
const cached = tokenCache.get(userId); return appTokenCache.accessToken;
if (cached && cached.expiresAt > Date.now() + 60_000) {
console.log(`[graph] OBO token from cache, expires in ${Math.round((cached.expiresAt - Date.now()) / 1000)}s`);
return cached.accessToken;
} }
if (!tenantId || !oboClientId || !clientSecret) { if (!tenantId || !graphClientId || !graphClientSecret) {
throw new Error(`OBO config incomplete: tenantId=${!!tenantId} oboClientId=${!!oboClientId} secret=${!!clientSecret}`); throw new Error(`Graph config incomplete: tenantId=${!!tenantId} clientId=${!!graphClientId} secret=${!!graphClientSecret}`);
} }
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: oboClientId, client_id: graphClientId,
client_secret: clientSecret, client_secret: graphClientSecret,
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', grant_type: 'client_credentials',
assertion: userToken, scope: 'https://graph.microsoft.com/.default',
requested_token_use: 'on_behalf_of',
scope: 'https://graph.microsoft.com/Calendars.Read',
}); });
console.log(`[graph] OBO token exchange → https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`);
const res = await fetch( const res = await fetch(
`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`,
{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body },
@ -49,18 +41,17 @@ async function getOboToken(userToken: string, userId: string): Promise<string> {
if (!res.ok) { if (!res.ok) {
const detail = await res.text(); const detail = await res.text();
console.error(`[graph] OBO exchange failed ${res.status}: ${detail}`); console.error(`[graph] app token exchange failed ${res.status}: ${detail}`);
throw new Error(`OBO token exchange failed (${res.status}): ${detail}`); throw new Error(`Graph app token exchange failed (${res.status}): ${detail}`);
} }
const json = await res.json() as { access_token: string; expires_in: number }; const json = await res.json() as { access_token: string; expires_in: number };
console.log(`[graph] OBO token obtained, expires_in=${json.expires_in}s`); appTokenCache = {
const entry: OboTokenEntry = {
accessToken: json.access_token, accessToken: json.access_token,
expiresAt: Date.now() + json.expires_in * 1000, expiresAt: Date.now() + json.expires_in * 1000,
}; };
tokenCache.set(userId, entry); console.log(`[graph] app token obtained, expires_in=${json.expires_in}s`);
return entry.accessToken; return appTokenCache.accessToken;
} }
function toHHMM(dateTime: string): string { function toHHMM(dateTime: string): string {
@ -69,20 +60,17 @@ function toHHMM(dateTime: string): string {
} }
export async function getCalendarEvents( export async function getCalendarEvents(
userToken: string,
userId: string,
userEmail: string, userEmail: string,
date: string, date: string,
): Promise<CalendarEvent[]> { ): Promise<CalendarEvent[]> {
console.log(`[graph] getCalendarEvents email=${userEmail} date=${date}`);
console.log(`[graph] getCalendarEvents userId=${userId} email=${userEmail} date=${date} tokenLen=${userToken.length}`); const graphToken = await getAppToken();
const graphToken = await getOboToken(userToken, userId);
const start = `${date}T00:00:00`; const start = `${date}T00:00:00`;
const end = `${date}T23:59:59`; const end = `${date}T23:59:59`;
const select = 'id,subject,start,end,bodyPreview,attendees,isAllDay'; const select = 'id,subject,start,end,bodyPreview,attendees,isAllDay';
const url = const url =
`https://graph.microsoft.com/v1.0/me/calendarView` + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/calendarView` +
`?startDateTime=${encodeURIComponent(start)}` + `?startDateTime=${encodeURIComponent(start)}` +
`&endDateTime=${encodeURIComponent(end)}` + `&endDateTime=${encodeURIComponent(end)}` +
`&$select=${select}` + `&$select=${select}` +

View File

@ -67,7 +67,7 @@ export const authMiddleware = createMiddleware<AuthEnv>(async (c, next) => {
const auth: AuthInfo = { const auth: AuthInfo = {
userId: payload.oid as string, userId: payload.oid as string,
name: (payload.name as string) ?? '', name: (payload.name as string) ?? '',
email: (payload.preferred_username as string) ?? '', email: ((payload.preferred_username ?? payload.upn ?? payload.unique_name) as string) ?? '',
}; };
if (!auth.userId) { if (!auth.userId) {

View File

@ -13,17 +13,13 @@ calendar.get('/events', async (c) => {
} }
const auth = c.get('auth'); const auth = c.get('auth');
const rawToken = c.req.header('Authorization')?.slice(7) ?? '';
// OBO requires a real MSAL JWT — API keys and dev-bypass tokens don't work if (!auth.email) {
const looksLikeJwt = rawToken.startsWith('eyJ') && rawToken.split('.').length === 3; return c.json({ error: 'graph_unavailable', detail: 'No email in auth context (API key or dev-bypass)' }, 502);
console.log(`[calendar] rawToken prefix="${rawToken.slice(0, 8)}..." looksLikeJwt=${looksLikeJwt} userId=${auth.userId}`);
if (!looksLikeJwt) {
return c.json({ error: 'graph_unavailable', detail: 'Calendar requires MSAL login (OBO not possible with API key or dev-bypass token)' }, 502);
} }
try { try {
const events = await getCalendarEvents(rawToken, auth.userId, auth.email, date); const events = await getCalendarEvents(auth.email, date);
return c.json(events); return c.json(events);
} catch (err) { } catch (err) {
console.error('[calendar] Graph error:', err instanceof Error ? err.message : err); console.error('[calendar] Graph error:', err instanceof Error ? err.message : err);