Compare commits

...

3 Commits

Author SHA1 Message Date
beo3000 039582b0c8 ignore 2026-02-27 17:12:39 +01:00
beo3000 41befde281 upd cal import 2026-02-27 17:12:23 +01:00
beo3000 b6f35431df azure login und cal sync 2026-02-27 16:30:32 +01:00
10 changed files with 62 additions and 57 deletions

1
.gitignore vendored
View File

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

View File

@ -1,21 +1,19 @@
AZURE_CLIENT_ID=<app-registration-client-id>
# ── SERVER ───────────────────────────────────────────────────────────────────
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>
# Set to true for local dev to skip JWT verification (never use in production)
# DEV_AUTH_BYPASS=true
# 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_GRAPH_CLIENT_ID=<graph-app-registration-client-id>
AZURE_GRAPH_CLIENT_SECRET=<graph-client-secret-value>
# AI lock duration in hours (default: 24, use 168 for 7 days)
# AI_LOCK_EXPIRY_HOURS=168
# 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)
# ── CLIENT (Vite — copy relevant lines to client/.env) ───────────────────────
# VITE_AZURE_CLIENT_ID=<frontend-app-registration-client-id>
# VITE_AZURE_TENANT_ID=<azure-ad-tenant-id>
# VITE_DEV_AUTH_BYPASS=true # DEV ONLY — never set in production

View File

@ -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<string, unknown> | null)?.personSubType === 'employee';

View File

@ -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}
<button
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"
>{p}</button>
{/each}

View File

@ -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;

Binary file not shown.

Binary file not shown.

View File

@ -7,32 +7,31 @@ export interface CalendarEvent {
attendees: { name: string; email: string }[];
}
interface OboTokenEntry {
interface AppTokenEntry {
accessToken: string;
expiresAt: number; // ms
}
const tokenCache = new Map<string, OboTokenEntry>();
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<string> {
const cached = tokenCache.get(userId);
if (cached && cached.expiresAt > Date.now() + 60_000) {
return cached.accessToken;
async function getAppToken(): Promise<string> {
if (appTokenCache && appTokenCache.expiresAt > Date.now() + 60_000) {
return appTokenCache.accessToken;
}
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',
});
const res = await fetch(
@ -42,16 +41,17 @@ async function getOboToken(userToken: string, userId: string): Promise<string> {
if (!res.ok) {
const detail = await res.text();
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 };
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 {
@ -60,19 +60,17 @@ function toHHMM(dateTime: string): string {
}
export async function getCalendarEvents(
userToken: string,
userId: string,
userEmail: string,
date: string,
): Promise<CalendarEvent[]> {
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}` +
@ -87,8 +85,10 @@ export async function getCalendarEvents(
if (!res.ok) {
const detail = await res.text();
console.error(`[graph] calendarView failed ${res.status}: ${detail}`);
throw new Error(`Graph calendarView failed (${res.status}): ${detail}`);
}
console.log(`[graph] calendarView OK`);
type GraphEvent = {
id: string;

View File

@ -67,7 +67,7 @@ export const authMiddleware = createMiddleware<AuthEnv>(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) {

View File

@ -13,10 +13,13 @@ calendar.get('/events', async (c) => {
}
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 {
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);