enhance(frontend): 外部アプリ認証画面の改良 (misskey-dev#14828)
Cherry-picked from 076cc953e2bcd9f7335e2d9799cdf902829816cb Co-authored-by: かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>
This commit is contained in:
parent
62e801bf3a
commit
d4bbae8d45
14 changed files with 964 additions and 226 deletions
|
@ -0,0 +1,7 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import MkAuthConfirm from './MkAuthConfirm.vue';
|
||||
void MkAuthConfirm;
|
450
packages/frontend/src/components/MkAuthConfirm.vue
Normal file
450
packages/frontend/src/components/MkAuthConfirm.vue
Normal file
|
@ -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(--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(--divider);
|
||||
background-color: #fff;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.iconFallback {
|
||||
border-radius: 50%;
|
||||
background-color: var(--accentedBg);
|
||||
color: var(--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(--radius);
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.permissionListWrapper {
|
||||
max-height: 350px;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
background-color: var(--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(--radius);
|
||||
border: 1px solid var(--divider);
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
}
|
||||
|
||||
.accountSelectorRadio {
|
||||
position: absolute;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
pointer-events: none;
|
||||
|
||||
&:focus-visible + .accountSelectorItem {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&:checked:focus-visible + .accountSelectorItem {
|
||||
outline-color: #fff;
|
||||
}
|
||||
|
||||
&:checked + .accountSelectorItem {
|
||||
background: color-mix(in srgb, var(--accent), transparent 50%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.accountSelectorItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--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(--accentedBg);
|
||||
color: var(--accent);
|
||||
font-size: 16px;
|
||||
line-height: 45px;
|
||||
text-align: center;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.accountSelectorName {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.accountSelectorAcct {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
|
@ -26,12 +26,12 @@ import { onMounted, onUnmounted, shallowRef, ref } from 'vue';
|
|||
import MkModal from './MkModal.vue';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
withOkButton: boolean;
|
||||
withCloseButton: boolean;
|
||||
okButtonDisabled: boolean;
|
||||
escKeyDisabled: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
withOkButton?: boolean;
|
||||
withCloseButton?: boolean;
|
||||
okButtonDisabled?: boolean;
|
||||
escKeyDisabled?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}>(), {
|
||||
withOkButton: false,
|
||||
withCloseButton: true,
|
||||
|
|
|
@ -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"/>
|
||||
|
@ -47,7 +47,8 @@ const props = withDefaults(defineProps<{
|
|||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(ev: 'done', res: Misskey.entities.SigninResponse): void;
|
||||
(ev: 'done', res: Misskey.entities.SignupResponse): void;
|
||||
(ev: 'cancelled'): void;
|
||||
(ev: 'closed'): void;
|
||||
}>();
|
||||
|
||||
|
@ -55,7 +56,12 @@ const dialog = shallowRef<InstanceType<typeof MkModalWindow>>();
|
|||
|
||||
const isAcceptedServerRule = ref(false);
|
||||
|
||||
function onSignup(res: Misskey.entities.SigninResponse) {
|
||||
function onClose() {
|
||||
emit('cancelled');
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
||||
function onSignup(res: Misskey.entities.SignupResponse) {
|
||||
emit('done', res);
|
||||
dialog.value?.close();
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue