diff --git a/ka-note/.env.example b/ka-note/.env.example index d1f2754..7c52c8f 100644 --- a/ka-note/.env.example +++ b/ka-note/.env.example @@ -7,12 +7,11 @@ AI_LOCK_EXPIRY_HOURS=168 AZURE_CLIENT_ID= AZURE_TENANT_ID= -# Graph / OBO — required for calendar integration -# App Registration → API permissions → Graph → Calendars.Read (delegated) → grant admin consent +# Graph — app-only calendar access (client credentials, independent of user auth) +# App Registration → API permissions → Graph → Calendars.Read (Application) → grant admin consent # App Registration → Certificates & secrets → New client secret -AZURE_CLIENT_SECRET= -# aud of client token must match this. Only needed if frontend uses a different app registration. -AZURE_OBO_CLIENT_ID= +AZURE_GRAPH_CLIENT_ID= +AZURE_GRAPH_CLIENT_SECRET= # ── CLIENT (Vite — copy relevant lines to client/.env) ─────────────────────── # VITE_AZURE_CLIENT_ID= diff --git a/ka-note/client/src/lib/actions/refClick.ts b/ka-note/client/src/lib/actions/refClick.ts index e5f0ab1..8329fc4 100644 --- a/ka-note/client/src/lib/actions/refClick.ts +++ b/ka-note/client/src/lib/actions/refClick.ts @@ -109,9 +109,10 @@ function showCreatePopup(name: string, type: 'person' | 'project' | 'company', x btnRow.style.cssText = 'display: flex; gap: 6px;'; 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 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' ? { website: '', address: '' } : 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'); if (ctx) { const isEmployee = (ctx.meta as Record | null)?.personSubType === 'employee'; diff --git a/ka-note/client/src/lib/components/EventCard.svelte b/ka-note/client/src/lib/components/EventCard.svelte index f0751c2..1bb45fc 100644 --- a/ka-note/client/src/lib/components/EventCard.svelte +++ b/ka-note/client/src/lib/components/EventCard.svelte @@ -5,9 +5,10 @@ import { db } from '$lib/db/schema'; import MarkdownEditor from './MarkdownEditor.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 { mention } from '$lib/actions/mention'; + import { handlePersonClick } from '$lib/actions/refClick'; import { goto } from '$app/navigation'; interface Props { @@ -86,10 +87,6 @@ editingNotes = false; } - async function navigateToPerson(name: string) { - const ctx = await findContextByMentionName(name, 'person'); - if (ctx) goto(`/context/${ctx.id}`); - } async function handleDelete() { if (confirm(`Meeting "${event.name}" löschen?`)) { @@ -172,7 +169,7 @@ {#each meta.participants as p} {/each} diff --git a/ka-note/client/src/lib/components/JournalView.svelte b/ka-note/client/src/lib/components/JournalView.svelte index 21ecfdc..caae225 100644 --- a/ka-note/client/src/lib/components/JournalView.svelte +++ b/ka-note/client/src/lib/components/JournalView.svelte @@ -18,6 +18,7 @@ import { eventsForDate } from '$lib/stores/agenda'; import { createEvent, updateEventNotes } from '$lib/db/repositories'; import { fetchCalendarEvents, type CalendarEvent } from '$lib/utils/calendarApi'; + import { extractMentionName, quoteMention } from '$lib/actions/mentionCore'; import type { PersonMeta } from '@ka-note/shared'; interface Props { @@ -248,8 +249,12 @@ 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}`; + if (match) { + 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(' '); calendarPickerOpen = false; diff --git a/ka-note/server/ka-note.db-shm b/ka-note/server/ka-note.db-shm index d202222..8fd4a43 100644 Binary files a/ka-note/server/ka-note.db-shm and b/ka-note/server/ka-note.db-shm differ diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 73b2e57..1f65a58 100644 Binary files a/ka-note/server/ka-note.db-wal and b/ka-note/server/ka-note.db-wal differ diff --git a/ka-note/server/src/lib/graph-service.ts b/ka-note/server/src/lib/graph-service.ts index 95c0def..dd1e206 100644 --- a/ka-note/server/src/lib/graph-service.ts +++ b/ka-note/server/src/lib/graph-service.ts @@ -7,41 +7,33 @@ export interface CalendarEvent { attendees: { name: string; email: string }[]; } -interface OboTokenEntry { +interface AppTokenEntry { accessToken: string; expiresAt: number; // ms } -const tokenCache = new Map(); +let appTokenCache: AppTokenEntry | null = null; 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 ?? ''; +const graphClientId = process.env.AZURE_GRAPH_CLIENT_ID ?? ''; +const graphClientSecret = process.env.AZURE_GRAPH_CLIENT_SECRET ?? ''; -async function getOboToken(userToken: string, userId: string): Promise { - console.log(`[graph] getOboToken userId=${userId} tenantId=${tenantId || '(missing)'} oboClientId=${oboClientId || '(missing)'} secretSet=${!!clientSecret}`); - const cached = tokenCache.get(userId); - 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; +async function getAppToken(): Promise { + if (appTokenCache && appTokenCache.expiresAt > Date.now() + 60_000) { + return appTokenCache.accessToken; } - if (!tenantId || !oboClientId || !clientSecret) { - throw new Error(`OBO config incomplete: tenantId=${!!tenantId} oboClientId=${!!oboClientId} secret=${!!clientSecret}`); + if (!tenantId || !graphClientId || !graphClientSecret) { + throw new Error(`Graph config incomplete: tenantId=${!!tenantId} clientId=${!!graphClientId} secret=${!!graphClientSecret}`); } 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', + client_id: graphClientId, + client_secret: graphClientSecret, + grant_type: 'client_credentials', + scope: 'https://graph.microsoft.com/.default', }); - console.log(`[graph] OBO token exchange → https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`); const res = await fetch( `https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body }, @@ -49,18 +41,17 @@ async function getOboToken(userToken: string, userId: string): Promise { if (!res.ok) { const detail = await res.text(); - console.error(`[graph] OBO exchange failed ${res.status}: ${detail}`); - throw new Error(`OBO token exchange failed (${res.status}): ${detail}`); + console.error(`[graph] app 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 }; - console.log(`[graph] OBO token obtained, expires_in=${json.expires_in}s`); - const entry: OboTokenEntry = { + appTokenCache = { accessToken: json.access_token, expiresAt: Date.now() + json.expires_in * 1000, }; - tokenCache.set(userId, entry); - return entry.accessToken; + console.log(`[graph] app token obtained, expires_in=${json.expires_in}s`); + return appTokenCache.accessToken; } function toHHMM(dateTime: string): string { @@ -69,20 +60,17 @@ function toHHMM(dateTime: string): string { } export async function getCalendarEvents( - userToken: string, - userId: string, userEmail: string, date: string, ): Promise { - - console.log(`[graph] getCalendarEvents userId=${userId} email=${userEmail} date=${date} tokenLen=${userToken.length}`); - const graphToken = await getOboToken(userToken, userId); + console.log(`[graph] getCalendarEvents email=${userEmail} date=${date}`); + const graphToken = await getAppToken(); 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` + + `https://graph.microsoft.com/v1.0/users/${encodeURIComponent(userEmail)}/calendarView` + `?startDateTime=${encodeURIComponent(start)}` + `&endDateTime=${encodeURIComponent(end)}` + `&$select=${select}` + diff --git a/ka-note/server/src/middleware/auth.ts b/ka-note/server/src/middleware/auth.ts index 38894cf..2eb9af5 100644 --- a/ka-note/server/src/middleware/auth.ts +++ b/ka-note/server/src/middleware/auth.ts @@ -67,7 +67,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { const auth: AuthInfo = { userId: payload.oid 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) { diff --git a/ka-note/server/src/routes/calendar.ts b/ka-note/server/src/routes/calendar.ts index 036eb3c..a0d1f76 100644 --- a/ka-note/server/src/routes/calendar.ts +++ b/ka-note/server/src/routes/calendar.ts @@ -13,17 +13,13 @@ calendar.get('/events', async (c) => { } 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 - const looksLikeJwt = rawToken.startsWith('eyJ') && rawToken.split('.').length === 3; - 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); + if (!auth.email) { + return c.json({ error: 'graph_unavailable', detail: 'No email in auth context (API key or dev-bypass)' }, 502); } try { - const events = await getCalendarEvents(rawToken, auth.userId, auth.email, date); + const events = await getCalendarEvents(auth.email, date); return c.json(events); } catch (err) { console.error('[calendar] Graph error:', err instanceof Error ? err.message : err);