This repository has no description
32 kB
898 lines
1diff --git a/frontend/public/index.html b/frontend/public/index.html
2index a300a8a4..48cb0e61 100644
3--- a/frontend/public/index.html
4+++ b/frontend/public/index.html
5@@ -1,21 +1,6 @@
6 <!DOCTYPE html>
7 <html lang="en">
8 <head>
9- <!-- Google Tag Manager -->
10- <script>
11- ;(function (w, d, s, l, i) {
12- w[l] = w[l] || []
13- w[l].push({ "gtm.start": new Date().getTime(), event: "gtm.js" })
14- var f = d.getElementsByTagName(s)[0],
15- j = d.createElement(s),
16- dl = l != "dataLayer" ? "&l=" + l : ""
17- j.async = true
18- j.src = "https://www.googletagmanager.com/gtm.js?id=" + i + dl
19- f.parentNode.insertBefore(j, f)
20- })(window, document, "script", "dataLayer", "GTM-M677X6V")
21- </script>
22- <!-- End Google Tag Manager -->
23- <script async src="https://cdn.fuseplatform.net/publift/tags/2/4241/fuse.js"></script>
24
25 <meta charset="utf-8" />
26 <meta http-equiv="X-UA-Compatible" content="IE=edge" />
27@@ -59,16 +44,6 @@
28 />
29 </head>
30 <body>
31- <!-- Google Tag Manager (noscript) -->
32- <noscript
33- ><iframe
34- src="https://www.googletagmanager.com/ns.html?id=GTM-M677X6V"
35- height="0"
36- width="0"
37- style="display: none; visibility: hidden"
38- ></iframe
39- ></noscript>
40- <!-- End Google Tag Manager (noscript) -->
41 <noscript>
42 <strong
43 >We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work
44diff --git a/frontend/src/App.vue b/frontend/src/App.vue
45index 176178b5..01469b17 100644
46--- a/frontend/src/App.vue
47+++ b/frontend/src/App.vue
48@@ -357,7 +357,10 @@ export default {
49 this.webviewDialog = true
50 return
51 }
52- this.$router.push({ name: "sign-in" })
53+ this.$router.push({
54+ name: "sign-in",
55+ query: { next: this.$route.fullPath },
56+ })
57 // this.signInDialog = true
58 } else {
59 this.$router.push({ name: "sign-in" })
60diff --git a/frontend/src/components/Footer.vue b/frontend/src/components/Footer.vue
61index 35d9a6d5..ed818994 100644
62--- a/frontend/src/components/Footer.vue
63+++ b/frontend/src/components/Footer.vue
64@@ -30,7 +30,7 @@
65 </svg>
66 </a>
67 <a
68- href="https://github.com/schej-it/timeful.app"
69+ href="https://github.com/Lassulus/superconfig/tree/master/pkgs/timeful"
70 aria-label="GitHub"
71 target="_blank"
72 ><v-icon class="tw-text-white/80 hover:tw-text-white"
73diff --git a/frontend/src/components/home/Dashboard.vue b/frontend/src/components/home/Dashboard.vue
74index c56eebff..af75f247 100644
75--- a/frontend/src/components/home/Dashboard.vue
76+++ b/frontend/src/components/home/Dashboard.vue
77@@ -8,7 +8,7 @@
78 Dashboard
79 </div>
80 <div
81- v-if="!isPremiumUser"
82+ v-if="enablePaywall && !isPremiumUser"
83 class="tw-flex tw-items-baseline tw-gap-2 tw-text-sm tw-font-normal tw-text-very-dark-gray"
84 >
85 <div>
86@@ -240,7 +240,13 @@ export default {
87 },
88 computed: {
89 ...mapGetters(["isPremiumUser"]),
90- ...mapState(["authUser", "events", "groupsEnabled", "folders"]),
91+ ...mapState([
92+ "authUser",
93+ "events",
94+ "groupsEnabled",
95+ "folders",
96+ "enablePaywall",
97+ ]),
98 orderedFolders() {
99 return this.folders.sort((a, b) => {
100 return a.name.localeCompare(b.name)
101diff --git a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
102index 1e028082..d7377fa3 100644
103--- a/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
104+++ b/frontend/src/components/schedule_overlap/ScheduleOverlap.vue
105@@ -1048,6 +1048,7 @@ import {
106 isPremiumUser,
107 prefersStartOnMonday,
108 } from "@/utils"
109+import { buildIcs, downloadIcs } from "@/utils/ics_utils"
110 import {
111 availabilityTypes,
112 calendarOptionsDefaults,
113@@ -1250,11 +1251,7 @@ export default {
114 ...mapState(["authUser", "overlayAvailabilitiesEnabled"]),
115 ...mapGetters(["isPremiumUser"]),
116 showAds() {
117- return (
118- !this.ownerIsPremium &&
119- !this.isPremiumUser &&
120- this.state !== this.states.SET_SPECIFIC_TIMES
121- )
122+ return false
123 },
124 /** Returns the width of the right side of the calendar */
125 rightSideWidth() {
126@@ -2076,7 +2073,7 @@ export default {
127 }
128 return `Tap and drag to add your "available" ${daysOrTimes} in green.`
129 case this.states.SCHEDULE_EVENT:
130- return "Tap and drag on the calendar to schedule a Google Calendar event during those times."
131+ return "Tap and drag on the calendar to schedule an event during those times."
132 default:
133 return ""
134 }
135@@ -2092,7 +2089,7 @@ export default {
136 }
137 return `Click and drag to add your "available" ${daysOrTimes} in green.`
138 case this.states.SCHEDULE_EVENT:
139- return "Click and drag on the calendar to schedule a Google Calendar event during those times."
140+ return "Click and drag on the calendar to schedule an event during those times."
141 default:
142 return ""
143 }
144@@ -3563,8 +3560,11 @@ export default {
145 this.state = this.defaultState
146 },
147
148- /** Redirect user to Google Calendar to finish the creation of the event */
149- confirmScheduleEvent(googleCalendar = true) {
150+ /**
151+ * Finish scheduling the dragged-out slot: open Google Calendar / Outlook
152+ * prefilled, or download an .ics file. target: "google" | "outlook" | "ics"
153+ */
154+ confirmScheduleEvent(target = "google") {
155 if (!this.curScheduledEvent) return
156 // if (!isPremiumUser(this.authUser)) {
157 // this.showUpgradeDialog({
158@@ -3615,9 +3615,27 @@ export default {
159 const emailsString = encodeURIComponent(emails.filter(Boolean).join(","))
160
161 const eventId = this.event.shortId ?? this.event._id
162+ const eventUrl = `${window.location.origin}/e/${eventId}`
163+ const description = `\n\nThis event was scheduled with Timeful: ${eventUrl}`
164+
165+ if (target === "ics") {
166+ const ics = buildIcs({
167+ uid: `${this.event._id}-${startDate.getTime()}@${window.location.host}`,
168+ start: startDate,
169+ end: endDate,
170+ summary: this.event.name,
171+ description: description.trim(),
172+ location: this.event.location,
173+ url: eventUrl,
174+ })
175+ const filename = `${this.event.name.replace(/[^\w-]+/g, "_") || "event"}.ics`
176+ downloadIcs(filename, ics)
177+ this.state = this.defaultState
178+ return
179+ }
180
181 let url = ""
182- if (googleCalendar) {
183+ if (target === "google") {
184 // Format start and end date to be in the format required by gcal (remove -, :, and .000)
185 const start = startDate.toISOString().replace(/([-:]|\.000)/g, "")
186 const end = endDate.toISOString().replace(/([-:]|\.000)/g, "")
187@@ -3626,14 +3644,13 @@ export default {
188 url = `https://calendar.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent(
189 this.event.name
190 )}&dates=${start}/${end}&details=${encodeURIComponent(
191- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/"
192- )}${eventId}&ctz=${this.curTimezone.value}&add=${emailsString}`
193+ description
194+ )}&ctz=${this.curTimezone.value}&add=${emailsString}`
195 } else {
196 url = `https://outlook.live.com/calendar/0/deeplink/compose?subject=${encodeURIComponent(
197 this.event.name
198 )}&body=${encodeURIComponent(
199- "\n\nThis event was scheduled with Timeful: https://timeful.app/e/" +
200- eventId
201+ description
202 )}&startdt=${startDate.toISOString()}&enddt=${endDate.toISOString()}&location=${encodeURIComponent(
203 this.event.location || ""
204 )}&path=/calendar/action/compose&timezone=${this.curTimezone.value}`
205diff --git a/frontend/src/components/schedule_overlap/ToolRow.vue b/frontend/src/components/schedule_overlap/ToolRow.vue
206index d8aaaddd..5822dd28 100644
207--- a/frontend/src/components/schedule_overlap/ToolRow.vue
208+++ b/frontend/src/components/schedule_overlap/ToolRow.vue
209@@ -118,7 +118,9 @@
210 </v-btn>
211 </template>
212 <v-list dense>
213- <v-list-item @click="(e) => $emit('confirmScheduleEvent', true)">
214+ <v-list-item
215+ @click="(e) => $emit('confirmScheduleEvent', 'google')"
216+ >
217 <v-img
218 src="@/assets/gcal_logo.png"
219 class="tw-mr-2 tw-flex-none"
220@@ -129,7 +131,9 @@
221 <v-list-item-title>Google Calendar</v-list-item-title>
222 </v-list-item-content>
223 </v-list-item>
224- <v-list-item @click="(e) => $emit('confirmScheduleEvent', false)">
225+ <v-list-item
226+ @click="(e) => $emit('confirmScheduleEvent', 'outlook')"
227+ >
228 <v-img
229 src="@/assets/outlook_logo.svg"
230 class="tw-mr-2 tw-flex-none"
231@@ -140,6 +144,14 @@
232 <v-list-item-title>Outlook</v-list-item-title>
233 </v-list-item-content>
234 </v-list-item>
235+ <v-list-item @click="(e) => $emit('confirmScheduleEvent', 'ics')">
236+ <v-icon class="tw-mr-2 tw-flex-none" size="20"
237+ >mdi-calendar-export</v-icon
238+ >
239+ <v-list-item-content>
240+ <v-list-item-title>Download .ics</v-list-item-title>
241+ </v-list-item-content>
242+ </v-list-item>
243 </v-list>
244 </v-menu>
245 </template>
246diff --git a/frontend/src/main.js b/frontend/src/main.js
247index 2d370f7a..9dfa971a 100644
248--- a/frontend/src/main.js
249+++ b/frontend/src/main.js
250@@ -5,19 +5,12 @@ import router from "./router"
251 import store from "./store"
252 import vuetify from "./plugins/vuetify"
253 import posthogPlugin from "./plugins/posthog"
254-import VueGtm from "@gtm-support/vue2-gtm"
255 import VueMeta from "vue-meta"
256 import "./index.css"
257
258 // Posthog
259 Vue.use(posthogPlugin)
260
261-// Google Analytics
262-Vue.use(VueGtm, {
263- id: "GTM-M677X6V",
264- vueRouter: router,
265-})
266-
267 // Site Metadata
268 Vue.use(VueMeta)
269
270diff --git a/frontend/src/store/index.js b/frontend/src/store/index.js
271index a0770841..b9460a8f 100644
272--- a/frontend/src/store/index.js
273+++ b/frontend/src/store/index.js
274@@ -29,7 +29,7 @@ export default new Vuex.Store({
275 signUpFormEnabled: false,
276 daysOnlyEnabled: true,
277 overlayAvailabilitiesEnabled: true,
278- enablePaywall: true,
279+ enablePaywall: false,
280
281 // Experiments
282 pricingPageConversion: "control",
283diff --git a/frontend/src/utils/ics_utils.js b/frontend/src/utils/ics_utils.js
284new file mode 100644
285index 00000000..c285bd1b
286--- /dev/null
287+++ b/frontend/src/utils/ics_utils.js
288@@ -0,0 +1,69 @@
289+/** Builds and downloads a single-event iCalendar (RFC 5545) file. */
290+
291+const utf8 = new TextEncoder()
292+
293+/** Escapes a TEXT property value (RFC 5545 §3.3.11) */
294+const escapeText = (text) =>
295+ text
296+ .replace(/\\/g, "\\\\")
297+ .replace(/;/g, "\\;")
298+ .replace(/,/g, "\\,")
299+ .replace(/\r?\n/g, "\\n")
300+
301+/** Formats a Date as a UTC DATE-TIME, e.g. 20261020T070000Z */
302+const formatDateTime = (date) =>
303+ date.toISOString().replace(/[-:]/g, "").replace(/\.\d{3}/, "")
304+
305+/** Folds a content line so no physical line exceeds 75 octets (RFC 5545 §3.1) */
306+const foldLine = (line) => {
307+ const parts = []
308+ let current = ""
309+ let currentBytes = 0
310+ for (const char of line) {
311+ const charBytes = utf8.encode(char).length
312+ // continuation lines start with a space, which counts toward the limit
313+ const limit = parts.length === 0 ? 75 : 74
314+ if (currentBytes + charBytes > limit) {
315+ parts.push(current)
316+ current = ""
317+ currentBytes = 0
318+ }
319+ current += char
320+ currentBytes += charBytes
321+ }
322+ parts.push(current)
323+ return parts.join("\r\n ")
324+}
325+
326+export const buildIcs = ({ uid, start, end, summary, description, location, url }) => {
327+ const lines = [
328+ "BEGIN:VCALENDAR",
329+ "VERSION:2.0",
330+ "PRODID:-//timeful//self-hosted//EN",
331+ "CALSCALE:GREGORIAN",
332+ "METHOD:PUBLISH",
333+ "BEGIN:VEVENT",
334+ `UID:${uid}`,
335+ `DTSTAMP:${formatDateTime(new Date())}`,
336+ `DTSTART:${formatDateTime(start)}`,
337+ `DTEND:${formatDateTime(end)}`,
338+ `SUMMARY:${escapeText(summary)}`,
339+ ]
340+ if (description) lines.push(`DESCRIPTION:${escapeText(description)}`)
341+ if (location) lines.push(`LOCATION:${escapeText(location)}`)
342+ if (url) lines.push(`URL:${url}`)
343+ lines.push("END:VEVENT", "END:VCALENDAR")
344+ return lines.map(foldLine).join("\r\n") + "\r\n"
345+}
346+
347+export const downloadIcs = (filename, icsText) => {
348+ const blob = new Blob([icsText], { type: "text/calendar;charset=utf-8" })
349+ const href = URL.createObjectURL(blob)
350+ const a = document.createElement("a")
351+ a.href = href
352+ a.download = filename
353+ document.body.appendChild(a)
354+ a.click()
355+ a.remove()
356+ URL.revokeObjectURL(href)
357+}
358diff --git a/frontend/src/views/Event.vue b/frontend/src/views/Event.vue
359index 81887280..87dba8d1 100644
360--- a/frontend/src/views/Event.vue
361+++ b/frontend/src/views/Event.vue
362@@ -463,13 +463,29 @@
363 Cancel
364 </v-btn>
365 <v-spacer />
366- <v-btn
367- :disabled="!allowScheduleEvent"
368- class="tw-bg-white tw-text-blue"
369- @click="confirmScheduleEvent"
370- >
371- Schedule
372- </v-btn>
373+ <v-menu top offset-y>
374+ <template v-slot:activator="{ on, attrs }">
375+ <v-btn
376+ :disabled="!allowScheduleEvent"
377+ class="tw-bg-white tw-text-blue"
378+ v-bind="attrs"
379+ v-on="on"
380+ >
381+ Schedule
382+ </v-btn>
383+ </template>
384+ <v-list dense>
385+ <v-list-item @click="confirmScheduleEvent('google')">
386+ <v-list-item-title>Google Calendar</v-list-item-title>
387+ </v-list-item>
388+ <v-list-item @click="confirmScheduleEvent('outlook')">
389+ <v-list-item-title>Outlook</v-list-item-title>
390+ </v-list-item>
391+ <v-list-item @click="confirmScheduleEvent('ics')">
392+ <v-list-item-title>Download .ics</v-list-item-title>
393+ </v-list-item>
394+ </v-list>
395+ </v-menu>
396 </template>
397 </div>
398 <PubliftAd
399@@ -638,11 +654,7 @@ export default {
400 ...mapState(["authUser", "events"]),
401 ...mapGetters(["isPremiumUser"]),
402 showAds() {
403- return (
404- !this.ownerIsPremium &&
405- !this.isPremiumUser &&
406- !this.isSettingSpecificTimes
407- )
408+ return false
409 },
410 allowScheduleEvent() {
411 return this.scheduleOverlapComponent?.allowScheduleEvent
412@@ -998,8 +1010,8 @@ export default {
413 cancelScheduleEvent() {
414 this.scheduleOverlapComponent?.cancelScheduleEvent()
415 },
416- confirmScheduleEvent() {
417- this.scheduleOverlapComponent?.confirmScheduleEvent()
418+ confirmScheduleEvent(target) {
419+ this.scheduleOverlapComponent?.confirmScheduleEvent(target)
420 },
421
422 highlightAvailabilityBtn() {
423diff --git a/frontend/src/views/Landing.vue b/frontend/src/views/Landing.vue
424index b8a50d8f..2c47ac54 100644
425--- a/frontend/src/views/Landing.vue
426+++ b/frontend/src/views/Landing.vue
427@@ -31,13 +31,10 @@
428 class="tw-mb-4 tw-flex tw-select-none tw-items-center tw-rounded-full tw-border tw-border-light-gray-stroke tw-bg-white/70 tw-px-2.5 tw-py-1.5 tw-text-sm tw-text-dark-gray"
429 >
430 We're open source!
431- <github-button
432- v-once
433- class="-tw-mb-1 tw-ml-2"
434- href="https://github.com/schej-it/timeful.app"
435- data-show-count="true"
436- aria-label="Star timeful.app on GitHub"
437- >Star</github-button
438+ <a
439+ class="tw-ml-2 tw-text-blue"
440+ href="https://github.com/Lassulus/superconfig/tree/master/pkgs/timeful"
441+ >Source</a
442 >
443 </div>
444 <div
445@@ -111,20 +108,6 @@
446 transition="fade-transition"
447 contain
448 />
449- <vue-vimeo-player
450- video-url="https://player.vimeo.com/video/1083205305?h=d58bef862a"
451- :player-width="800"
452- :player-height="800"
453- :options="{
454- muted: true,
455- playsinline: true,
456- responsive: true,
457- }"
458- :controls="false"
459- :autoplay="true"
460- :loop="true"
461- @play="onPlay"
462- />
463 </div>
464 </div>
465 </div>
466@@ -168,25 +151,6 @@
467 />
468 </div>
469
470- <!-- Video -->
471- <div
472- class="tw-flex tw-justify-center tw-bg-green tw-px-4 tw-pb-12 tw-pt-24 md:tw-pb-16"
473- >
474- <div
475- class="tw-h-[300px] tw-max-w-3xl tw-flex-1 sm:tw-h-[400px] md:tw-h-[450px]"
476- >
477- <iframe
478- class="tw-h-full tw-w-full"
479- src="https://www.youtube.com/embed/vFkBC8BrkOk?si=pF64JAIyDhom_1do"
480- title="Timeful demo"
481- frameborder="0"
482- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
483- referrerpolicy="strict-origin-when-cross-origin"
484- allowfullscreen
485- ></iframe>
486- </div>
487- </div>
488-
489 <!-- Reddit Testimonials -->
490 <div class="tw-flex tw-justify-center tw-bg-light-gray tw-py-12">
491 <div class="tw-mx-4 tw-max-w-3xl tw-flex-1 sm:tw-mx-16">
492@@ -299,11 +263,9 @@ import NewEvent from "@/components/NewEvent.vue"
493 import NewDialog from "@/components/NewDialog.vue"
494 import LandingPageHeader from "@/components/landing/LandingPageHeader.vue"
495 import Logo from "@/components/Logo.vue"
496-import GithubButton from "vue-github-button"
497 import SignInDialog from "@/components/SignInDialog.vue"
498 import { calendarTypes } from "@/constants"
499 import HowItWorksDialog from "@/components/HowItWorksDialog.vue"
500-import { vueVimeoPlayer } from "vue-vimeo-player"
501 import Footer from "@/components/Footer.vue"
502 import PronunciationMenu from "@/components/PronunciationMenu.vue"
503 import { mapState, mapMutations } from "vuex"
504@@ -325,11 +287,9 @@ export default {
505 NewEvent,
506 NewDialog,
507 LandingPageHeader,
508- GithubButton,
509 Logo,
510 SignInDialog,
511 HowItWorksDialog,
512- vueVimeoPlayer,
513 Footer,
514 PronunciationMenu,
515 AuthUserMenu,
516diff --git a/frontend/src/views/SignIn.vue b/frontend/src/views/SignIn.vue
517index d667be55..6be32f29 100644
518--- a/frontend/src/views/SignIn.vue
519+++ b/frontend/src/views/SignIn.vue
520@@ -32,70 +32,20 @@
521 </v-card-title>
522 <v-card-text class="tw-flex tw-flex-col tw-items-center tw-pt-6">
523 <div class="tw-mb-4 tw-flex tw-w-full tw-flex-col tw-gap-y-2">
524- <v-btn
525- block
526- @click="signIn(calendarTypes.GOOGLE)"
527- class="tw-bg-white"
528- >
529+ <v-btn block @click="signInGithub" class="tw-bg-white">
530 <div class="tw-flex tw-w-full tw-items-center tw-gap-2">
531- <v-img
532- class="tw-flex-initial"
533- width="20"
534- height="20"
535- src="@/assets/google_logo.svg"
536- />
537+ <v-icon class="tw-flex-initial" size="20">mdi-github</v-icon>
538 <v-spacer />
539- {{ isSignUp ? "Sign up with" : "Continue with" }} Google
540+ {{ isSignUp ? "Sign up with" : "Continue with" }} GitHub
541 <v-spacer />
542 </div>
543 </v-btn>
544- <v-btn
545- block
546- @click="signIn(calendarTypes.OUTLOOK)"
547- class="tw-bg-white"
548+ <div
549+ v-if="$route.query.error === 'github'"
550+ class="tw-text-center tw-text-sm tw-text-red"
551 >
552- <div class="tw-flex tw-w-full tw-items-center tw-gap-2">
553- <v-img
554- class="tw-flex-initial"
555- width="20"
556- height="20"
557- src="@/assets/outlook_logo.svg"
558- />
559- <v-spacer />
560- {{ isSignUp ? "Sign up with" : "Continue with" }} Outlook
561- <v-spacer />
562- </div>
563- </v-btn>
564-
565- <div class="tw-my-2 tw-flex tw-items-center tw-gap-3">
566- <v-divider />
567- <span class="tw-text-xs tw-text-gray">or</span>
568- <v-divider />
569- </div>
570-
571- <div>
572- <div class="tw-mb-1 tw-text-sm tw-font-medium">
573- Email address
574- </div>
575- <v-text-field
576- v-model="email"
577- class="tw-mb-2"
578- placeholder="Enter your email..."
579- type="email"
580- solo
581- hide-details="auto"
582- :error-messages="emailError"
583- @keydown.enter="submitEmail"
584- />
585- <v-btn
586- block
587- color="primary"
588- :loading="sending"
589- :disabled="sending"
590- @click="submitEmail"
591- >
592- {{ isSignUp ? "Sign up with" : "Continue with" }} Email
593- </v-btn>
594+ GitHub sign-in failed. Make sure your GitHub account has a
595+ verified email address and try again.
596 </div>
597 </div>
598 <div class="tw-text-center tw-text-xs">
599@@ -251,7 +201,7 @@
600
601 <script>
602 import { authTypes, calendarTypes } from "@/constants"
603-import { post, signInGoogle, signInOutlook } from "@/utils"
604+import { getEventsCreated, post, signInGoogle, signInOutlook } from "@/utils"
605 import { mapMutations } from "vuex"
606 import Logo from "@/components/Logo.vue"
607
608@@ -299,6 +249,15 @@ export default {
609
610 methods: {
611 ...mapMutations(["setAuthUser"]),
612+ signInGithub() {
613+ const params = new URLSearchParams({
614+ timezoneOffset: new Date().getTimezoneOffset(),
615+ eventsToLink: getEventsCreated().join(","),
616+ })
617+ const next = this.$route.query.next
618+ if (typeof next === "string") params.set("redirect", next)
619+ window.location.href = `/api/auth/github?${params}`
620+ },
621 signIn(provider) {
622 const state = this.upgradeRedirect
623 ? { type: authTypes.UPGRADE, upgradeParams: this.$route.query.upgradeParams }
624diff --git a/server/main.go b/server/main.go
625index b038861e..1678b7df 100644
626--- a/server/main.go
627+++ b/server/main.go
628@@ -175,7 +175,9 @@ func main() {
629 router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
630
631 // Run server
632- if os.Getenv("NODE_ENV") == "staging" {
633+ if listenAddr := os.Getenv("LISTEN_ADDR"); listenAddr != "" {
634+ router.Run(listenAddr)
635+ } else if os.Getenv("NODE_ENV") == "staging" {
636 router.Run(":3003")
637 } else {
638 router.Run(":3002")
639diff --git a/server/routes/auth.go b/server/routes/auth.go
640index 6444e714..2869116d 100644
641--- a/server/routes/auth.go
642+++ b/server/routes/auth.go
643@@ -41,6 +41,8 @@ func InitAuth(router *gin.RouterGroup) {
644 authRouter.POST("/otp/check-email", checkEmail)
645 authRouter.POST("/otp/send", sendOtp)
646 authRouter.POST("/otp/verify", verifyOtp)
647+
648+ initGithubAuth(authRouter)
649 }
650
651 // @Summary Signs user in
652diff --git a/server/routes/github_auth.go b/server/routes/github_auth.go
653new file mode 100644
654index 00000000..40ef9206
655--- /dev/null
656+++ b/server/routes/github_auth.go
657@@ -0,0 +1,241 @@
658+/* GitHub OAuth sign-in (self-host patch). Accounts are keyed by the GitHub
659+ * account's primary verified email, like the email OTP flow, and carry no
660+ * calendar access. */
661+package routes
662+
663+import (
664+ "context"
665+ "crypto/rand"
666+ "encoding/hex"
667+ "encoding/json"
668+ "fmt"
669+ "net/http"
670+ "net/url"
671+ "os"
672+ "strconv"
673+ "strings"
674+ "time"
675+
676+ "github.com/gin-contrib/sessions"
677+ "github.com/gin-gonic/gin"
678+ "go.mongodb.org/mongo-driver/bson"
679+ "go.mongodb.org/mongo-driver/bson/primitive"
680+ "schej.it/server/db"
681+ "schej.it/server/logger"
682+ "schej.it/server/models"
683+ "schej.it/server/utils"
684+)
685+
686+var githubHttpClient = &http.Client{Timeout: 10 * time.Second}
687+
688+// Session keys holding the in-flight sign-in between the redirect to GitHub
689+// and the callback.
690+const (
691+ githubStateKey = "githubState"
692+ githubRedirectKey = "githubRedirect"
693+ githubTzKey = "githubTimezoneOffset"
694+ githubLinkKey = "githubEventsToLink"
695+)
696+
697+func initGithubAuth(authRouter *gin.RouterGroup) {
698+ authRouter.GET("/github", githubSignIn)
699+ authRouter.GET("/github/callback", githubCallback)
700+}
701+
702+// @Summary Starts GitHub sign-in
703+// @Description Redirects to GitHub's OAuth consent page. Query: redirect (relative path to return to), timezoneOffset, eventsToLink (comma-separated event ids created while signed out)
704+// @Tags auth
705+// @Router /auth/github [get]
706+func githubSignIn(c *gin.Context) {
707+ clientId := os.Getenv("GITHUB_CLIENT_ID")
708+ if clientId == "" {
709+ c.String(http.StatusNotFound, "GitHub sign-in is not configured")
710+ return
711+ }
712+
713+ stateBytes := make([]byte, 32)
714+ if _, err := rand.Read(stateBytes); err != nil {
715+ logger.StdErr.Panicln(err)
716+ }
717+ state := hex.EncodeToString(stateBytes)
718+
719+ session := sessions.Default(c)
720+ session.Set(githubStateKey, state)
721+ session.Set(githubRedirectKey, safeRedirectPath(c.Query("redirect")))
722+ session.Set(githubTzKey, c.Query("timezoneOffset"))
723+ session.Set(githubLinkKey, c.Query("eventsToLink"))
724+ session.Save()
725+
726+ params := url.Values{}
727+ params.Set("client_id", clientId)
728+ params.Set("scope", "read:user user:email")
729+ params.Set("state", state)
730+ c.Redirect(http.StatusFound, "https://github.com/login/oauth/authorize?"+params.Encode())
731+}
732+
733+// @Summary GitHub OAuth callback
734+// @Description Exchanges the code, finds or creates the user by primary verified email, sets the session and redirects back into the app
735+// @Tags auth
736+// @Router /auth/github/callback [get]
737+func githubCallback(c *gin.Context) {
738+ session := sessions.Default(c)
739+ expectedState, _ := session.Get(githubStateKey).(string)
740+ redirect, _ := session.Get(githubRedirectKey).(string)
741+ tzString, _ := session.Get(githubTzKey).(string)
742+ eventsToLink, _ := session.Get(githubLinkKey).(string)
743+ // The state is single-use: every exit path below saves the session with
744+ // these keys removed. Each Save emits a Set-Cookie, so save exactly once.
745+ session.Delete(githubStateKey)
746+ session.Delete(githubRedirectKey)
747+ session.Delete(githubTzKey)
748+ session.Delete(githubLinkKey)
749+
750+ fail := func(reason string, err error) {
751+ logger.StdErr.Printf("GitHub sign-in failed: %s: %v", reason, err)
752+ session.Save()
753+ c.Redirect(http.StatusFound, "/sign-in?error=github")
754+ }
755+
756+ if expectedState == "" || c.Query("state") != expectedState {
757+ fail("state mismatch", nil)
758+ return
759+ }
760+ if c.Query("error") != "" {
761+ fail("denied", fmt.Errorf("%s", c.Query("error")))
762+ return
763+ }
764+
765+ accessToken, err := githubExchangeCode(c.Query("code"))
766+ if err != nil {
767+ fail("code exchange", err)
768+ return
769+ }
770+
771+ var ghUser struct {
772+ Login string `json:"login"`
773+ Name string `json:"name"`
774+ AvatarUrl string `json:"avatar_url"`
775+ }
776+ if err := githubGet(accessToken, "/user", &ghUser); err != nil {
777+ fail("fetch user", err)
778+ return
779+ }
780+
781+ var ghEmails []struct {
782+ Email string `json:"email"`
783+ Primary bool `json:"primary"`
784+ Verified bool `json:"verified"`
785+ }
786+ if err := githubGet(accessToken, "/user/emails", &ghEmails); err != nil {
787+ fail("fetch emails", err)
788+ return
789+ }
790+ email := ""
791+ for _, e := range ghEmails {
792+ if e.Verified && (e.Primary || email == "") {
793+ email = e.Email
794+ }
795+ }
796+ if email == "" {
797+ fail("no verified email", nil)
798+ return
799+ }
800+ email = utils.NormalizeEmail(email)
801+
802+ var userId primitive.ObjectID
803+ if existing := db.GetUserByEmail(email); existing != nil {
804+ userId = existing.Id
805+ } else {
806+ firstName, lastName := ghUser.Login, ""
807+ if name := strings.TrimSpace(ghUser.Name); name != "" {
808+ firstName, lastName, _ = strings.Cut(name, " ")
809+ }
810+ timezoneOffset, _ := strconv.Atoi(tzString)
811+
812+ res, err := db.UsersCollection.InsertOne(context.Background(), models.User{
813+ Email: email,
814+ FirstName: firstName,
815+ LastName: strings.TrimSpace(lastName),
816+ Picture: ghUser.AvatarUrl,
817+ TimezoneOffset: timezoneOffset,
818+ TokenOrigin: models.WEB,
819+ })
820+ if err != nil {
821+ logger.StdErr.Panicln(err)
822+ }
823+ userId = res.InsertedID.(primitive.ObjectID)
824+ }
825+
826+ // Claim events this browser created while signed out (same as /auth/sign-in)
827+ for _, eventIdString := range strings.Split(eventsToLink, ",") {
828+ eventId, err := primitive.ObjectIDFromHex(eventIdString)
829+ if err == nil {
830+ db.EventsCollection.UpdateOne(context.Background(), bson.M{"_id": eventId, "ownerId": nil}, bson.M{"$set": bson.M{"ownerId": userId}})
831+ }
832+ }
833+
834+ session.Set("userId", userId.Hex())
835+ session.Save()
836+
837+ c.Redirect(http.StatusFound, redirect)
838+}
839+
840+// safeRedirectPath only allows same-origin absolute paths, so the callback
841+// cannot be turned into an open redirect.
842+func safeRedirectPath(path string) string {
843+ if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") || strings.HasPrefix(path, "/\\") {
844+ return "/home"
845+ }
846+ return path
847+}
848+
849+func githubExchangeCode(code string) (string, error) {
850+ form := url.Values{}
851+ form.Set("client_id", os.Getenv("GITHUB_CLIENT_ID"))
852+ form.Set("client_secret", os.Getenv("GITHUB_CLIENT_SECRET"))
853+ form.Set("code", code)
854+
855+ req, err := http.NewRequest(http.MethodPost, "https://github.com/login/oauth/access_token", strings.NewReader(form.Encode()))
856+ if err != nil {
857+ return "", err
858+ }
859+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
860+ req.Header.Set("Accept", "application/json")
861+
862+ resp, err := githubHttpClient.Do(req)
863+ if err != nil {
864+ return "", err
865+ }
866+ defer resp.Body.Close()
867+
868+ var body struct {
869+ AccessToken string `json:"access_token"`
870+ Error string `json:"error"`
871+ }
872+ if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
873+ return "", err
874+ }
875+ if body.AccessToken == "" {
876+ return "", fmt.Errorf("no access token (status %d, error %q)", resp.StatusCode, body.Error)
877+ }
878+ return body.AccessToken, nil
879+}
880+
881+func githubGet(accessToken string, path string, out interface{}) error {
882+ req, err := http.NewRequest(http.MethodGet, "https://api.github.com"+path, nil)
883+ if err != nil {
884+ return err
885+ }
886+ req.Header.Set("Authorization", "Bearer "+accessToken)
887+ req.Header.Set("Accept", "application/vnd.github+json")
888+
889+ resp, err := githubHttpClient.Do(req)
890+ if err != nil {
891+ return err
892+ }
893+ defer resp.Body.Close()
894+ if resp.StatusCode != http.StatusOK {
895+ return fmt.Errorf("GET %s: status %d", path, resp.StatusCode)
896+ }
897+ return json.NewDecoder(resp.Body).Decode(out)
898+}