KI-AGENT: Ergänze Kalender-Abo für Mitarbeiterprofile
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "auth_profiles"
|
||||||
|
ADD COLUMN "calendar_subscription_token" text;
|
||||||
@@ -288,6 +288,13 @@
|
|||||||
"when": 1779141600000,
|
"when": 1779141600000,
|
||||||
"tag": "0040_filetag_system_types",
|
"tag": "0040_filetag_system_types",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 41,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1780149600000,
|
||||||
|
"tag": "0041_profile_calendar_subscription",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const authProfiles = pgTable("auth_profiles", {
|
|||||||
|
|
||||||
email: text("email"),
|
email: text("email"),
|
||||||
token_id: text("token_id"),
|
token_id: text("token_id"),
|
||||||
|
calendar_subscription_token: text("calendar_subscription_token"),
|
||||||
|
|
||||||
weekly_working_days: doublePrecision("weekly_working_days"),
|
weekly_working_days: doublePrecision("weekly_working_days"),
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import {
|
|||||||
resolveTenantTeamIds,
|
resolveTenantTeamIds,
|
||||||
syncProfileTeams,
|
syncProfileTeams,
|
||||||
} from "../utils/profileTeams";
|
} from "../utils/profileTeams";
|
||||||
|
import {
|
||||||
|
enrichProfileWithCalendarSubscription,
|
||||||
|
generateProfileCalendarSubscriptionToken,
|
||||||
|
} from "../utils/calendarSubscription";
|
||||||
|
|
||||||
export default async function authProfilesRoutes(server: FastifyInstance) {
|
export default async function authProfilesRoutes(server: FastifyInstance) {
|
||||||
|
|
||||||
@@ -38,7 +42,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(404).send({ error: "User not found or not in tenant" });
|
return reply.code(404).send({ error: "User not found or not in tenant" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile;
|
return enrichProfileWithCalendarSubscription(profile);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("GET /profiles/:id ERROR:", error);
|
console.error("GET /profiles/:id ERROR:", error);
|
||||||
@@ -50,10 +54,11 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const cleaned: any = { ...body }
|
const cleaned: any = { ...body }
|
||||||
|
|
||||||
// ❌ Systemfelder entfernen
|
// ❌ Systemfelder entfernen
|
||||||
const forbidden = [
|
const forbidden = [
|
||||||
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
"id", "user_id", "tenant_id", "created_at", "updated_at",
|
||||||
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
"updatedAt", "updatedBy", "old_profile_id", "full_name",
|
||||||
"branch"
|
"branch", "calendar_subscription_token",
|
||||||
|
"calendar_subscription_path", "calendar_subscription_url"
|
||||||
]
|
]
|
||||||
forbidden.forEach(f => delete cleaned[f])
|
forbidden.forEach(f => delete cleaned[f])
|
||||||
|
|
||||||
@@ -146,7 +151,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
const [profile] = profileWithBranches
|
const [profile] = profileWithBranches
|
||||||
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
: [null]
|
: [null]
|
||||||
return profile || updated[0]
|
return enrichProfileWithCalendarSubscription(profile || updated[0])
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PUT /profiles/:id ERROR:", err)
|
console.error("PUT /profiles/:id ERROR:", err)
|
||||||
@@ -159,4 +164,31 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
|
|||||||
return reply.code(500).send({ error: "Internal Server Error" })
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
server.post("/profiles/:id/calendar-subscription-token", async (req, reply) => {
|
||||||
|
try {
|
||||||
|
const tenantId = req.user?.tenant_id
|
||||||
|
|
||||||
|
if (!tenantId) {
|
||||||
|
return reply.code(400).send({ error: "No tenant selected" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = req.params as { id: string }
|
||||||
|
const updatedProfile = await generateProfileCalendarSubscriptionToken(server, id, tenantId)
|
||||||
|
|
||||||
|
if (!updatedProfile) {
|
||||||
|
return reply.code(404).send({ error: "User not found or not in tenant" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileWithBranches = await loadProfileWithBranches(server, id, tenantId)
|
||||||
|
const [profile] = profileWithBranches
|
||||||
|
? await enrichProfilesWithTeams(server, [profileWithBranches])
|
||||||
|
: [updatedProfile]
|
||||||
|
|
||||||
|
return enrichProfileWithCalendarSubscription(profile)
|
||||||
|
} catch (err) {
|
||||||
|
console.error("POST /profiles/:id/calendar-subscription-token ERROR:", err)
|
||||||
|
return reply.code(500).send({ error: "Internal Server Error" })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,32 @@
|
|||||||
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
import { FastifyRequest, FastifyReply, FastifyInstance } from 'fastify';
|
||||||
import { publicLinkService } from '../../modules/publiclinks.service';
|
import { publicLinkService } from '../../modules/publiclinks.service';
|
||||||
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
import dayjs from 'dayjs'; // Falls nicht installiert: npm install dayjs
|
||||||
|
import { buildProfileCalendarSubscriptionFeed, loadProfileByCalendarSubscriptionToken } from '../../utils/calendarSubscription';
|
||||||
|
|
||||||
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
export default async function publiclinksNonAuthenticatedRoutes(server: FastifyInstance) {
|
||||||
|
server.get("/api/public/calendar/subscriptions/:token.ics", async (req, reply) => {
|
||||||
|
const { token } = req.params as { token: string }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await loadProfileByCalendarSubscriptionToken(server, token)
|
||||||
|
|
||||||
|
if (!profile || !profile.active) {
|
||||||
|
return reply.code(404).send({ error: "Kalender-Abo nicht gefunden" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const icsFeed = await buildProfileCalendarSubscriptionFeed(server, profile)
|
||||||
|
|
||||||
|
reply.header("Content-Type", "text/calendar; charset=utf-8")
|
||||||
|
reply.header("Content-Disposition", `inline; filename="fedeo-${profile.id}.ics"`)
|
||||||
|
reply.header("Cache-Control", "private, max-age=300")
|
||||||
|
|
||||||
|
return reply.send(icsFeed)
|
||||||
|
} catch (error: any) {
|
||||||
|
server.log.error(error)
|
||||||
|
return reply.code(500).send({ error: "Interner Server Fehler" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
server.get("/workflows/context/:token", async (req, reply) => {
|
server.get("/workflows/context/:token", async (req, reply) => {
|
||||||
const { token } = req.params as { token: string };
|
const { token } = req.params as { token: string };
|
||||||
const pin = req.headers['x-public-pin'] as string | undefined;
|
const pin = req.headers['x-public-pin'] as string | undefined;
|
||||||
|
|||||||
193
backend/src/utils/calendarSubscription.ts
Normal file
193
backend/src/utils/calendarSubscription.ts
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import crypto from "crypto"
|
||||||
|
import { and, asc, eq, sql } from "drizzle-orm"
|
||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
|
||||||
|
import { authProfiles, events } from "../../db/schema"
|
||||||
|
import { secrets } from "./secrets"
|
||||||
|
|
||||||
|
const CALENDAR_FEED_PREFIX = "/api/public/calendar/subscriptions"
|
||||||
|
|
||||||
|
function escapeIcsText(value: string) {
|
||||||
|
return value
|
||||||
|
.replace(/\\/g, "\\\\")
|
||||||
|
.replace(/\r?\n/g, "\\n")
|
||||||
|
.replace(/;/g, "\\;")
|
||||||
|
.replace(/,/g, "\\,")
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldIcsLine(line: string) {
|
||||||
|
const maxLength = 73
|
||||||
|
if (line.length <= maxLength) return line
|
||||||
|
|
||||||
|
const parts: string[] = []
|
||||||
|
for (let index = 0; index < line.length; index += maxLength) {
|
||||||
|
parts.push(index === 0 ? line.slice(index, index + maxLength) : ` ${line.slice(index, index + maxLength)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join("\r\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUtcDate(value: string | Date | null | undefined) {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return null
|
||||||
|
|
||||||
|
return date
|
||||||
|
.toISOString()
|
||||||
|
.replace(/[-:]/g, "")
|
||||||
|
.replace(/\.\d{3}Z$/, "Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRecurrenceRule(repeatInterval?: string | null) {
|
||||||
|
switch (repeatInterval) {
|
||||||
|
case "Täglich":
|
||||||
|
return "FREQ=DAILY"
|
||||||
|
case "Wöchentlich":
|
||||||
|
return "FREQ=WEEKLY"
|
||||||
|
case "2-wöchentlich":
|
||||||
|
return "FREQ=WEEKLY;INTERVAL=2"
|
||||||
|
case "Monatlich":
|
||||||
|
return "FREQ=MONTHLY"
|
||||||
|
case "Jährlich":
|
||||||
|
return "FREQ=YEARLY"
|
||||||
|
default:
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeApiBaseUrl() {
|
||||||
|
const rawBase = secrets.API_BASE_URL?.trim()
|
||||||
|
if (!rawBase) return null
|
||||||
|
|
||||||
|
return rawBase.replace(/\/+$/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarSubscriptionPath(token: string) {
|
||||||
|
return `${CALENDAR_FEED_PREFIX}/${token}.ics`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCalendarSubscriptionUrl(token: string) {
|
||||||
|
const apiBaseUrl = normalizeApiBaseUrl()
|
||||||
|
const subscriptionPath = buildCalendarSubscriptionPath(token)
|
||||||
|
|
||||||
|
if (!apiBaseUrl) return subscriptionPath
|
||||||
|
|
||||||
|
if (apiBaseUrl.endsWith("/api")) {
|
||||||
|
return `${apiBaseUrl}${subscriptionPath.replace(/^\/api/, "")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${apiBaseUrl}${subscriptionPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enrichProfileWithCalendarSubscription(profile: Record<string, any> | null) {
|
||||||
|
if (!profile) return profile
|
||||||
|
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
calendar_subscription_path: profile.calendar_subscription_token
|
||||||
|
? buildCalendarSubscriptionPath(profile.calendar_subscription_token)
|
||||||
|
: null,
|
||||||
|
calendar_subscription_url: profile.calendar_subscription_token
|
||||||
|
? buildCalendarSubscriptionUrl(profile.calendar_subscription_token)
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateProfileCalendarSubscriptionToken(server: FastifyInstance, profileId: string, tenantId: number) {
|
||||||
|
const token = crypto.randomBytes(24).toString("hex")
|
||||||
|
|
||||||
|
const [profile] = await server.db
|
||||||
|
.update(authProfiles)
|
||||||
|
.set({
|
||||||
|
calendar_subscription_token: token,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(authProfiles.id, profileId),
|
||||||
|
eq(authProfiles.tenant_id, tenantId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return profile || null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadProfileByCalendarSubscriptionToken(server: FastifyInstance, token: string) {
|
||||||
|
return server.db.query.authProfiles.findFirst({
|
||||||
|
where: eq(authProfiles.calendar_subscription_token, token)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function buildProfileCalendarSubscriptionFeed(server: FastifyInstance, profile: typeof authProfiles.$inferSelect) {
|
||||||
|
const assignedEvents = await server.db
|
||||||
|
.select({
|
||||||
|
id: events.id,
|
||||||
|
name: events.name,
|
||||||
|
startDate: events.startDate,
|
||||||
|
endDate: events.endDate,
|
||||||
|
repeatInterval: events.repeatInterval,
|
||||||
|
notes: events.notes,
|
||||||
|
link: events.link,
|
||||||
|
state: events.state,
|
||||||
|
color: events.color,
|
||||||
|
createdAt: events.createdAt,
|
||||||
|
updatedAt: events.updatedAt
|
||||||
|
})
|
||||||
|
.from(events)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(events.tenant, profile.tenant_id),
|
||||||
|
eq(events.archived, false),
|
||||||
|
sql`${events.profiles} ? ${profile.id}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(events.startDate))
|
||||||
|
|
||||||
|
const nowStamp = formatUtcDate(new Date()) || ""
|
||||||
|
const calendarLines = [
|
||||||
|
"BEGIN:VCALENDAR",
|
||||||
|
"VERSION:2.0",
|
||||||
|
"PRODID:-//FEDEO//Kalender-Abo//DE",
|
||||||
|
"CALSCALE:GREGORIAN",
|
||||||
|
"METHOD:PUBLISH",
|
||||||
|
foldIcsLine(`X-WR-CALNAME:${escapeIcsText(`FEDEO - ${profile.full_name}`)}`),
|
||||||
|
foldIcsLine(`X-WR-CALDESC:${escapeIcsText(`Kalender-Abo für ${profile.full_name}`)}`),
|
||||||
|
]
|
||||||
|
|
||||||
|
assignedEvents.forEach((event) => {
|
||||||
|
const startDate = formatUtcDate(event.startDate)
|
||||||
|
if (!startDate) return
|
||||||
|
|
||||||
|
const endDate = formatUtcDate(event.endDate)
|
||||||
|
const lastModified = formatUtcDate(event.updatedAt || event.createdAt) || nowStamp
|
||||||
|
const recurrenceRule = buildRecurrenceRule(event.repeatInterval)
|
||||||
|
const descriptionParts = [event.notes, event.link].filter(Boolean)
|
||||||
|
const summary = event.state === "Entwurf" ? `[Entwurf] ${event.name}` : event.name
|
||||||
|
|
||||||
|
calendarLines.push("BEGIN:VEVENT")
|
||||||
|
calendarLines.push(foldIcsLine(`UID:fedeo-event-${event.id}@fedeo.local`))
|
||||||
|
calendarLines.push(`DTSTAMP:${nowStamp}`)
|
||||||
|
calendarLines.push(`DTSTART:${startDate}`)
|
||||||
|
if (endDate) {
|
||||||
|
calendarLines.push(`DTEND:${endDate}`)
|
||||||
|
}
|
||||||
|
calendarLines.push(`LAST-MODIFIED:${lastModified}`)
|
||||||
|
calendarLines.push(foldIcsLine(`SUMMARY:${escapeIcsText(summary)}`))
|
||||||
|
calendarLines.push(`STATUS:${event.state === "Entwurf" ? "TENTATIVE" : "CONFIRMED"}`)
|
||||||
|
if (event.color) {
|
||||||
|
calendarLines.push(foldIcsLine(`COLOR:${escapeIcsText(event.color)}`))
|
||||||
|
}
|
||||||
|
if (descriptionParts.length) {
|
||||||
|
calendarLines.push(foldIcsLine(`DESCRIPTION:${escapeIcsText(descriptionParts.join("\n\n"))}`))
|
||||||
|
}
|
||||||
|
if (recurrenceRule) {
|
||||||
|
calendarLines.push(`RRULE:${recurrenceRule}`)
|
||||||
|
}
|
||||||
|
calendarLines.push("END:VEVENT")
|
||||||
|
})
|
||||||
|
|
||||||
|
calendarLines.push("END:VCALENDAR")
|
||||||
|
|
||||||
|
return `${calendarLines.join("\r\n")}\r\n`
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ const toast = useToast()
|
|||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const admin = useAdmin()
|
const admin = useAdmin()
|
||||||
const { $api } = useNuxtApp()
|
const { $api } = useNuxtApp()
|
||||||
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
|
||||||
const id = route.params.id as string
|
const id = route.params.id as string
|
||||||
const profile = ref<any>(null)
|
const profile = ref<any>(null)
|
||||||
@@ -15,6 +16,7 @@ const saving = ref(false)
|
|||||||
const creatingLinkedUser = ref(false)
|
const creatingLinkedUser = ref(false)
|
||||||
const createLinkedUserModalOpen = ref(false)
|
const createLinkedUserModalOpen = ref(false)
|
||||||
const createdLinkedUserPassword = ref("")
|
const createdLinkedUserPassword = ref("")
|
||||||
|
const generatingCalendarSubscription = ref(false)
|
||||||
const createLinkedUserForm = reactive({
|
const createLinkedUserForm = reactive({
|
||||||
email: "",
|
email: "",
|
||||||
})
|
})
|
||||||
@@ -26,6 +28,30 @@ const selectMenuUi = {
|
|||||||
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
|
const canCreateLinkedUser = computed(() => Boolean(auth.user?.is_admin && profile.value && !profile.value.user_id))
|
||||||
const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
|
const linkedUserStatusLabel = computed(() => profile.value?.user_id ? "Benutzer verknüpft" : "Kein Benutzer verknüpft")
|
||||||
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
|
const linkedUserStatusColor = computed(() => profile.value?.user_id ? "green" : "orange")
|
||||||
|
const calendarSubscriptionHttpUrl = computed(() => {
|
||||||
|
const token = profile.value?.calendar_subscription_token
|
||||||
|
if (!token) return ""
|
||||||
|
|
||||||
|
const path = profile.value?.calendar_subscription_path || `/api/public/calendar/subscriptions/${token}.ics`
|
||||||
|
const apiBase = String(runtimeConfig.public.apiBase || "")
|
||||||
|
|
||||||
|
if (/^https?:\/\//i.test(apiBase)) {
|
||||||
|
const apiUrl = new URL(apiBase)
|
||||||
|
return new URL(path, `${apiUrl.protocol}//${apiUrl.host}`).toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.client) {
|
||||||
|
return `${window.location.origin}${path}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
})
|
||||||
|
|
||||||
|
const calendarSubscriptionWebcalUrl = computed(() =>
|
||||||
|
calendarSubscriptionHttpUrl.value
|
||||||
|
? calendarSubscriptionHttpUrl.value.replace(/^https?/i, "webcal")
|
||||||
|
: ""
|
||||||
|
)
|
||||||
|
|
||||||
async function fetchBranches() {
|
async function fetchBranches() {
|
||||||
try {
|
try {
|
||||||
@@ -195,6 +221,55 @@ async function createLinkedUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function generateCalendarSubscription() {
|
||||||
|
if (!profile.value || generatingCalendarSubscription.value) return
|
||||||
|
|
||||||
|
generatingCalendarSubscription.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
profile.value = await $api(`/api/profiles/${id}/calendar-subscription-token`, {
|
||||||
|
method: "POST"
|
||||||
|
})
|
||||||
|
|
||||||
|
ensureWorkingHoursStructure()
|
||||||
|
ensureBranchStructure()
|
||||||
|
ensureTeamStructure()
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
title: "Kalender-Abo erstellt",
|
||||||
|
description: "Der abonnierbare Kalender-Link wurde generiert.",
|
||||||
|
color: "green"
|
||||||
|
})
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("[generateCalendarSubscription]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Kalender-Abo konnte nicht erstellt werden",
|
||||||
|
description: err?.data?.error || err?.message || "Unbekannter Fehler",
|
||||||
|
color: "red"
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
generatingCalendarSubscription.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyCalendarSubscriptionUrl(value: string, successTitle: string) {
|
||||||
|
if (!value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value)
|
||||||
|
toast.add({
|
||||||
|
title: successTitle,
|
||||||
|
color: "green"
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[copyCalendarSubscriptionUrl]", err)
|
||||||
|
toast.add({
|
||||||
|
title: "Link konnte nicht kopiert werden",
|
||||||
|
color: "red"
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const weekdays = [
|
const weekdays = [
|
||||||
{ key: '1', label: 'Montag' },
|
{ key: '1', label: 'Montag' },
|
||||||
{ key: '2', label: 'Dienstag' },
|
{ key: '2', label: 'Dienstag' },
|
||||||
@@ -471,6 +546,57 @@ onMounted(async () => {
|
|||||||
</UForm>
|
</UForm>
|
||||||
</UCard>
|
</UCard>
|
||||||
|
|
||||||
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
|
<USeparator label="Kalender-Abo" />
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Hier kann ein abonnierbarer Kalender-Link für Handy-Kalender erzeugt werden. Das Abo nutzt einen persönlichen Backend-Link und kann in vielen Kalender-Apps direkt als `webcal` oder `ics` eingebunden werden.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
icon="i-heroicons-link"
|
||||||
|
color="primary"
|
||||||
|
:loading="generatingCalendarSubscription"
|
||||||
|
@click="generateCalendarSubscription"
|
||||||
|
>
|
||||||
|
{{ profile.calendar_subscription_token ? 'Link neu generieren' : 'Link generieren' }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="calendarSubscriptionHttpUrl"
|
||||||
|
icon="i-heroicons-clipboard-document"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyCalendarSubscriptionUrl(calendarSubscriptionHttpUrl, 'ICS-Link kopiert')"
|
||||||
|
>
|
||||||
|
ICS kopieren
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="calendarSubscriptionWebcalUrl"
|
||||||
|
icon="i-heroicons-device-phone-mobile"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
@click="copyCalendarSubscriptionUrl(calendarSubscriptionWebcalUrl, 'Webcal-Link kopiert')"
|
||||||
|
>
|
||||||
|
Webcal kopieren
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UForm :state="profile" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<UFormField label="ICS-Link" class="w-full">
|
||||||
|
<UInput :model-value="calendarSubscriptionHttpUrl" readonly class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Webcal-Link" class="w-full">
|
||||||
|
<UInput :model-value="calendarSubscriptionWebcalUrl" readonly class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</UForm>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
<UCard v-if="!pending && profile" class="mt-3">
|
<UCard v-if="!pending && profile" class="mt-3">
|
||||||
<USeparator label="Adresse & Standort" />
|
<USeparator label="Adresse & Standort" />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user