Skip to content

Commit ea2a2ba

Browse files
committed
feat(auth): adds device code polling support
1 parent 3c47116 commit ea2a2ba

File tree

7 files changed

+261
-18
lines changed

7 files changed

+261
-18
lines changed

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"@dvcol/base-http-client": "^1.10.0",
5555
"@dvcol/common-utils": "^1.3.0",
5656
"@dvcol/tmdb-http-client": "^1.2.3",
57-
"@dvcol/trakt-http-client": "^1.3.6",
57+
"@dvcol/trakt-http-client": "^1.4.0",
5858
"@dvcol/tvdb-http-client": "^1.1.3",
5959
"@dvcol/web-extension-utils": "^3.0.1",
6060
"@vue/devtools": "^7.0.15",

pnpm-lock.yaml

+14-12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<template>
2+
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
3+
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
4+
<g stroke-width="1.5">
5+
<path stroke-dasharray="66" stroke-dashoffset="66" d="M12 3H19V21H5V3H12Z">
6+
<animate
7+
fill="freeze"
8+
attributeName="stroke-dashoffset"
9+
dur="0.6s"
10+
values="66;0"
11+
/>
12+
</path>
13+
<path stroke-dasharray="5" stroke-dashoffset="5" d="M9 10H12">
14+
<animate
15+
fill="freeze"
16+
attributeName="stroke-dashoffset"
17+
begin="1s"
18+
dur="0.2s"
19+
values="5;0"
20+
/>
21+
</path>
22+
<path stroke-dasharray="6" stroke-dashoffset="6" d="M9 13H14">
23+
<animate
24+
fill="freeze"
25+
attributeName="stroke-dashoffset"
26+
begin="1.2s"
27+
dur="0.2s"
28+
values="6;0"
29+
/>
30+
</path>
31+
<path stroke-dasharray="7" stroke-dashoffset="7" d="M9 16H15">
32+
<animate
33+
fill="freeze"
34+
attributeName="stroke-dashoffset"
35+
begin="1.4s"
36+
dur="0.2s"
37+
values="7;0"
38+
/>
39+
</path>
40+
</g>
41+
<path stroke-dasharray="12" stroke-dashoffset="12" d="M14.5 3.5V6.5H9.5V3.5">
42+
<animate
43+
fill="freeze"
44+
attributeName="stroke-dashoffset"
45+
begin="0.7s"
46+
dur="0.2s"
47+
values="12;0"
48+
/>
49+
</path>
50+
</g>
51+
</svg>
52+
</template>

src/components/views/login/LoginCard.vue

+7
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ defineProps({
2828
type: String,
2929
required: false,
3030
},
31+
buttonDisabled: {
32+
type: Boolean,
33+
required: false,
34+
},
3135
buttonText: {
3236
type: String,
3337
required: false,
@@ -55,12 +59,15 @@ const emits = defineEmits<{
5559
<NH4 class="title" prefix="bar">{{ message ?? i18n('sub_title') }}</NH4>
5660
</slot>
5761

62+
<slot name="main" />
63+
5864
<slot name="button">
5965
<NButton
6066
class="button"
6167
secondary
6268
type="primary"
6369
round
70+
:disabled="buttonDisabled"
6471
v-bind="buttonProps"
6572
@click="e => emits('onSignIn', e)"
6673
>

src/components/views/login/LoginComponent.vue

+164-5
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,32 @@
11
<script lang="ts" setup>
2-
import { NCheckbox, NFlex, NText } from 'naive-ui';
2+
import { Config } from '@dvcol/trakt-http-client/config';
3+
import {
4+
type CancellablePolling,
5+
type TraktDeviceAuthentication,
6+
TraktPollingExpiredError,
7+
} from '@dvcol/trakt-http-client/models';
8+
import {
9+
NButton,
10+
NCheckbox,
11+
NFlex,
12+
NIcon,
13+
NInput,
14+
NInputGroup,
15+
NProgress,
16+
NText,
17+
} from 'naive-ui';
318
4-
import { onMounted, ref, Transition, watch } from 'vue';
19+
import { computed, onDeactivated, onMounted, ref, Transition, watch } from 'vue';
520
621
import { useRoute, useRouter } from 'vue-router';
722
823
import GridBackground from '~/components/common/background/GridBackground.vue';
24+
import IconClipboard from '~/components/icons/IconClipboard.vue';
925
import LoginCard from '~/components/views/login/LoginCard.vue';
26+
import { NotificationService } from '~/services/notification.service';
1027
import { TraktService } from '~/services/trakt.service';
1128
import { useAuthSettingsStoreRefs } from '~/stores/settings/auth.store';
29+
import { useLinksStore } from '~/stores/settings/links.store';
1230
import { logger } from '~/stores/settings/log.store';
1331
import { useI18n } from '~/utils/i18n.utils';
1432
@@ -47,6 +65,75 @@ const onSignIn = async () => {
4765
logger.error('Error:', error);
4866
}
4967
};
68+
69+
const { openTab } = useLinksStore();
70+
71+
const useCode = ref(false);
72+
const auth = ref<TraktDeviceAuthentication>();
73+
const code = computed(() => auth.value?.user_code);
74+
const getCodes = async () => {
75+
try {
76+
auth.value = await TraktService.device.code();
77+
// eslint-disable-next-line @typescript-eslint/no-use-before-define -- recursive call
78+
await polling();
79+
} catch (error) {
80+
logger.error('Failed to login with Trakt.tv');
81+
logger.error(error);
82+
}
83+
};
84+
85+
const poll = ref<CancellablePolling>();
86+
const progress = ref(0);
87+
const progressInterval = ref<ReturnType<typeof setInterval>>();
88+
89+
const onCancel = () => {
90+
if (poll.value) poll.value.cancel();
91+
if (progressInterval.value) clearInterval(progressInterval.value);
92+
progress.value = 0;
93+
};
94+
95+
const polling = async () => {
96+
if (!auth.value) return;
97+
if (poll.value) onCancel();
98+
try {
99+
poll.value = TraktService.device.poll(auth.value);
100+
progressInterval.value = setInterval(
101+
() => {
102+
progress.value += 0.1;
103+
},
104+
(auth.value.expires_in / 100) * 100,
105+
);
106+
const traktAuth = await poll.value;
107+
await TraktService.device.login(traktAuth);
108+
} catch (error) {
109+
logger.error('Failed to login with Trakt.tv');
110+
logger.error(error);
111+
if (error instanceof TraktPollingExpiredError) await getCodes();
112+
}
113+
};
114+
115+
const copyToClipBoard = () => {
116+
if (!code.value?.length) return;
117+
navigator.clipboard.writeText(code.value);
118+
NotificationService.message.success(i18n('notification__copied'));
119+
};
120+
121+
const openVerification = () => {
122+
const _code = auth.value?.user_code;
123+
openTab(_code ? Config.verification.code(_code) : Config.verification.url, true);
124+
};
125+
126+
const onCheckedToggle = (checked: boolean) => {
127+
if (checked) return getCodes();
128+
onCancel();
129+
};
130+
131+
const onClick = () => {
132+
if (useCode.value) return openVerification();
133+
return onSignIn();
134+
};
135+
136+
onDeactivated(() => onCancel());
50137
</script>
51138

52139
<template>
@@ -55,18 +142,55 @@ const onSignIn = async () => {
55142

56143
<Transition name="scale" mode="in-out">
57144
<div v-if="show">
58-
<LoginCard @on-sign-in="onSignIn">
145+
<LoginCard @on-sign-in="onClick">
59146
<NFlex class="checkboxes" vertical>
60-
<NCheckbox v-model:checked="signUp">
147+
<NCheckbox v-model:checked="signUp" :disabled="useCode">
61148
{{ i18n('checkbox__sign_up_for') }}
62149
<NText type="info">{{ i18n('checkbox__new_account') }}</NText>
63150
!
64151
</NCheckbox>
65-
<NCheckbox v-model:checked="useSession">
152+
<NCheckbox v-model:checked="useSession" :disabled="useCode">
66153
{{ i18n('checkbox__use') }}
67154
<NText type="info">{{ i18n('checkbox__active_user') }}</NText>
68155
{{ i18n('checkbox__session') }}
69156
</NCheckbox>
157+
158+
<NCheckbox v-model:checked="useCode" @update:checked="onCheckedToggle">
159+
{{ i18n('checkbox__use') }}
160+
<NText type="info">{{ i18n('checkbox__device_code') }}</NText>
161+
{{ i18n('checkbox__login') }}
162+
</NCheckbox>
163+
<div class="code-input" :class="{ show: useCode }">
164+
<NInputGroup class="input-group">
165+
<NInput
166+
:value="code"
167+
placeholder="Code"
168+
:disabled="!code?.length"
169+
readonly
170+
/>
171+
<NButton
172+
tertiary
173+
type="primary"
174+
:disabled="!code?.length"
175+
@click="copyToClipBoard"
176+
>
177+
<template #icon>
178+
<NIcon :component="IconClipboard" />
179+
</template>
180+
</NButton>
181+
<NProgress
182+
v-if="code?.length"
183+
class="timeout-code"
184+
type="line"
185+
status="success"
186+
:percentage="progress"
187+
:show-indicator="false"
188+
:theme-overrides="{
189+
railHeight: 'var(--rail-height)',
190+
}"
191+
/>
192+
</NInputGroup>
193+
</div>
70194
</NFlex>
71195
</LoginCard>
72196
</div>
@@ -83,4 +207,39 @@ const onSignIn = async () => {
83207
width: fit-content;
84208
margin-bottom: 1.5rem;
85209
}
210+
211+
.code-input {
212+
display: flex;
213+
align-items: center;
214+
justify-content: center;
215+
height: 0;
216+
opacity: 0;
217+
scale: 0.95;
218+
transition:
219+
height 1s ease,
220+
scale 0.5s ease,
221+
opacity 0.5s ease;
222+
223+
&.show {
224+
height: 3rem;
225+
opacity: 1;
226+
scale: 1;
227+
transition:
228+
height 0.5s ease,
229+
scale 0.5s ease,
230+
opacity 0.75s ease;
231+
}
232+
233+
.input-group {
234+
position: relative;
235+
}
236+
237+
.timeout-code {
238+
--rail-height: 2px;
239+
240+
position: absolute;
241+
bottom: calc(var(--rail-height) * -1);
242+
border-radius: 0;
243+
}
244+
}
86245
</style>

0 commit comments

Comments
 (0)