feat(profile): sign-in methods matrix — email add/change, provider link/unlink
Profile shows the account's sign-in methods for guests and durable accounts alike: add or change email, link Telegram on the web (login widget), and unlink a linked provider. Email is never unlinked (it is changed); unlink is offered only when another identity remains, and a change to an address owned by another account shows the non-disclosing 'check the address or contact support'. Add-VK-on-web stays deferred (no VK OAuth). Wires linkUnlink / changeEmailRequest / changeEmailConfirm through the client, transport, mock and codec (encodeLinkUnlink + LinkResult 'unlinked'/'changed' statuses); codec wire tests; ru/en i18n.
This commit is contained in:
@@ -177,6 +177,14 @@ export interface GatewayClient {
|
|||||||
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
linkEmailMerge(email: string, code: string): Promise<LinkResult>;
|
||||||
linkTelegram(data: string): Promise<LinkResult>;
|
linkTelegram(data: string): Promise<LinkResult>;
|
||||||
linkTelegramMerge(data: string): Promise<LinkResult>;
|
linkTelegramMerge(data: string): Promise<LinkResult>;
|
||||||
|
/** Detach a platform identity (kind 'telegram' | 'vk') from the account; email is never
|
||||||
|
* unlinked. Returns the refreshed link result (status 'unlinked'). */
|
||||||
|
linkUnlink(kind: string): Promise<LinkResult>;
|
||||||
|
/** Change the account's confirmed email: mail a code to the new address, then confirm
|
||||||
|
* to atomically switch (status 'changed'). A new address owned by another account is
|
||||||
|
* refused without disclosure. */
|
||||||
|
changeEmailRequest(email: string): Promise<void>;
|
||||||
|
changeEmailConfirm(email: string, code: string): Promise<LinkResult>;
|
||||||
|
|
||||||
// --- live stream ---
|
// --- live stream ---
|
||||||
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
subscribe(onEvent: (e: PushEvent) => void, onError?: (err: unknown) => void): Unsubscribe;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
encodeExchange,
|
encodeExchange,
|
||||||
encodeExportUrlRequest,
|
encodeExportUrlRequest,
|
||||||
encodeGuestLogin,
|
encodeGuestLogin,
|
||||||
|
encodeLinkUnlink,
|
||||||
encodeStateRequest,
|
encodeStateRequest,
|
||||||
encodeSubmitPlay,
|
encodeSubmitPlay,
|
||||||
encodeTarget,
|
encodeTarget,
|
||||||
@@ -458,6 +459,22 @@ describe('codec', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('encodes an unlink request carrying the provider kind', () => {
|
||||||
|
const r = fb.LinkUnlinkRequest.getRootAsLinkUnlinkRequest(new ByteBuffer(encodeLinkUnlink('telegram')));
|
||||||
|
expect(r.kind()).toBe('telegram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes an unlinked / changed LinkResult status straight through', () => {
|
||||||
|
for (const status of ['unlinked', 'changed'] as const) {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const s = b.createString(status);
|
||||||
|
fb.LinkResult.startLinkResult(b);
|
||||||
|
fb.LinkResult.addStatus(b, s);
|
||||||
|
b.finish(fb.LinkResult.endLinkResult(b));
|
||||||
|
expect(decodeLinkResult(b.asUint8Array()).status).toBe(status);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('decodes a merged LinkResult carrying a switched session', () => {
|
it('decodes a merged LinkResult carrying a switched session', () => {
|
||||||
const b = new Builder(128);
|
const b = new Builder(128);
|
||||||
const token = b.createString('tok-9');
|
const token = b.createString('tok-9');
|
||||||
|
|||||||
@@ -731,6 +731,14 @@ export function encodeLinkTelegram(data: string): Uint8Array {
|
|||||||
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
|
return finish(b, fb.LinkTelegramRequest.endLinkTelegramRequest(b));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encodeLinkUnlink(kind: string): Uint8Array {
|
||||||
|
const b = new Builder(64);
|
||||||
|
const k = b.createString(kind);
|
||||||
|
fb.LinkUnlinkRequest.startLinkUnlinkRequest(b);
|
||||||
|
fb.LinkUnlinkRequest.addKind(b, k);
|
||||||
|
return finish(b, fb.LinkUnlinkRequest.endLinkUnlinkRequest(b));
|
||||||
|
}
|
||||||
|
|
||||||
export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
export function decodeLinkResult(buf: Uint8Array): LinkResult {
|
||||||
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
const r = fb.LinkResult.getRootAsLinkResult(new ByteBuffer(buf));
|
||||||
const sess = r.session();
|
const sess = r.session();
|
||||||
|
|||||||
@@ -179,6 +179,15 @@ export const en = {
|
|||||||
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
|
'profile.mergeBody': 'This identity already belongs to “{name}” ({games} games, {friends} friends).',
|
||||||
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
|
'profile.mergeIrreversible': 'Merging combines both accounts into this one and cannot be undone.',
|
||||||
'profile.mergeConfirm': 'Merge',
|
'profile.mergeConfirm': 'Merge',
|
||||||
|
'profile.accountsTitle': 'Sign-in methods',
|
||||||
|
'profile.changeEmail': 'Change',
|
||||||
|
'profile.newEmailPlaceholder': 'New email address',
|
||||||
|
'profile.emailChanged': 'Email updated.',
|
||||||
|
'profile.emailChangeTaken': 'Check the address or contact support.',
|
||||||
|
'profile.unlink': 'Unlink',
|
||||||
|
'profile.unlinked': 'Account unlinked.',
|
||||||
|
'profile.unlinkTitle': 'Unlink account?',
|
||||||
|
'profile.unlinkBody': 'Remove {provider} as a sign-in method? You can link it again later.',
|
||||||
|
|
||||||
'settings.title': 'Settings',
|
'settings.title': 'Settings',
|
||||||
'settings.theme': 'Theme',
|
'settings.theme': 'Theme',
|
||||||
|
|||||||
@@ -179,6 +179,15 @@ export const ru: Record<MessageKey, string> = {
|
|||||||
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
|
'profile.mergeBody': 'Эта личность уже принадлежит «{name}» (игр: {games}, друзей: {friends}).',
|
||||||
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
|
'profile.mergeIrreversible': 'Объединение сольёт оба аккаунта в этот и необратимо.',
|
||||||
'profile.mergeConfirm': 'Объединить',
|
'profile.mergeConfirm': 'Объединить',
|
||||||
|
'profile.accountsTitle': 'Способы входа',
|
||||||
|
'profile.changeEmail': 'Изменить',
|
||||||
|
'profile.newEmailPlaceholder': 'Новый адрес e-mail',
|
||||||
|
'profile.emailChanged': 'E-mail изменён.',
|
||||||
|
'profile.emailChangeTaken': 'Проверьте правильность e-mail или обратитесь в поддержку.',
|
||||||
|
'profile.unlink': 'Отвязать',
|
||||||
|
'profile.unlinked': 'Аккаунт отвязан.',
|
||||||
|
'profile.unlinkTitle': 'Отвязать аккаунт?',
|
||||||
|
'profile.unlinkBody': 'Убрать {provider} как способ входа? Позже можно привязать снова.',
|
||||||
|
|
||||||
'settings.title': 'Настройки',
|
'settings.title': 'Настройки',
|
||||||
'settings.theme': 'Тема',
|
'settings.theme': 'Тема',
|
||||||
|
|||||||
@@ -656,20 +656,37 @@ export class MockGateway implements GatewayClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.profile.isGuest = false;
|
this.profile.isGuest = false;
|
||||||
|
this.profile.email = email;
|
||||||
return emptyLinked();
|
return emptyLinked();
|
||||||
}
|
}
|
||||||
async linkEmailMerge(_email: string, _code: string): Promise<LinkResult> {
|
async linkEmailMerge(email: string, _code: string): Promise<LinkResult> {
|
||||||
this.profile.isGuest = false;
|
this.profile.isGuest = false;
|
||||||
|
this.profile.email = email;
|
||||||
return { ...emptyLinked(), status: 'merged' };
|
return { ...emptyLinked(), status: 'merged' };
|
||||||
}
|
}
|
||||||
async linkTelegram(_data: string): Promise<LinkResult> {
|
async linkTelegram(_data: string): Promise<LinkResult> {
|
||||||
this.profile.isGuest = false;
|
this.profile.isGuest = false;
|
||||||
|
this.profile.telegramLinked = true;
|
||||||
return emptyLinked();
|
return emptyLinked();
|
||||||
}
|
}
|
||||||
async linkTelegramMerge(_data: string): Promise<LinkResult> {
|
async linkTelegramMerge(_data: string): Promise<LinkResult> {
|
||||||
this.profile.isGuest = false;
|
this.profile.isGuest = false;
|
||||||
|
this.profile.telegramLinked = true;
|
||||||
return { ...emptyLinked(), status: 'merged' };
|
return { ...emptyLinked(), status: 'merged' };
|
||||||
}
|
}
|
||||||
|
async linkUnlink(kind: string): Promise<LinkResult> {
|
||||||
|
if (kind === 'telegram') this.profile.telegramLinked = false;
|
||||||
|
if (kind === 'vk') this.profile.vkLinked = false;
|
||||||
|
return { ...emptyLinked(), status: 'unlinked' };
|
||||||
|
}
|
||||||
|
async changeEmailRequest(_email: string): Promise<void> {}
|
||||||
|
async changeEmailConfirm(email: string, _code: string): Promise<LinkResult> {
|
||||||
|
// An address containing "taken" stands in for one confirmed by another account, so the
|
||||||
|
// mock can drive the non-disclosing refusal (never a merge).
|
||||||
|
if (email.includes('taken')) throw new GatewayError('email_taken');
|
||||||
|
this.profile.email = email;
|
||||||
|
return { ...emptyLinked(), status: 'changed' };
|
||||||
|
}
|
||||||
async statsGet(): Promise<Stats> {
|
async statsGet(): Promise<Stats> {
|
||||||
return { ...this.stats };
|
return { ...this.stats };
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -346,13 +346,14 @@ export interface Session {
|
|||||||
displayName: string;
|
displayName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// LinkResult is the outcome of an account link/merge step. status is
|
// LinkResult is the outcome of an account link/merge/unlink/change step. status is
|
||||||
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
|
// 'linked' (bound to the current account), 'merge_required' (the identity belongs to
|
||||||
// another account — the secondary* fields summarise it for the irreversible
|
// another account — the secondary* fields summarise it for the irreversible
|
||||||
// confirmation) or 'merged'. session is set only when the active account switched
|
// confirmation), 'merged', 'unlinked' (a provider detached) or 'changed' (the email was
|
||||||
// (a guest initiator whose durable counterpart won); the client adopts it.
|
// switched). session is set only when the active account switched (a guest initiator
|
||||||
|
// whose durable counterpart won); the client adopts it.
|
||||||
export interface LinkResult {
|
export interface LinkResult {
|
||||||
status: 'linked' | 'merge_required' | 'merged';
|
status: 'linked' | 'merge_required' | 'merged' | 'unlinked' | 'changed';
|
||||||
secondaryUserId: string;
|
secondaryUserId: string;
|
||||||
secondaryDisplayName: string;
|
secondaryDisplayName: string;
|
||||||
secondaryGames: number;
|
secondaryGames: number;
|
||||||
|
|||||||
@@ -268,6 +268,15 @@ export function createTransport(baseUrl: string): GatewayClient {
|
|||||||
async linkTelegramMerge(data) {
|
async linkTelegramMerge(data) {
|
||||||
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
|
return codec.decodeLinkResult(await exec('link.telegram.merge', codec.encodeLinkTelegram(data)));
|
||||||
},
|
},
|
||||||
|
async linkUnlink(kind) {
|
||||||
|
return codec.decodeLinkResult(await exec('link.unlink', codec.encodeLinkUnlink(kind)));
|
||||||
|
},
|
||||||
|
async changeEmailRequest(email) {
|
||||||
|
await exec('link.email.change.request', codec.encodeLinkEmailRequest(email));
|
||||||
|
},
|
||||||
|
async changeEmailConfirm(email, code) {
|
||||||
|
return codec.decodeLinkResult(await exec('link.email.change.confirm', codec.encodeLinkEmailConfirm(email, code)));
|
||||||
|
},
|
||||||
async statsGet() {
|
async statsGet() {
|
||||||
return codec.decodeStats(await exec('stats.get', codec.empty()));
|
return codec.decodeStats(await exec('stats.get', codec.empty()));
|
||||||
},
|
},
|
||||||
|
|||||||
+186
-19
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Modal from '../components/Modal.svelte';
|
import Modal from '../components/Modal.svelte';
|
||||||
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
import { app, applyLinkResult, handleError, logout, showToast } from '../lib/app.svelte';
|
||||||
|
import { GatewayError } from '../lib/client';
|
||||||
import { connection } from '../lib/connection.svelte';
|
import { connection } from '../lib/connection.svelte';
|
||||||
import { gateway } from '../lib/gateway';
|
import { gateway } from '../lib/gateway';
|
||||||
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
|
||||||
@@ -38,6 +39,10 @@
|
|||||||
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
|
// dialog confirms it. tgData holds the Telegram widget payload for the merge step.
|
||||||
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
|
let pendingMerge = $state<null | { kind: 'email' | 'telegram'; name: string; games: number; friends: number }>(null);
|
||||||
let tgData = '';
|
let tgData = '';
|
||||||
|
// The change-email sub-form is open (a new address is being entered/confirmed).
|
||||||
|
let changingEmail = $state(false);
|
||||||
|
// A pending unlink awaiting the confirmation dialog (email is never unlinked).
|
||||||
|
let confirmUnlink = $state<null | { kind: 'telegram' | 'vk'; label: string }>(null);
|
||||||
const telegramLinkable = loginWidgetAvailable();
|
const telegramLinkable = loginWidgetAvailable();
|
||||||
|
|
||||||
function defaultTz(): string {
|
function defaultTz(): string {
|
||||||
@@ -73,6 +78,14 @@
|
|||||||
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
|
const awayOk = $derived(awayDurationOk(awayStart, awayEnd));
|
||||||
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
|
const formValid = $derived(nameOk && awayOk && variantPrefs.length >= 1);
|
||||||
const emailOk = $derived(validEmail(emailInput));
|
const emailOk = $derived(validEmail(emailInput));
|
||||||
|
// Count the account's confirmed sign-in methods, to hide an Unlink that would remove the
|
||||||
|
// last identity (the backend also refuses it — this is a UX guard, not the enforcement).
|
||||||
|
const linkedCount = $derived(
|
||||||
|
app.profile
|
||||||
|
? (app.profile.email ? 1 : 0) + (app.profile.telegramLinked ? 1 : 0) + (app.profile.vkLinked ? 1 : 0)
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
const canUnlink = $derived(linkedCount >= 2);
|
||||||
|
|
||||||
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
|
// toggleVariant flips a variant in the preference set, refusing to remove the last one —
|
||||||
// the player must allow themselves at least one variant.
|
// the player must allow themselves at least one variant.
|
||||||
@@ -172,6 +185,56 @@
|
|||||||
handleError(e);
|
handleError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// startChangeEmail opens the change-email sub-form on a clean slate (a new address then a
|
||||||
|
// code), independent of any half-finished add-email attempt.
|
||||||
|
function startChangeEmail() {
|
||||||
|
changingEmail = true;
|
||||||
|
emailSent = false;
|
||||||
|
emailInput = '';
|
||||||
|
codeInput = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestChangeEmail() {
|
||||||
|
if (!emailOk) return;
|
||||||
|
try {
|
||||||
|
await gateway.changeEmailRequest(emailInput.trim());
|
||||||
|
emailSent = true;
|
||||||
|
showToast(t('profile.emailSent', { email: emailInput.trim() }));
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmChangeEmail() {
|
||||||
|
try {
|
||||||
|
const r = await gateway.changeEmailConfirm(emailInput.trim(), codeInput.trim());
|
||||||
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
|
changingEmail = false;
|
||||||
|
resetEmail();
|
||||||
|
showToast(t('profile.emailChanged'));
|
||||||
|
} catch (e) {
|
||||||
|
// A new address confirmed by another account is refused without disclosing it.
|
||||||
|
if (e instanceof GatewayError && e.code === 'email_taken') {
|
||||||
|
showToast(t('profile.emailChangeTaken'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unlink(kind: 'telegram' | 'vk') {
|
||||||
|
confirmUnlink = null;
|
||||||
|
try {
|
||||||
|
const r = await gateway.linkUnlink(kind);
|
||||||
|
await applyLinkResult(r);
|
||||||
|
populate();
|
||||||
|
showToast(t('profile.unlinked'));
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
@@ -244,13 +307,41 @@
|
|||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- The guest's email upgrade path: bind an email to register / sign in (a returning
|
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
|
||||||
address triggers the merge dialog below). Guest-only for now; provider linking
|
(add) triggers the merge dialog below. On the web an account can add Telegram via the
|
||||||
(Add VK / Add Telegram / Unlink) and the non-guest matrix come next, together
|
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
|
||||||
with the skipped linking specs in e2e/social.spec.ts. -->
|
host provider is already linked. Email is never unlinked — it is changed. Unlink is
|
||||||
<section class="emailbox" hidden={!p.isGuest}>
|
offered only when another identity remains (the backend also refuses the last one). -->
|
||||||
<h3>{t('profile.linkAccount')}</h3>
|
<section class="accounts">
|
||||||
{#if !emailSent}
|
<h3>{t('profile.accountsTitle')}</h3>
|
||||||
|
|
||||||
|
{#if p.email}
|
||||||
|
<div class="acctrow">
|
||||||
|
<span class="prov"><span class="ic">✉</span>{p.email}</span>
|
||||||
|
{#if !changingEmail}
|
||||||
|
<button class="ghost" onclick={startChangeEmail} disabled={!connection.online}>{t('profile.changeEmail')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if changingEmail}
|
||||||
|
{#if !emailSent}
|
||||||
|
<div class="addrow">
|
||||||
|
<input
|
||||||
|
class:invalid={emailInput.length > 0 && !emailOk}
|
||||||
|
bind:value={emailInput}
|
||||||
|
placeholder={t('profile.newEmailPlaceholder')}
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
<button class="ghost" onclick={requestChangeEmail} disabled={!emailOk || !connection.online}>{t('login.sendCode')}</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="addrow">
|
||||||
|
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
|
||||||
|
<button class="btn" onclick={confirmChangeEmail} disabled={!connection.online}>{t('common.ok')}</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<button class="linklike" onclick={() => { changingEmail = false; resetEmail(); }}>{t('common.cancel')}</button>
|
||||||
|
{/if}
|
||||||
|
{:else if !emailSent}
|
||||||
<div class="addrow">
|
<div class="addrow">
|
||||||
<input
|
<input
|
||||||
class:invalid={emailInput.length > 0 && !emailOk}
|
class:invalid={emailInput.length > 0 && !emailOk}
|
||||||
@@ -262,19 +353,31 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="addrow">
|
<div class="addrow">
|
||||||
<input
|
<input class="codein" bind:value={codeInput} placeholder={t('profile.emailCode')} inputmode="numeric" maxlength="6" />
|
||||||
class="codein"
|
|
||||||
bind:value={codeInput}
|
|
||||||
placeholder={t('profile.emailCode')}
|
|
||||||
inputmode="numeric"
|
|
||||||
maxlength="6"
|
|
||||||
/>
|
|
||||||
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
|
<button class="btn" onclick={confirmEmail} disabled={!connection.online}>{t('common.ok')}</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if telegramLinkable}
|
|
||||||
<!-- Provider linking lands with the non-guest matrix (PR2); kept wired but hidden. -->
|
{#if p.telegramLinked}
|
||||||
<button class="ghost tg" hidden onclick={linkTelegram} disabled={!connection.online}>{t('profile.linkTelegram')}</button>
|
<div class="acctrow">
|
||||||
|
<span class="prov"><img src="telegram-logo.svg" alt="" width="18" height="18" />Telegram</span>
|
||||||
|
{#if canUnlink}
|
||||||
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'telegram', label: 'Telegram' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if telegramLinkable}
|
||||||
|
<button class="ghost tg" onclick={linkTelegram} disabled={!connection.online}>
|
||||||
|
<img src="telegram-logo.svg" alt="" width="18" height="18" />{t('profile.linkTelegram')}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if p.vkLinked}
|
||||||
|
<div class="acctrow">
|
||||||
|
<span class="prov"><img src="vk-logo.svg" alt="" width="18" height="18" />VK</span>
|
||||||
|
{#if canUnlink}
|
||||||
|
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -295,6 +398,17 @@
|
|||||||
</Modal>
|
</Modal>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if confirmUnlink}
|
||||||
|
{@const cu = confirmUnlink}
|
||||||
|
<Modal title={t('profile.unlinkTitle')} onclose={() => (confirmUnlink = null)}>
|
||||||
|
<p>{t('profile.unlinkBody', { provider: cu.label })}</p>
|
||||||
|
<div class="addrow end">
|
||||||
|
<button class="ghost" onclick={() => (confirmUnlink = null)}>{t('common.cancel')}</button>
|
||||||
|
<button class="btn danger" onclick={() => unlink(cu.kind)} disabled={!connection.online}>{t('profile.unlink')}</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
@@ -407,11 +521,64 @@
|
|||||||
.btn:disabled {
|
.btn:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
.emailbox h3 {
|
.accounts {
|
||||||
margin: 0 0 8px;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.accounts h3 {
|
||||||
|
margin: 0 0 2px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.acctrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.prov {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.prov .ic {
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.tg {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.ghost.danger {
|
||||||
|
border-color: var(--danger, #c0392b);
|
||||||
|
color: var(--danger, #c0392b);
|
||||||
|
}
|
||||||
|
.btn.danger {
|
||||||
|
background: var(--danger, #c0392b);
|
||||||
|
border-color: var(--danger, #c0392b);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.linklike {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
.addrow {
|
.addrow {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
|||||||
Reference in New Issue
Block a user