diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2024-10-25 14:20:33 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-10-25 14:20:33 +0900 |
| commit | 076cc953e2bcd9f7335e2d9799cdf902829816cb (patch) | |
| tree | 03043ce19df2a87708b0edc4d50700a639c41ed6 /packages/frontend/src | |
| parent | enhance(frontend): 「単なるラッキー」の調整 (#14807) (diff) | |
| download | sharkey-076cc953e2bcd9f7335e2d9799cdf902829816cb.tar.gz sharkey-076cc953e2bcd9f7335e2d9799cdf902829816cb.tar.bz2 sharkey-076cc953e2bcd9f7335e2d9799cdf902829816cb.zip | |
enhance(frontend): 外部アプリ認証画面の改良 (#14828)
* enhance(frontend): 外部アプリ認証画面の改良
* :art:
* lint
* Update Changelog
* indent
* lint
* enhance: miauthのリダイレクト先をUI内でも表示するように
* :art:
* fix
* fix
Diffstat (limited to 'packages/frontend/src')
| -rw-r--r-- | packages/frontend/src/_boot_.ts | 2 | ||||
| -rw-r--r-- | packages/frontend/src/account.ts | 70 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkAuthConfirm.stories.impl.ts | 7 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkAuthConfirm.vue | 450 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkModalWindow.vue | 10 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkSignupDialog.vue | 10 | ||||
| -rw-r--r-- | packages/frontend/src/pages/miauth.vue | 145 | ||||
| -rw-r--r-- | packages/frontend/src/pages/oauth.vue | 111 | ||||
| -rw-r--r-- | packages/frontend/src/pages/settings/accounts.vue | 20 |
9 files changed, 674 insertions, 151 deletions
diff --git a/packages/frontend/src/_boot_.ts b/packages/frontend/src/_boot_.ts index 13a97e433c..c90cc6bdd0 100644 --- a/packages/frontend/src/_boot_.ts +++ b/packages/frontend/src/_boot_.ts @@ -12,7 +12,7 @@ import '@/style.scss'; import { mainBoot } from '@/boot/main-boot.js'; import { subBoot } from '@/boot/sub-boot.js'; -const subBootPaths = ['/share', '/auth', '/miauth', '/signup-complete']; +const subBootPaths = ['/share', '/auth', '/miauth', '/oauth', '/signup-complete']; if (subBootPaths.some(i => location.pathname === i || location.pathname.startsWith(i + '/'))) { subBoot(); diff --git a/packages/frontend/src/account.ts b/packages/frontend/src/account.ts index f5a74a0581..36186ecac1 100644 --- a/packages/frontend/src/account.ts +++ b/packages/frontend/src/account.ts @@ -235,26 +235,6 @@ export async function openAccountMenu(opts: { }, ev: MouseEvent) { if (!$i) return; - function showSigninDialog() { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - addAccount(res.id, res.i); - success(); - }, - closed: () => dispose(), - }); - } - - function createAccount() { - const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: (res: Misskey.entities.SignupResponse) => { - addAccount(res.id, res.token); - switchAccountWithToken(res.token); - }, - closed: () => dispose(), - }); - } - async function switchAccount(account: Misskey.entities.UserDetailed) { const storedAccounts = await getAccounts(); const found = storedAccounts.find(x => x.id === account.id); @@ -323,10 +303,22 @@ export async function openAccountMenu(opts: { text: i18n.ts.addAccount, children: [{ text: i18n.ts.existingAccount, - action: () => { showSigninDialog(); }, + action: () => { + getAccountWithSigninDialog().then(res => { + if (res != null) { + success(); + } + }); + }, }, { text: i18n.ts.createAccount, - action: () => { createAccount(); }, + action: () => { + getAccountWithSignupDialog().then(res => { + if (res != null) { + switchAccountWithToken(res.token); + } + }); + }, }], }, { type: 'link', @@ -347,6 +339,40 @@ export async function openAccountMenu(opts: { }); } +export function getAccountWithSigninDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { + done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { + await addAccount(res.id, res.i); + resolve({ id: res.id, token: res.i }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + +export function getAccountWithSignupDialog(): Promise<{ id: string, token: string } | null> { + return new Promise((resolve) => { + const { dispose } = popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { + done: async (res: Misskey.entities.SignupResponse) => { + await addAccount(res.id, res.token); + resolve({ id: res.id, token: res.token }); + }, + cancelled: () => { + resolve(null); + }, + closed: () => { + dispose(); + }, + }); + }); +} + if (_DEV_) { (window as any).$i = $i; } diff --git a/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts b/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts new file mode 100644 index 0000000000..0adc44e204 --- /dev/null +++ b/packages/frontend/src/components/MkAuthConfirm.stories.impl.ts @@ -0,0 +1,7 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import MkAuthConfirm from './MkAuthConfirm.vue'; +void MkAuthConfirm; diff --git a/packages/frontend/src/components/MkAuthConfirm.vue b/packages/frontend/src/components/MkAuthConfirm.vue new file mode 100644 index 0000000000..f5f6d7f6cc --- /dev/null +++ b/packages/frontend/src/components/MkAuthConfirm.vue @@ -0,0 +1,450 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<div :class="$style.wrapper"> + <Transition + mode="out-in" + :enterActiveClass="$style.transition_enterActive" + :leaveActiveClass="$style.transition_leaveActive" + :enterFromClass="$style.transition_enterFrom" + :leaveToClass="$style.transition_leaveTo" + + :inert="_waiting" + > + <div v-if="phase === 'accountSelect'" key="accountSelect" :class="$style.root" class="_gaps"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-user"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts.pleaseSelectAccount }}</div> + </div> + <div :class="$style.accountSelectorRoot"> + <div :class="$style.accountSelectorLabel">{{ i18n.ts.selectAccount }}</div> + <div :class="$style.accountSelectorList"> + <template v-for="[id, user] in users"> + <input :id="'account-' + id" v-model="selectedUser" type="radio" name="accountSelector" :value="id" :class="$style.accountSelectorRadio"/> + <label :for="'account-' + id" :class="$style.accountSelectorItem"> + <MkAvatar :user="user" :class="$style.accountSelectorAvatar"/> + <div :class="$style.accountSelectorBody"> + <MkUserName :user="user" :class="$style.accountSelectorName"/> + <MkAcct :user="user" :class="$style.accountSelectorAcct"/> + </div> + </label> + </template> + <button class="_button" :class="[$style.accountSelectorItem, $style.accountSelectorAddAccountRoot]" @click="clickAddAccount"> + <div :class="[$style.accountSelectorAvatar, $style.accountSelectorAddAccountAvatar]"> + <i class="ti ti-user-plus"></i> + </div> + <div :class="[$style.accountSelectorBody, $style.accountSelectorName]">{{ i18n.ts.addAccount }}</div> + </button> + </div> + </div> + <div class="_buttonsCenter"> + <MkButton rounded gradate :disabled="selectedUser === null" @click="clickChooseAccount">{{ i18n.ts.continue }} <i class="ti ti-arrow-right"></i></MkButton> + </div> + </div> + <div v-else-if="phase === 'consent'" key="consent" :class="$style.root" class="_gaps"> + <div :class="$style.header" class="_gaps_s"> + <img v-if="icon" :class="$style.icon" :src="getProxiedImageUrl(icon, 'preview')"/> + <div v-else :class="$style.iconFallback"> + <i class="ti ti-apps"></i> + </div> + <div :class="$style.headerText">{{ name ? i18n.tsx._auth.shareAccess({ name }) : i18n.ts._auth.shareAccessAsk }}</div> + </div> + <div v-if="permissions && permissions.length > 0" class="_gaps_s" :class="$style.permissionRoot"> + <div>{{ name ? i18n.tsx._auth.permission({ name }) : i18n.ts._auth.permissionAsk }}</div> + <div :class="$style.permissionListWrapper"> + <ul :class="$style.permissionList"> + <li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> + </ul> + </div> + </div> + <slot name="consentAdditionalInfo"></slot> + <div :class="$style.accountSelectorRoot"> + <div :class="$style.accountSelectorLabel"> + {{ i18n.ts._auth.scopeUser }} <button class="_textButton" @click="clickBackToAccountSelect">{{ i18n.ts.switchAccount }}</button> + </div> + <div :class="$style.accountSelectorList"> + <div :class="[$style.accountSelectorItem, $style.static]"> + <MkAvatar :user="users.get(selectedUser!)!" :class="$style.accountSelectorAvatar"/> + <div :class="$style.accountSelectorBody"> + <MkUserName :user="users.get(selectedUser!)!" :class="$style.accountSelectorName"/> + <MkAcct :user="users.get(selectedUser!)!" :class="$style.accountSelectorAcct"/> + </div> + </div> + </div> + </div> + <div class="_buttonsCenter"> + <MkButton rounded @click="clickCancel">{{ i18n.ts.reject }}</MkButton> + <MkButton rounded gradate @click="clickAccept">{{ i18n.ts.accept }}</MkButton> + </div> + </div> + <div v-else-if="phase === 'success'" key="success" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-check"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts._auth.accepted }}</div> + <div :class="$style.headerTextSub">{{ i18n.ts._auth.pleaseGoBack }}</div> + </div> + </div> + <div v-else-if="phase === 'denied'" key="denied" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-x"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts._auth.denied }}</div> + </div> + </div> + <div v-else-if="phase === 'failed'" key="failed" :class="$style.root" class="_gaps_s"> + <div :class="$style.header" class="_gaps_s"> + <div :class="$style.iconFallback"> + <i class="ti ti-x"></i> + </div> + <div :class="$style.headerText">{{ i18n.ts.somethingHappened }}</div> + </div> + </div> + </Transition> + <div v-if="_waiting" :class="$style.waitingRoot"> + <MkLoading/> + </div> +</div> +</template> + +<script setup lang="ts"> +import { ref, computed } from 'vue'; +import * as Misskey from 'misskey-js'; + +import MkButton from '@/components/MkButton.vue'; + +import { $i, getAccounts, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; +import { i18n } from '@/i18n.js'; +import * as os from '@/os.js'; +import { getProxiedImageUrl } from '@/scripts/media-proxy.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; + +const props = defineProps<{ + name?: string; + icon?: string; + permissions?: (typeof Misskey.permissions[number])[]; + manualWaiting?: boolean; + waitOnDeny?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'accept', token: string): void; + (ev: 'deny', token: string): void; +}>(); + +const waiting = ref(true); +const _waiting = computed(() => waiting.value || props.manualWaiting); +const phase = ref<'accountSelect' | 'consent' | 'success' | 'denied' | 'failed'>('accountSelect'); + +const selectedUser = ref<string | null>(null); + +const users = ref(new Map<string, Misskey.entities.UserDetailed & { token: string; }>()); + +async function init() { + waiting.value = true; + + users.value.clear(); + + if ($i) { + users.value.set($i.id, $i); + } + + const accounts = await getAccounts(); + + const accountIdsToFetch = accounts.map(a => a.id).filter(id => !users.value.has(id)); + + if (accountIdsToFetch.length > 0) { + const usersRes = await misskeyApi('users/show', { + userIds: accountIdsToFetch, + }); + + for (const user of usersRes) { + if (users.value.has(user.id)) continue; + + users.value.set(user.id, { + ...user, + token: accounts.find(a => a.id === user.id)!.token, + }); + } + } + + waiting.value = false; +} + +init(); + +function clickAddAccount(ev: MouseEvent) { + selectedUser.value = null; + + os.popupMenu([{ + text: i18n.ts.existingAccount, + action: () => { + getAccountWithSigninDialog().then(async (res) => { + if (res != null) { + os.success(); + await init(); + if (users.value.has(res.id)) { + selectedUser.value = res.id; + } + } + }); + }, + }, { + text: i18n.ts.createAccount, + action: () => { + getAccountWithSignupDialog().then(async (res) => { + if (res != null) { + os.success(); + await init(); + if (users.value.has(res.id)) { + selectedUser.value = res.id; + } + } + }); + }, + }], ev.currentTarget ?? ev.target); +} + +function clickChooseAccount() { + if (selectedUser.value === null) return; + + phase.value = 'consent'; +} + +function clickBackToAccountSelect() { + selectedUser.value = null; + phase.value = 'accountSelect'; +} + +function clickCancel() { + if (selectedUser.value === null) return; + + const user = users.value.get(selectedUser.value)!; + + const token = user.token; + + if (props.waitOnDeny) { + waiting.value = true; + } + emit('deny', token); +} + +async function clickAccept() { + if (selectedUser.value === null) return; + + const user = users.value.get(selectedUser.value)!; + + const token = user.token; + + waiting.value = true; + emit('accept', token); +} + +function showUI(state: 'success' | 'denied' | 'failed') { + phase.value = state; + waiting.value = false; +} + +defineExpose({ + showUI, +}); +</script> + +<style lang="scss" module> +.transition_enterActive, +.transition_leaveActive { + transition: opacity 0.3s cubic-bezier(0,0,.35,1), transform 0.3s cubic-bezier(0,0,.35,1); +} +.transition_enterFrom { + opacity: 0; + transform: translateX(50px); +} +.transition_leaveTo { + opacity: 0; + transform: translateX(-50px); +} + +.wrapper { + overflow-x: hidden; + overflow-x: clip; + + position: relative; + width: 100%; + height: 100%; +} + +.waitingRoot { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: color-mix(in srgb, var(--MI_THEME-panel), transparent 50%); + display: flex; + justify-content: center; + align-items: center; + z-index: 1; + cursor: wait; +} + +.root { + position: relative; + box-sizing: border-box; + width: 100%; + padding: 48px 24px; +} + +.header { + margin: 0 auto; + max-width: 320px; +} + +.icon, +.iconFallback { + display: block; + margin: 0 auto; + width: 54px; + height: 54px; +} + +.icon { + border-radius: 50%; + border: 1px solid var(--MI_THEME-divider); + background-color: #fff; + object-fit: contain; +} + +.iconFallback { + border-radius: 50%; + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + text-align: center; + line-height: 54px; + font-size: 18px; +} + +.headerText, +.headerTextSub { + text-align: center; + word-break: normal; + word-break: auto-phrase; +} + +.headerText { + font-size: 16px; + font-weight: 700; +} + +.permissionRoot { + padding: 16px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-bg); +} + +.permissionListWrapper { + max-height: 350px; + overflow-y: auto; + padding: 12px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); +} + +.permissionList { + margin: 0 0 0 1.5em; + padding: 0; + font-size: 90%; +} + +.accountSelectorLabel { + font-size: 0.85em; + opacity: 0.7; + margin-bottom: 8px; +} + +.accountSelectorList { + border-radius: var(--MI-radius); + border: 1px solid var(--MI_THEME-divider); + overflow: hidden; + overflow: clip; +} + +.accountSelectorRadio { + position: absolute; + clip: rect(0, 0, 0, 0); + pointer-events: none; + + &:focus-visible + .accountSelectorItem { + outline: 2px solid var(--MI_THEME-accent); + outline-offset: -4px; + } + + &:checked:focus-visible + .accountSelectorItem { + outline-color: #fff; + } + + &:checked + .accountSelectorItem { + background: var(--MI_THEME-accent); + color: #fff; + } +} + +.accountSelectorItem { + display: flex; + align-items: center; + padding: 8px; + font-size: 14px; + -webkit-tap-highlight-color: transparent; + cursor: pointer; + + &:hover { + background: var(--MI_THEME-buttonHoverBg); + } + + &.static { + cursor: unset; + + &:hover { + background: none; + } + } +} + +.accountSelectorAddAccountRoot { + width: 100%; +} + +.accountSelectorBody { + padding: 0 8px; + min-width: 0; +} + +.accountSelectorAvatar { + width: 45px; + height: 45px; +} + +.accountSelectorAddAccountAvatar { + background-color: var(--MI_THEME-accentedBg); + color: var(--MI_THEME-accent); + font-size: 16px; + line-height: 45px; + text-align: center; + border-radius: 50%; +} + +.accountSelectorName { + display: block; + font-weight: bold; +} + +.accountSelectorAcct { + opacity: 0.5; +} +</style> diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue index fe9e1ce088..f06cfffee4 100644 --- a/packages/frontend/src/components/MkModalWindow.vue +++ b/packages/frontend/src/components/MkModalWindow.vue @@ -26,11 +26,11 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue'; import MkModal from './MkModal.vue'; const props = withDefaults(defineProps<{ - withOkButton: boolean; - withCloseButton: boolean; - okButtonDisabled: boolean; - width: number; - height: number; + withOkButton?: boolean; + withCloseButton?: boolean; + okButtonDisabled?: boolean; + width?: number; + height?: number; }>(), { withOkButton: false, withCloseButton: true, diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue index f240e6dc46..4f75a36fbe 100644 --- a/packages/frontend/src/components/MkSignupDialog.vue +++ b/packages/frontend/src/components/MkSignupDialog.vue @@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only ref="dialog" :width="500" :height="600" - @close="dialog?.close()" + @close="onClose" @closed="$emit('closed')" > <template #header>{{ i18n.ts.signup }}</template> @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only :leaveToClass="$style.transition_x_leaveTo" > <template v-if="!isAcceptedServerRule"> - <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog?.close()"/> + <XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/> </template> <template v-else> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> @@ -48,6 +48,7 @@ const props = withDefaults(defineProps<{ const emit = defineEmits<{ (ev: 'done', res: Misskey.entities.SignupResponse): void; + (ev: 'cancelled'): void; (ev: 'closed'): void; }>(); @@ -55,6 +56,11 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>(); const isAcceptedServerRule = ref(false); +function onClose() { + emit('cancelled'); + dialog.value?.close(); +} + function onSignup(res: Misskey.entities.SignupResponse) { emit('done', res); dialog.value?.close(); diff --git a/packages/frontend/src/pages/miauth.vue b/packages/frontend/src/pages/miauth.vue index ffaf739ed0..283f66ac45 100644 --- a/packages/frontend/src/pages/miauth.vue +++ b/packages/frontend/src/pages/miauth.vue @@ -4,95 +4,79 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> - <MkSpacer :contentMax="800"> - <div v-if="$i"> - <div v-if="state == 'waiting'"> - <MkLoading/> - </div> - <div v-if="state == 'denied'"> - <p>{{ i18n.ts._auth.denied }}</p> - </div> - <div v-else-if="state == 'accepted'" class="accepted"> - <p v-if="callback">{{ i18n.ts._auth.callback }}<MkEllipsis/></p> - <p v-else>{{ i18n.ts._auth.pleaseGoBack }}</p> - </div> - <div v-else> - <div v-if="_permissions.length > 0"> - <p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> - <p v-else>{{ i18n.ts._auth.permissionAsk }}</p> - <ul> - <li v-for="p in _permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> - </ul> - </div> - <div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div> - <div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> - <div :class="$style.buttons"> - <MkButton inline @click="deny">{{ i18n.ts.cancel }}</MkButton> - <MkButton inline primary @click="accept">{{ i18n.ts.accept }}</MkButton> - </div> - </div> +<div> + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <div :class="$style.form"> + <MkAuthConfirm + ref="authRoot" + :name="name" + :icon="icon || undefined" + :permissions="_permissions" + @accept="onAccept" + @deny="onDeny" + > + <template #consentAdditionalInfo> + <div v-if="callback != null" :class="$style.redirectRoot"> + <div>{{ i18n.ts._auth.byClickingYouWillBeRedirectedToThisUrl }}</div> + <div class="_monospace" :class="$style.redirectUrl">{{ callback }}</div> + </div> + </template> + </MkAuthConfirm> </div> - <div v-else> - <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> - <MkSignin @login="onLogin"/> - </div> - </MkSpacer> -</MkStickyContainer> + </div> +</div> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; -import MkSignin from '@/components/MkSignin.vue'; -import MkButton from '@/components/MkButton.vue'; -import { misskeyApi } from '@/scripts/misskey-api.js'; -import { $i, login } from '@/account.js'; +import { computed, useTemplateRef } from 'vue'; +import * as Misskey from 'misskey-js'; + +import MkAnimBg from '@/components/MkAnimBg.vue'; +import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; + import { i18n } from '@/i18n.js'; +import { misskeyApi } from '@/scripts/misskey-api.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; const props = defineProps<{ session: string; callback?: string; - name: string; - icon: string; - permission: string; // コンマ区切り + name?: string; + icon?: string; + permission?: string; // コンマ区切り }>(); -const _permissions = props.permission ? props.permission.split(',') : []; +const _permissions = computed(() => { + return (props.permission ? props.permission.split(',').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) : []); +}); -const state = ref<string | null>(null); +const authRoot = useTemplateRef('authRoot'); -async function accept(): Promise<void> { - state.value = 'waiting'; +async function onAccept(token: string) { await misskeyApi('miauth/gen-token', { session: props.session, name: props.name, iconUrl: props.icon, - permission: _permissions, + permission: _permissions.value, + }, token).catch(() => { + authRoot.value?.showUI('failed'); }); - state.value = 'accepted'; - if (props.callback) { + if (props.callback && props.callback !== '') { const cbUrl = new URL(props.callback); if (['javascript:', 'file:', 'data:', 'mailto:', 'tel:'].includes(cbUrl.protocol)) throw new Error('invalid url'); cbUrl.searchParams.set('session', props.session); - location.href = cbUrl.href; + location.href = cbUrl.toString(); + } else { + authRoot.value?.showUI('success'); } } -function deny(): void { - state.value = 'denied'; -} - -function onLogin(res): void { - login(res.i); +function onDeny() { + authRoot.value?.showUI('denied'); } -const headerActions = computed(() => []); - -const headerTabs = computed(() => []); - definePageMetadata(() => ({ title: 'MiAuth', icon: 'ti ti-apps', @@ -100,15 +84,38 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.buttons { - margin-top: 16px; - display: flex; - gap: 8px; - flex-wrap: wrap; +.formContainer { + min-height: 100svh; + padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px; + box-sizing: border-box; + display: grid; + place-content: center; +} + +.form { + position: relative; + z-index: 10; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + max-width: 500px; + width: calc(100vw - 64px); + height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px))); + overflow-y: scroll; +} + +.redirectRoot { + padding: 16px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-bg); } -.loginMessage { - text-align: center; - margin: 8px 0 24px; +.redirectUrl { + font-size: 90%; + padding: 12px; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + overflow-x: scroll; } </style> diff --git a/packages/frontend/src/pages/oauth.vue b/packages/frontend/src/pages/oauth.vue index 733e34eb2c..8719a769e5 100644 --- a/packages/frontend/src/pages/oauth.vue +++ b/packages/frontend/src/pages/oauth.vue @@ -4,40 +4,28 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkStickyContainer> - <template #header><MkPageHeader/></template> - <MkSpacer :contentMax="800"> - <div v-if="$i"> - <div v-if="permissions.length > 0"> - <p v-if="name">{{ i18n.tsx._auth.permission({ name }) }}</p> - <p v-else>{{ i18n.ts._auth.permissionAsk }}</p> - <ul> - <li v-for="p in permissions" :key="p">{{ i18n.ts._permissions[p] }}</li> - </ul> - </div> - <div v-if="name">{{ i18n.tsx._auth.shareAccess({ name }) }}</div> - <div v-else>{{ i18n.ts._auth.shareAccessAsk }}</div> - <form :class="$style.buttons" action="/oauth/decision" accept-charset="utf-8" method="post"> - <input name="login_token" type="hidden" :value="$i.token"/> - <input name="transaction_id" type="hidden" :value="transactionIdMeta?.content"/> - <MkButton inline name="cancel" value="cancel">{{ i18n.ts.cancel }}</MkButton> - <MkButton inline primary>{{ i18n.ts.accept }}</MkButton> - </form> +<div> + <MkAnimBg style="position: fixed; top: 0;"/> + <div :class="$style.formContainer"> + <div :class="$style.form"> + <MkAuthConfirm + ref="authRoot" + :name="name" + :permissions="permissions" + :waitOnDeny="true" + @accept="onAccept" + @deny="onDeny" + /> </div> - <div v-else> - <p :class="$style.loginMessage">{{ i18n.ts._auth.pleaseLogin }}</p> - <MkSignin @login="onLogin"/> - </div> - </MkSpacer> -</MkStickyContainer> + </div> +</div> </template> <script lang="ts" setup> -import MkSignin from '@/components/MkSignin.vue'; -import MkButton from '@/components/MkButton.vue'; -import { $i, login } from '@/account.js'; -import { i18n } from '@/i18n.js'; +import * as Misskey from 'misskey-js'; +import MkAnimBg from '@/components/MkAnimBg.vue'; import { definePageMetadata } from '@/scripts/page-metadata.js'; +import MkAuthConfirm from '@/components/MkAuthConfirm.vue'; const transactionIdMeta = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:transaction-id"]'); if (transactionIdMeta) { @@ -45,10 +33,44 @@ if (transactionIdMeta) { } const name = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:client-name"]')?.content; -const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ') ?? []; +const permissions = document.querySelector<HTMLMetaElement>('meta[name="misskey:oauth:scope"]')?.content.split(' ').filter((p): p is typeof Misskey.permissions[number] => (Misskey.permissions as readonly string[]).includes(p)) ?? []; + +function doPost(token: string, decision: 'accept' | 'deny') { + const form = document.createElement('form'); + form.action = '/oauth/decision'; + form.method = 'post'; + form.acceptCharset = 'utf-8'; + + const loginToken = document.createElement('input'); + loginToken.type = 'hidden'; + loginToken.name = 'login_token'; + loginToken.value = token; + form.appendChild(loginToken); + + const transactionId = document.createElement('input'); + transactionId.type = 'hidden'; + transactionId.name = 'transaction_id'; + transactionId.value = transactionIdMeta?.content ?? ''; + form.appendChild(transactionId); + + if (decision === 'deny') { + const cancel = document.createElement('input'); + cancel.type = 'hidden'; + cancel.name = 'cancel'; + cancel.value = 'cancel'; + form.appendChild(cancel); + } + + document.body.appendChild(form); + form.submit(); +} + +function onAccept(token: string) { + doPost(token, 'accept'); +} -function onLogin(res): void { - login(res.i); +function onDeny(token: string) { + doPost(token, 'deny'); } definePageMetadata(() => ({ @@ -58,15 +80,24 @@ definePageMetadata(() => ({ </script> <style lang="scss" module> -.buttons { - margin-top: 16px; - display: flex; - gap: 8px; - flex-wrap: wrap; +.formContainer { + min-height: 100svh; + padding: 32px 32px calc(env(safe-area-inset-bottom, 0px) + 32px) 32px; + box-sizing: border-box; + display: grid; + place-content: center; } -.loginMessage { - text-align: center; - margin: 8px 0 24px; +.form { + position: relative; + z-index: 10; + border-radius: var(--MI-radius); + background-color: var(--MI_THEME-panel); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1); + overflow: clip; + max-width: 500px; + width: calc(100vw - 64px); + height: min(65svh, calc(100svh - calc(env(safe-area-inset-bottom, 0px) + 64px))); + overflow-y: scroll; } </style> diff --git a/packages/frontend/src/pages/settings/accounts.vue b/packages/frontend/src/pages/settings/accounts.vue index 1bbedb817e..16f0716a12 100644 --- a/packages/frontend/src/pages/settings/accounts.vue +++ b/packages/frontend/src/pages/settings/accounts.vue @@ -19,13 +19,13 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { defineAsyncComponent, ref, computed } from 'vue'; +import { ref, computed } from 'vue'; import type * as Misskey from 'misskey-js'; import FormSuspense from '@/components/form/suspense.vue'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; -import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account.js'; +import { getAccounts, removeAccount as _removeAccount, login, $i, getAccountWithSigninDialog, getAccountWithSignupDialog } from '@/account.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkUserCardMini from '@/components/MkUserCardMini.vue'; @@ -74,23 +74,19 @@ async function removeAccount(account: Misskey.entities.UserDetailed) { } function addExistingAccount() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSigninDialog.vue')), {}, { - done: async (res: Misskey.entities.SigninFlowResponse & { finished: true }) => { - await addAccounts(res.id, res.i); + getAccountWithSigninDialog().then((res) => { + if (res != null) { os.success(); init(); - }, - closed: () => dispose(), + } }); } function createAccount() { - const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkSignupDialog.vue')), {}, { - done: async (res: Misskey.entities.SignupResponse) => { - await addAccounts(res.id, res.token); + getAccountWithSignupDialog().then((res) => { + if (res != null) { switchAccountWithToken(res.token); - }, - closed: () => dispose(), + } }); } |