Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export default {
})
.then(async () => {
await this.refreshAuthUser()
this.$emit("addedAppleCalendar")
this.$emit("addedCalendar")

this.$posthog.capture("Apple Calendar Added")
})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<template>
<div class="tw-flex tw-flex-col tw-gap-6">
<div class="tw-flex tw-flex-col tw-gap-3">
<div class="tw-text-md tw-flex tw-flex-row tw-items-center tw-justify-start tw-gap-2 tw-font-medium">
Connect an ICS calendar feed
</div>
<div class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-sm tw-text-very-dark-gray">
Paste the ICS feed URL from your calendar provider. This is usually found in your calendar's sharing or export settings.
</div>
</div>
</div>
<div class="tw-flex tw-flex-col tw-gap-3">
<v-text-field solo placeholder="Feed URL" v-model="feedUrl" hide-details="auto" :error-messages="feedUrlError" />
<v-text-field solo placeholder="Label" hide-details v-model="label" />
<div class="tw-flex tw-items-center tw-gap-2">
<v-btn text class="tw-grow" @click="$emit('back')">Back</v-btn>
<v-btn :disabled="!enableSubmit" color="primary" class="tw-grow" :loading="loading"
@click="submit">Submit</v-btn>
</div>
</div>
</div>
</template>

<script>
import { post } from "@/utils"
import { mapActions } from "vuex"
import { urlRegex } from "@/constants";

export default {
name: "ICSCredentials",

data() {
return {
feedUrl: "",
label: "",
loading: false,
}
},

computed: {
enableSubmit() {
return this.label && urlRegex.test(this.feedUrl)
},
feedUrlError() {
if (!this.feedUrl || this.feedUrl.length === 0) return ""
if (!urlRegex.test(this.feedUrl)) return "Please enter a valid URL"
return ""
},
},

methods: {
...mapActions(["showError", "refreshAuthUser"]),
submit() {
this.loading = true
post(`/user/add-ics-calendar-account`, {
feedUrl: this.feedUrl,
label: this.label,
})
.then(async () => {
await this.refreshAuthUser()
this.$emit("addedCalendar")

this.$posthog.capture("ICS Calendar Added")
})
.catch((err) => {
this.showError(
"An error occurred while adding your ICS Calendar! Please check your feed URL or try again later."
)
console.error(err)
})
.finally(() => {
this.loading = false
})
},
},
}
</script>
10 changes: 5 additions & 5 deletions frontend/src/components/settings/CalendarAccounts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@
</template>
<CalendarTypeSelector
@addGoogleCalendar="addGoogleCalendar"
@addedAppleCalendar="addedAppleCalendar"
@addOutlookCalendar="addOutlookCalendar"
@addedCalendar="addedCalendar"
/>
</v-dialog>
</span>
Expand Down Expand Up @@ -155,10 +155,6 @@ export default {
selectAccount: true,
})
},
addedAppleCalendar() {
this.addCalendarAccountDialog = false
this.calendarAccounts = this.authUser.calendarAccounts
},
addOutlookCalendar() {
signInOutlook({
state: {
Expand All @@ -171,6 +167,10 @@ export default {
requestCalendarPermission: true,
})
},
addedCalendar() {
this.addCalendarAccountDialog = false
this.calendarAccounts = this.authUser.calendarAccounts
},
openRemoveDialog(payload) {
this.removeDialog = true
this.removePayload = payload
Expand Down
25 changes: 24 additions & 1 deletion frontend/src/components/settings/CalendarTypeSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,19 @@
<v-spacer />
</div>
</v-btn>
<v-btn block @click="state = states.ICS_CREDENTIALS">
<div class="tw-flex tw-w-full tw-items-center tw-gap-2">
<v-icon
class="tw-flex-initial"
size="20"
>
mdi-calendar-sync
</v-icon>
<v-spacer />
ICS Calendar Feed
<v-spacer />
</div>
</v-btn>
</div>
</v-card-text>
</div>
Expand All @@ -54,27 +67,37 @@
<AppleCredentials
v-if="state === states.APPLE_CREDENTIALS"
@back="state = states.PICK_CALENDAR"
@addedAppleCalendar="$emit('addedAppleCalendar')"
@addedCalendar="$emit('addedCalendar')"
/>
</v-expand-transition>
<v-expand-transition>
<ICSCredentials
v-if="state === states.ICS_CREDENTIALS"
@back="state = states.PICK_CALENDAR"
@addedCalendar="$emit('addedCalendar')"
/>
</v-expand-transition>
</v-card>
</template>

<script>
import AppleCredentials from "@/components/calendar_permission_dialogs/AppleCredentials.vue"
import ICSCredentials from "@/components/calendar_permission_dialogs/ICSCredentials.vue";

export default {
name: "CalendarTypeSelector",

components: {
AppleCredentials,
ICSCredentials
},

data() {
return {
states: {
PICK_CALENDAR: "pick-calendar",
APPLE_CREDENTIALS: "apple-credentials",
ICS_CREDENTIALS: "ics-credentials",
},
state: "pick-calendar",
}
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const calendarTypes = Object.freeze({
GOOGLE: "google",
APPLE: "apple",
OUTLOOK: "outlook",
ICS: "ics"
})

export const upgradeDialogTypes = Object.freeze({
Expand Down Expand Up @@ -181,3 +182,5 @@ export const allTimezones = Object.freeze({
export const guestUserId = "000000000000000000000000"

export const numFreeEvents = 3

export const urlRegex = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/
4 changes: 2 additions & 2 deletions frontend/src/views/Landing.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@
<span
>Timeful allows you to autofill your availability from Google
Calendar,<br class="tw-hidden sm:tw-block" />
Outlook, and Apple Calendar</span
Outlook, Apple Calendar, or an ICS feed URL.</span
> </v-tooltip
>.
</div>
Expand Down Expand Up @@ -359,7 +359,7 @@ export default {
{
question: "What calendars does Timeful integrate with?",
answer:
"Timeful allows you to autofill your availability from your Google Calendar, Outlook, and Apple Calendar. We are working on adding more calendar types soon!",
"Timeful allows you to autofill your availability from your Google Calendar, Outlook, Apple Calendar, or an ICS feed URL. We are working on adding more calendar types soon!",
},
{
question: "Is calendar access required in order to use Timeful?",
Expand Down
7 changes: 7 additions & 0 deletions server/models/calendar.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const (
AppleCalendarType CalendarType = "apple"
GoogleCalendarType CalendarType = "google"
OutlookCalendarType CalendarType = "outlook"
ICSCalendarType CalendarType = "ics"
)

// OAuth2CalendarAuth contains necessary auth info for the user's google calendar account
Expand All @@ -27,11 +28,17 @@ type AppleCalendarAuth struct {
Password string `json:"-" bson:"password,omitempty"`
}

type ICSCalendarAuth struct {
FeedURL string `json:"-" bson:"feedUrl,omitempty"`
Label string `json:"label" bson:"label,omitempty"`
}

// CalendarAccount contains info about the user's other signed in calendar accounts
type CalendarAccount struct {
CalendarType CalendarType `json:"calendarType" bson:"calendarType,omitempty"`
OAuth2CalendarAuth *OAuth2CalendarAuth `json:"oAuth2CalendarAuth" bson:"oAuth2CalendarAuth,omitempty"`
AppleCalendarAuth *AppleCalendarAuth `json:"appleCalendarAuth" bson:"appleCalendarAuth,omitempty"`
ICSCalendarAuth *ICSCalendarAuth `json:"icsCalendarAuth" bson:"icsCalendarAuth,omitempty"`

Email string `json:"email" bson:"email"` // Email is required for all calendar accounts
Picture string `json:"picture" bson:"picture,omitempty"`
Expand Down
46 changes: 46 additions & 0 deletions server/routes/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func InitUser(router *gin.RouterGroup) {
userRouter.POST("/add-google-calendar-account", addGoogleCalendarAccount)
userRouter.POST("/add-apple-calendar-account", addAppleCalendarAccount)
userRouter.POST("/add-outlook-calendar-account", addOutlookCalendarAccount)
userRouter.POST("/add-ics-calendar-account", addICSCalendarAccount)
userRouter.DELETE("/remove-calendar-account", removeCalendarAccount)
userRouter.POST("/toggle-calendar", toggleCalendar)
userRouter.POST("/toggle-sub-calendar", toggleSubCalendar)
Expand Down Expand Up @@ -458,11 +459,54 @@ func addOutlookCalendarAccount(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{})
}

// @Summary Adds an ICS calendar account
// @Tags user
// @Accept json
// @Produce json
// @Param payload body object{feedUrl=string,label=string} true "Object containing the feed URL and label of the ICS calendar"
// @Success 200
// @Router /user/add-ics-calendar-account [post]
func addICSCalendarAccount(c *gin.Context) {
payload := struct {
FeedURL string `json:"feedUrl" binding:"required"`
Label string `json:"label" binding:"required"`
}{}
if err := c.BindJSON(&payload); err != nil {
return
}

auth := &models.ICSCalendarAuth{
FeedURL: payload.FeedURL,
Label: payload.Label,
}

// Check if the provided feed URL is reachable
calendarProvider := calendar.ICSCalendar{
ICSCalendarAuth: *auth,
}
_, err := calendarProvider.GetCalendarList()
if err != nil {
c.JSON(http.StatusBadRequest, responses.Error{Error: "Invalid ICS feed URL"})
return
}

addCalendarAccount(c, addCalendarAccountArgs{
calendarType: models.ICSCalendarType,
icsCalendarAuth: auth,
// ICS feeds don't have an email, so we use the label instead
email: payload.Label,
picture: "",
})

c.JSON(http.StatusOK, gin.H{})
}

// Implements the shared functionality for adding a calendar account
type addCalendarAccountArgs struct {
calendarType models.CalendarType
oAuth2CalendarAuth *models.OAuth2CalendarAuth
appleCalendarAuth *models.AppleCalendarAuth
icsCalendarAuth *models.ICSCalendarAuth
email string
picture string
}
Expand All @@ -486,6 +530,8 @@ func addCalendarAccount(c *gin.Context, args addCalendarAccountArgs) {
calendarAccount.OAuth2CalendarAuth = args.oAuth2CalendarAuth
case models.AppleCalendarType:
calendarAccount.AppleCalendarAuth = args.appleCalendarAuth
case models.ICSCalendarType:
calendarAccount.ICSCalendarAuth = args.icsCalendarAuth
}
calendarAccountKey := utils.GetCalendarAccountKey(args.email, args.calendarType)

Expand Down
Loading