Add license validation to Vue 2, Vue 3, and Nuxt apps with a reusable composable, store module, router guard, and drop-in validator component.
In a hurry? Here is the smallest possible licensed Vue 3 component — a single <script setup> block that calls the API and gates the UI on the result:
<script setup>
import { ref, onMounted } from 'vue'
const status = ref('checking')
const message = ref('')
async function validate() {
const res = await fetch('https://api.unifiedlicensing.com/api/v1/validate-license', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: 'ul_YOUR_API_KEY',
product_key: 'YOUR_PRODUCT_KEY',
license_key: localStorage.getItem('license_key'),
machine_id: crypto.randomUUID(),
platform: 'web'
})
})
const data = await res.json()
status.value = data.valid ? 'licensed' : 'unlicensed'
message.value = data.message || ''
}
onMounted(validate)
</script>
<template>
<div v-if="status === 'checking'">Checking license…</div>
<div v-else-if="status === 'licensed'">✓ Thanks for purchasing!</div>
<div v-else>
<p>No valid license found. {{ message }}</p>
<input v-model="key" placeholder="XXXX-XXXX-XXXX-XXXX" />
<button @click="activate">Activate</button>
</div>
</template>
The rest of this guide replaces this quick-and-dirty version with production-grade patterns: a proper machine ID that persists across reloads, a shared composable, offline caching, router protection, and tests.
Create your Vue project (skip if you already have one), then grab your credentials:
# Vue 3 with Vite
npm create vue@latest my-app
cd my-app
npm install
# Vue 2 (Vite-based, recommended over legacy webpack CLI)
npm create vue@latest # choose "no" to Vue 3-specific features, or use:
npm create vite@legacy-vue2 my-app -- --template vue2
# Nuxt 3
npx nuxi init my-nuxt-app
ul_).Store credentials in environment variables rather than hard-coding them. Vite exposes variables prefixed with VITE_; Nuxt exposes NUXT_PUBLIC_* for client-safe values:
VITE_UL_API_KEY=ul_YOUR_API_KEY
VITE_UL_PRODUCT_KEY=YOUR_PRODUCT_KEY
This composable is the foundation used by every later section. Create it once, call it anywhere:
src/composables/useLicense.jsimport { ref, computed, readonly } from 'vue'
const API_URL = 'https://api.unifiedlicensing.com/api/v1/validate-license'
const LICENSE_KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'
const config = {
apiKey: import.meta.env.VITE_UL_API_KEY,
productKey: import.meta.env.VITE_UL_PRODUCT_KEY
}
/** Stable per-browser machine ID. Persists across reloads. */
export function getMachineId() {
const STORAGE_KEY = 'ul_machine_id'
let id = localStorage.getItem(STORAGE_KEY)
if (!id) {
id = crypto.randomUUID
? crypto.randomUUID()
: 'mid-' + Array.from(crypto.getRandomValues(new Uint8Array(16)))
.map(b => b.toString(16).padStart(2, '0')).join('')
localStorage.setItem(STORAGE_KEY, id)
}
return id
}
/** Raw API call. Returns parsed JSON; throws on network failure. */
export async function validateAgainstApi(licenseKey) {
const res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: config.apiKey,
product_key: config.productKey,
license_key: licenseKey,
machine_id: getMachineId(),
platform: 'web'
})
})
if (!res.ok) {
throw new Error(`Validation request failed with HTTP ${res.status}`)
}
return res.json()
}
/**
* Shared reactive state (module scope = singleton across components).
*/
const status = ref('idle') // idle | loading | valid | invalid | error
const licenseData = ref(null) // full API response when valid
const error = ref(null)
export function useLicense() {
const isLicensed = computed(() => status.value === 'valid')
const isLoading = computed(() => status.value === 'loading')
/**
* Validate a license key. Returns the API response object.
*/
async function validate(licenseKey) {
if (!licenseKey) {
status.value = 'invalid'
error.value = 'No license key provided.'
return { valid: false, message: error.value }
}
status.value = 'loading'
error.value = null
try {
const data = await validateAgainstApi(licenseKey)
if (data.valid) {
licenseData.value = data
status.value = 'valid'
localStorage.setItem(LICENSE_KEY_STORAGE, licenseKey)
localStorage.setItem(CACHE_STORAGE, JSON.stringify({
...data,
license_key: licenseKey,
validated_at: Date.now()
}))
} else {
licenseData.value = null
status.value = 'invalid'
error.value = data.message || 'This license key is not valid.'
localStorage.removeItem(CACHE_STORAGE)
}
return data
} catch (err) {
status.value = 'error'
error.value = err.message
return { valid: false, message: err.message, network_error: true }
}
}
/** Restore session from cache without hitting the network. */
function restoreFromCache(maxAgeHours = 72) {
try {
const raw = localStorage.getItem(CACHE_STORAGE)
if (!raw) return false
const entry = JSON.parse(raw)
const ageHours = (Date.now() - entry.validated_at) / 3600000
if (ageHours > maxAgeHours) return false
licenseData.value = entry
status.value = 'valid'
return true
} catch {
return false
}
}
function reset() {
status.value = 'idle'
licenseData.value = null
error.value = null
localStorage.removeItem(LICENSE_KEY_STORAGE)
localStorage.removeItem(CACHE_STORAGE)
}
return {
status: readonly(status),
licenseData: readonly(licenseData),
error: readonly(error),
isLicensed,
isLoading,
validate,
restoreFromCache,
reset
}
}
Usage in any component:
<script setup>
import { useLicense } from '@/composables/useLicense'
const { status, error, isLicensed, isLoading, validate } = useLicense()
const key = defineModel ? null : null // (placeholder removed)
</script>
<template>
<p v-if="isLicensed">Licensed — thank you!</p>
<p v-else-if="isLoading">Validating…</p>
<p v-else-if="error">{{ error }}</p>
</template>
status outside useLicense() makes it a singleton: every component calling the composable shares the exact same state, like a tiny store with zero dependencies.Prefer data(), computed, and methods? Here is the equivalent as a fully self-contained component:
<template>
<div class="license-gate">
<!-- Loading -->
<div v-if="status === 'loading'" class="state loading">
Validating license…
</div>
<!-- Licensed: render protected content -->
<slot v-else-if="isValid" />
<!-- Activation form -->
<div v-else class="state activate">
<h2>Activate {{ productName }}</h2>
<p class="hint">
Enter the license key from your purchase email.
</p>
<form @submit.prevent="activate">
<input
v-model.trim="input"
type="text"
placeholder="XXXX-XXXX-XXXX-XXXX"
:disabled="status === 'loading'"
autocomplete="off"
spellcheck="false"
/>
<button type="submit" :disabled="status === 'loading' || !input">
{{ status === 'loading' ? 'Checking…' : 'Activate' }}
</button>
</form>
<p v-if="errorMessage" class="error" role="alert">{{ errorMessage }}</p>
<p v-if="isValidOffline" class="offline">
You're offline — using a cached license (grace period active).
</p>
</div>
</div>
</template>
<script>
import { getMachineId, validateAgainstApi } from '@/composables/useLicense'
const CACHE_STORAGE = 'ul_license_cache'
const KEY_STORAGE = 'ul_license_key'
const GRACE_HOURS = 72
export default {
name: 'LicenseGate',
props: {
productName: { type: String, default: 'this app' }
},
data() {
return {
status: 'idle', // idle | loading | valid | invalid | error
input: '',
errorMessage: '',
isValidOffline: false
}
},
computed: {
isValid() {
return this.status === 'valid'
}
},
mounted() {
// Fast path: trust a fresh cache first, then re-validate silently.
if (this.restoreFromCache()) {
this.status = 'valid'
}
const saved = localStorage.getItem(KEY_STORAGE)
if (saved) this.check(saved)
},
methods: {
async activate() {
this.errorMessage = ''
await this.check(this.input)
},
async check(licenseKey) {
this.status = 'loading'
this.isValidOffline = false
try {
const data = await validateAgainstApi(licenseKey)
if (data.valid) {
this.status = 'valid'
localStorage.setItem(KEY_STORAGE, licenseKey)
localStorage.setItem(CACHE_STORAGE, JSON.stringify({
...data,
license_key: licenseKey,
validated_at: Date.now()
}))
this.$emit('activated', data)
} else {
this.status = 'invalid'
this.errorMessage = data.message || 'That license key is not valid.'
this.$emit('failed', data)
}
} catch (err) {
// Network failed — fall back to cache within the grace period.
if (this.restoreFromCache()) {
this.status = 'valid'
this.isValidOffline = true
this.$emit('offline-grace')
} else {
this.status = 'error'
this.errorMessage =
'Could not reach the license server. Check your connection and retry.'
this.$emit('failed', { message: err.message, network_error: true })
}
}
},
restoreFromCache() {
try {
const raw = localStorage.getItem(CACHE_STORAGE)
if (!raw) return false
const entry = JSON.parse(raw)
const ageHours = (Date.now() - entry.validated_at) / 3600000
if (ageHours > GRACE_HOURS) return false
return Boolean(entry.valid)
} catch {
return false
}
}
}
}
</script>
<style scoped>
.license-gate { max-width: 420px; margin: 0 auto; padding: 32px 0; }
.state.activate h2 { margin-bottom: 4px; }
.hint { opacity: 0.7; margin-bottom: 16px; }
form { display: flex; gap: 8px; }
input {
flex: 1; padding: 10px 12px; border-radius: 6px;
border: 1px solid rgba(255,255,255,0.15); background: rgba(255,255,255,0.05);
color: inherit; font-family: 'JetBrains Mono', monospace;
}
button {
padding: 10px 18px; border-radius: 6px; border: none;
background: #C7A34C; color: #081220; font-weight: 600; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: not-allowed; }
.error { color: #E07A55; margin-top: 12px; }
.offline { color: #3FA76B; margin-top: 12px; }
</style>
Wrap any protected content with it:
<template>
<LicenseGate product-name="PhotoDesk Pro" @activated="onActivated">
<Dashboard />
</LicenseGate>
</template>
The Options API component above works almost verbatim in Vue 2. Three differences to handle:
fetch on old browsers — add the whatwg-fetch polyfill (npm i whatwg-fetch) or use axios.crypto.randomUUID in older WebViews — the fallback in getMachineId() already covers this.VUE_APP_ instead of VITE_: process.env.VUE_APP_UL_API_KEY.<template>
<div>
<div v-if="status === 'loading'">Validating license…</div>
<slot v-else-if="isValid"></slot>
<div v-else>
<h2>Activate {{ productName }}</h2>
<form @submit.prevent="activate">
<input v-model.trim="input" placeholder="XXXX-XXXX-XXXX-XXXX" />
<button type="submit" :disabled="status === 'loading'">Activate</button>
</form>
<p v-if="errorMessage" style="color:#E07A55">{{ errorMessage }}</p>
</div>
</div>
</template>
<script>
import 'whatwg-fetch' // remove if targeting modern browsers only
const API_URL = 'https://api.unifiedlicensing.com/api/v1/validate-license'
const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'
const MACHINE_STORAGE = 'ul_machine_id'
const GRACE_HOURS = 72
export default {
name: 'LicenseGate',
props: {
productName: { type: String, default: 'this app' }
},
data() {
return {
status: 'idle',
input: '',
errorMessage: ''
}
},
computed: {
isValid() {
return this.status === 'valid'
}
},
mounted() {
var saved = localStorage.getItem(KEY_STORAGE)
if (saved) this.check(saved)
},
methods: {
getMachineId() {
var id = localStorage.getItem(MACHINE_STORAGE)
if (!id) {
id = 'mid-' + Math.random().toString(36).slice(2) +
Date.now().toString(36)
localStorage.setItem(MACHINE_STORAGE, id)
}
return id
},
async check(licenseKey) {
this.status = 'loading'
this.errorMessage = ''
try {
var res = await fetch(API_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_key: process.env.VUE_APP_UL_API_KEY,
product_key: process.env.VUE_APP_UL_PRODUCT_KEY,
license_key: licenseKey,
machine_id: this.getMachineId(),
platform: 'web'
})
})
var data = await res.json()
if (data.valid) {
this.status = 'valid'
localStorage.setItem(KEY_STORAGE, licenseKey)
localStorage.setItem(CACHE_STORAGE, JSON.stringify({
valid: true,
license_key: licenseKey,
validated_at: Date.now()
}))
this.$emit('activated', data)
} else {
this.status = 'invalid'
this.errorMessage = data.message || 'That license key is not valid.'
this.$emit('failed', data)
}
} catch (err) {
var cached = this.readCache()
if (cached) {
this.status = 'valid'
this.$emit('offline-grace')
} else {
this.status = 'error'
this.errorMessage = 'Could not reach the license server.'
this.$emit('failed', { message: err.message, network_error: true })
}
}
},
readCache() {
try {
var raw = localStorage.getItem(CACHE_STORAGE)
if (!raw) return null
var entry = JSON.parse(raw)
var ageHours = (Date.now() - entry.validated_at) / 3600000
return ageHours <= GRACE_HOURS ? entry : null
} catch (e) {
return null
}
}
}
}
</script>
@vue/composition-api is built in), so the useLicense() composable from Section 3 works unchanged — just swap import.meta.env for process.env.Nuxt gives you something plain SPAs don't: a server. Use it to hide your API key entirely — the browser talks to your server route, which talks to UnifiedLicensing.
export default defineNuxtConfig({
runtimeConfig: {
// Server-only keys (never shipped to the browser)
ulApiKey: process.env.UL_API_KEY,
ulProductKey: process.env.UL_PRODUCT_KEY,
public: {
ulApiUrl: 'https://api.unifiedlicensing.com/api/v1'
}
}
})
.env
UL_API_KEY=ul_YOUR_API_KEY
UL_PRODUCT_KEY=YOUR_PRODUCT_KEY
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event)
const body = await readBody(event)
if (!body?.license_key) {
throw createError({ statusCode: 400, statusMessage: 'license_key is required' })
}
try {
return await $fetch(`${config.public.ulApiUrl}/validate-license`, {
method: 'POST',
body: {
api_key: config.ulApiKey,
product_key: config.ulProductKey,
license_key: body.license_key,
machine_id: body.machine_id,
platform: 'web'
}
})
} catch (err) {
throw createError({
statusCode: 502,
statusMessage: 'License service unreachable'
})
}
})
import { computed } from 'vue'
interface LicenseResponse {
valid: boolean
message?: string
[key: string]: unknown
}
export function useLicenseState() {
// useState is SSR-safe: serialized to the client during hydration
const state = useState<'idle' | 'loading' | 'valid' | 'invalid' | 'error'>(
'license-status', () => 'idle')
const data = useState<LicenseResponse | null>('license-data', () => null)
const isLicensed = computed(() => state.value === 'valid')
async function validate(licenseKey: string) {
state.value = 'loading'
try {
const res = await $fetch<LicenseResponse>('/api/validate-license', {
method: 'POST',
body: { license_key: licenseKey, machine_id: getMachineId() }
})
data.value = res
state.value = res.valid ? 'valid' : 'invalid'
if (import.meta.client) {
if (res.valid) {
localStorage.setItem('ul_license_key', licenseKey)
localStorage.setItem('ul_license_cache', JSON.stringify({
...res, license_key: licenseKey, validated_at: Date.now()
}))
} else {
localStorage.removeItem('ul_license_cache')
}
}
return res
} catch {
state.value = 'error'
return { valid: false, message: 'License service unreachable' }
}
}
return { state, data, isLicensed, validate }
}
function getMachineId(): string {
if (import.meta.server) return 'ssr-pending'
const KEY = 'ul_machine_id'
let id = localStorage.getItem(KEY)
if (!id) {
id = crypto.randomUUID()
localStorage.setItem(KEY, id)
}
return id
}
export default defineNuxtRouteMiddleware(async (to) => {
// Always allow the activation page itself
if (to.path === '/activate') return
const { state, isLicensed, validate } = useLicenseState()
// Already validated this session
if (isLicensed.value) return
// Try cached key from a previous visit (client only)
const savedKey = import.meta.client
? localStorage.getItem('ul_license_key')
: null
if (savedKey) {
await validate(savedKey)
if (isLicensed.value) return
}
// No license — bounce to activation with a return path
return navigateTo({ path: '/activate', query: { redirect: to.fullPath } })
})
<script setup lang="ts">
const { state, validate } = useLicenseState()
const key = ref('')
const message = ref('')
const route = useRoute()
const router = useRouter()
async function submit() {
message.value = ''
const res = await validate(key.value)
if (res.valid) {
router.push((route.query.redirect as string) || '/')
} else {
message.value = res.message || 'Invalid license key.'
}
}
</script>
<template>
<div class="activate">
<h1>Activate</h1>
<form @submit.prevent="submit">
<input v-model.trim="key" placeholder="XXXX-XXXX-XXXX-XXXX" />
<button :disabled="state === 'loading'">
{{ state === 'loading' ? 'Checking…' : 'Activate' }}
</button>
</form>
<p v-if="message" role="alert">{{ message }}</p>
</div>
</template>
serverMiddleware/ or use serverMiddleware with Express, secrets in privateRuntimeConfig, and fetch()/$axios instead of $fetch. Middleware lives in middleware/ with context.redirect().When multiple distant components need license state, promote it to a real store.
import { defineStore } from 'pinia'
import { validateAgainstApi } from '@/composables/useLicense'
const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'
export const useLicenseStore = defineStore('license', {
state: () => ({
status: 'idle', // idle|loading|valid|invalid|error
data: null, // last successful API response
error: null,
licenseKey: localStorage.getItem(KEY_STORAGE) || '',
offlineGrace: false
}),
getters: {
isValid: (s) => s.status === 'valid',
isLoading: (s) => s.status === 'loading',
plan: (s) => s.data?.plan ?? null,
expiresAt: (s) => s.data?.expires_at ?? null,
daysRemaining(state) {
if (!state.data?.expires_at) return null
return Math.max(0, Math.ceil(
(new Date(state.data.expires_at) - Date.now()) / 86400000
))
}
},
actions: {
async validate(licenseKey = this.licenseKey) {
if (!licenseKey) {
this.status = 'invalid'
this.error = 'No license key on file.'
return false
}
this.status = 'loading'
this.error = null
this.offlineGrace = false
try {
const data = await validateAgainstApi(licenseKey)
if (data.valid) {
this.status = 'valid'
this.data = data
this.licenseKey = licenseKey
localStorage.setItem(KEY_STORAGE, licenseKey)
localStorage.setItem(CACHE_STORAGE, JSON.stringify({
...data, license_key: licenseKey, validated_at: Date.now()
}))
} else {
this.status = 'invalid'
this.error = data.message || 'License is not valid.'
localStorage.removeItem(CACHE_STORAGE)
}
} catch (err) {
// Network down — grace period from cache
const cached = this.readCache()
if (cached) {
this.status = 'valid'
this.data = cached
this.offlineGrace = true
} else {
this.status = 'error'
this.error = err.message
}
}
return this.isValid
},
readCache() {
try {
const raw = localStorage.getItem(CACHE_STORAGE)
if (!raw) return null
const entry = JSON.parse(raw)
const ageHours = (Date.now() - entry.validated_at) / 3600000
return ageHours <= 72 ? entry : null
} catch {
return null
}
},
/** Silent re-check on app boot; falls back to cache. */
async verifyOnBoot() {
if (this.licenseKey) return this.validate()
const cached = this.readCache()
if (cached) {
this.status = 'valid'
this.data = cached
this.offlineGrace = true
return true
}
return false
},
deactivate() {
this.status = 'idle'
this.data = null
this.error = null
this.offlineGrace = false
this.licenseKey = ''
localStorage.removeItem(KEY_STORAGE)
localStorage.removeItem(CACHE_STORAGE)
}
}
})
import { validateAgainstApi } from '@/composables/useLicense'
const KEY_STORAGE = 'ul_license_key'
const CACHE_STORAGE = 'ul_license_cache'
export default {
namespaced: true,
state: () => ({
status: 'idle',
data: null,
error: null,
licenseKey: localStorage.getItem(KEY_STORAGE) || ''
}),
getters: {
isValid: (s) => s.status === 'valid',
plan: (s) => (s.data && s.data.plan) || null
},
mutations: {
SET_STATUS(state, status) { state.status = status },
SET_DATA(state, data) { state.data = data },
SET_ERROR(state, error) { state.error = error },
SET_KEY(state, key) { state.licenseKey = key }
},
actions: {
async validate({ commit, dispatch }, licenseKey) {
commit('SET_STATUS', 'loading')
commit('SET_ERROR', null)
try {
const data = await validateAgainstApi(licenseKey)
if (data.valid) {
commit('SET_STATUS', 'valid')
commit('SET_DATA', data)
commit('SET_KEY', licenseKey)
localStorage.setItem(KEY_STORAGE, licenseKey)
localStorage.setItem(CACHE_STORAGE, JSON.stringify({
...data, license_key: licenseKey, validated_at: Date.now()
}))
} else {
commit('SET_STATUS', 'invalid')
commit('SET_ERROR', data.message || 'License is not valid.')
}
} catch (err) {
await dispatch('restoreFromCache') || commit('SET_ERROR', err.message)
}
},
restoreFromCache({ commit }) {
try {
const raw = localStorage.getItem(CACHE_STORAGE)
if (!raw) return false
const entry = JSON.parse(raw)
if ((Date.now() - entry.validated_at) / 3600000 > 72) return false
commit('SET_STATUS', 'valid')
commit('SET_DATA', entry)
return true
} catch {
return false
}
}
}
}
// src/store/index.js
import { createStore } from 'vuex'
import license from './modules/license'
export default createStore({
modules: { license }
})
// Usage anywhere: this.$store.state.license.status
// this.$store.dispatch('license/validate', key)
<LicenseValidator> Component Vue 3A polished, dependency-free form component you can drop onto any settings page or paywall. Emits events so parent apps decide what happens next.
src/components/LicenseValidator.vue<template>
<div class="ul-validator" :class="{ compact }">
<form class="row" @submit.prevent="submit">
<input
v-model.trim="key"
class="input"
type="text"
:placeholder="placeholder"
:disabled="busy"
autocomplete="off"
spellcheck="false"
aria-label="License key"
/>
<button class="btn" type="submit" :disabled="busy || !key">
{{ busy ? 'Checking…' : buttonLabel }}
</button>
</form>
<transition name="fade">
<p v-if="feedback.text" class="feedback" :class="feedback.kind" role="status">
{{ feedback.text }}
</p>
</transition>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useLicense } from '@/composables/useLicense'
const props = defineProps({
buttonLabel: { type: String, default: 'Activate' },
placeholder: { type: String, default: 'XXXX-XXXX-XXXX-XXXX' },
compact: { type: Boolean, default: false }
})
const emit = defineEmits(['validated', 'failed'])
const { validate, isLoading } = useLicense()
const key = ref('')
const busy = ref(false)
const feedback = reactive({ kind: '', text: '' })
async function submit() {
if (!key.value || busy.value) return
busy.value = true
feedback.text = ''
const result = await validate(key.value)
if (result.valid) {
feedback.kind = 'ok'
feedback.text = 'License activated successfully.'
emit('validated', result)
key.value = ''
} else {
feedback.kind = 'err'
feedback.text = result.network_error
? 'Network error — please try again.'
: (result.message || 'That license key is not valid.')
emit('failed', result)
}
busy.value = false
}
</script>
<style scoped>
.ul-validator { max-width: 460px; }
.row { display: flex; gap: 8px; }
.input {
flex: 1; min-width: 0; padding: 11px 14px;
border-radius: 8px; border: 1px solid rgba(255,255,255,0.16);
background: rgba(255,255,255,0.05); color: inherit;
font-family: 'JetBrains Mono', monospace; font-size: 0.95rem;
}
.input:focus { outline: 2px solid #C7A34C; outline-offset: 1px; }
.btn {
padding: 11px 20px; border-radius: 8px; border: none;
background: #C7A34C; color: #081220; font-weight: 600; cursor: pointer;
}
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.compact .input, .compact .btn { padding: 7px 10px; font-size: 0.85rem; }
.feedback { margin-top: 10px; font-size: 0.9rem; }
.feedback.ok { color: #3FA76B; }
.feedback.err { color: #E07A55; }
.fade-enter-active, .fade-leave-active { transition: opacity 0.25s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>
Usage:
<template>
<section>
<h2>Your License</h2>
<LicenseValidator
button-label="Activate license"
@validated="onValidated"
@failed="onFailed"
/>
</section>
</template>
<script setup>
import LicenseValidator from '@/components/LicenseValidator.vue'
function onValidated(data) {
console.log('Plan:', data.plan, 'Expires:', data.expires_at)
}
function onFailed(result) {
console.warn('Activation failed:', result.message)
}
</script>
Laptops go on airplanes. Desktops lose Wi-Fi. A strict "phone home on every launch" policy punishes paying customers, so cache the last successful validation and honor a grace period:
src/utils/offlineLicense.jsconst CACHE_KEY = 'ul_license_cache'
export const DEFAULT_GRACE_HOURS = 72 // how long a cached license stays trusted
const FRESH_HOURS = 24 // below this, no re-check needed at boot
/**
* Read the cached validation. Returns null when missing, corrupt,
* or older than maxAgeHours.
*/
export function getCachedLicense(maxAgeHours = DEFAULT_GRACE_HOURS) {
try {
const raw = localStorage.getItem(CACHE_KEY)
if (!raw) return null
const entry = JSON.parse(raw)
if (!entry || !entry.valid) return null
const ageHours = (Date.now() - entry.validated_at) / 3600000
if (ageHours > maxAgeHours) {
localStorage.removeItem(CACHE_KEY) // expired — clean up
return null
}
return { ...entry, stale: ageHours > FRESH_HOURS }
} catch {
return null
}
}
export function cacheLicense(apiResponse, licenseKey) {
localStorage.setItem(CACHE_KEY, JSON.stringify({
...apiResponse,
license_key: licenseKey,
validated_at: Date.now()
}))
}
export function clearLicenseCache() {
localStorage.removeItem(CACHE_KEY)
}
/**
* Online-first strategy:
* 1. Fresh cache (< FRESH_HOURS)? Skip the network entirely.
* 2. Otherwise hit the API; cache success, purge cache on definitive rejection.
* 3. Network error? Serve the cache inside the grace period.
*/
export async function validateResilient(licenseKey, { graceHours = DEFAULT_GRACE_HOURS } = {}) {
const cached = getCachedLicense(graceHours)
if (cached && !cached.stale) {
return { ...cached, source: 'cache-fresh' }
}
try {
const { validateAgainstApi } = await import('@/composables/useLicense')
const data = await validateAgainstApi(licenseKey)
if (data.valid) {
cacheLicense(data, licenseKey)
return { ...data, source: 'network' }
}
// Definitive rejection (revoked/refunded/expired): purge and fail closed
clearLicenseCache()
return data
} catch (err) {
if (cached) {
return { ...cached, source: 'cache-grace', network_error: err.message }
}
throw err
}
}
Two complementary approaches — use either or both:
/validate-license endpoint, so zero extra frontend code — trials expire server-side and can't be wiped by clearing localStorage.import { getMachineId } from '@/composables/useLicense'
const TRIAL_KEY = 'ul_trial_record'
export const TRIAL_DAYS = 14
export function getTrial() {
try {
const raw = localStorage.getItem(TRIAL_KEY)
if (!raw) return null
const trial = JSON.parse(raw)
// Reject tampering: machine ID must match where the trial started
if (trial.machine_id !== getMachineId()) return null
if (Date.now() > trial.expires_at) {
localStorage.removeItem(TRIAL_KEY)
return null
}
return trial
} catch {
return null
}
}
export function startTrial() {
const existing = getTrial()
if (existing) return existing // never extend by re-clicking
const trial = {
machine_id: getMachineId(),
started_at: Date.now(),
expires_at: Date.now() + TRIAL_DAYS * 86400000
}
localStorage.setItem(TRIAL_KEY, JSON.stringify(trial))
return trial
}
export function trialDaysLeft() {
const trial = getTrial()
if (!trial) return 0
return Math.max(0, Math.ceil((trial.expires_at - Date.now()) / 86400000))
}
export function isTrialActive() {
return getTrial() !== null
}
Wire it into your root component so the decision happens once, on app load:
src/App.vue<script setup>
import { ref, onMounted } from 'vue'
import { useLicense } from '@/composables/useLicense'
import { isTrialActive, trialDaysLeft, startTrial } from '@/utils/trial'
const { isLicensed, validate, restoreFromCache } = useLicense()
const mode = ref('loading') // loading | licensed | trial | locked
const daysLeft = ref(0)
onMounted(async () => {
// 1. Cached license? Trust it instantly, re-validate in background.
if (restoreFromCache()) {
mode.value = 'licensed'
return
}
// 2. Saved license key? Verify with the server.
const saved = localStorage.getItem('ul_license_key')
if (saved) {
const res = await validate(saved)
if (res.valid) { mode.value = 'licensed'; return }
}
// 3. Active trial?
if (isTrialActive()) {
daysLeft.value = trialDaysLeft()
mode.value = 'trial'
return
}
// 4. Nothing — locked.
mode.value = 'locked'
})
function onStartTrial() {
startTrial()
daysLeft.value = trialDaysLeft()
mode.value = 'trial'
}
</script>
<template>
<main>
<div v-if="mode === 'loading'">Loading…</div>
<template v-else-if="mode === 'licensed'">
<slot />
</template>
<div v-else-if="mode === 'trial'" class="banner">
Trial: {{ daysLeft }} day{{ daysLeft === 1 ? '' : 's' }} remaining.
<router-link to="/activate">Upgrade now</router-link>
<router-view />
</div>
<div v-else class="locked">
<h1>Start your 14-day free trial</h1>
<p>Full features. No credit card required.</p>
<button @click="onStartTrial">Start free trial</button>
<p>or <router-link to="/activate">enter a license key</router-link></p>
</div>
</main>
</template>
Protect routes declaratively with meta flags. Unlicensed visitors land on /activate and get returned to where they were headed after activating:
import { createRouter, createWebHistory } from 'vue-router'
import { useLicenseStore } from '@/stores/license'
import ActivateView from '@/views/ActivateView.vue'
const routes = [
{
path: '/activate',
name: 'activate',
component: ActivateView,
meta: { public: true } // reachable without a license
},
{
path: '/',
name: 'home',
component: () => import('@/views/HomeView.vue')
// no meta.public — protected by default
},
{
path: '/settings',
name: 'settings',
component: () => import('@/views/SettingsView.vue')
},
{
path: '/pricing',
name: 'pricing',
component: () => import('@/views/PricingView.vue'),
meta: { public: true }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
router.beforeEach(async (to) => {
if (to.meta.public) return true
const license = useLicenseStore()
// Already good — pass through
if (license.isValid) return true
// First navigation of the session: verify quietly
const ok = await license.verifyOnBoot()
if (ok) return true
// Send them to activation, remembering the destination
return {
name: 'activate',
query: { redirect: to.fullPath }
}
})
export default router
src/views/ActivateView.vue
<script setup>
import { ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useLicenseStore } from '@/stores/license'
const route = useRoute()
const router = useRouter()
const license = useLicenseStore()
const key = ref('')
async function submit() {
const ok = await license.validate(key.value)
if (ok) {
router.push(route.query.redirect || '/')
}
}
</script>
<template>
<div class="activate-page">
<h1>Activate your license</h1>
<form @submit.prevent="submit">
<input v-model.trim="key" placeholder="XXXX-XXXX-XXXX-XXXX" autofocus />
<button :disabled="license.isLoading">
{{ license.isLoading ? 'Checking…' : 'Activate' }}
</button>
</form>
<p v-if="license.error" class="err">{{ license.error }}</p>
</div>
</template>
meta: { requiresPlan: 'pro' } and compare against license.plan to build tiered feature gating (Free / Pro / Enterprise) with the same mechanism.Everything above assembled into one coherent Vue 3 application. File layout:
src/
├── main.js
├── App.vue
├── router/index.js
├── stores/license.js (Section 7)
├── composables/useLicense.js (Section 3)
└── views/
├── ActivateView.vue (Section 11)
└── DashboardView.vue
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
<template>
<header class="topbar">
<strong>PhotoDesk Pro</strong>
<nav>
<router-link to="/">Dashboard</router-link>
<router-link to="/settings">Settings</router-link>
</nav>
<span v-if="license.isValid" class="pill" :class="{ grace: license.offlineGrace }">
{{ license.offlineGrace ? 'Offline grace' : (license.plan || 'Licensed') }}
</span>
</header>
<router-view />
</template>
<script setup>
import { useLicenseStore } from '@/stores/license'
const license = useLicenseStore()
</script>
<style>
body { margin: 0; font-family: Inter, system-ui, sans-serif; }
.topbar {
display: flex; align-items: center; gap: 24px;
padding: 14px 28px; border-bottom: 1px solid #22344c;
}
.topbar nav { display: flex; gap: 16px; flex: 1; }
.pill {
font-size: 0.75rem; padding: 3px 10px; border-radius: 999px;
background: rgba(63,167,107,0.15); color: #3FA76B;
}
.pill.grace { background: rgba(199,163,76,0.15); color: #C7A34C; }
</style>
import { createRouter, createWebHistory } from 'vue-router'
import { useLicenseStore } from '@/stores/license'
const routes = [
{ path: '/activate', name: 'activate', component: () => import('@/views/ActivateView.vue'), meta: { public: true } },
{ path: '/', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: '/settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
{ path: '/:pathMatch(.*)*', redirect: '/' }
]
const router = createRouter({ history: createWebHistory(), routes })
router.beforeEach(async (to) => {
if (to.meta.public) return true
const license = useLicenseStore()
if (license.isValid) return true
const ok = await license.verifyOnBoot()
return ok ? true : { name: 'activate', query: { redirect: to.fullPath } }
})
export default router
<template>
<section class="page">
<h1>Dashboard</h1>
<p v-if="license.daysRemaining !== null">
Your {{ license.plan || 'license' }} renews in {{ license.daysRemaining }} days.
</p>
<p v-if="license.offlineGrace" class="notice">
Working offline — license will re-verify when you reconnect.
</p>
<!-- Real app content here -->
</section>
</template>
<script setup>
import { useLicenseStore } from '@/stores/license'
const license = useLicenseStore()
</script>
<template>
<section class="page">
<h1>Settings</h1>
<h2>License</h2>
<p>
Status:
<strong>{{ license.isValid ? 'Active' : 'Inactive' }}</strong>
<span v-if="license.licenseKey">
(key ending {{ license.licenseKey.slice(-4) }})
</span>
</p>
<LicenseValidator @validated="notify" />
<button v-if="license.isValid" class="danger" @click="deactivate">
Deactivate on this device
</button>
</section>
</template>
<script setup>
import { useLicenseStore } from '@/stores/license'
import LicenseValidator from '@/components/LicenseValidator.vue'
const license = useLicenseStore()
function notify() {
alert('License activated!')
}
function deactivate() {
if (confirm('Deactivate PhotoDesk Pro on this device?')) {
license.deactivate()
}
}
</script>
Run it with npm run dev, set the two VITE_UL_* variables in .env, and the app locks itself until a valid key is entered — survives refreshes via cache, tolerates offline launches, and deactivates cleanly.
Vitest pairs naturally with Vite projects (Jest works too — see the note at the end).
npm i -D vitest @vue/test-utils jsdom
vitest.config.js
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
test: {
environment: 'jsdom',
globals: true
},
resolve: {
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }
}
})
fetchimport { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useLicenseStore } from '@/stores/license'
// Stable fake for crypto.randomUUID in jsdom
vi.stubGlobal('crypto', { randomUUID: () => 'test-machine-id' })
function mockFetchOnce(payload, ok = true) {
global.fetch = vi.fn(() =>
Promise.resolve({ ok, json: () => Promise.resolve(payload) })
)
}
beforeEach(() => {
setActivePinia(createPinia())
localStorage.clear()
vi.restoreAllMocks()
})
describe('license store', () => {
it('becomes valid on a successful validation', async () => {
mockFetchOnce({ valid: true, plan: 'pro', expires_at: '2027-01-01' })
const store = useLicenseStore()
await store.validate('AAAA-BBBB-CCCC-DDDD')
expect(store.isValid).toBe(true)
expect(store.plan).toBe('pro')
expect(localStorage.getItem('ul_license_key')).toBe('AAAA-BBBB-CCCC-DDDD')
})
it('stays invalid and reports the server message', async () => {
mockFetchOnce({ valid: false, message: 'License has been revoked.' })
const store = useLicenseStore()
await store.validate('REVOKED-KEY')
expect(store.isValid).toBe(false)
expect(store.error).toBe('License has been revoked.')
})
it('falls back to cache when the network fails', async () => {
// Seed a fresh cache entry
localStorage.setItem('ul_license_cache', JSON.stringify({
valid: true, plan: 'pro', validated_at: Date.now()
}))
global.fetch = vi.fn(() => Promise.reject(new Error('network down')))
const store = useLicenseStore()
const ok = await store.validate('AAAA-BBBB-CCCC-DDDD')
expect(ok).toBe(true)
expect(store.offlineGrace).toBe(true)
})
it('rejects cache entries older than the grace period', async () => {
localStorage.setItem('ul_license_cache', JSON.stringify({
valid: true, validated_at: Date.now() - 73 * 3600000 // 73h old
}))
global.fetch = vi.fn(() => Promise.reject(new Error('offline')))
const store = useLicenseStore()
const ok = await store.validate('AAAA-BBBB-CCCC-DDDD')
expect(ok).toBe(false)
expect(store.status).toBe('error')
})
it('clears stored state on deactivate', async () => {
mockFetchOnce({ valid: true })
const store = useLicenseStore()
await store.validate('AAAA-BBBB-CCCC-DDDD')
store.deactivate()
expect(store.isValid).toBe(false)
expect(localStorage.getItem('ul_license_key')).toBeNull()
expect(localStorage.getItem('ul_license_cache')).toBeNull()
})
})
import { describe, it, expect, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import LicenseValidator from '@/components/LicenseValidator.vue'
vi.stubGlobal('crypto', { randomUUID: () => 'test-machine-id' })
describe('<LicenseValidator>', () => {
it('disables the button while the key is empty', () => {
const wrapper = mount(LicenseValidator)
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('shows success feedback and emits validated', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: true, plan: 'pro' })
})
)
const wrapper = mount(LicenseValidator)
await wrapper.find('input').setValue('AAAA-BBBB-CCCC-DDDD')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.emitted('validated')).toBeTruthy()
})
expect(wrapper.find('.feedback').text()).toContain('successfully')
})
it('shows the server rejection message on failure', async () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ valid: false, message: 'Expired license.' })
})
)
const wrapper = mount(LicenseValidator)
await wrapper.find('input').setValue('EXPIRED-KEY')
await wrapper.find('form').trigger('submit.prevent')
await vi.waitFor(() => {
expect(wrapper.emitted('failed')).toBeTruthy()
})
expect(wrapper.find('.feedback').text()).toContain('Expired license.')
})
})
# package.json
"scripts": { "test": "vitest run", "test:watch": "vitest" }
npm test
vi.fn() → jest.fn(), vi.stubGlobal → assign to global.fetch directly, vi.waitFor → waitFor from the utils, and add vue-jest/babel transforms. Everything else is identical.Symptom: Access to fetch at 'https://api.unifiedlicensing.com/...' has been blocked by CORS policy. The API allows browser origins, but if you self-host or proxy, your layer must return Access-Control-Allow-Origin. During development, route around CORS with the Vite proxy:
// vite.config.js
export default {
server: {
proxy: {
'/api/ul': {
target: 'https://api.unifiedlicensing.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api\/ul/, '/api/v1')
}
}
}
}
// Then point the composable at the proxy in dev:
const API_URL = import.meta.env.DEV
? '/api/ul/validate-license'
: 'https://api.unifiedlicensing.com/api/v1/validate-license'
localStorage is not definedNuxt/server-rendered apps execute component code in Node, where window and localStorage don't exist. Defend every storage access:
function safeGet(key) {
if (typeof window === 'undefined') return null
try { return window.localStorage.getItem(key) } catch { return null }
}
// Or defer browser-only work:
onMounted(() => { /* localStorage OK here */ })
Note that useState-based state (Section 6) serializes correctly across the server/client boundary; bare module-level ref()s do not share between server requests — they leak across users on the server.
| Symptom | Cause & fix |
|---|---|
| Validation passes but UI never updates | You replaced a ref's value destructured from the composable. Destructuring returns the ref itself — always mutate via status.value inside the composable, never reassign the destructured variable. |
| Every page load asks for the key again | machine_id regenerated each visit because you called crypto.randomUUID() inline instead of persisting it (Section 3's getMachineId()). Some servers bind licenses to machine IDs. |
import.meta.env.VITE_UL_API_KEY is undefined |
Env vars are baked in at build time and must start with VITE_. Restart the dev server after editing .env. Vue CLI uses VUE_APP_; Nuxt uses NUXT_PUBLIC_. |
| Works in dev, breaks in production | Hard-coded localhost proxy URL, or missing env vars in the CI/build pipeline. Log import.meta.env.MODE temporarily to confirm what shipped. |
await res.json() throws Unexpected token |
The endpoint returned HTML (proxy error page, 404 page). Check res.status and res.headers.get('content-type') before parsing. |
| HTTP 429 responses | Rate limited. Cache validations (Section 9) and validate once per session, not on every route change. |
HTTP 401 vs valid: false |
Different failures: 401 means your api_key/product_key is wrong (config problem); { valid: false } means the customer's key is bad (their problem). Handle them separately in the UI. |
Vue 2: Unexpected token '.' at build |
Optional chaining (s.data?.plan) unsupported in old toolchains. Rewrite as (s.data && s.data.plan) or upgrade the build chain. |
| License expired mid-session but app keeps working forever | You only checked once at boot. Add a periodic re-check (e.g. every 30 minutes) or re-validate on window focus: |
// Re-validate when the user returns to the tab (max once per 30 min)
let lastCheck = Date.now()
window.addEventListener('focus', async () => {
if (Date.now() - lastCheck < 30 * 60 * 1000) return
lastCheck = Date.now()
const store = useLicenseStore()
if (store.licenseKey) await store.validate()
})
Questions or issues? Reach support from the UnifiedLicensing dashboard, and see the other integration guides for React, PHP, Python, .NET, WordPress, mobile, and more.