From 41befde2812cc39e0d0a0e4f16d69f16eb32df93 Mon Sep 17 00:00:00 2001 From: beo3000 Date: Fri, 27 Feb 2026 17:12:23 +0100 Subject: [PATCH] upd cal import --- ka-note/.env.example | 9 ++- ka-note/client/src/lib/actions/refClick.ts | 7 ++- .../src/lib/components/EventCard.svelte | 9 +-- .../src/lib/components/JournalView.svelte | 9 ++- ka-note/server/ka-note.db-shm | Bin 32768 -> 32768 bytes ka-note/server/ka-note.db-wal | Bin 111272 -> 148352 bytes ka-note/server/src/lib/graph-service.ts | 54 +++++++----------- ka-note/server/src/middleware/auth.ts | 2 +- ka-note/server/src/routes/calendar.ts | 10 +--- 9 files changed, 43 insertions(+), 57 deletions(-) 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 d20222225687299d59afacd4b11aef41f9db3843..8fd4a43c90775e6a624d0c3256006ed036a7994b 100644 GIT binary patch literal 32768 zcmeI*)o)Zm6vy!|?ofQ8NDX&)EADQ^o#IY$X>s@Bu21|KJn&cWLPALR3kaOK;ijA0 zrA^arZg=-L$;sY3J3Hr`Py0L*`PK(F(k*I0+9&(`Xz`nuUw-|_%XsoLzhFUTac=(i zPM^OP9qJdU;CkE28*bk}QRCk!_{%eBD&9~_WsP`g~dCI_ZA;4K3W9#{9mnpozBSR3Fh$k4enRodoZ7~ z^lkjVOI79z*7Lvo(f?bO$#3mwNI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb1SB8< z2}nQ!5|DrdBp?9^NI(J-kbndvAOQ(TKmrnwfCMBU0SQPz0uqpb1SB8<2}nQ!5|Drd zs#qYKe&lh8`&GPeqHNcNsfkjm>Xd*4Bv6h(XL>N2@hm2vo$Tctm$=3)J8#CF4ii1v zuqOId&k{%>fjYEJp>9jJ+JUZ&WGoBW!gh93$OW!&!_I&Ism&Sc6haARK}ItOzHuvPvLy zfgBF=AoME2E1)J5c^F=LWe!!KDMOgTT8?}7Yd7(;v)ZGiSX!DywOMaW1PCjcLUsj`AqZ znG${bPIOQ77E0AWztoH-Vhc=T9Vc*i#cCvrz=C9HB<_9GwOg7a3}rg&IqBV&9QTw; oAc43F)TRlo8O&r>bBxDj=)nYUvYGuXf>o|mywU!}IdUlop;K>z>% delta 313 zcmZo@U}|V!s+V}A%K!pQK+MR%AfN)I7X$I`-Pg|PxX!*m=S^lskm3v{-OZ<}Rg-5N zAXPokC@=t-`yUBFg_#(nCpJ#sY{GoXZnFck3=^aBW<%zStc>!L4Vg6=#ekf@Oq+jr zF)?m7V*bO#C<~HhX60ZIWRL%Jy)cT*138a;gkq^w_ z26EI{7!^QbOpJ;UN(o3^W@Ke%;9(E}GF2HF<$%<07FIR}eg+93^E?wP3j;5M=wzTI ND?5V#gCs~9697;{SULaz diff --git a/ka-note/server/ka-note.db-wal b/ka-note/server/ka-note.db-wal index 73b2e5733595dbf04e90361187b1e1fa3225678f..1f65a5827f7938baf6ab5c99eec54d7241e6c343 100644 GIT binary patch delta 12340 zcmbU{32+?8ad*H0xNmp)6Ceokcml)^d)WUunGXm8BnUnr0ZOWq9AWD^akpGs2@;Y zq~1$?g343V)F{O~{V%iMj$2#1_XPz`P*_f21$Ca6(;}DVq?E2GnJsNdWuDj4GM5&m zlp+e5fsRN)Oo=j=>FQBpD+ z+mJFI$th_`PRW{(+2BXYX<18iLQ2xa%=!*UIW1XoN>atlIzLuS3rd>TfFnJ#wgVDK zgN#%uv!)|b5>lMNWtfggQB4UPpP@0*a%C^z#b8NrDVY~DRNGKM%0vumN&)f4+mPZs zr-BU9vYO&JDHCf$a+oY2B?)9NXQFLLVcr6|vY6)elqxEjNE;T0g6z4pprv#{%!J#J z!n^?c1pc&?Ci0oSHl%OT-fJT*In&dI^(`jU8Ofy-UdnX0A$^l}4R-H~xYFh4VH06V za4AmXGjMEr$axxQ9;ny9bMDgC{a<;894NXGpc@Yy6ySv1M(^6yeKx2{Spnq`$8uUu zVHKYlcLdL1F#q`LV(zV+}=FTeO275Q9XOJE_ue1X{! z`CcqRy-D2~{$=b(eLJJcST+7qI88s_`!~H8d+DC{g`N$Cx<1x@x+@yIFYsbuq4BBU zsFmG9Z9ltxFdPU(_5~t=oKaZ3$}X19uZFL~xO3uo>HHEx)k5*SD-+*ouZsip*`dvw z!}VJNVLXc}$XQ-eW!A8G)np~f)L26jvaG4;YF~HLiq%RoeMfSa`N5pX_0F;MD?D2YUdC?5d0mR?Hap`2eL~ru0H)y1linQ^B@pghi&@`gRix>TrFDmrX$;Ou9ls@x{SA*Spvq5^EH%g z$POZC<0>$Iz?LSDkZK5~$%2W}S;av1Zh0kRXuktxF5B{NL3Rh0+jVSJ1gNb8vPKcYLs_=@X@vI7(Vh3i_d}#xqOzD zCEdvCStMr-9jIHb9lsX&W>jF)W`LCEq4PK%#UN zA?RByfrWxYgzeOw#XE}!Cl<;xi@1d?Rsy^PZnKFhc3&2BAia30g+myu&)J6%+)%l22|OXVBkX$|{WYc2L?&z`{UHh~>2rxGV@x0Kj*D~z{F<;3l~JxZ?_i>PwYA?-U9 z9>HD1ZxpMBS@rB#Uwj;YD3B(n_`W4_ifh)YPw|-$H8M0d7_ns?09jV9R*c+Iq39Lp zJTjINa}L5Ee7Z8VFD*S6Vip)JSAl_*8e}!alvqR5a~#qPJ_n(A)8K;kP8dbwJX(VA zGjZa0bFQwH3T#eRPlxet00okmF<4{Gg|c;V(IMDWAF&p;?z-D_XK(fbSh1N!?{3F? z*9{symg>5D+A&kG{|`>3oh_4S*PQnv)&sd@shpv)y2r(<+ef<-^I1Qp%(ddb& zgj0p`l96yWbu+NIJ9!ypD+O{%T&Px;E9tSZOUSZKXw%LaIk*s3usToFAUY^ls$ehq zQ5*@9a;6M2x%U6?HS2 zt^pS_+7a6(8|1w}ZX}=^xhZWRBW~o1vYw1+y9rqDa1~fbhTKTdzLt!zBk^|*#4Vii zSkvTdRnavGu9+rV*FJ>)Ge{4GSfaxASQ26tLXc`|xPv*5leuohjm9>IcHp_3h{I8P zbVFzm!#zR1h02j86g+3kafa8225?ueKk6eLy_Ozi*Sjq=HWa+3mF*b_jSK}l8M_dn zW&sM=bHj~p!um?{)3q_QeJJ7{GiOC`q#19X?(y&a?QI7h{KXM`tWWp88eo0`33W)H zLsDH~2IyZwYW-_8PZH>P`ZY+fTNfdp{t3vYPsjf&{v9~~M(j{$TuY?B&?g zu>-MWtS9>K(I=u0M61!;qTh^tD0Ve=C;fr=&Uh4Z@*AS*=uk8e`Elgyk-v!iL8KHp z6OkkR;a`UTIsDb|W8wFQFN9Box$yeFpZ9&g@7cbO_ubcr`i}PP?W23&?&^IN%*dYJ zYo_mI$g`hrOOLjt@0HA~9|@Us$iZ_dQR6bF{7AT3syM&Q%Sz^?2e}C{p!2*0DdIHb zo8X$7If1c_d*%L~<8EulZ5?x4({Agi+dAU5rrg$~+dAyFCfwE`w>3^$zpeK79CTX; z+}3`#wa;z6%Wd7_w32Dua=jfm0~P1EAz7eHQbs4;X0`qv&26b}OL1GW+mf7CP;9)S zZw(6MmnXj*{@pf)dw3^Q(js_RA;n8vCPg~3PXEhLt=QcGDWqjS zB??Mr*Gi-);iR~#DVd#(<(IbxZ*Bx%*%};y-+}#tni*a>LWXN22gMIQb5lnolmvKS zBuNPEz=T3TDB(1=6ZkgrJLCYjI*maB-$H%|9AG~*)<>cA1gn6W%VzHlMVtDNw%AGnAJl zITQDOLj4ARS}up}6&qi0xT0X=4Qks9HolSV1shllpO*MEC#F;=A@y#xyxY<@w7p>C zL$()ee8|-oY@jaIUbq6wyvA#?pN(x&Uir)Xqr_xK?hjB6>H;-R^~e7+{`L4n@oIb~ zo{ar(?B&Q?k&i`QVE&bPj`tS|cW=TTjqoj0_x4uYcbXKJ z+&as(L%Lbq5eH*Er$`IOU?JM?rrva9Vlx{8h#$pP(M zMNiUk&uTmFUS&s1Bc-~Uret@;sGk%R9%XpBqA4&NEaX%pD z2SnTKb_A$fj_C=vq5AwId)v@GZC@dO=k7Li7t=k`mhu4)xYqH4oA5)R*$=<`_J*nc zpA$D2`$i!4jRy{X0vye@*s9+xJoV7}&}7}Aiiw6J-+Sy&KBE2=1;7oxe1Q35hNeGA zuc!W$V&hN5HMrey(Law4!M%kGKNCLF_k5qy`*<%8^9FmZ+G%R+?Dm@m0ztcS=Dby4 zFO+KKqOrJ|T!(vOJ9n-*>fAZVW*07@U%Nq-=lKG@?bKtllreO6yX8#0noJ2xDn!n; za5W>&og+h5vg6#ZHk0>J3h;A^@Z+!p&U?>?sa$YWyB-PeEvz0p*-qifLwi2_7En;H zN5OklJAVSQeX!QVW*w-ce; zrspRo664v+1@q!!t$5xhdIjGHjmtBbgxn;N7fr)d^emLr^D>k?4W7+vqRxsUJhjnf zS(GHVuDNNj7@OLCfGHF3C=hKUq3dS z?!@T0X+pBMDULD`JV*jzbCFsER>{$S7bf{z)k#cotVvPhRndf$Fq<_H4+=&mn-z12 zl@witWH#L5H20~4cLfuLYPoPFFFHs)6VoSA^{&gP ze6hI~^JN2))U}J%T6070BE<3&nAm!U*juNDhdL2EI&;QeWrII4kiX{;9(15Ni05^| zR1A~NC#_?3V z!yk8Y7{}8jyXX}dDWHI&C*bM9B2>QM;RR7j6cb_U!GoB}v_mEF`C`6Am>DPq0*`og0??5^< z=BW_ei1$2=Q@@RW{KAN96e1)Ud?Pk@hS2!bt`W+SQi?!EcefxT1j7FHh+82DlX)7j ziCoaQ4T2rvf)3iC*FN9cYC6dWyKdiEtuuF@Jz~#Pi%4&i$Y+OL4v%6`N4S(nI<@>*+(P8V%!1tv-85A2!v33=>G|%dM5#g^$KD&$2Y!DcFFW>T+~|jJww} z?k2m_xm~SJD(Un}szuzcku?5kXRDJ!I{m`K6Df-{j*qptOp-otjg1_1bysNI`HV96PyRQ9YrnkS+ntKOnvr?5a!JKKb1VI42S9710ChQT0u8Bwy*qjQs58g*ftcG-z1>dXbNL2-qcl{J^ zkI+peiywf9l4wA+2ubi_7n}mC8@y!Z)SM0?F>cGAjrU)DBx;Q$R5HOs%nC(%E_DrUJBmT>RAyU79tZ~s^~h(w*1vj kGD6jJqL$@E7F&84kvCW^uV}0y8HR3X;1QIZqwY2T59=OOt^fc4 delta 11 ScmZo@=UnlWtziq}icJ6>9tAT1 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);