summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkAuthConfirm.vue
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2024-10-25 14:20:33 +0900
committerGitHub <noreply@github.com>2024-10-25 14:20:33 +0900
commit076cc953e2bcd9f7335e2d9799cdf902829816cb (patch)
tree03043ce19df2a87708b0edc4d50700a639c41ed6 /packages/frontend/src/components/MkAuthConfirm.vue
parentenhance(frontend): 「単なるラッキー」の調整 (#14807) (diff)
downloadsharkey-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/components/MkAuthConfirm.vue')
-rw-r--r--packages/frontend/src/components/MkAuthConfirm.vue450
1 files changed, 450 insertions, 0 deletions
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>