feat: integrate Just Works app and native business setup
Preserve hosted website UX inside Archipelago, add isolated Business and owner-authorized native wallet setup, and include deployment handoff. Based on main with native signer import already accepted.
This commit is contained in:
@@ -145,6 +145,14 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<JustworksSetup
|
||||
v-else-if="step.id === 'connect-justworks-site'"
|
||||
@connected="completeVerifyStep(step)"
|
||||
/>
|
||||
<JustworksPayments
|
||||
v-else-if="step.id === 'connect-justworks-payments'"
|
||||
@connected="completeVerifyStep(step)"
|
||||
/>
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
@@ -224,6 +232,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import JustworksPayments from './goals/JustworksPayments.vue'
|
||||
import JustworksSetup from './goals/JustworksSetup.vue'
|
||||
import BalanceAmount from '@/components/BalanceAmount.vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -247,6 +257,7 @@ import {
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
justworks: '/assets/img/app-icons/justworks.svg',
|
||||
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
|
||||
lnd: '/assets/img/app-icons/lnd.png',
|
||||
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
|
||||
@@ -270,6 +281,7 @@ function stepIconUrl(step: GoalStep): string | undefined {
|
||||
* overlay on top of the current screen; X-Frame-Options apps open a tab).
|
||||
*/
|
||||
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
|
||||
'instant-business-site': { label: 'Open Just Works', launchAppId: 'justworks' },
|
||||
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
|
||||
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
|
||||
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
|
||||
@@ -398,7 +410,7 @@ async function installApp(step: GoalStep) {
|
||||
|
||||
function openConfigureStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
if (step.completion !== 'app') goalStore.completeStep(goalId.value, step.id)
|
||||
const override = goalStepRouteOverride(step)
|
||||
if (override) {
|
||||
// Internal screens (channels, web5, settings) — tag where we came from so
|
||||
@@ -408,7 +420,7 @@ function openConfigureStep(step: GoalStep) {
|
||||
// Launch the app itself: iframe apps overlay on top of the wizard,
|
||||
// tab-only apps open a tab (mobile: the in-app browser) — the app
|
||||
// launcher handles every case.
|
||||
useAppLauncherStore().openSession(step.appId)
|
||||
useAppLauncherStore().openSession(step.appId, { path: step.appPath })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
||||
"immich": 2283,
|
||||
"indeedhub": 7778,
|
||||
"jellyfin": 8096,
|
||||
"justworks": 8340,
|
||||
"lnd-ui": 18083,
|
||||
"mempool": 4080,
|
||||
"mempool-api": 8999,
|
||||
@@ -79,6 +80,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
|
||||
"indeedhub-redis": "IndeedHub Redis",
|
||||
"indeedhub-relay": "IndeedHub Nostr Relay",
|
||||
"jellyfin": "Jellyfin",
|
||||
"justworks": "Just Works",
|
||||
"lnd": "LND",
|
||||
"lnd-ui": "LND UI",
|
||||
"mempool": "Mempool Explorer",
|
||||
@@ -120,4 +122,5 @@ export const GENERATED_NEW_TAB_APPS = new Set<string>([
|
||||
])
|
||||
|
||||
export const GENERATED_HOST_FRAME_APPS = new Set<string>([
|
||||
"justworks",
|
||||
])
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="space-y-3 rounded-xl border border-white/15 bg-white/5 p-4">
|
||||
<p class="text-sm text-white/70">Save the owner key from website creation once, as a separate business identity in Archipelago.</p>
|
||||
<p v-if="identity" class="text-sm text-white/80">Saved identity: {{ identity.name }}</p>
|
||||
<template v-else-if="importAvailable">
|
||||
<label class="block text-sm">Website owner nsec
|
||||
<input v-model="nsec" type="password" autocomplete="off" maxlength="100" :disabled="busy || disabled" class="mt-1 w-full rounded-lg bg-white/10 px-3 py-2" />
|
||||
</label>
|
||||
<p class="text-xs text-white/60">Copy the website owner nsec from Just Works. The key must match this website; your existing identities remain unchanged.</p>
|
||||
</template>
|
||||
<label v-if="identity || importAvailable" class="block text-sm">Archipelago node password
|
||||
<input v-model="password" type="password" autocomplete="current-password" :disabled="busy || disabled" class="mt-1 w-full rounded-lg bg-white/10 px-3 py-2" />
|
||||
</label>
|
||||
<label v-if="identity" class="flex items-start gap-2 text-sm text-white/80">
|
||||
<input v-model="approved" type="checkbox" :disabled="busy || disabled" class="mt-1" />
|
||||
Allow this request to send this identity’s private Nostr key to justworks.cash for the selected website action. This is key sharing, not local signing.
|
||||
</label>
|
||||
<p role="status" class="text-sm text-white/90">{{ status }}</p>
|
||||
<button v-if="identity || importAvailable" type="button" :disabled="busy || disabled || !npub" @click="identity ? useKey() : importKey()" class="glass-button rounded-lg px-4 py-2 text-sm disabled:opacity-50">{{ busy ? 'Working…' : identity ? 'Use saved key' : 'Add key to native signer' }}</button>
|
||||
<button v-if="loadFailed" type="button" @click="load" class="text-sm underline">Retry identity lookup</button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
const props = defineProps<{ siteId: string; npub: string; disabled?: boolean }>()
|
||||
const emit = defineEmits<{ credentials: [credentials: {ownerKey: string}] }>()
|
||||
interface Identity { id: string; name: string; nostr_npub?: string; is_node?: boolean }
|
||||
const identity = ref<Identity | null>(null)
|
||||
const nsec = ref(''), password = ref(''), approved = ref(false), busy = ref(false), status = ref(''), loadFailed = ref(false)
|
||||
const importAvailable = ref(false)
|
||||
let mounted = true
|
||||
function clear() { nsec.value = ''; password.value = ''; approved.value = false }
|
||||
async function load() {
|
||||
loadFailed.value = false
|
||||
try {
|
||||
const result = await rpcClient.call<{identities: Identity[]}>({method:'identity.list'})
|
||||
if (!mounted) return
|
||||
identity.value = result.identities.find(i => !i.is_node && i.nostr_npub === props.npub) || null
|
||||
if (!identity.value) {
|
||||
try {
|
||||
const capabilities = await rpcClient.call<{import_nostr?:boolean}>({method:'identity.capabilities', maxRetries:1})
|
||||
if (mounted) importAvailable.value = capabilities.import_nostr === true
|
||||
} catch { importAvailable.value = false }
|
||||
if (mounted && !importAvailable.value) status.value = 'This node needs a native signer update before it can import a website key. You can still use the owner key directly in the setup guide.'
|
||||
}
|
||||
} catch { if (mounted) { loadFailed.value = true; status.value = 'Could not load native identities. Retry before importing a key.' } }
|
||||
}
|
||||
async function importKey() {
|
||||
if (busy.value) return
|
||||
if (loadFailed.value) { status.value = 'Retry identity lookup before importing.'; return }
|
||||
if (!nsec.value.trim().startsWith('nsec1') || !password.value) { status.value = 'Enter the website owner nsec and your Archipelago node password.'; return }
|
||||
busy.value = true; status.value = 'Adding the matching website key to your native signer…'
|
||||
try {
|
||||
const result = await rpcClient.call<Identity>({method:'identity.import-nostr', params:{name:`Just Works: ${props.siteId}`.slice(0,100), nsec:nsec.value.trim(), expected_npub:props.npub, password:password.value}, maxRetries:1})
|
||||
if (!mounted) return
|
||||
if (result.nostr_npub !== props.npub) throw new Error('Identity mismatch')
|
||||
identity.value = result; status.value = 'Key saved in Archipelago. Authorize its use below to connect payments.'
|
||||
} catch { if (mounted) status.value = 'Could not import the key. Check the key and node password. Your node must support identity.import-nostr.' }
|
||||
finally { clear(); busy.value = false }
|
||||
}
|
||||
async function useKey() {
|
||||
if (busy.value || !identity.value) return
|
||||
if (!approved.value || !password.value) { status.value = 'Enter your node password and approve sharing this key with Just Works.'; return }
|
||||
busy.value = true; status.value = 'Unlocking the saved website key…'
|
||||
try {
|
||||
const result = await rpcClient.call<{nostr_npub?:string; nostr_nsec?:string}>({method:'identity.export-keys', params:{id:identity.value.id, password:password.value}, maxRetries:1})
|
||||
if (!mounted) return
|
||||
if (result.nostr_npub !== props.npub || !result.nostr_nsec?.startsWith('nsec1')) throw new Error('Identity mismatch')
|
||||
const ownerKey = result.nostr_nsec
|
||||
result.nostr_nsec = undefined
|
||||
emit('credentials', {ownerKey})
|
||||
status.value = 'Request sent. Check the publication status below.'
|
||||
} catch { if (mounted) status.value = 'Could not unlock the matching website key. Check your node password and retry.' }
|
||||
finally { clear(); busy.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
onUnmounted(() => { mounted = false; clear() })
|
||||
</script>
|
||||
@@ -0,0 +1,149 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<p v-if="IS_DEMO" class="text-sm text-white/60">Connect payments from your own node. This preview does not change a live payment address.</p>
|
||||
<template v-else>
|
||||
<p v-if="siteId" class="text-sm text-white/70 break-all">Website: https://justworks.cash/{{ siteId }}</p>
|
||||
<button v-if="!address" :disabled="busy || !siteId" @click="prepare" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Getting wallet address…' : 'Use my Archipelago wallet' }}</button>
|
||||
<form v-else novalidate @submit.prevent="publish" class="space-y-3">
|
||||
<p class="text-sm text-white/80 break-all">Payments will go to <strong>{{ address }}</strong>.</p>
|
||||
<p class="text-sm text-white/60">Authorize Just Works to update and republish your website. Your owner sign-in is used for this request only and is not saved.</p>
|
||||
<label class="block text-sm text-white/80">Sign in with
|
||||
<select v-model="mode" :disabled="busy" class="block mt-1 w-full rounded-lg bg-black/30 border border-white/20 px-3 py-2"><option value="login">Just Works owner login</option><option value="key">Just Works owner key</option><option value="native">Saved Archipelago identity</option></select>
|
||||
</label>
|
||||
<template v-if="mode === 'login'">
|
||||
<label class="block text-sm text-white/80">Owner login<input v-model="ownerIdentifier" :disabled="busy" required autocomplete="username" maxlength="200" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
|
||||
<label class="block text-sm text-white/80">Owner password<input v-model="ownerPassword" :disabled="busy" required type="password" autocomplete="current-password" maxlength="2048" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
|
||||
</template>
|
||||
<template v-else-if="mode === 'key'">
|
||||
<label class="block text-sm text-white/80">Owner key<input v-model="ownerKey" :disabled="busy" required type="password" autocomplete="off" maxlength="2048" placeholder="nsec1… or ncryptsec1…" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
|
||||
<label class="block text-sm text-white/80">Key password (if encrypted)<input v-model="keyPassword" :disabled="busy" type="password" autocomplete="off" maxlength="2048" class="block mt-1 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2" /></label>
|
||||
</template>
|
||||
<label v-if="mode === 'native'" class="block text-sm">Use the saved key to
|
||||
<select v-model="nativeAction" :disabled="busy" class="block mt-1 rounded-lg bg-black/30 px-3 py-2"><option value="payment">Connect wallet & publish</option><option value="business">Sign into Business tools</option></select>
|
||||
</label>
|
||||
<JustworksNativeKey v-if="mode === 'native'" :site-id="siteId" :npub="siteNpub" :disabled="busy" @credentials="publishNative" />
|
||||
<p role="status" aria-live="polite" class="text-sm text-white/90">{{ status }}</p>
|
||||
<button v-if="mode !== 'native'" type="submit" :disabled="busy" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Publishing and verifying…' : 'Connect wallet & publish' }}</button>
|
||||
</form>
|
||||
<button v-if="!siteId" @click="useAppLauncherStore().openSession('justworks')" class="glass-button rounded-lg px-4 py-2 text-sm">Open Just Works</button>
|
||||
<button @click="showReceive = true" class="text-sm text-white/70 underline">Open wallet Receive</button>
|
||||
<p class="text-xs text-white/50">Uses the existing hosted Lightning address and mint. Open wallet Receive → Ecash to claim incoming payments.</p>
|
||||
</template>
|
||||
<p v-if="!address" role="status" class="text-sm text-white/75">{{ status }}</p>
|
||||
<iframe v-if="!IS_DEMO" ref="bridge" :src="bridgeUrl" @load="loadConnection" title="Just Works payment connection" hidden />
|
||||
<ReceiveBitcoinModal :show="showReceive" @close="showReceive = false" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import JustworksNativeKey from './JustworksNativeKey.vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
const emit = defineEmits<{ connected: [] }>()
|
||||
const bridge = ref<HTMLIFrameElement | null>(null)
|
||||
const bridgeUrl = resolveAppUrl('justworks', '/bridge.html')
|
||||
const origin = new URL(bridgeUrl, location.origin).origin
|
||||
const nativeAction = ref('payment')
|
||||
const siteNpub = ref('')
|
||||
const siteId = ref(''), address = ref(''), status = ref('Checking your connected website…')
|
||||
const busy = ref(false), showReceive = ref(false), mode = ref('login')
|
||||
const ownerIdentifier = ref(''), ownerPassword = ref(''), ownerKey = ref(''), keyPassword = ref('')
|
||||
let pending = '', operation = '', timer: ReturnType<typeof setTimeout> | undefined
|
||||
function clearSecrets() { ownerPassword.value = ''; ownerKey.value = ''; keyPassword.value = '' }
|
||||
function send(type: string, payload?: unknown) {
|
||||
pending = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-')
|
||||
operation = type
|
||||
bridge.value?.contentWindow?.postMessage({type, requestId:pending, payload}, origin)
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
pending = ''; busy.value = false; clearSecrets()
|
||||
status.value = type === 'justworks:connect-payment' ? 'Publication could not be verified. The address may have been saved. Retry to check and publish again.' : 'Open Just Works to check app access, then reopen this guide.'
|
||||
}, type === 'justworks:connect-payment' ? 180000 : type === 'justworks:business-login' ? 65000 : 15000)
|
||||
}
|
||||
function loadConnection() { send('justworks:get-connection') }
|
||||
async function prepare() {
|
||||
busy.value = true; status.value = 'Getting the address from your native wallet…'
|
||||
try {
|
||||
const result = await rpcClient.call<{address?: string}>({method:'wallet.ecash-lnaddress', timeout:60000})
|
||||
if (!result.address || !/^[a-z0-9._+-]{1,100}@minibits\.cash$/.test(result.address)) throw new Error('Native wallet address is unavailable. Open wallet Receive → Ecash to finish wallet setup, then retry.')
|
||||
address.value = result.address
|
||||
status.value = 'Ready for your Just Works owner authorization.'
|
||||
} catch { status.value = 'Could not get the native wallet address. Open wallet Receive → Ecash to finish setup, then retry.' }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
function publishNative(credentials: {ownerKey: string}) {
|
||||
if (busy.value) return
|
||||
if (nativeAction.value === 'business') {
|
||||
busy.value = true
|
||||
status.value = 'Signing into your local Business tools…'
|
||||
try { send('justworks:business-login', {npub:siteNpub.value, ownerKey:credentials.ownerKey}) }
|
||||
catch { busy.value = false; pending = ''; status.value = 'Could not contact Business tools. Reload and retry.' }
|
||||
return
|
||||
}
|
||||
ownerKey.value = credentials.ownerKey
|
||||
mode.value = 'key'
|
||||
publish()
|
||||
mode.value = 'native'
|
||||
}
|
||||
function publish() {
|
||||
if (busy.value) return
|
||||
// Native validation can prevent submit without visible feedback on mobile.
|
||||
// Accept pasted owner keys in either mode; never send them as login identifiers.
|
||||
const identifier = ownerIdentifier.value.trim()
|
||||
const pastedKey = /^(nsec1|ncryptsec1)/.test(identifier)
|
||||
const key = (mode.value === 'key' ? ownerKey.value : pastedKey ? identifier : '').trim()
|
||||
if (!siteId.value || !address.value) {
|
||||
status.value = 'Reconnect your website and select your Archipelago wallet first.'
|
||||
return
|
||||
}
|
||||
if (mode.value === 'key' || pastedKey) {
|
||||
if (!/^(nsec1|ncryptsec1)/.test(key)) {
|
||||
status.value = 'Enter the website owner’s nsec1… or ncryptsec1… key. A public npub cannot authorize changes.'
|
||||
return
|
||||
}
|
||||
} else if (!identifier || !ownerPassword.value) {
|
||||
status.value = 'Enter your Just Works owner login and password, or choose Just Works owner key to use an nsec.'
|
||||
return
|
||||
}
|
||||
const credentials = key
|
||||
? {ownerKey:key, password:mode.value === 'key' ? keyPassword.value : ownerPassword.value}
|
||||
: {ownerIdentifier:identifier, ownerPassword:ownerPassword.value}
|
||||
busy.value = true
|
||||
status.value = 'Saving the payment address, publishing, and checking the public website…'
|
||||
try {
|
||||
if (!bridge.value?.contentWindow) throw new Error('Connector unavailable')
|
||||
send('justworks:connect-payment', {siteId:siteId.value, address:address.value, credentials})
|
||||
} catch {
|
||||
clearTimeout(timer); pending = ''; busy.value = false
|
||||
status.value = 'Could not contact the Just Works app. Reload this guide and try again.'
|
||||
} finally {
|
||||
if (pastedKey) ownerIdentifier.value = ''
|
||||
clearSecrets()
|
||||
}
|
||||
}
|
||||
function receive(event: MessageEvent) {
|
||||
if (!pending || event.source !== bridge.value?.contentWindow || event.origin !== origin || event.data?.requestId !== pending) return
|
||||
if (operation === 'justworks:get-connection' && event.data.type === 'justworks:connection') {
|
||||
siteNpub.value = typeof event.data.npub === 'string' ? event.data.npub : ''
|
||||
const id = event.data.siteId
|
||||
siteId.value = typeof id === 'string' && id.length <= 160 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id) ? id : ''
|
||||
status.value = siteId.value ? '' : 'Connect your website in the previous step first, in this browser.'
|
||||
} else if (operation === 'justworks:business-login' && event.data.type === 'justworks:business-result') {
|
||||
status.value = event.data.ok === true && event.data.npub === siteNpub.value
|
||||
? 'Business tools signed in. Open Just Works and choose Business tools.'
|
||||
: 'Business tools could not verify the owner key. Your website has not been changed.'
|
||||
} else if (operation === 'justworks:connect-payment' && event.data.type === 'justworks:payment-result') {
|
||||
if (event.data.verified === true && event.data.siteId === siteId.value && event.data.address === address.value) {
|
||||
status.value = 'The published website now uses your Archipelago wallet address.'
|
||||
emit('connected')
|
||||
} else status.value = typeof event.data.error === 'string' ? event.data.error : 'The published payment destination could not be verified. Retry.'
|
||||
} else if (event.data.type === 'justworks:site-error') status.value = 'Could not reach Just Works. Retry after checking app access.'
|
||||
else return
|
||||
clearTimeout(timer); pending = ''; busy.value = false; clearSecrets()
|
||||
}
|
||||
onMounted(() => window.addEventListener('message', receive))
|
||||
onUnmounted(() => { clearTimeout(timer); clearSecrets(); window.removeEventListener('message', receive) })
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-white/70">1. Open Just Works and choose Create. Add your business name and existing website or social links, then publish using the usual Just Works setup.</p>
|
||||
<button @click="useAppLauncherStore().openSession('justworks')" class="glass-button inline-flex rounded-lg px-5 py-2 text-sm">Open Just Works</button>
|
||||
<p class="text-sm text-white/70">2. Return here with your published page link. Connecting your first website reveals Business tools in the launcher. Use your existing Just Works owner login there.</p>
|
||||
<p v-if="IS_DEMO" class="text-sm text-amber-200">Demo: explore the original Just Works site above. Connecting a published page requires the installed app on your node.</p>
|
||||
<form v-else class="space-y-3" @submit.prevent="connect">
|
||||
<label class="block text-sm text-white/80">Your Just Works page or npub
|
||||
<input v-model="url" required maxlength="240" type="text" inputmode="url" autocomplete="url" placeholder="https://justworks.cash/your-business" class="mt-2 w-full rounded-lg bg-white/10 border border-white/20 px-3 py-2 text-white" />
|
||||
</label>
|
||||
<p class="text-xs text-white/50">Use a public page link or npub. Connecting a page does not sign you in or establish ownership.</p>
|
||||
<button :disabled="busy || !bridgeReady" class="glass-button rounded-lg px-5 py-2 text-sm disabled:opacity-50">{{ busy ? 'Checking…' : 'Check and connect' }}</button>
|
||||
</form>
|
||||
<p role="status" class="text-sm text-white/75">{{ status }}</p>
|
||||
<iframe v-if="!IS_DEMO" ref="bridge" :src="bridgeUrl" @load="bridgeReady = true" title="Just Works connection" hidden />
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
import { resolveAppUrl } from '../appSession/appSessionConfig'
|
||||
import { isJustworksCompletion } from './justworksCompletion'
|
||||
const emit = defineEmits<{ connected: [] }>()
|
||||
const bridge = ref<HTMLIFrameElement | null>(null)
|
||||
const bridgeUrl = resolveAppUrl('justworks', '/bridge.html')
|
||||
const origin = new URL(bridgeUrl, window.location.origin).origin
|
||||
const requestedSite = useRoute().query.site
|
||||
const url = ref(typeof requestedSite === 'string' && requestedSite.length <= 240 ? requestedSite : '')
|
||||
const bridgeReady = ref(false)
|
||||
const busy = ref(false)
|
||||
const status = ref('')
|
||||
let requestId = ''
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
function connect() {
|
||||
busy.value = true
|
||||
status.value = 'Checking your published website…'
|
||||
requestId = Array.from(crypto.getRandomValues(new Uint32Array(4))).join('-')
|
||||
bridge.value?.contentWindow?.postMessage({type:'justworks:check-site', requestId, url:url.value.trim()}, origin)
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(() => {
|
||||
busy.value = false
|
||||
requestId = ''
|
||||
status.value = 'Could not connect. Open the installed Just Works app to check access, then return here and retry.'
|
||||
}, 20000)
|
||||
}
|
||||
function receive(event: MessageEvent) {
|
||||
if (!busy.value || event.source !== bridge.value?.contentWindow || event.origin !== origin || event.data?.requestId !== requestId) return
|
||||
if (isJustworksCompletion(event, 'justworks', bridgeUrl, bridge.value?.contentWindow ?? null)) {
|
||||
status.value = event.data.remembered ? 'Website connected. Business tools are now available in your launcher.' : 'Allow browser storage for Just Works, then reconnect so the launcher can remember your website.'
|
||||
if (event.data.remembered) emit('connected')
|
||||
} else if (event.data?.type === 'justworks:site-error') {
|
||||
status.value = typeof event.data.error === 'string' ? event.data.error : 'Could not check this website. Try again.'
|
||||
} else return
|
||||
clearTimeout(timer)
|
||||
busy.value = false
|
||||
requestId = ''
|
||||
}
|
||||
onMounted(() => window.addEventListener('message', receive))
|
||||
onUnmounted(() => { clearTimeout(timer); window.removeEventListener('message', receive) })
|
||||
</script>
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isJustworksCompletion } from '../justworksCompletion'
|
||||
import { GOALS } from '@/data/goals'
|
||||
|
||||
describe('Just Works verified setup handoff', () => {
|
||||
const frame = {} as Window
|
||||
const url = 'https://node.local:8340/setup'
|
||||
const message = (changes = {}) => ({
|
||||
source: frame, origin: 'https://node.local:8340',
|
||||
data: { type: 'justworks:site-connected', site: { id: 'my-bakery', published: true } },
|
||||
...changes,
|
||||
}) as MessageEvent
|
||||
|
||||
it('accepts a published page from the active Just Works frame', () => {
|
||||
expect(isJustworksCompletion(message(), 'justworks', url, frame)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects other windows, origins, apps, and incomplete results', () => {
|
||||
for (const event of [message({ source: {} }), message({ origin: 'https://evil.test' }),
|
||||
message({ origin: 'http://node.local:8340' }), message({ data: null }),
|
||||
message({ data: { type: 'justworks:site-connected', site: { id: '../x', published: true } } }),
|
||||
message({ data: { type: 'justworks:site-connected', site: { id: 'my-bakery', published: false } } }),
|
||||
]) expect(isJustworksCompletion(event, 'justworks', url, frame)).toBe(false)
|
||||
expect(isJustworksCompletion(message(), 'filebrowser', url, frame)).toBe(false)
|
||||
expect(isJustworksCompletion(message(), 'justworks', url, null)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires verified app completion, with no payment-stack dependency', () => {
|
||||
const goal = GOALS.find(g => g.id === 'instant-business-site')!
|
||||
expect(goal.requiredApps).toEqual(['justworks'])
|
||||
const configure = goal.steps.find(s => s.action === 'configure')!
|
||||
expect(configure.completion).toBe('app')
|
||||
expect(configure.appPath).toBeUndefined()
|
||||
})
|
||||
|
||||
it('never completes real setup from demo progress', () => {
|
||||
const demo = message({ data: { type: 'justworks:demo-site-connected', site: { id: 'corner-bakery-demo', published: false } } })
|
||||
expect(isJustworksCompletion(demo, 'justworks', url, frame)).toBe(false)
|
||||
expect(isJustworksCompletion(demo, 'justworks', url, frame, true)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Accept setup progress only from the active, gate-protected Just Works frame.
|
||||
* A connected public page is not proof of merchant ownership or a login. */
|
||||
export function isJustworksCompletion(
|
||||
event: MessageEvent,
|
||||
appId: string,
|
||||
appUrl: string,
|
||||
frameWindow: Window | null,
|
||||
allowDemo = false,
|
||||
): boolean {
|
||||
if (appId !== 'justworks' || !frameWindow || event.source !== frameWindow) return false
|
||||
try {
|
||||
if (event.origin !== new URL(appUrl, window.location.origin).origin) return false
|
||||
} catch { return false }
|
||||
const site = event.data?.site
|
||||
const verified = event.data?.type === 'justworks:site-connected' && site?.published === true
|
||||
const sample = allowDemo && event.data?.type === 'justworks:demo-site-connected'
|
||||
&& site?.published === false && site?.id === 'corner-bakery-demo'
|
||||
return (verified || sample)
|
||||
&& typeof site.id === 'string'
|
||||
&& site.id.length <= 160
|
||||
&& /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(site.id)
|
||||
}
|
||||
Reference in New Issue
Block a user