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:
parent
7652262044
commit
030f478228
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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—I'll risk just one device for now
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 |
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export type Session = {
|
|||
username: string
|
||||
authed: boolean
|
||||
loading: boolean
|
||||
backupCreated: boolean
|
||||
error?: SessionError
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export type BackupView = 'backup' | 'backup-device' | 'are-you-sure'
|
||||
export type BackupView = 'backup' | 'are-you-sure'
|
||||
|
|
@ -32,9 +32,6 @@
|
|||
|
||||
const init = async () => {
|
||||
await initialize()
|
||||
|
||||
// TODO: Remove this debugging statement
|
||||
console.log('session at init', session)
|
||||
}
|
||||
|
||||
const clearNotification = () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import DelegateAccount from '$components/auth/link/DelegateAccount.svelte'
|
||||
</script>
|
||||
|
||||
<DelegateAccount />
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang="ts">
|
||||
import LinkDevice from '$components/auth/link/LinkDevice.svelte'
|
||||
</script>
|
||||
|
||||
<LinkDevice />
|
||||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in New Issue