Skip to content

Commit

Permalink
Disconnect Google:
Browse files Browse the repository at this point in the history
* Add a new "Connected Accounts" section in Settings.
* Adds a `google/disconnect` route which removes connection details and calendars.
* Make Settings mobile responsive.
* Adjust styling for SecondaryButton on dark mode.
* Adjust styling for Settings page titles.
* Fix Settings page internal routing.
  • Loading branch information
MelissaAutumn committed May 7, 2024
1 parent 6aa3ee1 commit 248d7cb
Show file tree
Hide file tree
Showing 12 changed files with 247 additions and 81 deletions.
10 changes: 10 additions & 0 deletions backend/src/appointment/database/repo/calendar.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,13 @@ def delete_by_subscriber(db: Session, subscriber_id: int):
for calendar in calendars:
delete(db, calendar_id=calendar.id)
return True


def delete_by_subscriber_and_provider(db: Session, subscriber_id: int, provider: models.CalendarProvider):
"""Delete all subscriber's calendar by a provider"""
calendars = get_by_subscriber(db, subscriber_id=subscriber_id)
for calendar in calendars:
if calendar.provider == provider:
delete(db, calendar_id=calendar.id)

return True
2 changes: 1 addition & 1 deletion backend/src/appointment/dependencies/google.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
)


def get_google_client():
def get_google_client() -> 'GoogleClient':
"""Returns the google client instance"""
try:
_google_client.setup()
Expand Down
32 changes: 25 additions & 7 deletions backend/src/appointment/routes/google.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
import json
import os
from datetime import datetime

from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse

from ..controller.apis.google_client import GoogleClient
from ..database import repo, schemas
from ..database import repo, schemas, models
from sqlalchemy.orm import Session

from ..dependencies.auth import get_subscriber
from ..dependencies.database import get_db

from ..database.models import Subscriber, ExternalConnectionType, ExternalConnections
from ..database.models import Subscriber, ExternalConnectionType
from ..dependencies.google import get_google_client
from ..exceptions.google_api import GoogleInvalidCredentials
from ..exceptions.google_api import GoogleScopeChanged
Expand Down Expand Up @@ -76,10 +74,12 @@ def google_callback(
if google_id is None:
return google_callback_error(l10n('google-auth-fail'))

external_connection = repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google, google_id)
external_connection = repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google,
google_id)

# Create an artificial limit of one google account per account, mainly because we didn't plan for multiple accounts!
remainder = list(filter(lambda ec: ec.type_id != google_id, repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google)))
remainder = list(filter(lambda ec: ec.type_id != google_id,
repo.external_connection.get_by_type(db, subscriber.id, ExternalConnectionType.google)))

if len(remainder) > 0:
return google_callback_error(l10n('google-only-one'))
Expand All @@ -97,7 +97,7 @@ def google_callback(
repo.external_connection.create(db, external_connection_schema)
else:
repo.external_connection.update_token(db, creds.to_json(), subscriber.id,
ExternalConnectionType.google, google_id)
ExternalConnectionType.google, google_id)

error_occurred = google_client.sync_calendars(db, subscriber_id=subscriber.id, token=creds)

Expand All @@ -111,3 +111,21 @@ def google_callback(
def google_callback_error(error: str):
"""Call if you encounter an unrecoverable error with the Google callback function"""
return RedirectResponse(f"{os.getenv('FRONTEND_URL', 'http://localhost:8080')}/settings/calendar?error={error}")


@router.post("/disconnect")
def disconnect_account(
db: Session = Depends(get_db),
subscriber: Subscriber = Depends(get_subscriber),
):
"""Disconnects a google account. Removes associated data from our services and deletes the connection details."""
google_connection = subscriber.get_external_connection(ExternalConnectionType.google)

# Remove all of their google calendars (We only support one connection so this should be good for now)
repo.calendar.delete_by_subscriber_and_provider(db, subscriber.id, provider=models.CalendarProvider.google)

# Remove their account details
repo.external_connection.delete_by_type(db, subscriber.id, google_connection.type, google_connection.type_id)


return True
45 changes: 2 additions & 43 deletions frontend/src/components/SettingsAccount.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-8">
<div class="text-3xl font-semibold text-gray-500">{{ t('heading.accountSettings') }}</div>
<div class="text-3xl font-thin text-gray-500 dark:text-gray-200">{{ t('heading.accountSettings') }}</div>
<div class="flex max-w-3xl flex-col pl-6">
<div class="text-xl">{{ t('heading.profile') }}</div>
<label class="mt-4 flex items-center pl-4">
Expand Down Expand Up @@ -59,30 +59,6 @@
/>
</div>
</div>
<div class="max-w-3xl pl-6">
<div class="mb-4 text-xl">{{ t('heading.zoom') }}</div>
<div>
<p>{{ t('text.connectZoom') }}</p>
</div>
<div class="mt-4 flex items-center pl-4">
<div class="w-full max-w-2xs">
<p v-if="hasZoomAccountConnected">{{ t('label.connectedAs', { name: zoomAccountName }) }}</p>
<p v-if="!hasZoomAccountConnected">{{ t('label.notConnected') }}</p>
</div>
<div class="mx-auto mr-0">
<primary-button
v-if="!hasZoomAccountConnected"
:label="t('label.connect')"
@click="connectZoom"
/>
<caution-button
v-if="hasZoomAccountConnected"
:label="t('label.disconnect')"
@click="disconnectZoom"
/>
</div>
</div>
</div>
<div class="pl-6">
<div class="text-xl">{{ t('heading.accountData') }}</div>
<div class="mt-4 pl-4">
Expand Down Expand Up @@ -147,12 +123,11 @@

<script setup>
import {
ref, inject, onMounted, computed,
ref, inject, onMounted,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user-store';
import { storeToRefs } from 'pinia';
import CautionButton from '@/elements/CautionButton.vue';
import ConfirmationModal from '@/components/ConfirmationModal.vue';
import PrimaryButton from '@/elements/PrimaryButton.vue';
Expand All @@ -171,11 +146,6 @@ const call = inject('call');
const router = useRouter();
const user = useUserStore();
const externalConnectionsStore = useExternalConnectionsStore();
const { zoom: zoomConnections, $reset: resetConnections } = storeToRefs(externalConnectionsStore);
// Currently we only support one zoom account being connected at once.
const hasZoomAccountConnected = computed(() => (zoomConnections.value.length) > 0);
const zoomAccountName = computed(() => (zoomConnections.value[0]?.name ?? null));
const activeUsername = ref(user.data.username);
const activeDisplayName = ref(user.data.name);
Expand Down Expand Up @@ -237,17 +207,6 @@ onMounted(async () => {
await refreshData();
});
const connectZoom = async () => {
const { data } = await call('zoom/auth').get().json();
// Ship them to the auth link
window.location.href = data.value.url;
};
const disconnectZoom = async () => {
await call('zoom/disconnect').post();
await resetConnections();
await refreshData();
};
const downloadData = async () => {
downloadAccountModalOpen.value = true;
};
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/SettingsCalendar.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-8">
<div class="text-3xl font-semibold text-gray-500">{{ t('heading.calendarSettings') }}</div>
<div class="text-3xl font-thin text-gray-500 dark:text-gray-200">{{ t('heading.calendarSettings') }}</div>
<div class="flex flex-col gap-6 pl-6">
<alert-box
@close="calendarConnectError = ''"
Expand Down
151 changes: 151 additions & 0 deletions frontend/src/components/SettingsConnections.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<template>
<div class="flex flex-col gap-8">
<div class="text-3xl font-thin text-gray-500 dark:text-gray-200">{{ t('heading.connectedAccounts') }}</div>
<div class="max-w-3xl pl-6" v-for="(connection, category) in connections" v-bind:key="category">
<h2 class="mb-4 text-xl font-medium">{{ t(`heading.settings.connectedAccounts.${category}`) }}</h2>
<p>{{ t(`text.settings.connectedAccounts.connect.${category}`) }}</p>
<div v-if="category === 'google'" class="pt-2">
<p>
<i18n-t :keypath="`text.settings.connectedAccounts.connect.${category}Legal.text`" tag="label" :for="`text.settings.connectedAccounts.connect.${category}Legal.link`">
<a class="underline" href="https://developers.google.com/terms/api-services-user-data-policy" target="_blank">{{ t(`text.settings.connectedAccounts.connect.${category}Legal.link`) }}</a>
</i18n-t>
</p>
</div>
<div class="mt-4 flex items-center pl-4">
<div class="w-full max-w-md">
<p v-if="connection[0]">{{ t('label.connectedAs', { name: connection[0].name }) }}</p>
<p v-if="!connection[0]">{{ t('label.notConnected') }}</p>
</div>
<div class="mx-auto mr-0" v-if="category !== 'fxa'">
<primary-button
v-if="!connection[0]"
:label="t('label.connect')"
@click="() => connectAccount(category)"
/>
<caution-button
v-if="connection[0]"
:label="t('label.disconnect')"
@click="() => displayModal(category)"
/>
</div>
<div class="mx-auto mr-0" v-else>
<secondary-button
:label="t('label.editProfile')"
@click="editProfile"
/>
</div>
</div>
</div>
</div>
<!-- Disconnect Google Modal -->
<ConfirmationModal
:open="disconnectGoogleModalOpen"
:title="t('text.settings.connectedAccounts.disconnect.google.title')"
:message="t('text.settings.connectedAccounts.disconnect.google.message')"
:confirm-label="t('text.settings.connectedAccounts.disconnect.google.confirm')"
:cancel-label="t('text.settings.connectedAccounts.disconnect.google.cancel')"
:use-caution-button="true"
@confirm="() => disconnectAccount('google')"
@close="closeModals"
></ConfirmationModal>
<!-- Disconnect Zoom Modal -->
<ConfirmationModal
:open="disconnectZoomModalOpen"
:title="t('text.settings.connectedAccounts.disconnect.zoom.title')"
:message="t('text.settings.connectedAccounts.disconnect.zoom.message')"
:confirm-label="t('text.settings.connectedAccounts.disconnect.zoom.confirm')"
:cancel-label="t('text.settings.connectedAccounts.disconnect.zoom.cancel')"
:use-caution-button="true"
@confirm="() => disconnectAccount('zoom')"
@close="closeModals"
></ConfirmationModal>
</template>

<script setup>
import {
ref, inject, onMounted, computed,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user-store';
import { storeToRefs } from 'pinia';
import CautionButton from '@/elements/CautionButton.vue';
import ConfirmationModal from '@/components/ConfirmationModal.vue';
import PrimaryButton from '@/elements/PrimaryButton.vue';
import SecondaryButton from '@/elements/SecondaryButton.vue';
import TextButton from '@/elements/TextButton.vue';
// icons
import { IconExternalLink } from '@tabler/icons-vue';
// stores
import { useExternalConnectionsStore } from '@/stores/external-connections-store';
import { useCalendarStore } from '@/stores/calendar-store';
// component constants
const { t } = useI18n({ useScope: 'global' });
const call = inject('call');
const router = useRouter();
const externalConnectionsStore = useExternalConnectionsStore();
const calendarStore = useCalendarStore();
const { connections } = storeToRefs(externalConnectionsStore);
const { $reset: resetConnections } = externalConnectionsStore;
const fxaEditProfileUrl = inject('fxaEditProfileUrl');
const disconnectZoomModalOpen = ref(false);
const disconnectGoogleModalOpen = ref(false);
const closeModals = () => {
disconnectZoomModalOpen.value = false;
disconnectGoogleModalOpen.value = false;
};
const refreshData = async () => {
// Need to reset calendar store first!
await calendarStore.$reset();
await Promise.all([
externalConnectionsStore.fetch(call),
calendarStore.fetch(call),
]);
};
onMounted(async () => {
await refreshData();
});
const displayModal = async (category) => {
if (category === 'zoom') {
disconnectZoomModalOpen.value = true;
} else if (category === 'google') {
disconnectGoogleModalOpen.value = true;
}
};
const connectAccount = async (category) => {
if (category === 'zoom') {
const { data } = await call('zoom/auth').get().json();
// Ship them to the auth link
window.location.href = data.value.url;
} else if (category === 'google') {
await router.push('/settings/calendar');
}
};
const disconnectAccount = async (category) => {
if (category === 'zoom') {
await call('zoom/disconnect').post();
} else if (category === 'google') {
await call('google/disconnect').post();
}
await resetConnections();
await refreshData();
await closeModals();
};
const editProfile = async () => {
window.location = fxaEditProfileUrl;
};
</script>
2 changes: 1 addition & 1 deletion frontend/src/components/SettingsGeneral.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<template>
<div class="flex flex-col gap-8">
<div class="text-3xl font-semibold text-gray-500">{{ t('heading.generalSettings') }}</div>
<div class="text-3xl font-thin text-gray-500 dark:text-gray-200">{{ t('heading.generalSettings') }}</div>
<div class="pl-6">
<div class="text-xl">{{ t('heading.languageAndAppearance') }}</div>
<div class="mt-6 pl-6">
Expand Down
6 changes: 2 additions & 4 deletions frontend/src/definitions.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,8 @@ export const viewTypes = {
export const settingsSections = {
general: 1,
calendar: 2,
// appointmentsAndBooking: 3,
account: 4,
// privacy: 5,
// faq: 6,
account: 3,
connectedAccounts: 4,
};

/**
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/elements/SecondaryButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
relative flex h-10 items-center justify-center gap-2 whitespace-nowrap rounded-full border
border-teal-500 bg-white px-6 text-base font-semibold text-gray-600 transition-all ease-in-out
hover:scale-102 hover:shadow-md active:scale-98 disabled:scale-100 disabled:opacity-50 disabled:shadow-none
dark:bg-gray-700 dark:text-gray-400
dark:bg-gray-700 dark:text-gray-100
"
:class="{ '!text-transparent': waiting }"
@click="copy ? copyToClipboard() : null"
Expand Down
Loading

0 comments on commit 248d7cb

Please sign in to comment.