Compare commits

..

No commits in common. "039582b0c87ef0fd094e1bb83189b63b980f6410" and "2f9218ce0e8194d5f5235a21bc6e00db83ea8a45" have entirely different histories.

10 changed files with 57 additions and 62 deletions

1
.gitignore vendored
View File

@ -2,4 +2,3 @@ work/
/import /import
ka-note/server/ka-note.db-wal ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-wal ka-note/server/ka-note.db-wal
ka-note/server/ka-note.db-wal

View File

@ -1,19 +1,21 @@
# ── SERVER ─────────────────────────────────────────────────────────────────── AZURE_CLIENT_ID=<app-registration-client-id>
PORT=9000
DEV_AUTH_BYPASS=false
AI_LOCK_EXPIRY_HOURS=168
# Azure AD — server app registration (validates incoming JWTs)
AZURE_CLIENT_ID=<server-app-registration-client-id>
AZURE_TENANT_ID=<azure-ad-tenant-id> AZURE_TENANT_ID=<azure-ad-tenant-id>
# Graph — app-only calendar access (client credentials, independent of user auth) # Set to true for local dev to skip JWT verification (never use in production)
# App Registration → API permissions → Graph → Calendars.Read (Application) → grant admin consent # DEV_AUTH_BYPASS=true
# App Registration → Certificates & secrets → New client secret
AZURE_GRAPH_CLIENT_ID=<graph-app-registration-client-id>
AZURE_GRAPH_CLIENT_SECRET=<graph-client-secret-value>
# ── CLIENT (Vite — copy relevant lines to client/.env) ─────────────────────── # AI lock duration in hours (default: 24, use 168 for 7 days)
# VITE_AZURE_CLIENT_ID=<frontend-app-registration-client-id> # AI_LOCK_EXPIRY_HOURS=168
# VITE_AZURE_TENANT_ID=<azure-ad-tenant-id>
# VITE_DEV_AUTH_BYPASS=true # DEV ONLY — never set in production # 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>
# VITE_DEV_AUTH_BYPASS=true ← DEV ONLY: skips MS login in browser (never set in production)

View File

@ -109,10 +109,9 @@ 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 displayName = name.replace(/_/g, ' '); const slug = name.toLowerCase().replace(/\s+/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 ${displayName}` : asType === 'person' ? `Person ${displayName}` : `Project ${displayName}`; const contextName = asType === 'company' ? `Firma ${name}` : asType === 'person' ? `Person ${name}` : `Project ${name}`;
const meta = asType === 'company' const meta = asType === 'company'
? { website: '', address: '' } ? { website: '', address: '' }
: asType === 'person' : asType === 'person'
@ -157,7 +156,7 @@ function showCreatePopup(name: string, type: 'person' | 'project' | 'company', x
}; };
} }
export async function handlePersonClick(name: string, event: MouseEvent, sourceEl: HTMLElement) { 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,10 +5,9 @@
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 } from '$lib/db/repositories'; import { updateEvent, updateEventNotes, softDeleteContext, findContextByMentionName } 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 {
@ -87,6 +86,10 @@
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?`)) {
@ -169,7 +172,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(); handlePersonClick(p, e, e.currentTarget as HTMLElement); }} onclick={(e) => { e.stopPropagation(); navigateToPerson(p); }}
title="Zur Person navigieren" title="Zur Person navigieren"
>{p}</button> >{p}</button>
{/each} {/each}

View File

@ -18,7 +18,6 @@
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 {
@ -249,12 +248,8 @@
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()
); );
if (match) { const name = match ? match.name : att.name;
return quoteMention('@', extractMentionName(match)); return name.includes(' ') ? `@"${name}"` : `@${name}`;
}
// 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,31 +7,32 @@ export interface CalendarEvent {
attendees: { name: string; email: string }[]; attendees: { name: string; email: string }[];
} }
interface AppTokenEntry { interface OboTokenEntry {
accessToken: string; accessToken: string;
expiresAt: number; // ms expiresAt: number; // ms
} }
let appTokenCache: AppTokenEntry | null = null; const tokenCache = new Map<string, OboTokenEntry>();
const tenantId = process.env.AZURE_TENANT_ID ?? ''; const tenantId = process.env.AZURE_TENANT_ID ?? '';
const graphClientId = process.env.AZURE_GRAPH_CLIENT_ID ?? ''; // OBO client_id must match the audience of the client's access token.
const graphClientSecret = process.env.AZURE_GRAPH_CLIENT_SECRET ?? ''; // 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 getAppToken(): Promise<string> { async function getOboToken(userToken: string, userId: string): Promise<string> {
if (appTokenCache && appTokenCache.expiresAt > Date.now() + 60_000) { const cached = tokenCache.get(userId);
return appTokenCache.accessToken; if (cached && cached.expiresAt > Date.now() + 60_000) {
} return cached.accessToken;
if (!tenantId || !graphClientId || !graphClientSecret) {
throw new Error(`Graph config incomplete: tenantId=${!!tenantId} clientId=${!!graphClientId} secret=${!!graphClientSecret}`);
} }
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: graphClientId, client_id: oboClientId,
client_secret: graphClientSecret, client_secret: clientSecret,
grant_type: 'client_credentials', grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
scope: 'https://graph.microsoft.com/.default', assertion: userToken,
requested_token_use: 'on_behalf_of',
scope: 'https://graph.microsoft.com/Calendars.Read',
}); });
const res = await fetch( const res = await fetch(
@ -41,17 +42,16 @@ async function getAppToken(): Promise<string> {
if (!res.ok) { if (!res.ok) {
const detail = await res.text(); const detail = await res.text();
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 };
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,
}; };
console.log(`[graph] app token obtained, expires_in=${json.expires_in}s`); tokenCache.set(userId, entry);
return appTokenCache.accessToken; return entry.accessToken;
} }
function toHHMM(dateTime: string): string { function toHHMM(dateTime: string): string {
@ -60,17 +60,19 @@ 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}`);
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/users/${encodeURIComponent(userEmail)}/calendarView` + `https://graph.microsoft.com/v1.0/me/calendarView` +
`?startDateTime=${encodeURIComponent(start)}` + `?startDateTime=${encodeURIComponent(start)}` +
`&endDateTime=${encodeURIComponent(end)}` + `&endDateTime=${encodeURIComponent(end)}` +
`&$select=${select}` + `&$select=${select}` +
@ -85,10 +87,8 @@ export async function getCalendarEvents(
if (!res.ok) { if (!res.ok) {
const detail = await res.text(); const detail = await res.text();
console.error(`[graph] calendarView failed ${res.status}: ${detail}`);
throw new Error(`Graph calendarView failed (${res.status}): ${detail}`); throw new Error(`Graph calendarView failed (${res.status}): ${detail}`);
} }
console.log(`[graph] calendarView OK`);
type GraphEvent = { type GraphEvent = {
id: string; id: string;

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 ?? payload.upn ?? payload.unique_name) as string) ?? '', email: (payload.preferred_username as string) ?? '',
}; };
if (!auth.userId) { if (!auth.userId) {

View File

@ -13,13 +13,10 @@ calendar.get('/events', async (c) => {
} }
const auth = c.get('auth'); const auth = c.get('auth');
const rawToken = c.req.header('Authorization')?.slice(7) ?? '';
if (!auth.email) {
return c.json({ error: 'graph_unavailable', detail: 'No email in auth context (API key or dev-bypass)' }, 502);
}
try { try {
const events = await getCalendarEvents(auth.email, date); const events = await getCalendarEvents(rawToken, auth.userId, 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);