Compare commits
3 Commits
2f9218ce0e
...
039582b0c8
| Author | SHA1 | Date |
|---|---|---|
|
|
039582b0c8 | |
|
|
41befde281 | |
|
|
b6f35431df |
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue