upd cal import
This commit is contained in:
parent
b6f35431df
commit
41befde281
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
@ -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}` +
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue