feat(account): VK ID web login to link a VK identity from a browser
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 18s
CI / ui (pull_request) Successful in 1m4s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m39s

A browser has no signed VK Mini App launch params, so linking VK on the web uses
VK ID's raw OAuth 2.1 flow (PKCE, no @vkid/sdk): the SPA redirects to VK's hosted
login and returns with an authorization code, which the gateway exchanges
server-side (confidential, under the VK "Web" app's protected key) for the trusted
vk user id — then the existing link/merge machinery attaches or merges it.

- fbs LinkVKRequest{code, device_id, code_verifier}; codec + TS bindings.
- backend link.Service ConfirmVK/MergeVK/attachVK (KindVK, mirror Telegram),
  handleLinkVK[Merge], routes /user/link/vk[/merge], backendclient LinkVK[Merge].
- gateway internal/vkid confidential code exchange (id.vk.com/oauth2/auth);
  transcode link.vk.confirm/merge (registered only when configured) + config
  GATEWAY_VK_ID_{APP_ID,CLIENT_SECRET,REDIRECT_URL} + main wiring.
- UI lib/vkid (PKCE authorize redirect + callback), Profile "Link VK" control,
  boot callback handling; a merge re-authorizes for a fresh code (VK codes are
  single-use). Web-only (a redirect strands a Mini App webview).
- Deploy: VITE_VK_APP_ID + VITE_VK_ID_REDIRECT_URL build args + gateway env,
  ci.yaml/prod-deploy TEST_/PROD_ vars, compose/Dockerfile/.env.example/README.
- Tests: vkid exchange unit (string/number user_id, id_token fallback, errors),
  transcode link.vk, backend ConfirmVK/MergeVK inttest, codec encodeLinkVK.
- Docs: ARCHITECTURE §4, FUNCTIONAL(+ru), gateway README.
This commit is contained in:
Ilia Denisov
2026-07-03 17:59:33 +02:00
parent 60faa4f064
commit 2c465c01d2
36 changed files with 1131 additions and 16 deletions
+54 -3
View File
@@ -14,6 +14,7 @@
import { connection } from '../lib/connection.svelte';
import { gateway } from '../lib/gateway';
import { loginWidgetAvailable, requestTelegramLogin } from '../lib/telegram';
import { startVKLink, vkWebLinkAvailable } from '../lib/vkid';
import { t } from '../lib/i18n/index.svelte';
import {
awayDurationOk,
@@ -45,7 +46,7 @@
let emailSent = $state(false);
// A pending irreversible merge surfaced after the code/widget was verified; the
// 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' | 'vk'; name: string; games: number; friends: number }>(null);
let tgData = '';
// The change-email sub-form is open (a new address is being entered/confirmed).
let changingEmail = $state(false);
@@ -56,6 +57,7 @@
let deleteCode = $state('');
let deletePhrase = $state('');
const telegramLinkable = loginWidgetAvailable();
const vkLinkable = vkWebLinkAvailable();
function defaultTz(): string {
const b = browserOffset();
@@ -82,7 +84,12 @@
notificationsInAppOnly = p.notificationsInAppOnly;
variantPrefs = [...p.variantPreferences];
}
onMount(populate);
onMount(() => {
populate();
// A VK ID web-link callback captured on boot (app.vkLinkPending) is finished here, since
// the redirect back from VK lost the in-app route and lands the app on a fresh mount.
if (app.vkLinkPending) void processVKLink();
});
const awayStart = $derived(`${startH}:${startM}`);
const awayEnd = $derived(`${endH}:${endM}`);
@@ -180,8 +187,48 @@
}
}
// addVK starts VK ID web login for linking: it redirects the whole tab to VK's hosted login,
// returning to the app with an auth code that boot hands back to processVKLink.
function addVK() {
void startVKLink('link');
}
// processVKLink finishes a VK ID web-link callback captured on boot: it exchanges the code via
// the gateway and either completes the link or opens the merge dialog. VK's authorization code
// is single-use, so a required merge re-authorizes for a fresh code (see confirmMerge); that
// returning 'merge' callback lands back here and completes.
async function processVKLink() {
const cb = app.vkLinkPending;
app.vkLinkPending = null;
if (!cb) return;
try {
if (cb.mode === 'merge') {
await applyLinkResult(await gateway.linkVKMerge(cb.code, cb.deviceId, cb.verifier));
populate();
showToast(t('profile.merged'));
return;
}
const r = await gateway.linkVK(cb.code, cb.deviceId, cb.verifier);
if (r.status === 'merge_required') {
pendingMerge = { kind: 'vk', name: r.secondaryDisplayName, games: r.secondaryGames, friends: r.secondaryFriends };
return;
}
await applyLinkResult(r);
populate();
showToast(t('profile.linked'));
} catch (e) {
handleError(e);
}
}
async function confirmMerge() {
if (!pendingMerge) return;
// A VK merge cannot reuse VK's single-use authorization code, so it re-authorizes for a fresh
// one; the returning callback completes it (processVKLink). Email/Telegram re-send their proof.
if (pendingMerge.kind === 'vk') {
void startVKLink('merge');
return;
}
try {
const r =
pendingMerge.kind === 'email'
@@ -344,7 +391,7 @@
<!-- Sign-in methods: bind/change an email and link/unlink providers. A returning email
(add) triggers the merge dialog below. On the web an account can add Telegram via the
login widget; adding VK on the web is deferred (no VK OAuth) and inside a Mini App the
login widget or VK via VK ID web login (a full-page redirect); inside a Mini App the
host provider is already linked. Email is never unlinked — it is changed. Unlink is
offered only when another identity remains (the backend also refuses the last one). -->
<section class="accounts">
@@ -413,6 +460,10 @@
<button class="ghost danger" onclick={() => (confirmUnlink = { kind: 'vk', label: 'VK' })} disabled={!connection.online}>{t('profile.unlink')}</button>
{/if}
</div>
{:else if vkLinkable}
<button class="ghost tg" onclick={addVK} disabled={!connection.online}>
<img src="vk-logo.svg" alt="" width="18" height="18" />{t('profile.linkVK')}
</button>
{/if}
</section>