Web client (warp-web) with E2EE, and two sync fixes
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
CI / Admin panel (push) Canceled after 0s
CI / Web client (push) Canceled after 0s
apps/web: a Matrix client for the browser on Vue 3, Tailwind and matrix-js-sdk, built on @warp/ui. Like the admin panel it is an optional, separate service: warp-web embeds the built app, serves /config.json and proxies only /_matrix/client and /_matrix/media to the homeserver (or, with -public-homeserver, lets the browser call it directly). Sign-in and sign-up with registration tokens, room list, timeline with back-pagination, text, images, video and files over authenticated media, local echo with sent/read/failed states, receipts, typing, invites, creating, joining and leaving rooms, room info, settings, phone layout. End-to-end encryption through the Rust crypto: encrypted messages and files, emoji verification, key backup setup and restore. apps/ui: WRoomItem, WMessageBubble and WComposer from the brand package, and the icons the client needs. syncapi fixes found with the client: - A room joined inside the sync window was sent as the join alone with limited: false, so clients thought they had the whole room. It is now read like an initial sync; a profile change is not mistaken for a join. - A live typing notification was reported on every sync, so every long poll returned at once while anybody typed. Typing now has a position in the sync token and is reported when it changes, including the empty list when the last person stops. Also: Dockerfile, a `web` compose profile, Makefile targets, a CI job with a proxy smoke test, docs. PLAN.md moves out of the repository. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
adc18e7cf3
commit
b6f3be6ac4
90 files changed
+6608
-472
No files matched your search
@@ -0,0 +1,252 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { WBanner, WButton, WCheckbox, WDialog, WIcon, WSpinner, WTextField } from '@warp/ui'
|
||||
import { errorMessage } from '@/lib/format'
|
||||
import {
|
||||
createKeyBackup,
|
||||
PasswordNeededError,
|
||||
recoveryKeyKnown,
|
||||
restoreKeyBackup,
|
||||
setUpEncryption,
|
||||
unlockWithRecoveryKey,
|
||||
} from '@/matrix/crypto'
|
||||
import { useSecurity } from '@/stores/security'
|
||||
import { useToasts } from '@/stores/toasts'
|
||||
|
||||
// Encryption for the account and this session, one task per mode:
|
||||
// setup – first time: cross-signing, secret storage, key backup; shows the recovery key
|
||||
// reset – the recovery key is lost: new keys, everyone verifies again
|
||||
// unlock – verify this session with the recovery key, or with another session
|
||||
// restore – read older messages from the key backup
|
||||
// backup – start a key backup on an account that has none
|
||||
|
||||
export type EncryptionMode = 'setup' | 'reset' | 'unlock' | 'restore' | 'backup'
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const props = defineProps<{ mode: EncryptionMode }>()
|
||||
|
||||
const security = useSecurity()
|
||||
const toasts = useToasts()
|
||||
|
||||
const step = ref<EncryptionMode | 'saveKey' | 'restoring' | 'restored'>(props.mode)
|
||||
const password = ref('')
|
||||
const needPassword = ref(false)
|
||||
const recoveryKey = ref('')
|
||||
const savedKey = ref(false)
|
||||
const input = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const progress = ref<{ done: number; total: number } | null>(null)
|
||||
const restored = ref({ imported: 0, total: 0 })
|
||||
|
||||
watch(open, (v) => {
|
||||
if (!v) return
|
||||
step.value = props.mode
|
||||
password.value = ''
|
||||
needPassword.value = false
|
||||
recoveryKey.value = ''
|
||||
savedKey.value = false
|
||||
input.value = ''
|
||||
error.value = ''
|
||||
progress.value = null
|
||||
if (props.mode === 'restore') void restore()
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
switch (step.value) {
|
||||
case 'setup':
|
||||
case 'saveKey':
|
||||
return 'Set up encryption'
|
||||
case 'reset':
|
||||
return 'Reset encryption'
|
||||
case 'unlock':
|
||||
return 'Verify this session'
|
||||
case 'backup':
|
||||
return 'Set up key backup'
|
||||
default:
|
||||
return 'Restore from key backup'
|
||||
}
|
||||
})
|
||||
|
||||
async function run(fn: () => Promise<void>) {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await fn()
|
||||
} catch (e) {
|
||||
if (e instanceof PasswordNeededError) needPassword.value = true
|
||||
error.value = errorMessage(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
void security.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function setUp() {
|
||||
return run(async () => {
|
||||
recoveryKey.value = await setUpEncryption({ password: password.value || undefined, reset: step.value === 'reset' })
|
||||
step.value = 'saveKey'
|
||||
})
|
||||
}
|
||||
|
||||
function unlock() {
|
||||
return run(async () => {
|
||||
await unlockWithRecoveryKey(input.value)
|
||||
toasts.success('This session is verified.')
|
||||
await restore()
|
||||
})
|
||||
}
|
||||
|
||||
function backup() {
|
||||
return run(async () => {
|
||||
if (!recoveryKeyKnown()) await unlockWithRecoveryKey(input.value)
|
||||
await createKeyBackup()
|
||||
toasts.success('Key backup is on.')
|
||||
open.value = false
|
||||
})
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
step.value = 'restoring'
|
||||
progress.value = null
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await restoreKeyBackup((done, total) => (progress.value = { done, total }))
|
||||
restored.value = { imported: res.imported, total: res.total }
|
||||
step.value = 'restored'
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e)
|
||||
step.value = 'restored'
|
||||
restored.value = { imported: 0, total: 0 }
|
||||
} finally {
|
||||
void security.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
async function withOtherSession() {
|
||||
error.value = ''
|
||||
try {
|
||||
await security.verifyThisSession()
|
||||
open.value = false
|
||||
} catch (e) {
|
||||
error.value = errorMessage(e)
|
||||
}
|
||||
}
|
||||
|
||||
function copyKey() {
|
||||
navigator.clipboard.writeText(recoveryKey.value).then(() => toasts.success('Copied.'))
|
||||
}
|
||||
|
||||
function downloadKey() {
|
||||
const blob = new Blob([`Warp recovery key\n\n${recoveryKey.value}\n`], { type: 'text/plain' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = 'warp-recovery-key.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
const needsKey = computed(() => step.value === 'unlock' || (step.value === 'backup' && !recoveryKeyKnown()))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WDialog v-model:open="open" :title="title" :persistent="busy || step === 'saveKey' || step === 'restoring'">
|
||||
<!-- First time, or starting over -->
|
||||
<form v-if="step === 'setup' || step === 'reset'" id="enc-setup" class="flex flex-col gap-4" @submit.prevent="setUp">
|
||||
<p v-if="step === 'setup'" class="body m-0">
|
||||
Encryption keys for your account let you verify your sessions, and keep a backup of your message keys on the homeserver,
|
||||
locked with a recovery key only you have. A new session can then read your older messages.
|
||||
</p>
|
||||
<WBanner v-else tone="warning" title="Everyone will need to verify you again">
|
||||
This replaces your account’s keys and starts a new key backup. Messages only your old keys could read stay unreadable here.
|
||||
</WBanner>
|
||||
<WTextField
|
||||
v-if="needPassword || step === 'reset'"
|
||||
v-model="password"
|
||||
wide
|
||||
type="password"
|
||||
label="Account password"
|
||||
autocomplete="current-password"
|
||||
hint="The homeserver asks for it before it takes new keys."
|
||||
autofocus
|
||||
/>
|
||||
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
|
||||
</form>
|
||||
|
||||
<!-- The new recovery key, shown once -->
|
||||
<div v-else-if="step === 'saveKey'" class="flex flex-col gap-4">
|
||||
<WBanner tone="success" title="Encryption is set up">This session is verified, and key backup is on.</WBanner>
|
||||
<p class="body m-0">
|
||||
Save your recovery key somewhere safe, like a password manager. You need it to verify a new session when no other session is
|
||||
signed in. Warp can’t show it to you again.
|
||||
</p>
|
||||
<p class="mxid m-0 rounded-md bg-surface-sunken p-3 text-center break-all select-all">{{ recoveryKey }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<WButton size="sm" icon="copy" @click="copyKey">Copy</WButton>
|
||||
<WButton size="sm" icon="download" @click="downloadKey">Download</WButton>
|
||||
</div>
|
||||
<WCheckbox v-model="savedKey" label="I’ve saved my recovery key" />
|
||||
</div>
|
||||
|
||||
<!-- Unlock with the recovery key -->
|
||||
<form v-else-if="step === 'unlock' || step === 'backup'" id="enc-unlock" class="flex flex-col gap-4" @submit.prevent="step === 'unlock' ? unlock() : backup()">
|
||||
<template v-if="step === 'unlock'">
|
||||
<p class="body m-0">
|
||||
Verify this session to read your encrypted messages here, and so others can trust it. Use one of your other sessions, or
|
||||
your recovery key.
|
||||
</p>
|
||||
<WButton icon="shield-check" class="self-start" :disabled="busy" @click="withOtherSession">Verify with another session</WButton>
|
||||
</template>
|
||||
<p v-else class="body m-0">The key backup is locked with your recovery key.</p>
|
||||
<WTextField
|
||||
v-if="needsKey"
|
||||
v-model="input"
|
||||
wide
|
||||
mono
|
||||
type="password"
|
||||
label="Recovery key"
|
||||
hint="Starts with “Es”, in groups of four. A recovery passphrase works too."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<WBanner v-if="error" tone="danger">{{ error }}</WBanner>
|
||||
<button v-if="step === 'unlock'" type="button" class="small self-start text-ink-muted hover:text-ink hover:underline" @click="step = 'reset'">
|
||||
Lost your recovery key and other sessions? Reset encryption
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Restoring -->
|
||||
<div v-else-if="step === 'restoring'" class="flex flex-col items-center gap-3 py-2 text-center">
|
||||
<WSpinner :size="28" />
|
||||
<p class="body m-0">
|
||||
Restoring message keys<template v-if="progress && progress.total"> · {{ progress.done }} of {{ progress.total }}</template>…
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="step === 'restored'" class="flex flex-col gap-3">
|
||||
<WBanner v-if="error" tone="danger" title="Can’t restore from the key backup">{{ error }}</WBanner>
|
||||
<p v-else class="body m-0 flex items-start gap-2">
|
||||
<WIcon name="shield-check" class="mt-[2px] shrink-0 text-teal-text" />
|
||||
<span v-if="restored.total">Restored {{ restored.imported }} of {{ restored.total }} message keys. Older messages decrypt as they load.</span>
|
||||
<span v-else>The key backup has no message keys yet.</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<template v-if="step === 'setup' || step === 'reset'">
|
||||
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
|
||||
<WButton type="submit" form="enc-setup" :variant="step === 'reset' ? 'danger' : 'primary'" :loading="busy" :disabled="step === 'reset' && !password">
|
||||
{{ step === 'reset' ? 'Reset encryption' : 'Set up encryption' }}
|
||||
</WButton>
|
||||
</template>
|
||||
<WButton v-else-if="step === 'saveKey'" variant="primary" :disabled="!savedKey" @click="open = false">Done</WButton>
|
||||
<template v-else-if="step === 'unlock' || step === 'backup'">
|
||||
<WButton variant="ghost" :disabled="busy" @click="open = false">Cancel</WButton>
|
||||
<WButton type="submit" form="enc-unlock" variant="primary" icon="key" :loading="busy" :disabled="needsKey && !input.trim()">
|
||||
{{ step === 'unlock' ? 'Verify session' : 'Set up key backup' }}
|
||||
</WButton>
|
||||
</template>
|
||||
<WButton v-else-if="step === 'restored'" variant="primary" @click="open = false">Done</WButton>
|
||||
</template>
|
||||
</WDialog>
|
||||
</template>
|
||||
Reference in new issue
Block a user