Link device flow (#32)

* Wire up delegate account page
* Wire up link device page
* Add pin error when no matching pin
* Add backup created flag and store it in WNFS
* Move backup status into a dedicated module
* Move linking into a dedicated module


Co-authored-by: Brian Ginsburg <gins@brianginsburg.com>
This commit is contained in:
Jess Martin 2022-08-24 10:36:59 -04:00 committed by GitHub
parent 7652262044
commit 030f478228
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 376 additions and 88 deletions

View File

@ -1,5 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { sessionStore } from '../stores'
import Shield from '$components/icons/Shield.svelte'
</script>
<header class="navbar bg-base-100 pt-0">
@ -21,9 +24,21 @@
</button>
</div>
{#if !$sessionStore.loading && !$sessionStore.authed}
<div class="flex-none">
<a class="btn btn-sm btn-primary normal-case" href="/connect">Connect</a>
</div>
{#if !$sessionStore.loading}
{#if !$sessionStore.authed}
<div class="flex-none">
<a class="btn btn-sm btn-primary normal-case" href="/connect">
Connect
</a>
</div>
{:else if $sessionStore.backupCreated === false}
<span
on:click={() => goto('delegate-account')}
class="btn btn-sm btn-warning rounded-full font-extralight"
>
<Shield />
<span class="ml-2">Backup required</span>
</span>
{/if}
{/if}
</header>

View File

@ -1,13 +1,27 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { filesystemStore, sessionStore } from '../../../stores'
import { setBackupStatus } from '$lib/auth/backup'
import type { BackupView } from '$lib/views'
import { goto } from '$app/navigation'
const dispatch = createEventDispatcher()
const navigate = (view: BackupView) => {
dispatch('navigate', { view })
}
const skipBackup = () => {
setBackupStatus($filesystemStore, { created: false })
sessionStore.update(session => ({
...session,
backupCreated: false
}))
goto('/')
}
</script>
<input type="checkbox" id="are-you-sure-modal" checked class="modal-toggle" />
@ -20,15 +34,15 @@
Without a backup device, if you lose this device or reset your browser,
you will not be able to recover your account data.
</p>
<button
class="btn btn-primary"
on:click={() => navigate('backup-device')}
>
<button class="btn btn-primary" on:click={() => goto('delegate-account')}>
Connect a backup device
</button>
<a class="text-error underline block mt-4" href="/">
<span
class="text-error underline block mt-4 cursor-pointer"
on:click={skipBackup}
>
YOLO&mdash;I'll risk just one device for now
</a>
</span>
</div>
</div>
</div>

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { goto } from '$app/navigation'
import { appName } from '$lib/app-name'
import type { BackupView } from '$lib/views'
@ -25,10 +26,7 @@
device.
</p>
<button
class="btn btn-primary"
on:click={() => navigate('backup-device')}
>
<button class="btn btn-primary" on:click={() => goto('delegate-account')}>
Connect a backup device
</button>
<button

View File

@ -1,50 +0,0 @@
<script lang="ts">
import clipboardCopy from 'clipboard-copy'
import QRCode from 'qrcode-svg'
import { createEventDispatcher } from 'svelte'
import { sessionStore, theme } from '../../../stores'
import type { BackupView } from '$lib/views'
import ClipboardIcon from '$components/icons/ClipboardIcon.svelte'
const dispatch = createEventDispatcher()
const origin = window.location.origin
const connectionLink = `${origin}/link?username=${$sessionStore.username}`
const qrcode = new QRCode({
content: connectionLink,
color: $theme === 'light' ? '#334155' : '#E2E8F0',
background: '#ffffff00'
}).svg()
const copyLink = async () => {
await clipboardCopy(connectionLink)
}
const navigate = (view: BackupView) => {
dispatch('navigate', { view })
}
</script>
<input type="checkbox" id="backup-device-modal" checked class="modal-toggle" />
<div class="modal">
<div class="modal-box w-80 relative text-center">
<div>
<h3 class="pb-1 text-xl font-serif">Connect a backup device</h3>
{@html qrcode}
<p class="font-extralight pt-1 mb-8">
Scan this code on the new device, or share the connection link.
</p>
<button class="btn btn-primary btn-outline" on:click={copyLink}>
<ClipboardIcon />
<span class="ml-2">Copy connection link</span>
</button>
<button
class="btn btn-xs btn-link text-base text-error font-normal underline mt-4"
on:click={() => navigate('are-you-sure')}
>
Skip for now
</button>
</div>
</div>
</div>

View File

@ -0,0 +1,162 @@
<script lang="ts">
import clipboardCopy from 'clipboard-copy'
import QRCode from 'qrcode-svg'
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import { createAccountLinkingProducer } from '$lib/auth/linking'
import { filesystemStore, sessionStore, theme } from '../../../stores'
import { setBackupStatus } from '$lib/auth/backup'
import ClipboardIcon from '$components/icons/ClipboardIcon.svelte'
let view: 'backup-device' | 'delegate-account' = 'backup-device'
let connectionLink = null
let qrcode = null
let pin: number[]
let pinInput = ''
let pinError = false
let confirmPin = () => {}
let rejectPin = () => {}
onMount(() => {
sessionStore.subscribe(val => {
const username = val.username
if (username) {
const origin = window.location.origin
connectionLink = `${origin}/link-device?username=${username}`
qrcode = new QRCode({
content: connectionLink,
color: $theme === 'light' ? '#334155' : '#E2E8F0',
background: '#ffffff00'
}).svg()
initAccountLinkingProducer(username)
}
})
})
const initAccountLinkingProducer = async (username: string) => {
const accountLinkingProducer = await createAccountLinkingProducer(username)
accountLinkingProducer.on('challenge', detail => {
pin = detail.pin
confirmPin = detail.confirmPin
rejectPin = detail.rejectPin
view = 'delegate-account'
})
accountLinkingProducer.on('link', async ({ approved }) => {
if (approved) {
sessionStore.update(session => ({
...session,
backupCreated: true
}))
const fs = $filesystemStore
await setBackupStatus(fs, { created: true })
goto('/')
// Send up a toast on '/'
}
})
}
const copyLink = async () => {
await clipboardCopy(connectionLink)
}
const checkPin = () => {
if (pin.join('') === pinInput) {
confirmPin()
} else {
pinError = true
}
}
const refuseConnection = () => {
rejectPin()
view = 'backup-device'
}
</script>
{#if view === 'backup-device'}
<input
type="checkbox"
id="backup-device-modal"
checked
class="modal-toggle"
/>
<div class="modal">
<div class="modal-box w-80 relative text-center">
<div>
<h3 class="pb-1 text-xl font-serif">Connect a backup device</h3>
{@html qrcode}
<p class="font-extralight pt-1 mb-8">
Scan this code on the new device, or share the connection link.
</p>
<button class="btn btn-primary btn-outline" on:click={copyLink}>
<ClipboardIcon />
<span class="ml-2">Copy connection link</span>
</button>
<button
class="btn btn-xs btn-link text-base text-error font-normal underline mt-4"
on:click={() => goto('/backup?view=are-you-sure')}
>
Skip for now
</button>
</div>
</div>
</div>
{:else if view === 'delegate-account'}
<input
type="checkbox"
id="delegate-account-modal"
checked
class="modal-toggle"
/>
<div class="modal">
<div class="modal-box w-80 relative text-center">
<div>
<h3 class="mb-7 text-xl font-serif">
A new device would like to connect to your account
</h3>
<div class="mb-5">
<input
id="pin"
type="text"
class="input input-bordered w-full max-w-xs mb-2"
bind:value={pinInput}
/>
<label for="pin" class="label">
{#if !pinError}
<span class="label-text-alt text-slate-500">
Enter the connection code to approve the connection
</span>
{:else}
<span class="label-text-alt text-error">
Entered pin does not match a pin from a known device
</span>
{/if}
</label>
</div>
<div>
<button class="btn btn-primary mb-5 w-full" on:click={checkPin}>
Approve the connection
</button>
<button
class="btn btn-error btn-outline w-full"
on:click={refuseConnection}
>
Refuse the connection
</button>
</div>
</div>
</div>
</div>
{/if}

View File

@ -0,0 +1,68 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { appName } from '$lib/app-name'
import { createAccountLinkingConsumer } from '$lib/auth/linking'
import { loadAccount } from '$lib/common/webnative'
let displayPin: string = ''
let url = $page.url
const username = url.searchParams.get('username')
// clear the params
url.searchParams.delete('username')
history.replaceState(null, document.title, url.toString())
const initAccountLinkingConsumer = async () => {
const accountLinkingConsumer = await createAccountLinkingConsumer(username)
accountLinkingConsumer.on('challenge', ({ pin }) => {
displayPin = pin.join(' ')
})
accountLinkingConsumer.on('link', async ({ approved, username }) => {
if (approved) {
await loadAccount(username)
goto('/')
// Send up a toast on '/'
}
})
}
initAccountLinkingConsumer()
</script>
<input type="checkbox" id="my-modal-5" checked class="modal-toggle" />
<div class="modal">
<div class="modal-box w-80 relative text-center">
<div class="grid grid-flow-row auto-rows-max gap-7">
<h3 class="text-xl font-serif">Connection Requested</h3>
<div class="grid grid-flow-row auto-rows-max gap-4 justify-items-center">
{#if displayPin}
<span
class="btn btn-info btn-lg rounded-full text-2xl font-extralight tracking-widest w-3/4 cursor-default"
>
{displayPin}
</span>
{/if}
<span class="text-md">
Open {appName} on your already connected device and enter this code.
</span>
<div
class="grid grid-flow-col auto-cols-max gap-4 justify-center items-center text-slate-500"
>
<span
class="rounded-lg border-t-2 border-l-2 border-slate-600 w-4 h-4 block animate-spin"
/>
Waiting for a response...
</div>
</div>
<div>
<button class="btn btn-primary btn-outline text-base font-normal mt-4">
Cancel Request
</button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,14 @@
<svg
width="17"
height="18"
viewBox="0 0 17 18"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.5 0.944458C6.41528 2.81033 3.67437 3.95809 0.666107 3.99891C0.556864 4.64968 0.5 5.31821 0.5 6.00003C0.5 11.2249 3.83923 15.6698 8.5 17.3172C13.1608 15.6698 16.5 11.2249 16.5 6.00003C16.5 5.31821 16.4431 4.64968 16.3339 3.99891C13.3256 3.95809 10.5847 2.81033 8.5 0.944458ZM9.5 13C9.5 13.5523 9.05228 14 8.5 14C7.94772 14 7.5 13.5523 7.5 13C7.5 12.4477 7.94772 12 8.5 12C9.05228 12 9.5 12.4477 9.5 13ZM9.5 6C9.5 5.44772 9.05229 5 8.5 5C7.94772 5 7.5 5.44772 7.5 6V9C7.5 9.55228 7.94772 10 8.5 10C9.05229 10 9.5 9.55228 9.5 9V6Z"
fill="#7C2D12"
/>
</svg>

After

Width:  |  Height:  |  Size: 733 B

29
src/lib/auth/backup.ts Normal file
View File

@ -0,0 +1,29 @@
import * as webnative from 'webnative'
import type FileSystem from 'webnative/fs/index'
export type BackupStatus = { created: boolean } | null
export const setBackupStatus = async (fs: FileSystem, status: BackupStatus): Promise<void> => {
const backupStatusPath = webnative.path.file('private', 'backup-status.json')
await fs.write(backupStatusPath, JSON.stringify(status))
await fs.publish()
}
export const getBackupStatus = async (fs: FileSystem): Promise<BackupStatus> => {
const backupStatusPath = webnative.path.file('private', 'backup-status.json')
if (await fs.exists(backupStatusPath)) {
const fileContent = await fs.read(backupStatusPath)
if (typeof fileContent === 'string') {
return JSON.parse(fileContent) as BackupStatus
}
console.warn('Unable to load backup status')
return { created: false }
} else {
return { created: false }
}
}

14
src/lib/auth/linking.ts Normal file
View File

@ -0,0 +1,14 @@
import * as webnative from 'webnative'
import type { account } from 'webnative'
export const createAccountLinkingConsumer = async (
username: string
): Promise<account.AccountLinkingConsumer> => {
return await webnative.account.createConsumer({ username })
}
export const createAccountLinkingProducer = async (
username: string
): Promise<account.AccountLinkingProducer> => {
return await webnative.account.createProducer({ username })
}

View File

@ -1,12 +1,16 @@
import * as webnative from 'webnative'
import type FileSystem from 'webnative/fs/index'
import { setup } from 'webnative'
import { asyncDebounce } from '$lib/common/utils'
import { filesystemStore, sessionStore } from '../../stores'
import { getBackupStatus, type BackupStatus } from '$lib/auth/backup'
// runfission.net = staging
setup.endpoints({ api: 'https://runfission.net', lobby: 'https://auth.runfission.net', user: 'fissionuser.net' })
setup.endpoints({
api: 'https://runfission.net',
lobby: 'https://auth.runfission.net',
user: 'fissionuser.net'
})
let state: webnative.AppState
@ -15,6 +19,8 @@ setup.debug({ enabled: false })
export const initialize = async (): Promise<void> => {
try {
let backupStatus: BackupStatus = null
state = await webnative.app({ useWnfs: true })
switch (state.scenario) {
@ -22,16 +28,21 @@ export const initialize = async (): Promise<void> => {
sessionStore.set({
username: '',
authed: false,
loading: false
loading: false,
backupCreated: null
})
break
case webnative.AppScenario.Authed:
backupStatus = await getBackupStatus(state.fs)
sessionStore.set({
username: state.username,
authed: state.authenticated,
loading: false
loading: false,
backupCreated: backupStatus.created
})
filesystemStore.set(state.fs)
break
@ -77,7 +88,7 @@ export const isUsernameAvailable = async (
export const register = async (username: string): Promise<boolean> => {
const { success } = await webnative.account.register({ username })
const fs = await bootstrapFilesystem()
const fs = await webnative.bootstrapRootFileSystem()
filesystemStore.set(fs)
sessionStore.update(session => ({
@ -89,12 +100,13 @@ export const register = async (username: string): Promise<boolean> => {
return success
}
export const bootstrapFilesystem = async (): Promise<FileSystem> => {
return await webnative.bootstrapRootFileSystem()
}
export const loadAccount = async (username: string): Promise<void> => {
const fs = await webnative.loadRootFileSystem()
filesystemStore.set(fs)
// interface StateFS {
// fs?: FileSystem
// }
// export const getWNFS: () => FileSystem = () => (state as StateFS)?.fs
sessionStore.update(session => ({
...session,
username,
authed: true
}))
}

View File

@ -4,6 +4,7 @@ export type Session = {
username: string
authed: boolean
loading: boolean
backupCreated: boolean
error?: SessionError
}

View File

@ -1 +1 @@
export type BackupView = 'backup' | 'backup-device' | 'are-you-sure'
export type BackupView = 'backup' | 'are-you-sure'

View File

@ -32,9 +32,6 @@
const init = async () => {
await initialize()
// TODO: Remove this debugging statement
console.log('session at init', session)
}
const clearNotification = () => {

View File

@ -1,10 +1,15 @@
<script lang="ts">
import { page } from '$app/stores'
import type { BackupView } from '$lib/views'
import Backup from '$components/auth/backup/Backup.svelte'
import BackupDevice from '$components/auth/backup/BackupDevice.svelte'
import AreYouSure from '$components/auth/backup/AreYouSure.svelte'
let view: BackupView = 'backup'
let url = $page.url
let view = url.searchParams.get('view') ?? 'backup'
// clear the params
url.searchParams.delete('view')
history.replaceState(null, document.title, url.toString())
const navigate = (event: CustomEvent<{ view: BackupView }>) => {
view = event.detail.view
@ -13,8 +18,6 @@
{#if view === 'backup'}
<Backup on:navigate={navigate} />
{:else if view === 'backup-device'}
<BackupDevice on:navigate={navigate} />
{:else if view === 'are-you-sure'}
<AreYouSure on:navigate={navigate} />
{/if}

View File

@ -0,0 +1,5 @@
<script lang="ts">
import DelegateAccount from '$components/auth/link/DelegateAccount.svelte'
</script>
<DelegateAccount />

View File

@ -0,0 +1,5 @@
<script lang="ts">
import LinkDevice from '$components/auth/link/LinkDevice.svelte'
</script>
<LinkDevice />

View File

@ -10,11 +10,12 @@ import type { Theme } from '$lib/theme'
export const theme: Writable<Theme> = writable(loadTheme())
export const sessionStore: Writable<Session> = writable({
username: '',
username: null,
authed: false,
loading: true
loading: true,
backupCreated: null
})
export const filesystemStore: Writable<FileSystem | null> = writable(null)
export const deviceStore: Writable<Device> = writable({ isMobile: true })
export const deviceStore: Writable<Device> = writable({ isMobile: true })